diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.html index 386966e9ac39..c7e6c5fb8942 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.html +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.html @@ -1,5 +1,11 @@ @if (myFormGroup) { -
+ @if (field.warnings && field.warnings.length) { {{ field.label }}
- {{ field.hint }} + {{ field.hint }}
} @else {
@@ -46,7 +52,7 @@

{{ field.label }}

*ngTemplateOutlet="warningIcon; context: { field: field }" />
- {{ field.hint }} + {{ field.hint }} } @case ('STRING') { @@ -62,9 +68,33 @@

{{ field.label }}

pTextarea [autoResize]="true"> - {{ field.hint }} + {{ field.hint }} } + @case ('JSON') { + + + @if ($isUveApp()) { + + + } @else { + + + {{ field.hint }} + + } + } @case ('GENERATED_STRING') { @@ -86,7 +116,7 @@

{{ field.label }}

- {{ field.hint }} + {{ field.hint }} } @case ('SELECT') { @@ -100,7 +130,7 @@

{{ field.label }}

[class.required]="field.required" [options]="field.options" /> - {{ field.hint }} + {{ field.hint }} } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.scss b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.scss index 3e84a7c67f93..336f02ec43a9 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.scss +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.scss @@ -5,22 +5,109 @@ @use "variables" as *; :host { - form { - max-width: 64.25rem; - } - textarea { font-family: fonts.$font-code; max-height: 21.5rem; overflow: auto !important; } +} - ::ng-deep { - .p-field-hint { - markdown pre { - background-color: colors.$color-palette-secondary-100; - } - } +// Rendered markdown hints. Selectors stay fully qualified (not nested) to keep at most three +// levels while preserving specificity, so the code-block rules still override the plain ones. +:host ::ng-deep { + .p-field-hint markdown { + display: block; + line-height: 1.5; + } + + .p-field-hint markdown p, + .p-field-hint markdown ul { + margin: 0 0 spacing.$spacing-2; + } + + .p-field-hint markdown ul { + padding-left: spacing.$spacing-4; + list-style: disc; + } + + .p-field-hint markdown code { + font-family: fonts.$font-code; + font-size: fonts.$font-size-sm; + background-color: colors.$color-palette-gray-100; + border-radius: $border-radius-sm; + padding: 0 spacing.$spacing-0; + } + + .p-field-hint markdown pre { + background-color: colors.$color-palette-gray-100; + border: 1px solid colors.$color-palette-gray-300; + border-radius: $border-radius-md; + margin: spacing.$spacing-2 0 0; + max-height: 21.5rem; // same cap as the textarea above + overflow: auto; + padding: spacing.$spacing-3; + } + + .p-field-hint markdown pre code { + background: none; + color: colors.$color-palette-gray-800; + font-size: fonts.$font-size-sm; + line-height: 1.6; + padding: 0; + white-space: pre; + } + + // Code blocks upgraded by DotAppsCodeBlocksDirective: white panel with a purple header, + // using the primary palette so it follows the instance theme color. + .p-field-hint markdown .dot-code-block { + background-color: colors.$white; + border: 1px solid colors.$color-palette-primary-200; + border-radius: $border-radius-md; + margin-top: spacing.$spacing-2; + overflow: hidden; + } + + .p-field-hint markdown .dot-code-block pre { + background: none; + border: 0; + border-radius: 0; + margin: 0; + } + + .p-field-hint markdown .dot-code-block pre code { + color: colors.$color-palette-gray-700; + text-shadow: none; + } + + .dot-code-block__header { + align-items: center; + background-color: colors.$color-palette-primary-100; + border-bottom: 1px solid colors.$color-palette-primary-200; + display: flex; + justify-content: space-between; + padding: spacing.$spacing-1 spacing.$spacing-1 spacing.$spacing-1 spacing.$spacing-3; + } + + .dot-code-block__language { + color: colors.$color-palette-primary-700; + font-family: fonts.$font-code; + font-size: fonts.$font-size-sm; + font-weight: fonts.$font-weight-semi-bold; + text-transform: uppercase; + } + + .dot-code-block__key { + color: colors.$color-palette-primary-700; + font-weight: fonts.$font-weight-semi-bold; + } + + .dot-code-block__string { + color: colors.$color-palette-primary-500; + } + + .dot-code-block__number, + .dot-code-block__literal { + color: colors.$color-palette-secondary-600; } } diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.spec.ts index 0b1c58045d92..f6a0af16ace4 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.spec.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.spec.ts @@ -18,6 +18,7 @@ import { DotFieldRequiredDirective } from '@dotcms/ui'; import { DotAppsConfigurationDetailFormComponent } from './dot-apps-configuration-detail-form.component'; import { DotAppsConfigurationDetailGeneratedStringFieldComponent } from '../dot-apps-configuration-detail-generated-string-field/dot-apps-configuration-detail-generated-string-field.component'; +import { DotAppsConfigurationDetailJsonFieldComponent } from '../dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component'; const headingSecret = { dynamic: false, @@ -166,10 +167,77 @@ describe('DotAppsConfigurationDetailFormComponent', () => { MockComponent(DotAppsConfigurationDetailGeneratedStringFieldComponent), MockComponent(MarkdownComponent) ], + overrideComponents: [ + [ + DotAppsConfigurationDetailFormComponent, + { + remove: { imports: [DotAppsConfigurationDetailJsonFieldComponent] }, + add: { imports: [MockComponent(DotAppsConfigurationDetailJsonFieldComponent)] } + } + ] + ], providers: [FormGroupDirective], declarations: [] }); + describe('Form layout', () => { + it('should not center the form by default', () => { + spectator = createComponent({ props: { formFields: secrets } as never }); + spectator.detectChanges(); + + expect(spectator.query('form')).not.toHaveClass('mx-auto'); + expect(spectator.query('form')).toHaveClass('max-w-content'); + }); + + it('should center the form for apps in the centered list', () => { + spectator = createComponent({ + props: { formFields: secrets, appKey: 'dotema-config-v2' } as never + }); + spectator.detectChanges(); + + expect(spectator.query('form')).toHaveClass('mx-auto'); + }); + }); + + describe('With a JSON field', () => { + const jsonSecret = { + dynamic: false, + name: 'configuration', + hidden: false, + hint: 'UVE configuration', + label: 'Configuration', + required: true, + type: 'JSON', + value: '{"config":[]}', + hasEnvVar: false, + envShow: true, + hasEnvVarValue: false + }; + + beforeEach(() => { + spectator = createComponent({ + props: { + formFields: [jsonSecret] + } as never + }); + spectator.detectChanges(); + }); + + it('should render the JSON editor field with its label and hint', () => { + const row = spectator.query(byTestId('configuration')); + + expect(row?.querySelector('dot-apps-configuration-detail-json-field')).toBeTruthy(); + expect(row?.querySelector('label')?.textContent?.trim()).toBe(jsonSecret.label); + expect(row?.querySelector('.p-field-hint')?.textContent).toBe(jsonSecret.hint); + }); + + it('should load the saved JSON into the form control', () => { + expect(spectator.component.myFormGroup.get('configuration')?.value).toBe( + jsonSecret.value + ); + }); + }); + describe('Without warnings', () => { beforeEach(() => { spectator = createComponent({ diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.ts index 1f1f6d00cb4f..448b7ee47634 100644 --- a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.ts +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-form/dot-apps-configuration-detail-form.component.ts @@ -4,6 +4,7 @@ import { Subscription } from 'rxjs'; import { NgTemplateOutlet } from '@angular/common'; import { Component, + computed, effect, ElementRef, inject, @@ -33,7 +34,14 @@ import { DotMessageService } from '@dotcms/data-access'; import { DotAppsSecret } from '@dotcms/dotcms-models'; import { DotFieldRequiredDirective, DotIconComponent } from '@dotcms/ui'; +import { DotAppsCodeBlocksDirective } from '../../directives/dot-apps-code-block.directive'; import { DotAppsConfigurationDetailGeneratedStringFieldComponent } from '../dot-apps-configuration-detail-generated-string-field/dot-apps-configuration-detail-generated-string-field.component'; +import { DotAppsConfigurationDetailJsonFieldComponent } from '../dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component'; +import { DotAppsUveConfigFieldComponent } from '../dot-apps-uve-config-field/dot-apps-uve-config-field.component'; +import { UVE_APP_KEY } from '../dot-apps-uve-config-field/dot-apps-uve-config.utils'; + +/** Apps whose configuration form is centered on the page; add keys as more apps are redesigned. */ +const CENTERED_FORM_APP_KEYS: readonly string[] = [UVE_APP_KEY]; enum FieldStatus { EDITABLE, @@ -56,7 +64,10 @@ enum FieldStatus { DotIconComponent, DotFieldRequiredDirective, MarkdownComponent, + DotAppsCodeBlocksDirective, DotAppsConfigurationDetailGeneratedStringFieldComponent, + DotAppsConfigurationDetailJsonFieldComponent, + DotAppsUveConfigFieldComponent, NgTemplateOutlet ], changeDetection: ChangeDetectionStrategy.Eager, @@ -69,6 +80,14 @@ export class DotAppsConfigurationDetailFormComponent implements OnInit, OnDestro $formFields = input([], { alias: 'formFields' }); $appConfigured = input(false, { alias: 'appConfigured' }); + /** Key of the app being configured; the UVE app gets a rule editor for its JSON param. */ + $appKey = input('', { alias: 'appKey' }); + + /** True on the UVE app, whose JSON param gets the route editor. */ + readonly $isUveApp = computed(() => this.$appKey() === UVE_APP_KEY); + + /** True for apps that use the centered form layout. */ + readonly $shouldCenterForm = computed(() => CENTERED_FORM_APP_KEYS.includes(this.$appKey())); readonly data = output<{ [key: string]: string }>(); readonly valid = output(); @@ -126,6 +145,9 @@ export class DotAppsConfigurationDetailFormComponent implements OnInit, OnDestro } private getFieldValueFn = { + JSON: (field: DotAppsSecret, status: FieldStatus) => { + return this.getFieldValueFn.STRING(field, status); + }, BOOL: (field: DotAppsSecret) => { return field.value ? JSON.parse(field.value) : field.value; }, diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component.html b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component.html new file mode 100644 index 000000000000..1c57cc77ed95 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component.html @@ -0,0 +1,39 @@ +@let parseError = $parseError(); + +
+
+ +
+ +
+ @if (parseError) { + + {{ 'apps.json.field.invalid' | dm: [parseError] }} + + } @else { + + {{ 'apps.json.field.valid' | dm }} + + } + + +
+
diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component.spec.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component.spec.ts new file mode 100644 index 000000000000..0b5b6543ecb8 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component.spec.ts @@ -0,0 +1,253 @@ +import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; +import { + byTestId, + createComponentFactory, + createHostFactory, + mockProvider, + Spectator, + SpectatorHost +} from '@openng/spectator/vitest'; + +import { Component, input, output } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; + +import { DotMessageService } from '@dotcms/data-access'; + +import { + DotAppsConfigurationDetailJsonFieldComponent, + getJsonParseError +} from './dot-apps-configuration-detail-json-field.component'; + +/** Stands in for `ngx-monaco-editor`, which needs a global `monaco` that tests don't load. */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'ngx-monaco-editor', + template: '' +}) +class MonacoEditorStubComponent { + options = input>(); + init = output(); +} + +/** Minimal editor: setValue fires the content listener, like Monaco does. */ +class FakeEditor { + value = ''; + #listener: () => void = () => { + // + }; + getValue = () => this.value; + setValue = (value: string) => { + this.value = value; + this.#listener(); + }; + onDidChangeModelContent = (listener: () => void) => { + this.#listener = listener; + + return { + dispose: () => { + // + } + }; + }; + /** Simulates the user typing. */ + type(value: string) { + this.setValue(value); + } +} + +const VALID_JSON = '{"config":[{"pattern":".*","url":"https://myspa.com:3000"}]}'; +const INVALID_JSON = '{"config": ['; + +describe('DotAppsConfigurationDetailJsonFieldComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotAppsConfigurationDetailJsonFieldComponent, + overrideComponents: [ + [ + DotAppsConfigurationDetailJsonFieldComponent, + { + remove: { imports: [MonacoEditorModule] }, + add: { imports: [MonacoEditorStubComponent] } + } + ] + ], + providers: [ + mockProvider(DotMessageService, { + get: (key: string, ...args: string[]) => [key, ...args].join(' ') + }) + ], + detectChanges: false + }); + + beforeEach(() => { + spectator = createComponent({ props: { fieldId: 'configuration' } as never }); + }); + + describe('getJsonParseError', () => { + it('should return null for valid JSON', () => { + expect(getJsonParseError(VALID_JSON)).toBeNull(); + }); + + it('should return null for empty values', () => { + expect(getJsonParseError('')).toBeNull(); + expect(getJsonParseError(' ')).toBeNull(); + expect(getJsonParseError(null)).toBeNull(); + }); + + it('should return the parse error for invalid JSON', () => { + expect(getJsonParseError(INVALID_JSON)).toEqual(expect.any(String)); + }); + }); + + describe('validate', () => { + it('should be valid for valid JSON', () => { + expect(spectator.component.validate(new FormControl(VALID_JSON))).toBeNull(); + }); + + it('should flag invalid JSON', () => { + expect(spectator.component.validate(new FormControl(INVALID_JSON))).toEqual({ + invalidJson: { message: expect.any(String) } + }); + }); + }); + + it('should give the wrapper the field id so the label points at it', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('json-field-editor-wrapper'))?.id).toBe('configuration'); + }); + + it('should show the valid message for valid JSON', () => { + spectator.component.writeValue(VALID_JSON); + spectator.detectChanges(); + + expect(spectator.query(byTestId('json-field-valid'))).toBeTruthy(); + expect(spectator.query(byTestId('json-field-error'))).toBeFalsy(); + }); + + it('should show the parse error for invalid JSON', () => { + spectator.component.writeValue(INVALID_JSON); + spectator.detectChanges(); + + const error = spectator.query(byTestId('json-field-error')); + expect(error?.textContent).toContain('apps.json.field.invalid'); + }); + + it('should format valid JSON and notify the form', () => { + const onChange = vi.fn(); + spectator.component.registerOnChange(onChange); + spectator.component.writeValue(VALID_JSON); + spectator.detectChanges(); + + spectator.click(byTestId('json-field-format')); + + const formatted = JSON.stringify(JSON.parse(VALID_JSON), null, 4); + expect(spectator.component.$value()).toBe(formatted); + expect(onChange).toHaveBeenCalledWith(formatted); + }); + + it('should disable the format button for invalid JSON', () => { + spectator.component.writeValue(INVALID_JSON); + spectator.detectChanges(); + + expect(spectator.query(byTestId('json-field-format'))?.disabled).toBe( + true + ); + }); + + it('should make the editor read-only when the control is disabled', () => { + spectator.component.setDisabledState(true); + + expect(spectator.component.$editorOptions().readOnly).toBe(true); + }); + describe('editor sync', () => { + const initEditor = (editor: FakeEditor) => + spectator.query(MonacoEditorStubComponent)?.init.emit(editor); + + it('should load the current value into a newly created editor without echoing it', () => { + const onChange = vi.fn(); + spectator.component.registerOnChange(onChange); + spectator.component.writeValue(VALID_JSON); + spectator.detectChanges(); + + const editor = new FakeEditor(); + initEditor(editor); + + expect(editor.value).toBe(VALID_JSON); + expect(onChange).not.toHaveBeenCalled(); + expect(spectator.component.$value()).toBe(VALID_JSON); + }); + + it('should push user edits to the form', () => { + const onChange = vi.fn(); + spectator.component.registerOnChange(onChange); + spectator.detectChanges(); + const editor = new FakeEditor(); + initEditor(editor); + + editor.type(VALID_JSON); + + expect(onChange).toHaveBeenCalledWith(VALID_JSON); + }); + + it('should write values set later by the form into the editor', () => { + spectator.detectChanges(); + const editor = new FakeEditor(); + initEditor(editor); + + spectator.component.writeValue(VALID_JSON); + spectator.detectChanges(); + + expect(editor.value).toBe(VALID_JSON); + }); + }); +}); + +describe('DotAppsConfigurationDetailJsonFieldComponent in a reactive form', () => { + let spectator: SpectatorHost< + DotAppsConfigurationDetailJsonFieldComponent, + { control: FormControl } + >; + let editor: FakeEditor; + + const createHost = createHostFactory({ + component: DotAppsConfigurationDetailJsonFieldComponent, + imports: [ReactiveFormsModule], + overrideComponents: [ + [ + DotAppsConfigurationDetailJsonFieldComponent, + { + remove: { imports: [MonacoEditorModule] }, + add: { imports: [MonacoEditorStubComponent] } + } + ] + ], + providers: [mockProvider(DotMessageService, { get: (key: string) => key })] + }); + + beforeEach(() => { + spectator = createHost( + '', + { hostProps: { control: new FormControl(VALID_JSON, { nonNullable: true }) } } + ); + editor = new FakeEditor(); + spectator.query(MonacoEditorStubComponent)?.init.emit(editor); + }); + + it('should load the form control value into the editor', () => { + expect(editor.value).toBe(VALID_JSON); + expect(spectator.hostComponent.control.valid).toBe(true); + }); + + it('should mark the form control invalid while the JSON is broken, and valid again once fixed', () => { + editor.type(INVALID_JSON); + expect(spectator.hostComponent.control.value).toBe(INVALID_JSON); + expect(spectator.hostComponent.control.errors).toEqual({ + invalidJson: { message: expect.any(String) } + }); + + editor.type(VALID_JSON); + expect(spectator.hostComponent.control.errors).toBeNull(); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component.ts b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component.ts new file mode 100644 index 000000000000..d73d0f3d5692 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/portlets/dot-apps/components/dot-apps-configuration-detail/components/dot-apps-configuration-detail-json-field/dot-apps-configuration-detail-json-field.component.ts @@ -0,0 +1,196 @@ +import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + forwardRef, + inject, + input, + signal +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator +} from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; + +import { DOT_MONACO_BASE_OPTIONS, DotMessagePipe } from '@dotcms/ui'; + +/** The part of the Monaco editor instance this field uses. */ +interface JsonEditor { + getValue(): string; + setValue(value: string): void; + onDidChangeModelContent(listener: () => void): { dispose(): void }; +} + +/** Spaces used to indent the JSON when the user formats it. */ +const JSON_INDENT = 4; + +/** + * Returns the parse error message for a JSON string, or `null` when it is valid. + * Empty values are considered valid here: the `required` validator owns that case. + * + * @param value the raw text typed by the user + */ +export function getJsonParseError(value: string | null | undefined): string | null { + if (!value || !value.trim()) { + return null; + } + + try { + JSON.parse(value); + + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +/** + * Form control for App params of type `JSON`. It renders a Monaco editor in JSON mode, + * so the user gets syntax highlighting, bracket matching and inline error markers, + * and it marks the control invalid while the text can't be parsed as JSON. + */ +@Component({ + selector: 'dot-apps-configuration-detail-json-field', + imports: [MonacoEditorModule, ButtonModule, DotMessagePipe], + templateUrl: './dot-apps-configuration-detail-json-field.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DotAppsConfigurationDetailJsonFieldComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DotAppsConfigurationDetailJsonFieldComponent), + multi: true + } + ] +}) +export class DotAppsConfigurationDetailJsonFieldComponent + implements ControlValueAccessor, Validator +{ + /** Id given to the editor wrapper, so the field label can point at it. */ + $fieldId = input.required({ alias: 'fieldId' }); + + /** Accessible name of the editor; a `