AAE-47579 feat(core): Introduce EDIT_JSON_EDITOR injection token and add copy button to EditJsonDialogComponent (#12046)

This commit is contained in:
Munir Fati Haji
2026-07-30 10:41:15 +02:00
committed by GitHub
parent f0c60b422d
commit 4f32198afd
6 changed files with 341 additions and 21 deletions
@@ -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<string>;
/** Whether the editor is read-only. */
readOnly: InputSignal<boolean>;
}
/**
* 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<string>()` for the JSON content
* - Expose a `readOnly = input<boolean>()` and disable editing when it is `true`
*/
export const EDIT_JSON_EDITOR = new InjectionToken<Type<JsonEditorComponent> | null>('EDIT_JSON_EDITOR', {
providedIn: 'root',
factory: () => null
});
@@ -1,13 +1,21 @@
<h1 mat-dialog-title>{{ title | translate }}</h1>
<mat-dialog-content class="adf-edit-json-dialog-content">
@if (customEditor) {
<ng-container #editorHost />
} @else {
<textarea [(ngModel)]="value" [attr.readonly]="!editable ? true : null"></textarea>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial>
{{ 'CORE.DIALOG.EDIT_JSON.CLOSE' | translate }}
</button>
<button *ngIf="editable" mat-button [mat-dialog-close]="value">
{{ 'CORE.DIALOG.EDIT_JSON.UPDATE' | translate }}
@if (customEditor) {
<button mat-button data-automation-id="adf-edit-json-dialog-copy" (click)="copyToClipboard()">
<mat-icon>content_copy</mat-icon>
{{ 'CORE.DIALOG.EDIT_JSON.COPY' | translate }}
</button>
}
<button mat-button mat-dialog-close cdkFocusInitial>{{ 'CORE.DIALOG.EDIT_JSON.CLOSE' | translate }}</button>
@if (editable) {
<button mat-button [mat-dialog-close]="value()">{{ 'CORE.DIALOG.EDIT_JSON.UPDATE' | translate }}</button>
}
</mat-dialog-actions>
@@ -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: '<span data-automation-id="stub-json-editor">{{ value() }}</span>' })
class StubJsonEditorComponent implements JsonEditorComponent {
readonly value = model('');
readonly readOnly = input(false);
}
describe('EditJsonDialogComponent', () => {
let fixture: ComponentFixture<EditJsonDialogComponent>;
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<EditJsonDialogComponent>;
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<EditJsonDialogComponent>;
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<EditJsonDialogComponent>;
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);
});
});
@@ -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<EditJsonDialogSettings>(MAT_DIALOG_DATA);
export class EditJsonDialogComponent implements AfterViewInit {
private readonly settings = inject<EditJsonDialogSettings | null>(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)] });
}
}
+1
View File
@@ -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';
+3 -1
View File
@@ -171,7 +171,9 @@
},
"EDIT_JSON": {
"CLOSE": "Close",
"UPDATE": "Update"
"UPDATE": "Update",
"COPY": "Copy",
"COPIED": "Copied to clipboard"
},
"UNSAVED_CHANGES": {
"TITLE": "Unsaved changes",