From f57783bdfffb6e006efbfa0457fe81f5d9e37ff7 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Mon, 15 Jun 2026 10:54:49 -0400 Subject: [PATCH 01/26] feat(edit-content): integrate legacy image editor with binary field wrapper - Added a new `DotLegacyImageEditorLauncherService` to manage the image editor dialog lifecycle. - Introduced `DotLegacyImageEditorDialogComponent` for rendering the image editor iframe. - Updated `DotBinaryFieldWrapperComponent` to utilize the image editor service and handle value updates. - Enhanced the binary field HTML template to include an image editor toggle. - Added unit tests for the new image editor dialog and launcher service. This implementation allows users to edit images directly within the binary field, improving the content editing experience. --- .../dot-binary-field-wrapper.component.html | 1 + .../dot-binary-field-wrapper.component.ts | 27 ++- ...gacy-image-editor-dialog.component.spec.ts | 37 ++++ ...ot-legacy-image-editor-dialog.component.ts | 65 +++++++ ...gacy-image-editor-launcher.service.spec.ts | 158 ++++++++++++++++++ ...ot-legacy-image-editor-launcher.service.ts | 150 +++++++++++++++++ core-web/yarn.lock | 23 +-- .../dijit/image/image-editor-standalone.jsp | 86 ++++++++++ 8 files changed, 531 insertions(+), 16 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts create mode 100644 dotCMS/src/main/webapp/html/js/dotcms/dijit/image/image-editor-standalone.jsp diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.html index 40e6338104b2..658276264aea 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.html @@ -13,6 +13,7 @@ [formControlName]="field.variable" [contentlet]="$contentlet()" [field]="field" + [imageEditor]="true" (valueUpdated)="valueUpdated.emit($event)" [attr.data-testId]="'field-' + field.variable" /> diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts index 7556778e6062..7c6be318f48a 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts @@ -1,6 +1,16 @@ -import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + input, + OnInit, + output +} from '@angular/core'; import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; +import { DialogService } from 'primeng/dynamicdialog'; + import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; @@ -10,6 +20,7 @@ import { DotCardFieldLabelComponent } from '../../../dot-card-field/components/d import { DotCardFieldComponent } from '../../../dot-card-field/dot-card-field.component'; import { BaseWrapperField } from '../../../shared/base-wrapper-field'; import { DotEditContentBinaryFieldComponent } from '../../dot-edit-content-binary-field.component'; +import { DotLegacyImageEditorLauncherService } from '../../service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service'; /** * JSON field editor component that uses Monaco Editor for JSON content editing. @@ -27,6 +38,7 @@ import { DotEditContentBinaryFieldComponent } from '../../dot-edit-content-binar DotMessagePipe, DotEditContentBinaryFieldComponent ], + providers: [DialogService, DotLegacyImageEditorLauncherService], templateUrl: './dot-binary-field-wrapper.component.html', changeDetection: ChangeDetectionStrategy.OnPush, viewProviders: [ @@ -36,7 +48,10 @@ import { DotEditContentBinaryFieldComponent } from '../../dot-edit-content-binar } ] }) -export class DotBinaryFieldWrapperComponent extends BaseWrapperField { +export class DotBinaryFieldWrapperComponent extends BaseWrapperField implements OnInit { + readonly #legacyImageEditorLauncher = inject(DotLegacyImageEditorLauncherService); + readonly #destroyRef = inject(DestroyRef); + /** * A signal that holds the field. * It is used to display the field in the binary field wrapper component. @@ -56,4 +71,12 @@ export class DotBinaryFieldWrapperComponent extends BaseWrapperField { * It is used to display the value in the binary field wrapper component. */ valueUpdated = output<{ value: string; fileName: string }>(); + + ngOnInit(): void { + this.#legacyImageEditorLauncher.listen(this.$field().variable); + + this.#destroyRef.onDestroy(() => { + this.#legacyImageEditorLauncher.stopListening(); + }); + } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.spec.ts new file mode 100644 index 000000000000..5c5727f38ce8 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.spec.ts @@ -0,0 +1,37 @@ +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; + +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; + +import { DotLegacyImageEditorDialogComponent } from './dot-legacy-image-editor-dialog.component'; + +describe('DotLegacyImageEditorDialogComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotLegacyImageEditorDialogComponent, + providers: [ + { + provide: DynamicDialogConfig, + useValue: { + data: { + inode: 'inode-1', + tempId: 'temp-1', + variable: 'binaryField' + } + } + } + ] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should build iframe src with inode, tempId, and variable params', () => { + const iframe = spectator.query('[data-testid="legacy-image-editor-iframe"]'); + + expect(iframe?.getAttribute('src')).toBe( + '/html/js/dotcms/dijit/image/image-editor-standalone.jsp?inode=inode-1&tempId=temp-1&fieldName=binaryField&variable=binaryField' + ); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.ts new file mode 100644 index 000000000000..a0b4087f58e4 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; + +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; + +export interface DotLegacyImageEditorDialogData { + inode?: string; + tempId?: string; + variable: string; +} + +const IMAGE_EDITOR_STANDALONE_JSP = '/html/js/dotcms/dijit/image/image-editor-standalone.jsp'; + +@Component({ + selector: 'dot-legacy-image-editor-dialog', + template: ` + + `, + styles: [ + ` + :host { + display: block; + height: 100%; + width: 100%; + } + + .legacy-image-editor__iframe { + border: none; + display: block; + height: 100%; + width: 100%; + } + ` + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotLegacyImageEditorDialogComponent { + readonly #dialogConfig = inject(DynamicDialogConfig); + readonly #sanitizer = inject(DomSanitizer); + + readonly $iframeSrc = computed(() => { + const { inode, tempId, variable } = this.#dialogConfig.data; + const params = new URLSearchParams(); + + if (inode) { + params.set('inode', inode); + } + + if (tempId) { + params.set('tempId', tempId); + } + + params.set('fieldName', variable); + params.set('variable', variable); + + const url = `${IMAGE_EDITOR_STANDALONE_JSP}?${params.toString()}`; + + return this.#sanitizer.bypassSecurityTrustResourceUrl(url); + }); +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts new file mode 100644 index 000000000000..3a0627a4a62f --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts @@ -0,0 +1,158 @@ +import { expect } from '@jest/globals'; +import { createServiceFactory, SpectatorService, SpyObject } from '@ngneat/spectator/jest'; + +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { DotLegacyImageEditorDialogComponent } from './dot-legacy-image-editor-dialog.component'; +import { DotLegacyImageEditorLauncherService } from './dot-legacy-image-editor-launcher.service'; + +describe('DotLegacyImageEditorLauncherService', () => { + let spectator: SpectatorService; + let dialogService: SpyObject; + let dialogRef: DynamicDialogRef; + + const createService = createServiceFactory({ + service: DotLegacyImageEditorLauncherService, + mocks: [DialogService] + }); + + const variable = 'binaryField'; + const openEventName = `binaryField-open-image-editor-${variable}`; + const tempEventName = `binaryField-tempfile-${variable}`; + const closeEventName = `binaryField-close-image-editor-${variable}`; + + beforeEach(() => { + dialogRef = { close: jest.fn() } as unknown as DynamicDialogRef; + spectator = createService(); + dialogService = spectator.inject(DialogService); + dialogService.open.mockReturnValue(dialogRef); + }); + + afterEach(() => { + spectator.service.stopListening(); + }); + + it('should open dialog when open-image-editor event is dispatched', () => { + spectator.service.listen(variable); + + document.dispatchEvent( + new CustomEvent(openEventName, { + detail: { + inode: 'inode-1', + tempId: 'temp-1', + variable + } + }) + ); + + expect(dialogService.open).toHaveBeenCalledWith( + DotLegacyImageEditorDialogComponent, + expect.objectContaining({ + appendTo: 'body', + modal: true, + data: { + inode: 'inode-1', + tempId: 'temp-1', + variable + } + }) + ); + }); + + it('should re-dispatch tempfile event when postMessage tempfile is received', () => { + const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); + const tempFile = { id: 'temp-123' } as DotCMSTempFile; + + spectator.service.listen(variable); + document.dispatchEvent( + new CustomEvent(openEventName, { + detail: { inode: 'inode-1', tempId: 'temp-1', variable } + }) + ); + + window.dispatchEvent( + new MessageEvent('message', { + origin: window.location.origin, + data: { + source: 'dot-image-editor', + type: 'tempfile', + tempFile + } + }) + ); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: tempEventName, + detail: { tempFile } + }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('should re-dispatch close event when postMessage close is received', () => { + const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); + + spectator.service.listen(variable); + document.dispatchEvent( + new CustomEvent(openEventName, { + detail: { inode: 'inode-1', tempId: 'temp-1', variable } + }) + ); + + window.dispatchEvent( + new MessageEvent('message', { + origin: window.location.origin, + data: { + source: 'dot-image-editor', + type: 'close' + } + }) + ); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: closeEventName + }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('should ignore postMessage from a different origin', () => { + const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); + + spectator.service.listen(variable); + + window.dispatchEvent( + new MessageEvent('message', { + origin: 'https://evil.example', + data: { + source: 'dot-image-editor', + type: 'tempfile', + tempFile: { id: 'temp-123' } + } + }) + ); + + const tempfileDispatches = dispatchSpy.mock.calls.filter( + ([event]) => (event as Event).type === tempEventName + ); + + expect(tempfileDispatches).toHaveLength(0); + }); + + it('should not open dialog after stopListening', () => { + spectator.service.listen(variable); + spectator.service.stopListening(); + + document.dispatchEvent( + new CustomEvent(openEventName, { + detail: { inode: 'inode-1', tempId: 'temp-1', variable } + }) + ); + + expect(dialogService.open).not.toHaveBeenCalled(); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts new file mode 100644 index 000000000000..c5c2c3bf9147 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts @@ -0,0 +1,150 @@ +import { Injectable, inject, OnDestroy } from '@angular/core'; + +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { + DotLegacyImageEditorDialogComponent, + DotLegacyImageEditorDialogData +} from './dot-legacy-image-editor-dialog.component'; + +const POST_MESSAGE_SOURCE = 'dot-image-editor'; + +interface DotImageEditorPostMessage { + source: typeof POST_MESSAGE_SOURCE; + type: 'tempfile' | 'close'; + tempFile?: DotCMSTempFile; +} + +interface ImageEditorOpenDetail { + inode?: string; + tempId?: string; + variable: string; +} + +@Injectable() +export class DotLegacyImageEditorLauncherService implements OnDestroy { + readonly #dialogService = inject(DialogService); + + #dialogRef: DynamicDialogRef | null = null; + #openEventHandler: ((event: Event) => void) | null = null; + #messageHandler: ((event: MessageEvent) => void) | null = null; + #variable: string | null = null; + + listen(variable: string): void { + this.stopListening(); + + this.#variable = variable; + const openEventName = `binaryField-open-image-editor-${variable}`; + + this.#openEventHandler = (event: Event) => { + const { + inode, + tempId, + variable: fieldVariable + } = (event as CustomEvent).detail; + + this.openDialog({ inode, tempId, variable: fieldVariable }); + }; + + document.addEventListener(openEventName, this.#openEventHandler); + this.#registerMessageListener(); + } + + stopListening(): void { + if (this.#variable && this.#openEventHandler) { + document.removeEventListener( + `binaryField-open-image-editor-${this.#variable}`, + this.#openEventHandler + ); + } + + this.#openEventHandler = null; + this.#variable = null; + this.#unregisterMessageListener(); + this.#closeDialog(); + } + + ngOnDestroy(): void { + this.stopListening(); + } + + private openDialog({ inode, tempId, variable }: DotLegacyImageEditorDialogData): void { + this.#closeDialog(); + + this.#dialogRef = this.#dialogService.open(DotLegacyImageEditorDialogComponent, { + appendTo: 'body', + closable: false, + closeOnEscape: false, + draggable: false, + keepInViewport: false, + modal: true, + resizable: false, + showHeader: false, + width: '90%', + height: '90%', + style: { maxWidth: 'none' }, + contentStyle: { height: '100%', overflow: 'hidden', padding: 0 }, + data: { + inode, + tempId, + variable + } satisfies DotLegacyImageEditorDialogData + }); + } + + #registerMessageListener(): void { + if (this.#messageHandler) { + return; + } + + this.#messageHandler = (event: MessageEvent) => { + if (event.origin !== window.location.origin) { + return; + } + + const data = event.data as DotImageEditorPostMessage | undefined; + + if (!data || data.source !== POST_MESSAGE_SOURCE) { + return; + } + + const variable = this.#variable; + + if (!variable) { + return; + } + + if (data.type === 'tempfile' && data.tempFile) { + document.dispatchEvent( + new CustomEvent(`binaryField-tempfile-${variable}`, { + detail: { tempFile: data.tempFile } + }) + ); + this.#closeDialog(); + } + + if (data.type === 'close') { + document.dispatchEvent( + new CustomEvent(`binaryField-close-image-editor-${variable}`, {}) + ); + this.#closeDialog(); + } + }; + + window.addEventListener('message', this.#messageHandler); + } + + #unregisterMessageListener(): void { + if (this.#messageHandler) { + window.removeEventListener('message', this.#messageHandler); + this.#messageHandler = null; + } + } + + #closeDialog(): void { + this.#dialogRef?.close(); + this.#dialogRef = null; + } +} diff --git a/core-web/yarn.lock b/core-web/yarn.lock index d7a16494e7a4..67dd225915ee 100644 --- a/core-web/yarn.lock +++ b/core-web/yarn.lock @@ -3834,11 +3834,6 @@ resolved "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.1.tgz#a6793654d6ffaead81f040e3becc063a265deb7c" integrity sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og== -"@jsonjoy.com/fs-node-builtins@4.57.3": - version "4.57.3" - resolved "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.3.tgz#b6b34ed532554916e186977d00c19ffcafb3515f" - integrity sha512-JAI3PqNuY8BR7ovy4h0bADLrqJLIcUauONNZfyTxUnj3Wf3tpTYe39eJ6z7FzYyA+tdMt33VpiQQUikGr3QOBw== - "@jsonjoy.com/fs-node-builtins@4.57.6": version "4.57.6" resolved "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.6.tgz#76a2a3300c9d0f1d5e45ec45785f2aacaec212be" @@ -3869,12 +3864,12 @@ dependencies: "@jsonjoy.com/fs-node-builtins" "4.57.1" -"@jsonjoy.com/fs-node-utils@4.57.3", "@jsonjoy.com/fs-node-utils@4.57.6": - version "4.57.3" - resolved "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.3.tgz#25fb8dbf0406c1cd5a9e0a22274f1e45bf6d460c" - integrity sha512-quCil8AvfcOxob4pn0drGdcQWpkPVgkt9q1+EjeyXXT40/L3l5lvYrr6hR8LmHu0eg+DNNaUwqjLT6Hr7V4sdQ== +"@jsonjoy.com/fs-node-utils@4.57.6": + version "4.57.6" + resolved "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.6.tgz#67f1caff1ced836d8206bd769ecf3a4d3f04ca35" + integrity sha512-foyUrfS7WmYEUzqYXSNxmJBcSj04TABrkpFabwO9SCDCpVCfJ+qG+2sk5FjfiflG2n0SDFZDCJ6vYlJAEpxJFg== dependencies: - "@jsonjoy.com/fs-node-builtins" "4.57.3" + "@jsonjoy.com/fs-node-builtins" "4.57.6" "@jsonjoy.com/fs-node@4.57.1": version "4.57.1" @@ -3929,12 +3924,12 @@ "@jsonjoy.com/util" "^17.65.0" "@jsonjoy.com/fs-snapshot@4.57.6": - version "4.57.3" - resolved "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.3.tgz#db3e113b77189d37f63d7f01c50d5e883ba2409b" - integrity sha512-wdNaG2DxCtvj9lKldAnEV3ycYPEpk+p2cP2lHD1qdxkoQGlWUtQverqvG9KZSkm6BHFha4PP6XRZbpARNfHRxA== + version "4.57.6" + resolved "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.6.tgz#6bde49a518a0ae4a9469a046fa4359c12f4f588d" + integrity sha512-V57CMzbOgTzUWGOWQ8GzHQdpJP6JnrYVNCtTBNxVYEnlVRvo4uEJqHhtAT8vhDFrIuJOXLrTL1Fki4h5oI7xxg== dependencies: "@jsonjoy.com/buffers" "^17.65.0" - "@jsonjoy.com/fs-node-utils" "4.57.3" + "@jsonjoy.com/fs-node-utils" "4.57.6" "@jsonjoy.com/json-pack" "^17.65.0" "@jsonjoy.com/util" "^17.65.0" diff --git a/dotCMS/src/main/webapp/html/js/dotcms/dijit/image/image-editor-standalone.jsp b/dotCMS/src/main/webapp/html/js/dotcms/dijit/image/image-editor-standalone.jsp new file mode 100644 index 000000000000..a9326e854580 --- /dev/null +++ b/dotCMS/src/main/webapp/html/js/dotcms/dijit/image/image-editor-standalone.jsp @@ -0,0 +1,86 @@ +<%@page import="com.dotcms.enterprise.LicenseUtil"%> +<%@page import="com.dotcms.enterprise.license.LicenseLevel"%> +<%@page import="com.dotmarketing.util.Config"%> +<%@page import="com.dotmarketing.util.UtilMethods"%> +<%@page import="com.liferay.portal.model.User"%> +<%@page import="com.liferay.portal.util.PortalUtil"%> +<% + String dojoPath = Config.getStringProperty("path.to.dojo"); + String inode = UtilMethods.isSet(request.getParameter("inode")) ? request.getParameter("inode") : ""; + String tempId = UtilMethods.isSet(request.getParameter("tempId")) ? request.getParameter("tempId") : ""; + String variable = UtilMethods.isSet(request.getParameter("variable")) ? request.getParameter("variable") : "fileAsset"; + String fieldName = UtilMethods.isSet(request.getParameter("fieldName")) ? request.getParameter("fieldName") : variable; + + User user = PortalUtil.getUser(request); + + if (user == null || LicenseLevel.COMMUNITY.level == LicenseUtil.getLevel()) { + response.getWriter().println("Unauthorized"); + return; + } +%> + + + + dotCMS Image Editor + + + + + + + + + + From 2a48c92ecf2e59bf48d1bddd85f1493202e6c922 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Mon, 15 Jun 2026 11:00:06 -0400 Subject: [PATCH 02/26] fix(dot-legacy-image-editor): restore dispatch spy in unit tests for message event handling - Added `dispatchSpy.restore()` calls to ensure proper cleanup after each test case. - Updated the origin check in the message event test to use a variable for invalid origins, improving test reliability. - Enhanced assertions to ensure that only valid events trigger the expected behavior. These changes improve the robustness of the unit tests for the `DotLegacyImageEditorLauncherService`. --- .../dot-legacy-image-editor-launcher.service.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts index 3a0627a4a62f..2003d55e5f49 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts @@ -90,6 +90,7 @@ describe('DotLegacyImageEditorLauncherService', () => { }) ); expect(dialogRef.close).toHaveBeenCalled(); + dispatchSpy.restore(); }); it('should re-dispatch close event when postMessage close is received', () => { @@ -118,16 +119,21 @@ describe('DotLegacyImageEditorLauncherService', () => { }) ); expect(dialogRef.close).toHaveBeenCalled(); + dispatchSpy.restore(); }); it('should ignore postMessage from a different origin', () => { const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); + const invalidOrigin = + window.location.origin === 'https://evil.example' + ? 'http://evil.example' + : 'https://evil.example'; spectator.service.listen(variable); window.dispatchEvent( new MessageEvent('message', { - origin: 'https://evil.example', + origin: invalidOrigin, data: { source: 'dot-image-editor', type: 'tempfile', @@ -137,10 +143,11 @@ describe('DotLegacyImageEditorLauncherService', () => { ); const tempfileDispatches = dispatchSpy.mock.calls.filter( - ([event]) => (event as Event).type === tempEventName + ([event]) => event instanceof CustomEvent && event.type === tempEventName ); expect(tempfileDispatches).toHaveLength(0); + dispatchSpy.restore(); }); it('should not open dialog after stopListening', () => { From 1cdb42e1487e61178e5b6b63d42dffd02c453c39 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Mon, 15 Jun 2026 11:14:33 -0400 Subject: [PATCH 03/26] feat(binary-field): add end-to-end tests for binary field image editor - Introduced a new specification for the binary field image editor, verifying the visibility of the Edit button and the opening of the legacy Dojo Image Editor in both new and legacy content editors. - Created helper classes for managing interactions with the binary field and legacy binary field within the tests. - Updated the binary field helper to include methods for image upload and editor interaction. - Enhanced the legacy binary field helper to support image editing in the legacy editor. These changes improve the testing coverage for the binary field image editor functionality, ensuring a seamless user experience across different content editor versions. --- .../specs/binary-field-image-editor.md | 72 +++++++++++++ .../apps/dotcms-ui-e2e/src/pages/index.ts | 1 + .../src/pages/legacyEditContentForm.page.ts | 48 +++++++++ .../binary-field-image-editor.spec.ts | 102 ++++++++++++++++++ .../binary-field/helpers/binary-field.ts | 53 ++++++++- .../helpers/legacy-binary-field.ts | 100 +++++++++++++++++ .../dot-binary-field-wrapper.component.ts | 10 +- ...ot-legacy-image-editor-dialog.component.ts | 36 +++---- ...ot-legacy-image-editor-launcher.service.ts | 24 +++++ 9 files changed, 424 insertions(+), 22 deletions(-) create mode 100644 core-web/apps/dotcms-ui-e2e/specs/binary-field-image-editor.md create mode 100644 core-web/apps/dotcms-ui-e2e/src/pages/legacyEditContentForm.page.ts create mode 100644 core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field-image-editor.spec.ts create mode 100644 core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/legacy-binary-field.ts diff --git a/core-web/apps/dotcms-ui-e2e/specs/binary-field-image-editor.md b/core-web/apps/dotcms-ui-e2e/specs/binary-field-image-editor.md new file mode 100644 index 000000000000..b7fe9f86ae15 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/specs/binary-field-image-editor.md @@ -0,0 +1,72 @@ +# Spec: Binary Field Image Editor E2E + +## Objective + +Verify that after importing an image into a **binary field**, the **Edit** button is visible and opens the legacy Dojo Image Editor in: + +1. **New edit-content editor** (`CONTENT_EDITOR2_ENABLED: true`) — PrimeNG dialog + `legacy-image-editor-iframe` +2. **Legacy content editor** (`CONTENT_EDITOR2_ENABLED: false`) — `#dotImageDialog` + `#imageToolIframe` inside `detailFrame` + +Issue: [#36056](https://github.com/dotCMS/core/issues/36056) + +**Scope:** open-only — confirm the editor shell appears; no crop/save round-trip. + +## Tech Stack + +- Playwright (`@playwright/test`) +- Nx project: `dotcms-ui-e2e` +- POM helpers under `src/tests/edit-content/fields/binary-field/helpers/` + +## Commands + +```bash +cd core-web && yarn nx e2e dotcms-ui-e2e --grep "binary field image editor" +HEADLESS=true yarn nx e2e dotcms-ui-e2e --grep "binary field image editor" +``` + +Requires dotCMS running (`yarn e2e:dev` from `dotcms-ui-e2e/` or `:4200` proxy / `:8080` direct). + +## Project Structure + +``` +core-web/apps/dotcms-ui-e2e/ + specs/binary-field-image-editor.md # this file + src/pages/legacyEditContentForm.page.ts # legacy navigation + src/tests/edit-content/fields/binary-field/ + binary-field-image-editor.spec.ts # E2E tests + helpers/binary-field.ts # new editor locators + editor assertions + helpers/legacy-binary-field.ts # legacy frame locators +``` + +## Code Style + +- Locators: `getByTestId` / `getByRole` on main page; CSS (`#dotImageDialog`, `#imageToolIframe`) only inside Dojo/legacy iframe per AGENTS.md +- Helper classes scope to field root (`field-{variable}` or `#binary-field-{variable}`) +- Tests: `test('action description @critical')` — action only, no "should" + +## Testing Strategy + +| Concern | Level | +|---------|-------| +| Edit button visible after PNG import | E2E Playwright | +| New editor opens dialog + standalone iframe | E2E Playwright | +| Legacy editor opens Dojo image dialog | E2E Playwright | +| Launcher service / dialog component | Unit (existing Jest specs) | + +Image setup: `importFromUrl(E2E_IMPORT_URL)` — stable PNG URL, already used in `binary-field.spec.ts`. + +## Boundaries + +- **Always:** create content type via API in `beforeEach`, delete in `afterEach`; serial describe mode +- **Ask first:** changing production `data-testid` attributes; extending scope to file/image fields +- **Never:** full image edit save round-trip in E2E (out of scope); CSS selectors on Angular shell when `data-testid` exists + +## Success Criteria + +- [ ] New editor: after import, `edit-button` visible; click opens dialog with `legacy-image-editor-iframe` and nested `#dotImageDialog` or `#imageToolIframe` +- [ ] Legacy editor: after import inside `detailFrame`, `edit-button` visible; click opens `#dotImageDialog` and `#imageToolIframe` +- [ ] Both tests tagged `@critical` and pass in CI + +## Open Questions + +None — open-only verification agreed. diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/index.ts b/core-web/apps/dotcms-ui-e2e/src/pages/index.ts index c2eae74081a2..a9d03e4b118c 100644 --- a/core-web/apps/dotcms-ui-e2e/src/pages/index.ts +++ b/core-web/apps/dotcms-ui-e2e/src/pages/index.ts @@ -1,3 +1,4 @@ +export { LegacyEditContentFormPage } from './legacyEditContentForm.page'; export { ListingContentPage } from './listingContent.page'; export { ListingContentTypesPage } from './listingContentTypes.page'; export { LoginPage } from './login.page'; diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/legacyEditContentForm.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/legacyEditContentForm.page.ts new file mode 100644 index 000000000000..8e32e1cc8487 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/pages/legacyEditContentForm.page.ts @@ -0,0 +1,48 @@ +import { expect, type Frame, type Page } from '@playwright/test'; + +const LEGACY_EDIT_FRAME_URL_PATTERN = /edit_contentlet|portlet\/ext\/contentlet/; + +export class LegacyEditContentFormPage { + constructor(private page: Page) {} + + /** + * Returns the legacy content edit iframe (edit_contentlet JSP), distinct from the shell detailFrame. + */ + async getLegacyContentFrame(): Promise { + let frame: Frame | undefined; + + await expect + .poll( + () => { + frame = this.page + .frames() + .find((f) => LEGACY_EDIT_FRAME_URL_PATTERN.test(f.url())); + return frame; + }, + { timeout: 20000 } + ) + .toBeTruthy(); + + if (!frame) { + throw new Error('Legacy content edit iframe (edit_contentlet) not found'); + } + + return frame; + } + + /** + * Navigates to create new content in the legacy editor (Dojo portlet inside detailFrame). + * URL: /dotAdmin/#/c/content/new/{contentTypeVariable} + */ + async goToLegacyNew(contentTypeVariable: string) { + await this.page.goto(`/dotAdmin/#/c/content/new/${contentTypeVariable}`); + await this.page.waitForLoadState('domcontentloaded'); + + const frame = await this.getLegacyContentFrame(); + await frame.locator('dotcms-binary-field').first().waitFor({ + state: 'attached', + timeout: 20000 + }); + await frame.getByTestId('dropzone').first().waitFor({ state: 'visible', timeout: 15000 }); + } +} diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field-image-editor.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field-image-editor.spec.ts new file mode 100644 index 000000000000..c96621246007 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field-image-editor.spec.ts @@ -0,0 +1,102 @@ +import { NewEditContentFormPage, LegacyEditContentFormPage } from '@pages'; +import { test } from '@playwright/test'; +import { ContentType, createFakeContentType, deleteContentType } from '@requests/contentType'; +import { + createFakePayloadBinaryField, + createFakePayloadTextField +} from '@utils/dot-content-types.mock'; +import { uniqueSuffix } from '@utils/utils'; + +import { BinaryField, E2E_IMPORT_URL } from './helpers/binary-field'; +import { LegacyBinaryField } from './helpers/legacy-binary-field'; + +const BINARY_FIELD_VARIABLE = 'binaryField'; + +async function createBinaryFieldContentType( + request: Parameters[0], + options: { contentEditor2Enabled?: boolean } = {} +) { + const { contentEditor2Enabled = true } = options; + + return createFakeContentType(request, { + name: `E2EBinaryImageEditor${uniqueSuffix()}`, + metadata: { CONTENT_EDITOR2_ENABLED: contentEditor2Enabled }, + fields: [ + createFakePayloadTextField({ + name: 'Title', + variable: 'title', + sortOrder: 1 + }), + createFakePayloadBinaryField({ + name: 'Binary Field', + variable: BINARY_FIELD_VARIABLE, + sortOrder: 2 + }) + ] + }); +} + +test.describe('Binary field image editor — new editor', () => { + test.describe.configure({ mode: 'serial' }); + + let contentType: ContentType | null = null; + let contentTypeVariable: string; + + test.beforeEach(async ({ request }) => { + contentType = await createBinaryFieldContentType(request, { + contentEditor2Enabled: true + }); + contentTypeVariable = contentType.variable; + }); + + test.afterEach(async ({ request }) => { + if (contentType) { + await deleteContentType(request, contentType.id); + contentType = null; + } + }); + + test('import image and Edit opens legacy image editor dialog @critical', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new BinaryField(page, BINARY_FIELD_VARIABLE); + await field.expectVisible(); + await field.importFromUrl(E2E_IMPORT_URL); + await field.openImageEditorInNewEditor(); + }); +}); + +test.describe('Binary field image editor — legacy editor', () => { + test.describe.configure({ mode: 'serial' }); + + let contentType: ContentType | null = null; + let contentTypeVariable: string; + + test.beforeEach(async ({ request }) => { + contentType = await createBinaryFieldContentType(request, { + contentEditor2Enabled: false + }); + contentTypeVariable = contentType.variable; + }); + + test.afterEach(async ({ request }) => { + if (contentType) { + await deleteContentType(request, contentType.id); + contentType = null; + } + }); + + test('import image and Edit opens Dojo image editor in legacy form @critical', async ({ + page + }) => { + const formPage = new LegacyEditContentFormPage(page); + await formPage.goToLegacyNew(contentTypeVariable); + + const legacyFrame = await formPage.getLegacyContentFrame(); + const field = new LegacyBinaryField(legacyFrame, page, BINARY_FIELD_VARIABLE); + await field.expectVisible(); + await field.importFromUrl(E2E_IMPORT_URL); + await field.openImageEditorInLegacyEditor(); + }); +}); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/binary-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/binary-field.ts index 2826e8b3c5c9..cf10a1fc1593 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/binary-field.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/binary-field.ts @@ -3,10 +3,11 @@ import { expect, type Locator, type Page } from '@playwright/test'; import { E2E_IMPORT_URL, REQUIRED_FIELD_ERROR, + createTestPngFile, createTestTextFile } from '../../helpers/file-test-data'; -export { E2E_IMPORT_URL, createTestTextFile }; +export { E2E_IMPORT_URL, createTestPngFile, createTestTextFile }; const AI_DISABLED_TOOLTIP = 'Please configure dotAI to enable this feature'; @@ -24,6 +25,8 @@ export class BinaryField { readonly generateWithAiBtn: Locator; readonly preview: Locator; readonly requiredError: Locator; + readonly editButton: Locator; + readonly editButtonResponsive: Locator; constructor( private page: Page, @@ -38,6 +41,8 @@ export class BinaryField { this.generateWithAiBtn = this.root.getByTestId('action-ai-btn'); this.preview = this.root.getByTestId('preview'); this.requiredError = this.root.locator('.error-message small'); + this.editButton = this.root.getByTestId('edit-button'); + this.editButtonResponsive = this.root.getByTestId('edit-button-responsive'); } async expectVisible() { @@ -60,6 +65,22 @@ export class BinaryField { await this.expectPreviewVisible(); } + async uploadImage( + file: { name: string; mimeType: string; buffer: Buffer } = createTestPngFile() + ) { + const uploadResponse = this.page.waitForResponse( + (response) => + response.url().includes('/api/v1/temp') && + !response.url().includes('/byUrl') && + response.request().method() === 'POST' && + response.status() === 200 + ); + + await this.fileInput.setInputFiles(file); + await uploadResponse; + await this.expectPreviewVisible(); + } + async expectPreviewVisible() { await expect(this.preview).toBeVisible({ timeout: 15000 }); } @@ -131,4 +152,34 @@ export class BinaryField { async isAiButtonEnabled(): Promise { return this.generateWithAiBtn.getByRole('button').isEnabled(); } + + async expectEditButtonVisible() { + await expect(this.editButton.or(this.editButtonResponsive).first()).toBeVisible({ + timeout: 15000 + }); + } + + async clickEditImage() { + await this.editButton.or(this.editButtonResponsive).first().click(); + } + + /** New editor: PrimeNG dialog + standalone JSP iframe */ + async expectNewEditorImageEditorOpen() { + const dialog = this.page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 15000 }); + + const iframe = dialog.getByTestId('legacy-image-editor-iframe'); + await expect(iframe).toBeVisible(); + + const editorFrame = this.page.frameLocator('[data-testid="legacy-image-editor-iframe"]'); + await expect(editorFrame.locator('#dotImageDialog, #imageToolIframe').first()).toBeVisible({ + timeout: 30000 + }); + } + + async openImageEditorInNewEditor() { + await this.expectEditButtonVisible(); + await this.clickEditImage(); + await this.expectNewEditorImageEditorOpen(); + } } diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/legacy-binary-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/legacy-binary-field.ts new file mode 100644 index 000000000000..82824e2ae4b8 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/legacy-binary-field.ts @@ -0,0 +1,100 @@ +import { expect, type Frame, type Locator, type Page } from '@playwright/test'; + +import { E2E_IMPORT_URL } from '../../helpers/file-test-data'; + +export { E2E_IMPORT_URL }; + +/** + * Locator wrapper for the binary field web component inside the legacy editor (edit_contentlet iframe). + * Scopes to `#binary-field-{variable}` / `dotcms-binary-field` — no `field-{variable}` wrapper. + */ +export class LegacyBinaryField { + readonly root: Locator; + readonly dropzone: Locator; + readonly fileInput: Locator; + readonly importFromUrlBtn: Locator; + readonly preview: Locator; + readonly editButton: Locator; + readonly editButtonResponsive: Locator; + + constructor( + private frame: Frame, + private page: Page, + readonly fieldVariable = 'binaryField' + ) { + this.root = frame.locator(`#binary-field-${fieldVariable}, dotcms-binary-field`); + this.dropzone = this.root.getByTestId('dropzone'); + this.fileInput = this.root.getByTestId('binary-field__file-input'); + this.importFromUrlBtn = this.root.getByTestId('action-url-btn'); + this.preview = this.root.getByTestId('preview'); + this.editButton = this.root.getByTestId('edit-button'); + this.editButtonResponsive = this.root.getByTestId('edit-button-responsive'); + } + + async expectVisible() { + await expect(this.dropzone).toBeVisible({ timeout: 15000 }); + } + + async expectPreviewVisible() { + await expect(this.preview).toBeVisible({ timeout: 15000 }); + } + + async openImportFromUrlDialog() { + await this.importFromUrlBtn.getByRole('button').click(); + const dialog = this.frame.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + await expect(dialog).toContainText('URL'); + return dialog; + } + + getImportDialogLocators() { + const dialog = this.frame.getByRole('dialog'); + return { + dialog, + urlInput: dialog.getByTestId('url-input'), + importButton: dialog.getByTestId('import-button') + }; + } + + async importFromUrl(url: string) { + await this.openImportFromUrlDialog(); + const { urlInput, importButton } = this.getImportDialogLocators(); + + const byUrlResponse = this.page.waitForResponse( + (response) => + response.url().includes('/api/v1/temp/byUrl') && + response.request().method() === 'POST', + { timeout: 30000 } + ); + + await urlInput.fill(url); + await importButton.getByRole('button').click(); + + const response = await byUrlResponse; + expect(response.ok()).toBeTruthy(); + + await this.expectPreviewVisible(); + } + + async expectEditButtonVisible() { + await expect(this.editButton.or(this.editButtonResponsive).first()).toBeVisible({ + timeout: 15000 + }); + } + + async clickEditImage() { + await this.editButton.or(this.editButtonResponsive).first().click(); + } + + /** Legacy editor: Dojo ImageEditor dialog inside edit_contentlet frame */ + async expectLegacyImageEditorOpen() { + await expect(this.frame.locator('#dotImageDialog')).toBeVisible({ timeout: 15000 }); + await expect(this.frame.locator('#imageToolIframe')).toBeVisible({ timeout: 30000 }); + } + + async openImageEditorInLegacyEditor() { + await this.expectEditButtonVisible(); + await this.clickEditImage(); + await this.expectLegacyImageEditorOpen(); + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts index 7c6be318f48a..042158024ffc 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts @@ -23,9 +23,10 @@ import { DotEditContentBinaryFieldComponent } from '../../dot-edit-content-binar import { DotLegacyImageEditorLauncherService } from '../../service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service'; /** - * JSON field editor component that uses Monaco Editor for JSON content editing. - * Uses DotEditContentMonacoEditorControl for editor functionality with JSON language forced. - * Supports language variable insertion through DotLanguageVariableSelectorComponent. + * Wrapper for binary fields in the content editor card layout. + * + * Enables the legacy image editor by wiring {@link DotLegacyImageEditorLauncherService} + * to the binary field web component lifecycle. */ @Component({ selector: 'dot-binary-field-wrapper', @@ -72,6 +73,9 @@ export class DotBinaryFieldWrapperComponent extends BaseWrapperField implements */ valueUpdated = output<{ value: string; fileName: string }>(); + /** + * Starts listening for legacy image editor events for this field and cleans up on destroy. + */ ngOnInit(): void { this.#legacyImageEditorLauncher.listen(this.$field().variable); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.ts index a0b4087f58e4..455878d0fabe 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-dialog.component.ts @@ -3,46 +3,46 @@ import { DomSanitizer } from '@angular/platform-browser'; import { DynamicDialogConfig } from 'primeng/dynamicdialog'; +/** + * Data passed to {@link DotLegacyImageEditorDialogComponent} when opening the legacy image editor. + */ export interface DotLegacyImageEditorDialogData { + /** Content inode when editing a published asset. */ inode?: string; + /** Temporary file id when editing an unsaved upload. */ tempId?: string; + /** Binary field variable name used for editor event routing. */ variable: string; } const IMAGE_EDITOR_STANDALONE_JSP = '/html/js/dotcms/dijit/image/image-editor-standalone.jsp'; +/** + * Renders the legacy Dojo image editor inside a PrimeNG dialog iframe. + * + * Builds a sanitized URL to `image-editor-standalone.jsp` with the inode, temp file, + * and field variable required by the legacy editor. + */ @Component({ selector: 'dot-legacy-image-editor-dialog', template: ` `, - styles: [ - ` - :host { - display: block; - height: 100%; - width: 100%; - } - - .legacy-image-editor__iframe { - border: none; - display: block; - height: 100%; - width: 100%; - } - ` - ], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'block h-full w-full' } }) export class DotLegacyImageEditorDialogComponent { readonly #dialogConfig = inject(DynamicDialogConfig); readonly #sanitizer = inject(DomSanitizer); + /** + * Sanitized iframe URL for the standalone legacy image editor JSP. + */ readonly $iframeSrc = computed(() => { const { inode, tempId, variable } = this.#dialogConfig.data; const params = new URLSearchParams(); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts index c5c2c3bf9147..8cb96e218445 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts @@ -11,18 +11,27 @@ import { const POST_MESSAGE_SOURCE = 'dot-image-editor'; +/** Payload sent from the legacy image editor iframe via `postMessage`. */ interface DotImageEditorPostMessage { source: typeof POST_MESSAGE_SOURCE; type: 'tempfile' | 'close'; tempFile?: DotCMSTempFile; } +/** Detail of the `binaryField-open-image-editor-{variable}` custom event. */ interface ImageEditorOpenDetail { inode?: string; tempId?: string; variable: string; } +/** + * Bridges the Angular binary field with the legacy Dojo image editor. + * + * Listens for open events dispatched by the binary field web component, opens a + * modal dialog with the editor iframe, and relays `postMessage` results back as + * `binaryField-tempfile-{variable}` and `binaryField-close-image-editor-{variable}` events. + */ @Injectable() export class DotLegacyImageEditorLauncherService implements OnDestroy { readonly #dialogService = inject(DialogService); @@ -32,6 +41,11 @@ export class DotLegacyImageEditorLauncherService implements OnDestroy { #messageHandler: ((event: MessageEvent) => void) | null = null; #variable: string | null = null; + /** + * Registers listeners for the given binary field variable. + * + * @param variable - Content type field variable that scopes editor events. + */ listen(variable: string): void { this.stopListening(); @@ -52,6 +66,9 @@ export class DotLegacyImageEditorLauncherService implements OnDestroy { this.#registerMessageListener(); } + /** + * Removes event listeners, closes any open dialog, and clears the active field scope. + */ stopListening(): void { if (this.#variable && this.#openEventHandler) { document.removeEventListener( @@ -70,6 +87,13 @@ export class DotLegacyImageEditorLauncherService implements OnDestroy { this.stopListening(); } + /** + * Opens the legacy image editor dialog for the given asset and field. + * + * @param inode - Content inode when editing a published asset. + * @param tempId - Temporary file id when editing an unsaved upload. + * @param variable - Binary field variable used for editor event routing. + */ private openDialog({ inode, tempId, variable }: DotLegacyImageEditorDialogData): void { this.#closeDialog(); From 9ceade38fd89101e6d6b8ccd631f99b1aa5d456e Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Mon, 15 Jun 2026 11:41:15 -0400 Subject: [PATCH 04/26] refactor(dot-legacy-image-editor): enhance unit tests and message handling --- ...gacy-image-editor-launcher.service.spec.ts | 172 ++++++++++++------ ...ot-legacy-image-editor-launcher.service.ts | 8 +- .../dijit/image/image-editor-standalone.jsp | 47 ++++- 3 files changed, 162 insertions(+), 65 deletions(-) diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts index 2003d55e5f49..458bb6430c90 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.spec.ts @@ -32,20 +32,37 @@ describe('DotLegacyImageEditorLauncherService', () => { afterEach(() => { spectator.service.stopListening(); + jest.restoreAllMocks(); }); - it('should open dialog when open-image-editor event is dispatched', () => { - spectator.service.listen(variable); - + const openEditorDialog = (): void => { document.dispatchEvent( new CustomEvent(openEventName, { - detail: { - inode: 'inode-1', - tempId: 'temp-1', - variable - } + detail: { inode: 'inode-1', tempId: 'temp-1', variable } + }) + ); + }; + + const dispatchPostMessage = (data: Record): void => { + window.dispatchEvent( + new MessageEvent('message', { + origin: window.location.origin, + data }) ); + }; + + const getTempfileDispatches = (dispatchSpy: jest.SpyInstance): CustomEvent[] => + dispatchSpy.mock.calls + .map(([event]) => event) + .filter( + (event): event is CustomEvent => + event instanceof CustomEvent && event.type === tempEventName + ); + + it('should open dialog when open-image-editor event is dispatched', () => { + spectator.service.listen(variable); + openEditorDialog(); expect(dialogService.open).toHaveBeenCalledWith( DotLegacyImageEditorDialogComponent, @@ -66,52 +83,33 @@ describe('DotLegacyImageEditorLauncherService', () => { const tempFile = { id: 'temp-123' } as DotCMSTempFile; spectator.service.listen(variable); - document.dispatchEvent( - new CustomEvent(openEventName, { - detail: { inode: 'inode-1', tempId: 'temp-1', variable } - }) - ); - - window.dispatchEvent( - new MessageEvent('message', { - origin: window.location.origin, - data: { - source: 'dot-image-editor', - type: 'tempfile', - tempFile - } - }) - ); - - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: tempEventName, - detail: { tempFile } - }) - ); + openEditorDialog(); + dispatchSpy.mockClear(); + + dispatchPostMessage({ + source: 'dot-image-editor', + type: 'tempfile', + tempFile, + variable + }); + + expect(getTempfileDispatches(dispatchSpy)).toHaveLength(1); + expect(getTempfileDispatches(dispatchSpy)[0].detail).toEqual({ tempFile }); expect(dialogRef.close).toHaveBeenCalled(); - dispatchSpy.restore(); }); it('should re-dispatch close event when postMessage close is received', () => { const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); spectator.service.listen(variable); - document.dispatchEvent( - new CustomEvent(openEventName, { - detail: { inode: 'inode-1', tempId: 'temp-1', variable } - }) - ); + openEditorDialog(); + dispatchSpy.mockClear(); - window.dispatchEvent( - new MessageEvent('message', { - origin: window.location.origin, - data: { - source: 'dot-image-editor', - type: 'close' - } - }) - ); + dispatchPostMessage({ + source: 'dot-image-editor', + type: 'close', + variable + }); expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -119,7 +117,6 @@ describe('DotLegacyImageEditorLauncherService', () => { }) ); expect(dialogRef.close).toHaveBeenCalled(); - dispatchSpy.restore(); }); it('should ignore postMessage from a different origin', () => { @@ -130,6 +127,8 @@ describe('DotLegacyImageEditorLauncherService', () => { : 'https://evil.example'; spectator.service.listen(variable); + openEditorDialog(); + dispatchSpy.mockClear(); window.dispatchEvent( new MessageEvent('message', { @@ -137,28 +136,83 @@ describe('DotLegacyImageEditorLauncherService', () => { data: { source: 'dot-image-editor', type: 'tempfile', - tempFile: { id: 'temp-123' } + tempFile: { id: 'temp-123' }, + variable } }) ); - const tempfileDispatches = dispatchSpy.mock.calls.filter( - ([event]) => event instanceof CustomEvent && event.type === tempEventName - ); + expect(getTempfileDispatches(dispatchSpy)).toHaveLength(0); + }); + + it('should ignore postMessage when dialog is not open', () => { + const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); + + spectator.service.listen(variable); + dispatchSpy.mockClear(); + + dispatchPostMessage({ + source: 'dot-image-editor', + type: 'tempfile', + tempFile: { id: 'temp-123' }, + variable + }); - expect(tempfileDispatches).toHaveLength(0); - dispatchSpy.restore(); + expect(getTempfileDispatches(dispatchSpy)).toHaveLength(0); + }); + + it('should ignore postMessage when variable does not match', () => { + const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); + + spectator.service.listen(variable); + openEditorDialog(); + dispatchSpy.mockClear(); + + dispatchPostMessage({ + source: 'dot-image-editor', + type: 'tempfile', + tempFile: { id: 'temp-123' }, + variable: 'otherField' + }); + + expect(getTempfileDispatches(dispatchSpy)).toHaveLength(0); + }); + + it('should ignore postMessage after the editor dialog closes', () => { + const dispatchSpy = jest.spyOn(document, 'dispatchEvent'); + const tempFile = { id: 'temp-123' } as DotCMSTempFile; + + spectator.service.listen(variable); + openEditorDialog(); + + dispatchPostMessage({ + source: 'dot-image-editor', + type: 'tempfile', + tempFile, + variable + }); + + expect(getTempfileDispatches(dispatchSpy)).toHaveLength(1); + expect(dialogRef.close).toHaveBeenCalledTimes(1); + + dispatchSpy.mockClear(); + (dialogRef.close as jest.Mock).mockClear(); + + dispatchPostMessage({ + source: 'dot-image-editor', + type: 'tempfile', + tempFile, + variable + }); + + expect(getTempfileDispatches(dispatchSpy)).toHaveLength(0); + expect(dialogRef.close).not.toHaveBeenCalled(); }); it('should not open dialog after stopListening', () => { spectator.service.listen(variable); spectator.service.stopListening(); - - document.dispatchEvent( - new CustomEvent(openEventName, { - detail: { inode: 'inode-1', tempId: 'temp-1', variable } - }) - ); + openEditorDialog(); expect(dialogService.open).not.toHaveBeenCalled(); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts index 8cb96e218445..0bfc4a6fa7bb 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service.ts @@ -16,6 +16,8 @@ interface DotImageEditorPostMessage { source: typeof POST_MESSAGE_SOURCE; type: 'tempfile' | 'close'; tempFile?: DotCMSTempFile; + /** Binary field variable that opened the editor; scopes message routing. */ + variable?: string; } /** Detail of the `binaryField-open-image-editor-{variable}` custom event. */ @@ -134,9 +136,13 @@ export class DotLegacyImageEditorLauncherService implements OnDestroy { return; } + if (!this.#dialogRef) { + return; + } + const variable = this.#variable; - if (!variable) { + if (!variable || data.variable !== variable) { return; } diff --git a/dotCMS/src/main/webapp/html/js/dotcms/dijit/image/image-editor-standalone.jsp b/dotCMS/src/main/webapp/html/js/dotcms/dijit/image/image-editor-standalone.jsp index a9326e854580..d86a2131e4e6 100644 --- a/dotCMS/src/main/webapp/html/js/dotcms/dijit/image/image-editor-standalone.jsp +++ b/dotCMS/src/main/webapp/html/js/dotcms/dijit/image/image-editor-standalone.jsp @@ -1,9 +1,15 @@ <%@page import="com.dotcms.enterprise.LicenseUtil"%> <%@page import="com.dotcms.enterprise.license.LicenseLevel"%> +<%@page import="com.dotcms.rest.api.v1.temp.TempFileAPI"%> +<%@page import="com.dotmarketing.business.APILocator"%> +<%@page import="com.dotmarketing.business.PermissionAPI"%> +<%@page import="com.dotmarketing.portlets.contentlet.model.Contentlet"%> <%@page import="com.dotmarketing.util.Config"%> +<%@page import="com.dotmarketing.util.InodeUtils"%> <%@page import="com.dotmarketing.util.UtilMethods"%> <%@page import="com.liferay.portal.model.User"%> <%@page import="com.liferay.portal.util.PortalUtil"%> +<%@page import="org.apache.commons.lang.StringEscapeUtils"%> <% String dojoPath = Config.getStringProperty("path.to.dojo"); String inode = UtilMethods.isSet(request.getParameter("inode")) ? request.getParameter("inode") : ""; @@ -17,6 +23,36 @@ response.getWriter().println("Unauthorized"); return; } + + if (!UtilMethods.isSet(inode) && !UtilMethods.isSet(tempId)) { + response.getWriter().println("Unauthorized"); + return; + } + + if (UtilMethods.isSet(tempId)) { + TempFileAPI tempFileAPI = APILocator.getTempFileAPI(); + if (!tempFileAPI.getTempFile(request, tempId).isPresent()) { + response.getWriter().println("Unauthorized"); + return; + } + } + + if (UtilMethods.isSet(inode)) { + Contentlet contentlet = APILocator.getContentletAPI().find(inode, user, false); + PermissionAPI permissionAPI = APILocator.getPermissionAPI(); + + if (contentlet == null + || !InodeUtils.isSet(contentlet.getInode()) + || !permissionAPI.doesUserHavePermission(contentlet, PermissionAPI.PERMISSION_EDIT, user, false)) { + response.getWriter().println("Unauthorized"); + return; + } + } + + String jsVariable = StringEscapeUtils.escapeJavaScript(variable); + String jsInode = StringEscapeUtils.escapeJavaScript(inode); + String jsTempId = StringEscapeUtils.escapeJavaScript(tempId); + String jsFieldName = StringEscapeUtils.escapeJavaScript(fieldName); %> @@ -38,10 +74,10 @@