From f5ef63bcecc0733ba87daad169807e3629dc2b59 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Fri, 31 Jan 2025 10:07:33 +0300 Subject: [PATCH] POC/Provisioning: Replace EditingOptions with a readOnly flag (#99859) --- .betterer.results | 23 ++++----- pkg/apis/provisioning/v0alpha1/types.go | 13 +---- .../v0alpha1/zz_generated.deepcopy.go | 17 ------- .../v0alpha1/zz_generated.openapi.go | 51 +++---------------- .../provisioning/v0alpha1/editingoptions.go | 43 ---------------- .../provisioning/v0alpha1/repositoryspec.go | 10 ++-- pkg/generated/applyconfiguration/utils.go | 2 - pkg/registry/apis/provisioning/export.go | 6 +++ pkg/registry/apis/provisioning/files.go | 14 +++-- pkg/registry/apis/provisioning/jobs/worker.go | 7 +++ .../provisioning.grafana.app-v0alpha1.json | 41 +++------------ .../provisioned/SaveProvisionedDashboard.tsx | 6 +++ .../saving/provisioned/hooks.ts | 2 + .../app/features/provisioning/ConfigForm.tsx | 21 ++------ .../provisioning/api/endpoints.gen.ts | 12 +---- .../app/features/provisioning/utils/data.ts | 3 +- 16 files changed, 64 insertions(+), 207 deletions(-) delete mode 100644 pkg/generated/applyconfiguration/provisioning/v0alpha1/editingoptions.go diff --git a/.betterer.results b/.betterer.results index 7ef79733a91..10a8dd7e282 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3420,13 +3420,15 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "10"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "11"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "12"], + [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "12"], [0, 0, 0, "No untranslated strings. Wrap text with ", "13"], [0, 0, 0, "No untranslated strings. Wrap text with ", "14"], [0, 0, 0, "No untranslated strings. Wrap text with ", "15"], [0, 0, 0, "No untranslated strings. Wrap text with ", "16"], [0, 0, 0, "No untranslated strings. Wrap text with ", "17"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "18"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "18"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "19"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "20"] ], "public/app/features/dashboard-scene/saving/shared.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], @@ -5834,21 +5836,16 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "23"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "24"], [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "25"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "26"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "27"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "28"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "29"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "30"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "26"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "27"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "28"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "29"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "30"], [0, 0, 0, "No untranslated strings. Wrap text with ", "31"], [0, 0, 0, "No untranslated strings. Wrap text with ", "32"], [0, 0, 0, "No untranslated strings. Wrap text with ", "33"], [0, 0, 0, "No untranslated strings. Wrap text with ", "34"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "35"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "36"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "37"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "38"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "39"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "40"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "35"] ], "public/app/features/provisioning/DeleteRepositoryButton.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index a85b96d038d..b15abba20e3 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -74,8 +74,8 @@ type RepositorySpec struct { // Repository description Description string `json:"description,omitempty"` - // Edit options within the repository - Editing EditingOptions `json:"editing"` + // ReadOnly repository does not allow any write commands + ReadOnly bool `json:"readOnly"` // Sync settings -- how values are pulled from the repository into grafana Sync SyncOptions `json:"sync"` @@ -130,15 +130,6 @@ type SyncOptions struct { IntervalSeconds int64 `json:"intervalSeconds,omitempty"` } -type EditingOptions struct { - // End users can create new files in the remote file system - Create bool `json:"create"` - // End users can update existing files in the remote file system - Update bool `json:"update"` - // End users can delete existing files in the remote file system - Delete bool `json:"delete"` -} - // The status of a Repository. // This is expected never to be created by a kubectl call or similar, and is expected to rarely (if ever) be edited manually. // As such, it is also a little less well structured than the spec, such as conditional-but-ever-present fields. diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 68ef293def5..b3734c25d79 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -27,22 +27,6 @@ func (in *Author) DeepCopy() *Author { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *EditingOptions) DeepCopyInto(out *EditingOptions) { - *out = *in - return -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EditingOptions. -func (in *EditingOptions) DeepCopy() *EditingOptions { - if in == nil { - return nil - } - out := new(EditingOptions) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExportJobOptions) DeepCopyInto(out *ExportJobOptions) { *out = *in @@ -475,7 +459,6 @@ func (in *RepositoryList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RepositorySpec) DeepCopyInto(out *RepositorySpec) { *out = *in - out.Editing = in.Editing out.Sync = in.Sync if in.Local != nil { in, out := &in.Local, &out.Local diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 7a19f4b4643..a163fe1e78c 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -15,7 +15,6 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.Author": schema_pkg_apis_provisioning_v0alpha1_Author(ref), - "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.EditingOptions": schema_pkg_apis_provisioning_v0alpha1_EditingOptions(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ExportJobOptions": schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref), @@ -86,43 +85,6 @@ func schema_pkg_apis_provisioning_v0alpha1_Author(ref common.ReferenceCallback) } } -func schema_pkg_apis_provisioning_v0alpha1_EditingOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "create": { - SchemaProps: spec.SchemaProps{ - Description: "End users can create new files in the remote file system", - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "update": { - SchemaProps: spec.SchemaProps{ - Description: "End users can update existing files in the remote file system", - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "delete": { - SchemaProps: spec.SchemaProps{ - Description: "End users can delete existing files in the remote file system", - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - Required: []string{"create", "update", "delete"}, - }, - }, - } -} - func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -974,11 +936,12 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa Format: "", }, }, - "editing": { + "readOnly": { SchemaProps: spec.SchemaProps{ - Description: "Edit options within the repository", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.EditingOptions"), + Description: "ReadOnly repository does not allow any write commands", + Default: false, + Type: []string{"boolean"}, + Format: "", }, }, "sync": { @@ -1016,11 +979,11 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa }, }, }, - Required: []string{"title", "editing", "sync", "type"}, + Required: []string{"title", "readOnly", "sync", "type"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.EditingOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions"}, + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.SyncOptions"}, } } diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/editingoptions.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/editingoptions.go deleted file mode 100644 index 9807b8fa4d8..00000000000 --- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/editingoptions.go +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -// Code generated by applyconfiguration-gen. DO NOT EDIT. - -package v0alpha1 - -// EditingOptionsApplyConfiguration represents a declarative configuration of the EditingOptions type for use -// with apply. -type EditingOptionsApplyConfiguration struct { - Create *bool `json:"create,omitempty"` - Update *bool `json:"update,omitempty"` - Delete *bool `json:"delete,omitempty"` -} - -// EditingOptionsApplyConfiguration constructs a declarative configuration of the EditingOptions type for use with -// apply. -func EditingOptions() *EditingOptionsApplyConfiguration { - return &EditingOptionsApplyConfiguration{} -} - -// WithCreate sets the Create field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Create field is set to the value of the last call. -func (b *EditingOptionsApplyConfiguration) WithCreate(value bool) *EditingOptionsApplyConfiguration { - b.Create = &value - return b -} - -// WithUpdate sets the Update field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Update field is set to the value of the last call. -func (b *EditingOptionsApplyConfiguration) WithUpdate(value bool) *EditingOptionsApplyConfiguration { - b.Update = &value - return b -} - -// WithDelete sets the Delete field in the declarative configuration to the given value -// and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Delete field is set to the value of the last call. -func (b *EditingOptionsApplyConfiguration) WithDelete(value bool) *EditingOptionsApplyConfiguration { - b.Delete = &value - return b -} diff --git a/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go b/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go index a62be2803f2..8cdedec2731 100644 --- a/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go +++ b/pkg/generated/applyconfiguration/provisioning/v0alpha1/repositoryspec.go @@ -13,7 +13,7 @@ import ( type RepositorySpecApplyConfiguration struct { Title *string `json:"title,omitempty"` Description *string `json:"description,omitempty"` - Editing *EditingOptionsApplyConfiguration `json:"editing,omitempty"` + ReadOnly *bool `json:"readOnly,omitempty"` Sync *SyncOptionsApplyConfiguration `json:"sync,omitempty"` Type *provisioningv0alpha1.RepositoryType `json:"type,omitempty"` Local *LocalRepositoryConfigApplyConfiguration `json:"local,omitempty"` @@ -43,11 +43,11 @@ func (b *RepositorySpecApplyConfiguration) WithDescription(value string) *Reposi return b } -// WithEditing sets the Editing field in the declarative configuration to the given value +// WithReadOnly sets the ReadOnly field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. -// If called multiple times, the Editing field is set to the value of the last call. -func (b *RepositorySpecApplyConfiguration) WithEditing(value *EditingOptionsApplyConfiguration) *RepositorySpecApplyConfiguration { - b.Editing = value +// If called multiple times, the ReadOnly field is set to the value of the last call. +func (b *RepositorySpecApplyConfiguration) WithReadOnly(value bool) *RepositorySpecApplyConfiguration { + b.ReadOnly = &value return b } diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go index 2042aac7eb1..bae4891ada9 100644 --- a/pkg/generated/applyconfiguration/utils.go +++ b/pkg/generated/applyconfiguration/utils.go @@ -20,8 +20,6 @@ import ( func ForKind(kind schema.GroupVersionKind) interface{} { switch kind { // Group=provisioning.grafana.app, Version=v0alpha1 - case v0alpha1.SchemeGroupVersion.WithKind("EditingOptions"): - return &provisioningv0alpha1.EditingOptionsApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("GitHubRepositoryConfig"): return &provisioningv0alpha1.GitHubRepositoryConfigApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("HealthStatus"): diff --git a/pkg/registry/apis/provisioning/export.go b/pkg/registry/apis/provisioning/export.go index 94c39723e84..14c3d7c3fbc 100644 --- a/pkg/registry/apis/provisioning/export.go +++ b/pkg/registry/apis/provisioning/export.go @@ -52,6 +52,12 @@ func (c *exportConnector) Connect( return nil, err } cfg := repo.Config() + if !cfg.Spec.ReadOnly { + return nil, &apierrors.StatusError{ErrStatus: v1.Status{ + Code: http.StatusPreconditionFailed, + Message: "Repository is read only", + }} + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { options := &provisioning.ExportJobOptions{} diff --git a/pkg/registry/apis/provisioning/files.go b/pkg/registry/apis/provisioning/files.go index 1edde27db2f..f28d1fad3be 100644 --- a/pkg/registry/apis/provisioning/files.go +++ b/pkg/registry/apis/provisioning/files.go @@ -183,11 +183,9 @@ func (s *filesConnector) doRead(ctx context.Context, repo repository.Repository, } func (s *filesConnector) doWrite(ctx context.Context, update bool, repo repository.Repository, path string, ref string, message string, req *http.Request) (*provisioning.ResourceWrapper, error) { - settings := repo.Config().Spec.Editing - if update && !settings.Update { - return nil, apierrors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), "updating files not enabled", nil) - } else if !settings.Create { - return nil, apierrors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), "creating files not enabled", nil) + if repo.Config().Spec.ReadOnly { + return nil, apierrors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), + "this repository is read only", nil) } defer func() { _ = req.Body.Close() }() @@ -270,9 +268,9 @@ func (s *filesConnector) doWrite(ctx context.Context, update bool, repo reposito } func (s *filesConnector) doDelete(ctx context.Context, repo repository.Repository, path string, ref string, message string) (*provisioning.ResourceWrapper, error) { - settings := repo.Config().Spec.Editing - if !settings.Delete { - return nil, apierrors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), "deleting is not supported", nil) + if repo.Config().Spec.ReadOnly { + return nil, apierrors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), + "this repository is read only", nil) } err := repo.Delete(ctx, path, ref, message) diff --git a/pkg/registry/apis/provisioning/jobs/worker.go b/pkg/registry/apis/provisioning/jobs/worker.go index 5d61c2da2e5..e8e2734d741 100644 --- a/pkg/registry/apis/provisioning/jobs/worker.go +++ b/pkg/registry/apis/provisioning/jobs/worker.go @@ -131,6 +131,13 @@ func (g *JobWorker) Process(ctx context.Context, job provisioning.Job, progress }, nil } + if repo.Config().Spec.ReadOnly { + return &provisioning.JobStatus{ + State: provisioning.JobStateError, + Errors: []string{"Exporting to a read only repository is not supported"}, + }, nil + } + var exporter Exporter // Test for now... so we have something with long spinners for UI testing!!! diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index 565bc45841a..10a8b667905 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -2653,31 +2653,6 @@ "additionalProperties": true, "x-kubernetes-preserve-unknown-fields": true }, - "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.EditingOptions": { - "type": "object", - "required": [ - "create", - "update", - "delete" - ], - "properties": { - "create": { - "description": "End users can create new files in the remote file system", - "type": "boolean", - "default": false - }, - "delete": { - "description": "End users can delete existing files in the remote file system", - "type": "boolean", - "default": false - }, - "update": { - "description": "End users can update existing files in the remote file system", - "type": "boolean", - "default": false - } - } - }, "com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.ExportJobOptions": { "type": "object", "properties": { @@ -3140,7 +3115,7 @@ "type": "object", "required": [ "title", - "editing", + "readOnly", "sync", "type" ], @@ -3149,15 +3124,6 @@ "description": "Repository description", "type": "string" }, - "editing": { - "description": "Edit options within the repository", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.provisioning.v0alpha1.EditingOptions" - } - ] - }, "github": { "description": "The repository on GitHub. Mutually exclusive with local | s3 | github.", "allOf": [ @@ -3174,6 +3140,11 @@ } ] }, + "readOnly": { + "description": "ReadOnly repository does not allow any write commands", + "type": "boolean", + "default": false + }, "s3": { "description": "The repository in an S3 bucket. Mutually exclusive with local | s3 | github.", "allOf": [ diff --git a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx index 780ac08397b..49a9ab68165 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx +++ b/public/app/features/dashboard-scene/saving/provisioned/SaveProvisionedDashboard.tsx @@ -111,6 +111,12 @@ export function SaveProvisionedDashboard({ drawer, changeInfo, dashboard }: Prop return (
+ {repositoryConfig?.readOnly && ( + + If you have direct access to the target, copy the JSON and paste it there. + + )} + {isNew && ( <> diff --git a/public/app/features/dashboard-scene/saving/provisioned/hooks.ts b/public/app/features/dashboard-scene/saving/provisioned/hooks.ts index f2f061c8610..23e5bc59227 100644 --- a/public/app/features/dashboard-scene/saving/provisioned/hooks.ts +++ b/public/app/features/dashboard-scene/saving/provisioned/hooks.ts @@ -21,6 +21,8 @@ function generatePath(timestamp: number, pathFromAnnotation?: string, slug?: str } export function useDefaultValues({ meta, defaultTitle, defaultDescription }: UseDefaultValuesParams) { + // ???? why is this getting the repository from the folder, not the file??? + // Should we only get it from the folder when new? const folderRepository = useFolderRepository(meta.folderUid); const timestamp = Date.now(); const annotations = meta.k8s?.annotations; diff --git a/public/app/features/provisioning/ConfigForm.tsx b/public/app/features/provisioning/ConfigForm.tsx index 1dbfe3b379d..ce8dc7d73b5 100644 --- a/public/app/features/provisioning/ConfigForm.tsx +++ b/public/app/features/provisioning/ConfigForm.tsx @@ -13,7 +13,6 @@ import { Switch, TextLink, ControlledCollapse, - FieldSet, RadioButtonGroup, Stack, } from '@grafana/ui'; @@ -46,11 +45,7 @@ function getDefaultValues(repository?: RepositorySpec): RepositoryFormData { enabled: false, target: 'instance', }, - editing: { - create: true, - delete: true, - update: true, - }, + readOnly: false, }; } return specToData(repository); @@ -216,17 +211,9 @@ export function ConfigForm({ data }: ConfigFormProps) { -
- - - - - - - - - -
+ + +