From 4f32198afde3b923ddf7b55ceeed809e7a6d3db5 Mon Sep 17 00:00:00 2001 From: Munir Fati Haji <101482431+munir-fati-haji@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:41:15 +0200 Subject: [PATCH] AAE-47579 feat(core): Introduce EDIT_JSON_EDITOR injection token and add copy button to EditJsonDialogComponent (#12046) --- .../edit-json/edit-json-editor.token.ts | 55 ++++ .../dialogs/edit-json/edit-json.dialog.html | 18 +- .../edit-json/edit-json.dialog.spec.ts | 237 ++++++++++++++++++ .../lib/dialogs/edit-json/edit-json.dialog.ts | 47 ++-- lib/core/src/lib/dialogs/public-api.ts | 1 + lib/core/src/lib/i18n/en.json | 4 +- 6 files changed, 341 insertions(+), 21 deletions(-) create mode 100644 lib/core/src/lib/dialogs/edit-json/edit-json-editor.token.ts create mode 100644 lib/core/src/lib/dialogs/edit-json/edit-json.dialog.spec.ts diff --git a/lib/core/src/lib/dialogs/edit-json/edit-json-editor.token.ts b/lib/core/src/lib/dialogs/edit-json/edit-json-editor.token.ts new file mode 100644 index 0000000000..919e83f96c --- /dev/null +++ b/lib/core/src/lib/dialogs/edit-json/edit-json-editor.token.ts @@ -0,0 +1,55 @@ +/*! + * @license + * Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InjectionToken, InputSignal, ModelSignal, Type } from '@angular/core'; + +/** + * Contract for a pluggable JSON editor component. + * + * The component participates in a two-way binding for its content: the dialog binds + * its own value to the `value` model, so user edits made inside the editor flow back + * out automatically (no manual signal mutation across the component boundary). + */ +export interface JsonEditorComponent { + /** Two-way bound JSON content. */ + value: ModelSignal; + /** Whether the editor is read-only. */ + readOnly: InputSignal; +} + +/** + * InjectionToken for swapping ONLY the editor control inside the JSON dialog. + * + * Default: `null` — the dialog uses the built-in textarea (backward-compatible). + * Override: provide a component type that implements JsonEditorComponent. + * + * Example (Monaco editor): + * ```typescript + * export const provideMonacoJsonEditor = (): EnvironmentProviders => + * makeEnvironmentProviders([ + * { provide: EDIT_JSON_EDITOR, useValue: MonacoJsonEditorComponent } + * ]); + * ``` + * + * The custom editor component must: + * - Expose a two-way `value = model()` for the JSON content + * - Expose a `readOnly = input()` and disable editing when it is `true` + */ +export const EDIT_JSON_EDITOR = new InjectionToken | null>('EDIT_JSON_EDITOR', { + providedIn: 'root', + factory: () => null +}); diff --git a/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.html b/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.html index 7121466836..41c76ec740 100644 --- a/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.html +++ b/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.html @@ -1,13 +1,21 @@

{{ title | translate }}

+ @if (customEditor) { + + } @else { + } - - + } + + @if (editable) { + + } diff --git a/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.spec.ts b/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.spec.ts new file mode 100644 index 0000000000..5c7d7b9d63 --- /dev/null +++ b/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.spec.ts @@ -0,0 +1,237 @@ +/*! + * @license + * Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Component, input, model } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; +import { ClipboardService } from '../../clipboard'; +import { NoopTranslateModule } from '../../testing'; +import { UnitTestingUtils } from '../../testing/unit-testing-utils'; +import { EditJsonDialogComponent } from './edit-json.dialog'; +import { EDIT_JSON_EDITOR, JsonEditorComponent } from './edit-json-editor.token'; + +@Component({ template: '{{ value() }}' }) +class StubJsonEditorComponent implements JsonEditorComponent { + readonly value = model(''); + readonly readOnly = input(false); +} + +describe('EditJsonDialogComponent', () => { + let fixture: ComponentFixture; + let testingUtils: UnitTestingUtils; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopTranslateModule, EditJsonDialogComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: { value: '{"key": "value"}', title: 'Test', editable: false } }, + { provide: MatDialogRef, useValue: {} } + ] + }); + + fixture = TestBed.createComponent(EditJsonDialogComponent); + testingUtils = new UnitTestingUtils(fixture.debugElement); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + describe('copy button', () => { + it('should not be visible when no custom editor is provided', () => { + const copyButton = testingUtils.getByDataAutomationId('adf-edit-json-dialog-copy'); + expect(copyButton).toBeFalsy(); + }); + }); + + describe('editor fallback', () => { + it('should render the textarea when no custom editor is provided', () => { + expect(fixture.nativeElement.querySelector('textarea')).not.toBeNull(); + }); + }); + + describe('editable state', () => { + it('should render the textarea as read-only when editable is false', () => { + const textarea = fixture.nativeElement.querySelector('textarea'); + expect(textarea.getAttribute('readonly')).not.toBeNull(); + }); + }); +}); + +describe('EditJsonDialogComponent — editable', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopTranslateModule, EditJsonDialogComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: { value: '', editable: true } }, + { provide: MatDialogRef, useValue: {} } + ] + }); + + fixture = TestBed.createComponent(EditJsonDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should render the textarea as editable when editable is true', () => { + const textarea = fixture.nativeElement.querySelector('textarea'); + expect(textarea.getAttribute('readonly')).toBeNull(); + }); +}); + +describe('EditJsonDialogComponent — custom editor', () => { + let fixture: ComponentFixture; + + const getEditor = (): StubJsonEditorComponent => fixture.debugElement.query(By.directive(StubJsonEditorComponent)).componentInstance; + + const setup = async (data: { value?: string; editable?: boolean }) => { + TestBed.configureTestingModule({ + imports: [NoopTranslateModule, EditJsonDialogComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: {} }, + { provide: EDIT_JSON_EDITOR, useValue: StubJsonEditorComponent } + ] + }); + + fixture = TestBed.createComponent(EditJsonDialogComponent); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + afterEach(() => { + fixture.destroy(); + }); + + it('should render the provided custom editor instead of the textarea', async () => { + await setup({ value: '{"key": "value"}', editable: true }); + + expect(fixture.nativeElement.querySelector('textarea')).toBeNull(); + expect(fixture.nativeElement.querySelector('[data-automation-id="stub-json-editor"]')).toBeTruthy(); + }); + + it('should pass the initial value into the custom editor', async () => { + await setup({ value: '{"key": "value"}', editable: true }); + + expect(getEditor().value()).toBe('{"key": "value"}'); + }); + + it('should bind readOnly to the negation of the editable flag', async () => { + await setup({ value: '{}', editable: false }); + + expect(getEditor().readOnly()).toBe(true); + }); + + it('should reflect edits from the custom editor back into the dialog value (two-way binding)', async () => { + await setup({ value: '{"key": "value"}', editable: true }); + + getEditor().value.set('{"updated": true}'); + fixture.detectChanges(); + + expect(fixture.componentInstance.value()).toBe('{"updated": true}'); + }); +}); + +describe('EditJsonDialogComponent — copy button with custom editor', () => { + let fixture: ComponentFixture; + let clipboardService: ClipboardService; + let translateService: TranslateService; + let testingUtils: UnitTestingUtils; + + const setup = (data: { value?: string; editable?: boolean }) => { + TestBed.configureTestingModule({ + imports: [NoopTranslateModule, EditJsonDialogComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: {} }, + { provide: EDIT_JSON_EDITOR, useValue: StubJsonEditorComponent } + ] + }); + + clipboardService = TestBed.inject(ClipboardService); + translateService = TestBed.inject(TranslateService); + + fixture = TestBed.createComponent(EditJsonDialogComponent); + testingUtils = new UnitTestingUtils(fixture.debugElement); + fixture.detectChanges(); + }; + + afterEach(() => { + fixture.destroy(); + }); + + it('should be visible', () => { + setup({ value: '{"key": "value"}', editable: false }); + + const copyButton = testingUtils.getByDataAutomationId('adf-edit-json-dialog-copy'); + expect(copyButton).toBeTruthy(); + }); + + it('should copy the dialog value to clipboard when clicked', () => { + setup({ value: '{"key": "value"}', editable: false }); + spyOn(clipboardService, 'copyContentToClipboard'); + + testingUtils.clickByDataAutomationId('adf-edit-json-dialog-copy'); + + expect(clipboardService.copyContentToClipboard).toHaveBeenCalledWith('{"key": "value"}', jasmine.any(String)); + expect(clipboardService.copyContentToClipboard).toHaveBeenCalledTimes(1); + }); + + it('should show a confirmation notification when clicked', () => { + setup({ value: '{"key": "value"}', editable: false }); + const translatedMessage = 'Copied to clipboard'; + spyOn(translateService, 'instant').and.returnValue(translatedMessage); + spyOn(clipboardService, 'copyContentToClipboard'); + + testingUtils.clickByDataAutomationId('adf-edit-json-dialog-copy'); + + expect(clipboardService.copyContentToClipboard).toHaveBeenCalledWith('{"key": "value"}', translatedMessage); + expect(clipboardService.copyContentToClipboard).toHaveBeenCalledTimes(1); + }); + + it('should copy the updated value when the dialog value changes', () => { + setup({ value: '{"key": "value"}', editable: false }); + const updatedValue = '{"updated": true}'; + spyOn(clipboardService, 'copyContentToClipboard'); + fixture.componentInstance.value.set(updatedValue); + fixture.detectChanges(); + + testingUtils.clickByDataAutomationId('adf-edit-json-dialog-copy'); + + expect(clipboardService.copyContentToClipboard).toHaveBeenCalledWith(updatedValue, jasmine.any(String)); + expect(clipboardService.copyContentToClipboard).toHaveBeenCalledTimes(1); + }); + + it('should copy an empty value when the dialog has no content', () => { + setup({ value: '', editable: false }); + spyOn(clipboardService, 'copyContentToClipboard'); + + testingUtils.clickByDataAutomationId('adf-edit-json-dialog-copy'); + + expect(clipboardService.copyContentToClipboard).toHaveBeenCalledWith('', jasmine.any(String)); + expect(clipboardService.copyContentToClipboard).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.ts b/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.ts index b6790669ba..b7e458d632 100644 --- a/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.ts +++ b/lib/core/src/lib/dialogs/edit-json/edit-json.dialog.ts @@ -15,11 +15,14 @@ * limitations under the License. */ -import { Component, OnInit, Input, ViewEncapsulation, inject } from '@angular/core'; +import { AfterViewInit, Component, ViewEncapsulation, inject, model, viewChild, ViewContainerRef, inputBinding, twoWayBinding } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MatIconModule } from '@angular/material/icon'; +import { ClipboardService } from '../../clipboard'; +import { EDIT_JSON_EDITOR } from './edit-json-editor.token'; export interface EditJsonDialogSettings { title?: string; @@ -29,26 +32,40 @@ export interface EditJsonDialogSettings { @Component({ standalone: true, - imports: [MatDialogModule, FormsModule, MatButtonModule, TranslatePipe], + imports: [MatDialogModule, FormsModule, MatButtonModule, MatIconModule, TranslatePipe], templateUrl: './edit-json.dialog.html', styleUrls: ['./edit-json.dialog.scss'], encapsulation: ViewEncapsulation.None, host: { class: 'adf-edit-json-dialog' } }) -export class EditJsonDialogComponent implements OnInit { - private readonly settings = inject(MAT_DIALOG_DATA); +export class EditJsonDialogComponent implements AfterViewInit { + private readonly settings = inject(MAT_DIALOG_DATA); + private readonly clipboardService = inject(ClipboardService); + private readonly translateService = inject(TranslateService); + private readonly editorHost = viewChild('editorHost', { read: ViewContainerRef }); - editable: boolean = false; - title: string = 'JSON'; + protected readonly customEditor = inject(EDIT_JSON_EDITOR); + protected title = this.settings?.title ?? 'JSON'; - @Input() - value: string = ''; + editable = this.settings?.editable ?? false; + readonly value = model(this.settings?.value ?? ''); - ngOnInit() { - if (this.settings) { - this.editable = this.settings.editable; - this.value = this.settings.value || ''; - this.title = this.settings.title || 'JSON'; - } + ngAfterViewInit(): void { + this.renderCustomEditor(); + } + + protected copyToClipboard(): void { + const key = 'CORE.DIALOG.EDIT_JSON.COPIED'; + const message = this.translateService.instant(key); + this.clipboardService.copyContentToClipboard(this.value(), message); + } + + private renderCustomEditor(): void { + const host = this.editorHost(); + + if (!this.customEditor || !host) return; + + host.clear(); + host.createComponent(this.customEditor, { bindings: [twoWayBinding('value', this.value), inputBinding('readOnly', () => !this.editable)] }); } } diff --git a/lib/core/src/lib/dialogs/public-api.ts b/lib/core/src/lib/dialogs/public-api.ts index 56018a53ce..1c5aa170a7 100755 --- a/lib/core/src/lib/dialogs/public-api.ts +++ b/lib/core/src/lib/dialogs/public-api.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +export * from './edit-json/edit-json-editor.token'; export * from './edit-json/edit-json.dialog'; export * from './unsaved-changes-dialog/unsaved-changes-dialog.component'; diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index 1d497c1064..db470df2ab 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -171,7 +171,9 @@ }, "EDIT_JSON": { "CLOSE": "Close", - "UPDATE": "Update" + "UPDATE": "Update", + "COPY": "Copy", + "COPIED": "Copied to clipboard" }, "UNSAVED_CHANGES": { "TITLE": "Unsaved changes",