diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/file-field.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/file-field.spec.ts index 17f78daaf6b5..a94f2fa9d38e 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/file-field.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/file-field.spec.ts @@ -107,14 +107,15 @@ test('import from URL completes without 400 and shows preview @critical', async await field.importFromUrl(E2E_IMPORT_URL); }); -test('import image URL does not show Edit image button', async ({ page }) => { +test('import image URL shows Edit image button', async ({ page }) => { const formPage = new NewEditContentFormPage(page); await formPage.goToNew(contentTypeVariable); const field = new FileField(page, FILE_FIELD_VARIABLE); await field.expectVisible(); await field.importFromUrl(E2E_IMPORT_URL); - await field.expectEditButtonHidden(); + // File fields expose the image editor when the file is an image (#36363). + await field.expectEditButtonVisible(); }); test.describe('required file field', () => { diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/helpers/file-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/helpers/file-field.ts index a9757f7bbf2b..34e5fd7e99b2 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/helpers/file-field.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/helpers/file-field.ts @@ -77,7 +77,8 @@ export class FileField { } /** - * The "Edit image" action is only available for Binary fields with an image file. + * The "Edit image" action is available for Binary, Image and File fields when the + * previewed file is an image (#36363). */ async expectEditButtonVisible() { await expect(this.editButton.or(this.editButtonResponsive).first()).toBeVisible({ diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/image-field.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/image-field.spec.ts index 328187f2d233..71f6104a39e2 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/image-field.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/image-field.spec.ts @@ -81,14 +81,15 @@ test('upload an image, save, reload, and thumbnail still displayed @critical', a await field.expectPreviewShowsFileName(TEST_IMAGE.name); }); -test('upload image does not show Edit image button', async ({ page }) => { +test('upload image shows Edit image button', async ({ page }) => { const formPage = new NewEditContentFormPage(page); await formPage.goToNew(contentTypeVariable); const field = new ImageField(page, IMAGE_FIELD_VARIABLE); await field.expectVisible(); await field.uploadFile(TEST_IMAGE); - await field.expectEditButtonHidden(); + // Image fields expose the image editor for images (#36363). + await field.expectEditButtonVisible(); }); test.describe('required image field', () => { diff --git a/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.ts b/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.ts index f95b342bf1d8..deae5161f80e 100644 --- a/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.ts +++ b/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.ts @@ -17,6 +17,8 @@ export interface DotActionRequestOptions { action: ActionToFire; individualPermissions?: { [key: string]: string[] }; formData?: FormData; + /** Language of the contentlet version to fire against (sent as `?language=`). */ + language?: string | number; } export interface DotFireActionOptions { @@ -195,6 +197,28 @@ export class DotWorkflowActionsFireService { }); } + /** + * Fire the default PUBLISH action against an existing contentlet, resolved by the + * `identifier` in `data` and the given language. Used to check in and publish a new + * version of the `dotAsset` referenced by an Image/File field from the image editor. + * + * @template T + * @param {{ [key: string]: string }} data contentlet fields (must include `identifier`) + * @param {string | number} [language] language of the version to fire against + * @returns {Observable} + * @memberof DotWorkflowActionsFireService + */ + publishContentletByIdentifier( + data: { [key: string]: string }, + language?: string | number + ): Observable { + return this.request({ + data, + action: ActionToFire.PUBLISH, + language + }); + } + /** * Fire an "DELETE" action over the content type received with the specified data * @@ -239,7 +263,8 @@ export class DotWorkflowActionsFireService { data, action, individualPermissions, - formData + formData, + language }: DotActionRequestOptions): Observable { let url = `${this.BASE_URL}/actions/default/fire/${action}`; @@ -249,6 +274,10 @@ export class DotWorkflowActionsFireService { : { contentlet }; const params = new URLSearchParams({}); + if (language !== undefined && language !== null && `${language}` !== '') { + params.append('language', `${language}`); + } + // It's not best approach but this legacy code if (contentlet['inode']) { params.append('inode', contentlet['inode']); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html index 2a8e2a1ae6b4..de2681dd11ca 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html @@ -162,22 +162,22 @@ [closeOnEscape]="true" [dismissableMask]="true" [modal]="true" - [header]="metadata.title" + [header]="metadata?.title" [appendTo]="'body'" [style]="{ maxWidth: '582px', width: '100%' }">
{{ 'Size' | dm }}:
- @if (metadata.width && metadata.height) { + @if (metadata?.width && metadata?.height) {
- {{ metadata.width }}px, {{ metadata.height }}px + {{ metadata?.width }}px, {{ metadata?.height }}px
}
- {{ metadata.fileSize | dotFileSizeFormat }} + {{ metadata?.fileSize | dotFileSizeFormat }}
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.spec.ts index b984fd55dc63..986dc53e3983 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.spec.ts @@ -89,6 +89,19 @@ describe('DotFileFieldPreviewComponent', () => { expect(spectator.query(byTestId('download-btn'))).toBeFalsy(); }); + + it('renders without crashing when the temp file has no metadata', () => { + // Regression: the image-editor Save servlet can return a temp file with + // `metadata: null`. The file-info dialog header bound `metadata.title` + // unguarded and threw "Cannot read properties of null (reading 'title')". + spectator.setInput('previewFile', { + source: 'temp', + file: { ...TEMP_FILE_MOCK, image: false, mimeType: 'unknown', metadata: null } + } as unknown); + + expect(() => spectator.detectChanges()).not.toThrow(); + expect(spectator.component).toBeTruthy(); + }); }); describe('contentlet without content preview file', () => { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.legacy-availability.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.legacy-availability.spec.ts new file mode 100644 index 000000000000..5bf7a846c383 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.legacy-availability.spec.ts @@ -0,0 +1,115 @@ +import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { DialogService } from 'primeng/dynamicdialog'; + +import { + DotAiService, + DotMessageService, + DotWorkflowActionsFireService +} from '@dotcms/data-access'; +import { createFakeContentlet } from '@dotcms/utils-testing'; + +import { DotFileFieldComponent } from './dot-file-field.component'; + +import { BINARY_FIELD_MOCK, FILE_FIELD_MOCK, IMAGE_FIELD_MOCK } from '../../../../utils/mocks'; +import { + LegacyDialogImageEditorLauncher, + LegacyDojoImageEditorLauncher +} from '../../services/image-editor'; +import { DotFileFieldUploadService } from '../../services/upload-file/upload-file.service'; +import { FileFieldStore } from '../../store/file-field.store'; +import { DotFileFieldPreviewComponent } from '../dot-file-field-preview/dot-file-field-preview.component'; +import { DotFileFieldUiMessageComponent } from '../dot-file-field-ui-message/dot-file-field-ui-message.component'; + +/** + * Availability of the image editor in the legacy Dojo host — i.e. when the + * Angular image-editor launcher (`IMAGE_EDITOR_LAUNCHER`) is NOT provided (the + * token is only supplied by the new Angular Edit Content shell). + * + * Binary fields keep the binary inline and fall back to the legacy editor, so + * they still expose the action. Image/File fields reference a separate dotAsset + * and are new-Edit-Content-only, so they must NOT expose the editor here. + * + * Kept in a dedicated spec because Spectator allows a single + * `createComponentFactory` per file, and this scenario needs a factory that + * omits the launcher token. + */ +describe('DotFileFieldComponent — legacy host availability (no Angular launcher)', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotFileFieldComponent, + imports: [ReactiveFormsModule], + componentMocks: [DotFileFieldPreviewComponent, DotFileFieldUiMessageComponent], + providers: [ + FileFieldStore, + mockProvider(DotFileFieldUploadService), + mockProvider(DialogService), + LegacyDialogImageEditorLauncher, + LegacyDojoImageEditorLauncher, + mockProvider(DotWorkflowActionsFireService), + mockProvider(DotMessageService, { get: jest.fn().mockReturnValue('Test Message') }), + mockProvider(DotAiService, { + checkPluginInstallation: jest.fn().mockReturnValue(of(false)) + }), + provideHttpClient(), + provideHttpClientTesting() + // IMAGE_EDITOR_LAUNCHER intentionally not provided (legacy host). + ] + }); + + const setReferencedImageAsset = (field: typeof IMAGE_FIELD_MOCK) => { + spectator = createComponent({ + props: { + field, + contentlet: createFakeContentlet({ [field.variable]: 'ref-identifier' }), + hasError: false + } as never + }); + spectator.detectChanges(); + spectator.component.store.setPreviewFile({ + source: 'contentlet', + file: { + identifier: 'ref-identifier', + assetMetaData: { isImage: true, contentType: 'image/png', name: 'beach.png' } + } as never + }); + spectator.detectChanges(); + }; + + it('hides the editor for an Image field even when the asset is an image', () => { + setReferencedImageAsset(IMAGE_FIELD_MOCK); + expect(spectator.component.$canEditImage()).toBe(false); + }); + + it('hides the editor for a File field even when the asset is an image', () => { + setReferencedImageAsset(FILE_FIELD_MOCK); + expect(spectator.component.$canEditImage()).toBe(false); + }); + + it('still exposes the editor for a Binary image field (legacy fallback)', () => { + spectator = createComponent({ + props: { + field: BINARY_FIELD_MOCK, + contentlet: createFakeContentlet({ [BINARY_FIELD_MOCK.variable]: null }), + hasError: false + } as never + }); + spectator.detectChanges(); + spectator.component.store.setPreviewFile({ + source: 'temp', + file: { + id: 'temp-1', + fileName: 'img.png', + metadata: { isImage: true, contentType: 'image/png', name: 'img.png' } + } + } as never); + spectator.detectChanges(); + expect(spectator.component.$canEditImage()).toBe(true); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.spec.ts index d483e45b992e..332e3f5aec9b 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.spec.ts @@ -7,13 +7,17 @@ import { ReactiveFormsModule } from '@angular/forms'; import { DialogService } from 'primeng/dynamicdialog'; -import { DotAiService, DotMessageService } from '@dotcms/data-access'; +import { + DotAiService, + DotMessageService, + DotWorkflowActionsFireService +} from '@dotcms/data-access'; import { DotGeneratedAIImage, PromptType } from '@dotcms/dotcms-models'; import { createFakeContentlet } from '@dotcms/utils-testing'; import { DotFileFieldComponent } from './dot-file-field.component'; -import { BINARY_FIELD_MOCK, IMAGE_FIELD_MOCK } from '../../../../utils/mocks'; +import { BINARY_FIELD_MOCK, FILE_FIELD_MOCK, IMAGE_FIELD_MOCK } from '../../../../utils/mocks'; import { IMAGE_EDITOR_LAUNCHER } from '../../../shared/image-editor-launcher'; import { LegacyDialogImageEditorLauncher, @@ -45,6 +49,7 @@ describe('DotFileFieldComponent', () => { mockProvider(DialogService), LegacyDialogImageEditorLauncher, LegacyDojoImageEditorLauncher, + mockProvider(DotWorkflowActionsFireService), { provide: IMAGE_EDITOR_LAUNCHER, useValue: mockImageEditorLauncher }, mockProvider(DotMessageService, { get: jest.fn().mockReturnValue('Test Message') @@ -180,7 +185,9 @@ describe('DotFileFieldComponent', () => { mockImageEditorLauncher.open.mockReturnValue(of(EDITED_TEMP_FILE)); setupBinaryWithImage(); - const applySpy = jest.spyOn(spectator.component.store, 'applyTempFile'); + const applySpy = jest + .spyOn(spectator.component.store, 'applyEditedImage') + .mockImplementation(); spectator.component.onEditImage(); @@ -192,7 +199,7 @@ describe('DotFileFieldComponent', () => { mimeType: 'image/png' }) ); - expect(applySpy).toHaveBeenCalledWith(EDITED_TEMP_FILE); + expect(applySpy).toHaveBeenCalled(); }); it('should fall back to the legacy editor when the new launcher is unavailable', () => { @@ -205,13 +212,94 @@ describe('DotFileFieldComponent', () => { const legacyOpenSpy = jest .spyOn(legacy, 'open') .mockReturnValue(of(EDITED_TEMP_FILE) as never); - const applySpy = jest.spyOn(spectator.component.store, 'applyTempFile'); + const applySpy = jest + .spyOn(spectator.component.store, 'applyEditedImage') + .mockImplementation(); spectator.component.onEditImage(); expect(mockImageEditorLauncher.open).not.toHaveBeenCalled(); expect(legacyOpenSpy).toHaveBeenCalled(); - expect(applySpy).toHaveBeenCalledWith(EDITED_TEMP_FILE); + expect(applySpy).toHaveBeenCalled(); + }); + }); + + describe('edit image availability', () => { + const IMAGE_ASSET_META = { isImage: true, contentType: 'image/png', name: 'beach.png' }; + const NON_IMAGE_ASSET_META = { + isImage: false, + contentType: 'application/pdf', + name: 'doc.pdf' + }; + + // Hydrates the preview from a referenced dotAsset contentlet (Image/File + // fields), whose metadata lives in `assetMetaData`. + const setReferencedAsset = ( + field: typeof IMAGE_FIELD_MOCK, + assetMetaData: Record + ) => { + spectator = createComponent({ + props: { + field, + contentlet: createFakeContentlet({ [field.variable]: 'ref-identifier' }), + hasError: false + } as never + }); + spectator.detectChanges(); + + spectator.component.store.setPreviewFile({ + source: 'contentlet', + file: { identifier: 'ref-identifier', inode: 'ref-inode', assetMetaData } as never + }); + spectator.detectChanges(); + }; + + it('shows the editor for an Image field pointing at an image asset', () => { + setReferencedAsset(IMAGE_FIELD_MOCK, IMAGE_ASSET_META); + expect(spectator.component.$canEditImage()).toBe(true); + }); + + it('shows the editor for a File field when the referenced asset is an image', () => { + setReferencedAsset(FILE_FIELD_MOCK, IMAGE_ASSET_META); + expect(spectator.component.$canEditImage()).toBe(true); + }); + + it('hides the editor for a File field when the referenced asset is not an image', () => { + setReferencedAsset(FILE_FIELD_MOCK, NON_IMAGE_ASSET_META); + expect(spectator.component.$canEditImage()).toBe(false); + }); + + it('hides the editor for an empty Image field', () => { + spectator = createComponent({ + props: { + field: IMAGE_FIELD_MOCK, + contentlet: createFakeContentlet({ [IMAGE_FIELD_MOCK.variable]: null }), + hasError: false + } as never + }); + spectator.detectChanges(); + expect(spectator.component.$canEditImage()).toBe(false); + }); + + it('keeps Binary hidden for a non-image file', () => { + spectator = createComponent({ + props: { + field: BINARY_FIELD_MOCK, + contentlet: createFakeContentlet({ [BINARY_FIELD_MOCK.variable]: null }), + hasError: false + } as never + }); + spectator.detectChanges(); + spectator.component.store.setPreviewFile({ + source: 'temp', + file: { + id: 'temp-1', + fileName: 'doc.pdf', + metadata: { isImage: false, contentType: 'application/pdf', name: 'doc.pdf' } + } + } as never); + spectator.detectChanges(); + expect(spectator.component.$canEditImage()).toBe(false); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts index 9b1f531332eb..5587af417c46 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts @@ -1,5 +1,4 @@ import { signalMethod } from '@ngrx/signals'; -import { Observable } from 'rxjs'; import { AfterViewInit, @@ -23,7 +22,11 @@ import { TooltipModule } from 'primeng/tooltip'; import { filter, map } from 'rxjs/operators'; -import { DotAiService, DotMessageService } from '@dotcms/data-access'; +import { + DotAiService, + DotMessageService, + DotWorkflowActionsFireService +} from '@dotcms/data-access'; import { DotCMSContentlet, DotCMSContentTypeField, @@ -31,6 +34,7 @@ import { DotFileMetadata, DotGeneratedAIImage } from '@dotcms/dotcms-models'; +import { isImageFile } from '@dotcms/image-editor'; import { DotAIImagePromptComponent, DotBrowserSelectorComponent, @@ -48,7 +52,11 @@ import { } from './../../services/image-editor'; import { DotFileFieldUploadService } from './../../services/upload-file/upload-file.service'; import { FileFieldStore } from './../../store/file-field.store'; -import { parseFocalPoint } from './../../utils/focal-point.util'; +import { + focalPointFromContentlet, + focalPointFromMetadata, + parseFocalPoint +} from './../../utils/focal-point.util'; import { getUiMessage } from './../../utils/messages'; import { DotFileFieldPreviewComponent } from './../dot-file-field-preview/dot-file-field-preview.component'; import { DotFileFieldUiMessageComponent } from './../dot-file-field-ui-message/dot-file-field-ui-message.component'; @@ -80,6 +88,7 @@ import { IMAGE_EDITOR_LAUNCHER } from '../../../shared/image-editor-launcher'; DialogService, LegacyDialogImageEditorLauncher, LegacyDojoImageEditorLauncher, + DotWorkflowActionsFireService, { multi: true, provide: NG_VALUE_ACCESSOR, @@ -201,20 +210,27 @@ export class DotFileFieldComponent /** * Whether the "Edit image" action is available for the current file. * - * Only Binary fields expose the image editor when enabled, and only when - * the previewed file is actually an image. File/Image fields never show it. + * Shown when image editing is enabled and the previewed file is an image + * (see {@link isImageFile}). Binary fields keep the binary inline and have a + * legacy fallback, so they work in any host. Image/File fields reference a + * separate dotAsset and are only supported in the new Angular Edit Content — + * where the image-editor launcher is provided — never in the legacy Dojo host. */ $canEditImage = computed(() => { if (!this.$enableImageEditor()) { return false; } - // Image editing is only supported for Binary fields, not File or Image fields. - if (this.store.inputType() !== INPUT_TYPES.Binary) { - return false; + // Binary keeps its original, strict gate: the authoritative isImage flag + // only. It works in any host via the legacy fallback. + if (this.store.inputType() === INPUT_TYPES.Binary) { + return !!this.#currentMetadata()?.isImage; } - return !!this.#currentMetadata()?.isImage; + // Image/File resolve a separate dotAsset/FileAsset and are only supported + // in the new Angular Edit Content, where the image-editor launcher is + // provided — never in the legacy Dojo host. + return isImageFile(this.#currentMetadata()) && !!this.#imageEditorLauncher; }); /** @@ -415,12 +431,16 @@ export class DotFileFieldComponent if (newLauncher?.isAvailable()) { const metadata = this.#currentMetadata(); - // Seed the editor with the asset's stored focal point (exposed on the binary - // metadata as an "x,y" string by DefaultTransformStrategy) so reopening restores - // the marker instead of resetting it to centre. - const focalPoint = parseFocalPoint(metadata?.focalPoint); + // Seed the editor with the asset's stored focal point so reopening restores + // the marker instead of resetting it to centre. A referenced dotAsset/FileAsset + // exposes it on assetMetaData/fileAssetMetaData; an inline binary temp on metaData. + const focalPoint = parseFocalPoint( + uploaded?.source === 'contentlet' + ? focalPointFromContentlet(uploaded.file) + : focalPointFromMetadata(metadata) + ); - this.#applyEditedImage( + this.store.applyEditedImage( newLauncher.open({ inode, tempId, @@ -440,7 +460,7 @@ export class DotFileFieldComponent ? this.#legacyDojoImageEditorLauncher : this.#legacyDialogImageEditorLauncher; - this.#applyEditedImage( + this.store.applyEditedImage( legacyLauncher.open({ inode, tempId, @@ -450,29 +470,6 @@ export class DotFileFieldComponent ); } - /** - * Applies the edited image emitted by an image-editor launcher to the preview, - * shared by the new Angular editor and the legacy launchers. Ignores a closed - * editor (no temp file) and surfaces a server error if the stream fails. - * - * @param result$ the launcher's close stream, emitting the edited temp file or null - */ - #applyEditedImage(result$: Observable): void { - result$ - .pipe( - filter((tempFile): tempFile is DotCMSTempFile => !!tempFile), - takeUntilDestroyed(this.#destroyRef) - ) - .subscribe({ - next: (tempFile) => { - this.store.applyTempFile(tempFile); - }, - error: () => { - this.store.setUIMessage(getUiMessage('SERVER_ERROR')); - } - }); - } - /** * Handle file drop event. * diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts index 79a43adb2f4c..f785fa6ce2fb 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts @@ -12,7 +12,8 @@ import { DotContentletService, DotMessageService, DotUploadFileService, - DotUploadService + DotUploadService, + DotWorkflowActionsFireService } from '@dotcms/data-access'; import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; import { DotDropZoneComponent, DropZoneErrorType, DropZoneFileEvent } from '@dotcms/ui'; @@ -74,6 +75,7 @@ describe('DotFileFieldComponent', () => { DotFileFieldUploadService, LegacyDialogImageEditorLauncher, LegacyDojoImageEditorLauncher, + mockProvider(DotWorkflowActionsFireService), provideHttpClient(), provideHttpClientTesting(), mockProvider(DotUploadFileService), @@ -565,13 +567,13 @@ describe('DotFileFieldComponent', () => { setImagePreview(true); - const spyApply = jest.spyOn(store, 'applyTempFile'); + const spyApply = jest.spyOn(store, 'applyEditedImage').mockImplementation(); spectator.component.onEditImage(); expect(dialogLauncher.open).toHaveBeenCalled(); expect(dojoLauncher.open).not.toHaveBeenCalled(); - expect(spyApply).toHaveBeenCalledWith(tempFile); + expect(spyApply).toHaveBeenCalled(); }); it('should open the Dojo launcher when useLegacyDojoImageEditor is true', () => { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts index 00dcc94e74dd..02359eb1272b 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.ts @@ -3,6 +3,7 @@ import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; import { DialogService } from 'primeng/dynamicdialog'; +import { DotWorkflowActionsFireService } from '@dotcms/data-access'; import { DotCMSContentTypeField, DotCMSContentlet } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; @@ -28,7 +29,12 @@ import { BaseWrapperField } from '../shared/base-wrapper-field'; DotMessagePipe, ReactiveFormsModule ], - providers: [DotFileFieldUploadService, FileFieldStore, DialogService], + providers: [ + DotFileFieldUploadService, + FileFieldStore, + DialogService, + DotWorkflowActionsFireService + ], templateUrl: './dot-edit-content-file-field.component.html', changeDetection: ChangeDetectionStrategy.OnPush, viewProviders: [ diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.spec.ts index 460991a7e575..777fdfc7b0b0 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.spec.ts @@ -3,6 +3,9 @@ import { of, throwError } from 'rxjs'; import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { DotWorkflowActionsFireService } from '@dotcms/data-access'; +import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; + import { FileFieldStore } from './file-field.store'; import { UIMessage } from '../../../models/dot-edit-content-file.model'; @@ -16,7 +19,11 @@ describe('FileFieldStore', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [FileFieldStore, mockProvider(DotFileFieldUploadService)] + providers: [ + FileFieldStore, + mockProvider(DotFileFieldUploadService), + mockProvider(DotWorkflowActionsFireService) + ] }); store = TestBed.inject(FileFieldStore); @@ -257,4 +264,133 @@ describe('FileFieldStore', () => { expect(store.uiMessage()).toEqual(getUiMessage('SERVER_ERROR')); }); }); + + describe('Method: publishEditedAsset', () => { + let fire: SpyObject; + const TEMP = { id: 'temp_1', fileName: 'edited.png' } as DotCMSTempFile; + const ASSET = { + identifier: 'ref-id', + inode: 'ref-inode', + languageId: 1 + } as DotCMSContentlet; + + beforeEach(() => { + fire = TestBed.inject( + DotWorkflowActionsFireService + ) as SpyObject; + // Hydrate the preview with a referenced dotAsset. + service.getContentById.mockReturnValue(of(ASSET)); + store.getAssetData('ref-id'); + }); + + it('publishes a new version of the referenced dotAsset in its own language', () => { + fire.publishContentletByIdentifier.mockReturnValue(of(ASSET)); + + store.publishEditedAsset(TEMP); + + expect(fire.publishContentletByIdentifier).toHaveBeenCalledWith( + { identifier: 'ref-id', asset: 'temp_1' }, + 1 + ); + }); + + it('targets the fileAsset field for a legacy FileAsset reference', () => { + service.getContentById.mockReturnValue(of({ ...ASSET, titleImage: 'fileAsset' })); + store.getAssetData('ref-id'); + fire.publishContentletByIdentifier.mockReturnValue(of(ASSET)); + + store.publishEditedAsset(TEMP); + + expect(fire.publishContentletByIdentifier).toHaveBeenCalledWith( + { identifier: 'ref-id', fileAsset: 'temp_1' }, + 1 + ); + }); + + it('refreshes the preview from the new version without changing the value', () => { + const newVersion = { ...ASSET, inode: 'new-inode' }; + fire.publishContentletByIdentifier.mockReturnValue(of(ASSET)); + service.getContentById.mockReturnValue(of(newVersion)); + + store.publishEditedAsset(TEMP); + + expect(service.getContentById).toHaveBeenCalledWith('ref-id'); + expect(store.uploadedFile()).toEqual({ source: 'contentlet', file: newVersion }); + expect(store.value()).toBe('ref-id'); + expect(store.fileStatus()).toBe('preview'); + }); + + it('surfaces a server error when the publish fails', () => { + fire.publishContentletByIdentifier.mockReturnValue(throwError(() => 'error')); + + store.publishEditedAsset(TEMP); + + expect(store.uiMessage()).toEqual(getUiMessage('SERVER_ERROR')); + }); + + it('surfaces a message and does not publish without a referenced asset', () => { + store.removeFile(); + + store.publishEditedAsset(TEMP); + + expect(fire.publishContentletByIdentifier).not.toHaveBeenCalled(); + expect(store.uiMessage()).toEqual(getUiMessage('SERVER_ERROR')); + }); + }); + + describe('Method: applyEditedImage', () => { + const TEMP = { id: 'temp_1', fileName: 'edited.png' } as DotCMSTempFile; + const ASSET = { + identifier: 'ref-id', + inode: 'ref-inode', + languageId: 1 + } as DotCMSContentlet; + let fire: SpyObject; + + beforeEach(() => { + fire = TestBed.inject( + DotWorkflowActionsFireService + ) as SpyObject; + fire.publishContentletByIdentifier.mockReturnValue(of(ASSET)); + service.getContentById.mockReturnValue(of(ASSET)); + }); + + it('routes Binary edits to the inline apply (no asset publish)', () => { + store.initLoad({ fieldVariable: 'bin', inputType: 'Binary' }); + + store.applyEditedImage(of(TEMP)); + + expect(fire.publishContentletByIdentifier).not.toHaveBeenCalled(); + expect(store.value()).toBe('temp_1'); + expect(store.uploadedFile()).toEqual({ source: 'temp', file: TEMP }); + }); + + it('routes Image/File edits to the referenced-asset publish', () => { + store.initLoad({ fieldVariable: 'img', inputType: 'Image' }); + store.getAssetData('ref-id'); // hydrate the referenced contentlet + + store.applyEditedImage(of(TEMP)); + + expect(fire.publishContentletByIdentifier).toHaveBeenCalledWith( + { identifier: 'ref-id', asset: 'temp_1' }, + 1 + ); + }); + + it('ignores a closed editor (null) without persisting', () => { + store.initLoad({ fieldVariable: 'img', inputType: 'Image' }); + + store.applyEditedImage(of(null)); + + expect(fire.publishContentletByIdentifier).not.toHaveBeenCalled(); + }); + + it('surfaces a server error when the launcher stream fails', () => { + store.initLoad({ fieldVariable: 'img', inputType: 'Image' }); + + store.applyEditedImage(throwError(() => 'error')); + + expect(store.uiMessage()).toEqual(getUiMessage('SERVER_ERROR')); + }); + }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts index d250c324703f..7bfeac7dd822 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/store/file-field.store.ts @@ -1,13 +1,15 @@ import { tapResponse } from '@ngrx/operators'; import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { Observable, of, pipe } from 'rxjs'; +import { EMPTY, Observable, of, pipe } from 'rxjs'; import { HttpClient } from '@angular/common/http'; -import { computed, inject } from '@angular/core'; +import { computed, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { filter, map, switchMap, tap } from 'rxjs/operators'; +import { DotWorkflowActionsFireService } from '@dotcms/data-access'; import { DotCMSContentlet, DotCMSTempFile, DotFileMetadata } from '@dotcms/dotcms-models'; import { @@ -114,6 +116,7 @@ export const FileFieldStore = signalStore( })), withMethods((store) => { const uploadService = inject(DotFileFieldUploadService); + const workflowActionsFire = inject(DotWorkflowActionsFireService); const http = inject(HttpClient); /** @@ -333,6 +336,68 @@ export const FileFieldStore = signalStore( ) ) ), + /** + * publishEditedAsset versions the referenced asset (Image/File fields). + * + * Image/File fields store only an `identifier` referencing a separate + * `dotAsset`/`FileAsset` contentlet (the binary lives in its `asset` / + * `fileAsset` field). Saving an edit checks in and publishes a NEW version + * of that referenced contentlet in its own language, then re-hydrates the + * preview. The field value (the identifier) is unchanged, so the reference + * is preserved and other content sharing the asset sees the update. + * + * `switchMap` serializes concurrent saves: a re-triggered edit cancels an + * in-flight publish+refresh instead of racing it. + * @param tempFile edited image staged as a temp file by the image editor + */ + publishEditedAsset: rxMethod( + pipe( + switchMap((tempFile) => { + const uploaded = store.uploadedFile(); + + // Only a resolved dotAsset/FileAsset reference can be versioned; + // reaching here without one is unexpected — surface it instead of + // silently discarding the edit. + if (uploaded?.source !== 'contentlet') { + patchState(store, { uiMessage: getUiMessage('SERVER_ERROR') }); + + return EMPTY; + } + + const { identifier, languageId } = uploaded.file; + // The binary field variable differs by referenced type: `asset` + // for a dotAsset, `fileAsset` for a legacy FileAsset. titleImage + // carries the server-computed field name; default to `asset`. + const fieldVariable = (uploaded.file['titleImage'] as string) || 'asset'; + + patchState(store, { fileStatus: 'uploading' }); + + return workflowActionsFire + .publishContentletByIdentifier( + { identifier, [fieldVariable]: tempFile.id }, + languageId + ) + .pipe( + switchMap(() => uploadService.getContentById(identifier)), + tapResponse({ + next: (file) => { + patchState(store, { + fileStatus: 'preview', + value: file.identifier, + uploadedFile: { source: 'contentlet', file } + }); + }, + error: () => { + patchState(store, { + fileStatus: 'preview', + uiMessage: getUiMessage('SERVER_ERROR') + }); + } + }) + ); + }) + ) + ), /** * setFileFromContentlet hydrates the preview from a saved contentlet. * Used by the binary web component, which receives the contentlet @@ -424,5 +489,41 @@ export const FileFieldStore = signalStore( ) ) }; + }), + withMethods((store) => { + const destroyRef = inject(DestroyRef); + + return { + /** + * Consumes an image-editor launcher's close stream and persists the edit. + * + * Owns the whole apply flow so callers just hand over the launcher stream: + * ignores a closed editor (null), routes by input type (Binary applies the + * edit inline; Image/File version the referenced dotAsset/FileAsset), and + * surfaces a server error if the stream fails. The subscription is torn + * down with the store. + * + * @param result$ the launcher close stream emitting the edited temp file or null + */ + applyEditedImage(result$: Observable): void { + result$ + .pipe( + filter((tempFile): tempFile is DotCMSTempFile => !!tempFile), + takeUntilDestroyed(destroyRef) + ) + .subscribe({ + next: (tempFile) => { + if (store.inputType() === INPUT_TYPES.Binary) { + store.applyTempFile(tempFile); + } else { + store.publishEditedAsset(tempFile); + } + }, + error: () => { + patchState(store, { uiMessage: getUiMessage('SERVER_ERROR') }); + } + }); + } + }; }) ); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/focal-point.util.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/focal-point.util.spec.ts index 111bc6c2bbaa..71c1fd92a504 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/focal-point.util.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/focal-point.util.spec.ts @@ -1,4 +1,58 @@ -import { parseFocalPoint } from './focal-point.util'; +import { DotCMSContentlet, DotFileMetadata } from '@dotcms/dotcms-models'; + +import { + focalPointFromContentlet, + focalPointFromMetadata, + parseFocalPoint +} from './focal-point.util'; + +describe('focalPointFromContentlet', () => { + it('reads a dotAsset focal from assetMetaData (titleImage=asset)', () => { + const file = { + titleImage: 'asset', + assetMetaData: { focalPoint: '0.4,0.6' } + } as unknown as DotCMSContentlet; + expect(focalPointFromContentlet(file)).toBe('0.4,0.6'); + }); + + it('reads a legacy FileAsset focal from fileAssetMetaData (titleImage=fileAsset)', () => { + const file = { + titleImage: 'fileAsset', + metaData: { name: 'a.png' }, + fileAssetMetaData: { focalPoint: '0.76,0.13' } + } as unknown as DotCMSContentlet; + expect(focalPointFromContentlet(file)).toBe('0.76,0.13'); + }); + + it('defaults the field variable to asset when titleImage is absent', () => { + const file = { assetMetaData: { focalPoint: '0.1,0.2' } } as unknown as DotCMSContentlet; + expect(focalPointFromContentlet(file)).toBe('0.1,0.2'); + }); + + it('returns undefined when the field metadata carries no focal', () => { + expect(focalPointFromContentlet(null)).toBeUndefined(); + expect( + focalPointFromContentlet({ + titleImage: 'fileAsset', + fileAssetMetaData: { name: 'a.png' } + } as unknown as DotCMSContentlet) + ).toBeUndefined(); + }); +}); + +describe('focalPointFromMetadata', () => { + it('reads the focalPoint key', () => { + expect(focalPointFromMetadata({ focalPoint: '0.4,0.6' } as DotFileMetadata)).toBe( + '0.4,0.6' + ); + }); + + it('returns undefined for null/undefined or when the key is absent', () => { + expect(focalPointFromMetadata(null)).toBeUndefined(); + expect(focalPointFromMetadata(undefined)).toBeUndefined(); + expect(focalPointFromMetadata({} as DotFileMetadata)).toBeUndefined(); + }); +}); describe('parseFocalPoint', () => { it('parses an "x,y" focal point string into a point', () => { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/focal-point.util.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/focal-point.util.ts index a5b59d65e62d..06d9bde92b82 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/focal-point.util.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/focal-point.util.ts @@ -1,5 +1,47 @@ +import { DotCMSContentlet, DotFileMetadata } from '@dotcms/dotcms-models'; import { NormalizedPoint } from '@dotcms/image-editor'; +/** + * Reads the focal point string from a resolved binary metadata object. + * + * The backend surfaces it under the single clean `focalPoint` key on the binary + * field's metadata — the same mechanism Binary fields have always used. + * + * @param metadata - The resolved file metadata. + * @returns The focal point `"x,y"` string, or undefined when none is present. + */ +export function focalPointFromMetadata( + metadata: DotFileMetadata | null | undefined +): string | undefined { + return metadata?.focalPoint; +} + +/** + * Resolves the focal point from a referenced asset contentlet. + * + * The focal lives on the binary field's metadata, exposed by the backend as + * `{fieldVar}MetaData`, where `fieldVar` is the asset's binary field (`asset` for a + * dotAsset, `fileAsset` for a legacy FileAsset) — carried by the contentlet's + * `titleImage`. This is the same `{fieldVar}MetaData.focalPoint` mechanism a Binary + * field uses, applied uniformly. + * + * @param file - The referenced asset contentlet (dotAsset or FileAsset). + * @returns The focal point `"x,y"` string, or undefined when none is present. + */ +export function focalPointFromContentlet( + file: DotCMSContentlet | null | undefined +): string | undefined { + if (!file) { + return undefined; + } + + const raw = file as unknown as Record; + const fieldVar = + ((file as unknown as Record)['titleImage'] ?? 'asset') || 'asset'; + + return focalPointFromMetadata(raw[`${fieldVar}MetaData`]); +} + /** * Parses a focal point stored as an `"x,y"` string (the backend exposes it on the * binary field metadata as a custom `focalPoint` attribute) into a normalized 0..1 diff --git a/core-web/libs/image-editor/src/index.ts b/core-web/libs/image-editor/src/index.ts index 3899a11ec43f..7be46854cbda 100644 --- a/core-web/libs/image-editor/src/index.ts +++ b/core-web/libs/image-editor/src/index.ts @@ -2,6 +2,7 @@ export { DotImageEditorComponent } from './lib/components/dot-image-editor/dot-i export * from './lib/models/image-editor.models'; export { RANGES } from './lib/image-editor.constants'; export { buildFilterChain, buildPreviewUrl, cleanUrl } from './lib/utils/image-filter-url.builder'; +export { isImageFile } from './lib/utils/is-image-file.util'; export * from './lib/store/image-editor.events'; export { ImageEditorStore } from './lib/store/image-editor.store'; export { initialImageEditorState } from './lib/store/image-editor.state'; diff --git a/core-web/libs/image-editor/src/lib/store/features/with-save.feature.spec.ts b/core-web/libs/image-editor/src/lib/store/features/with-save.feature.spec.ts index 88f3b58d98c7..203d9c8c16c6 100644 --- a/core-web/libs/image-editor/src/lib/store/features/with-save.feature.spec.ts +++ b/core-web/libs/image-editor/src/lib/store/features/with-save.feature.spec.ts @@ -99,13 +99,63 @@ describe('withSave', () => { lifecycle.saveRequested(); expect(service.saveEditedImage).toHaveBeenCalledTimes(1); + // The editor only edits images, so the result is always flagged as an image and + // seeded with the current focal point (see `enrichEditedImage`). expect(dispatchSpy).toHaveBeenCalledWith( - imageEditorLifecycleEvents.saveSucceeded(TEMP_FILE) + imageEditorLifecycleEvents.saveSucceeded({ + ...TEMP_FILE, + image: true, + metadata: { + contentType: 'image/png', + fileSize: 1024, + length: 1024, + modDate: 0, + name: 'edited.png', + sha256: '', + title: 'edited.png', + version: 0, + isImage: true, + focalPoint: '0.25,0.75' + } + }) ); expect(store.saveStatus()).toBe('idle'); expect(store.saveError()).toBeNull(); }); + it('recognizes the edit as an image even when the servlet returns no metadata', () => { + // Regression: the Save servlet can return the edited temp file as + // `metadata: null, image: false, mimeType: "unknown"`. Without enrichment the + // thumbnail, the "edit image" gate and the file-info dialog stop treating it as + // an image (blank thumb / hidden pencil / crash on `metadata.title`). + service.saveEditedImage.mockReturnValue( + of({ ...TEMP_FILE, image: false, mimeType: 'unknown', metadata: null } as never) + ); + const dispatchSpy = setup(); + + lifecycle.saveRequested(); + + expect(dispatchSpy).toHaveBeenCalledWith( + imageEditorLifecycleEvents.saveSucceeded({ + ...TEMP_FILE, + image: true, + mimeType: 'unknown', + metadata: { + contentType: 'unknown', + fileSize: 1024, + length: 1024, + modDate: 0, + name: 'edited.png', + sha256: '', + title: 'edited.png', + version: 0, + isImage: true, + focalPoint: '0.25,0.75' + } + }) + ); + }); + it('folds the current focal point into the returned temp metadata', () => { // The servlet returns a temp whose serialized metadata is basic (no focalPoint), // so the feature injects the focal so an in-session reopen can re-seed the marker. diff --git a/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts b/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts index c12fa101d964..49693a77b950 100644 --- a/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts +++ b/core-web/libs/image-editor/src/lib/store/features/with-save.feature.ts @@ -8,6 +8,7 @@ import { exhaustMap } from 'rxjs/operators'; import { AppliedFilter, ImageEditorState } from '../../models/image-editor.models'; import { DotImageEditorService } from '../../services/dot-image-editor.service'; +import { enrichEditedImage } from '../../utils/enrich-edited-image.util'; import { buildSaveUrl } from '../../utils/image-filter-url.builder'; import { imageEditorLifecycleEvents } from '../image-editor.events'; import { errorMessage } from '../image-editor.store-utils'; @@ -72,15 +73,13 @@ export function withSave() { tapResponse({ next: (tempFile) => dispatcher.dispatch( - imageEditorLifecycleEvents.saveSucceeded({ - ...tempFile, - metadata: tempFile.metadata - ? { - ...tempFile.metadata, - focalPoint: `${focalPoint.x},${focalPoint.y}` - } - : tempFile.metadata - }) + imageEditorLifecycleEvents.saveSucceeded( + enrichEditedImage( + tempFile, + store.assetContext(), + focalPoint + ) + ) ), error: (error) => dispatcher.dispatch( diff --git a/core-web/libs/image-editor/src/lib/utils/enrich-edited-image.util.spec.ts b/core-web/libs/image-editor/src/lib/utils/enrich-edited-image.util.spec.ts new file mode 100644 index 000000000000..c8ed826e9aa4 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/enrich-edited-image.util.spec.ts @@ -0,0 +1,104 @@ +import { DotCMSTempFile } from '@dotcms/dotcms-models'; + +import { enrichEditedImage } from './enrich-edited-image.util'; + +import { ImageEditorAssetContext, NormalizedPoint } from '../models/image-editor.models'; + +const CTX: ImageEditorAssetContext = { + idOrTempId: 'abc123', + inode: 'abc123', + tempId: null, + variable: 'binary', + fieldName: 'binary', + fileName: 'beach.png', + mimeType: 'image/png', + isTempFile: false, + byInode: true, + naturalWidth: 2752, + naturalHeight: 1536, + originalUrl: '/contentAsset/image/abc123/binary' +}; + +const FOCAL: NormalizedPoint = { x: 0.25, y: 0.75 }; + +const BASE_TEMP: DotCMSTempFile = { + fileName: 'edited.png', + folder: 'shared', + id: 'temp_1', + image: false, + length: 1024, + mimeType: 'image/png', + referenceUrl: '/dA/temp_1', + thumbnailUrl: '' +}; + +describe('enrichEditedImage', () => { + it('flags the temp file as an image and seeds the focal point', () => { + const result = enrichEditedImage(BASE_TEMP, CTX, FOCAL); + + expect(result.image).toBe(true); + expect(result.metadata?.isImage).toBe(true); + expect(result.metadata?.focalPoint).toBe('0.25,0.75'); + }); + + it('synthesizes metadata from the context when the servlet returns none', () => { + const tempFile = { ...BASE_TEMP, mimeType: 'unknown', metadata: null } as never; + + const result = enrichEditedImage(tempFile, CTX, FOCAL); + + expect(result.metadata).toEqual({ + contentType: 'image/png', // ctx.mimeType wins over the temp's "unknown" + fileSize: 1024, + length: 1024, + modDate: 0, + name: 'edited.png', + sha256: '', + title: 'edited.png', + version: 0, + width: 2752, + height: 1536, + isImage: true, + focalPoint: '0.25,0.75' + }); + }); + + it('preserves real server metadata and only forces isImage + focal point', () => { + const metadata = { + contentType: 'image/jpeg', + fileSize: 5000, + isImage: true, + length: 5000, + modDate: 123, + name: 'server.jpg', + sha256: 'deadbeef', + title: 'Server Title', + version: 7, + width: 800, + height: 600 + }; + + const result = enrichEditedImage({ ...BASE_TEMP, metadata }, CTX, FOCAL); + + expect(result.metadata).toEqual({ ...metadata, isImage: true, focalPoint: '0.25,0.75' }); + }); + + it('falls back to the context file name when the temp file has none', () => { + const result = enrichEditedImage( + { ...BASE_TEMP, fileName: '', metadata: null } as never, + CTX, + FOCAL + ); + + expect(result.metadata?.name).toBe('beach.png'); + expect(result.metadata?.title).toBe('beach.png'); + }); + + it('omits width/height when the context has no natural dimensions', () => { + const ctx = { ...CTX, naturalWidth: 0, naturalHeight: 0 }; + + const result = enrichEditedImage({ ...BASE_TEMP, metadata: null } as never, ctx, FOCAL); + + expect(result.metadata).not.toHaveProperty('width'); + expect(result.metadata).not.toHaveProperty('height'); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/utils/enrich-edited-image.util.ts b/core-web/libs/image-editor/src/lib/utils/enrich-edited-image.util.ts new file mode 100644 index 000000000000..e1c7a35aa66e --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/enrich-edited-image.util.ts @@ -0,0 +1,47 @@ +import { DotCMSTempFile, DotFileMetadata } from '@dotcms/dotcms-models'; + +import { ImageEditorAssetContext, NormalizedPoint } from '../models/image-editor.models'; + +/** + * Enriches the temp file returned by the Save servlet so downstream consumers + * keep recognizing it as an image. + * + * The editor only ever edits images, but the Save endpoint can return a temp + * file without metadata (`metadata: null`, `image: false`, `mimeType: "unknown"`). + * Left untouched, the thumbnail, the "edit image" gate and the file-info dialog + * all stop treating it as an image (blank thumb / hidden pencil / crash on + * `metadata.title`). Synthesizing minimal metadata here — the one place that + * knows the result is an edited image — keeps every consumer and host consistent, + * and always folds in the current focal point. + * + * @param tempFile - The temp file returned by the Save servlet. + * @param ctx - The asset context the editor opened with (source of name/mime/size). + * @param focalPoint - The focal point at save time, seeded into the metadata. + * @returns A temp file flagged as an image with complete, focal-seeded metadata. + */ +export function enrichEditedImage( + tempFile: DotCMSTempFile, + ctx: ImageEditorAssetContext, + focalPoint: NormalizedPoint +): DotCMSTempFile { + const name = tempFile.fileName || ctx.fileName || ''; + const metadata: DotFileMetadata = { + contentType: ctx.mimeType || tempFile.mimeType || 'image/*', + fileSize: tempFile.length ?? 0, + length: tempFile.length ?? 0, + modDate: 0, + name, + sha256: '', + title: name, + version: 0, + ...(ctx.naturalWidth ? { width: ctx.naturalWidth } : {}), + ...(ctx.naturalHeight ? { height: ctx.naturalHeight } : {}), + // Preserve real server metadata when present; then force the invariants: + // the result is an image, seeded with the current focal point. + ...tempFile.metadata, + isImage: true, + focalPoint: `${focalPoint.x},${focalPoint.y}` + }; + + return { ...tempFile, image: true, metadata }; +} diff --git a/core-web/libs/image-editor/src/lib/utils/is-image-file.util.spec.ts b/core-web/libs/image-editor/src/lib/utils/is-image-file.util.spec.ts new file mode 100644 index 000000000000..e8ed8c84beb4 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/is-image-file.util.spec.ts @@ -0,0 +1,37 @@ +import { isImageFile } from './is-image-file.util'; + +describe('isImageFile', () => { + it('returns false for null or undefined metadata', () => { + expect(isImageFile(null)).toBe(false); + expect(isImageFile(undefined)).toBe(false); + }); + + it('returns false for empty metadata', () => { + expect(isImageFile({})).toBe(false); + }); + + it('trusts the authoritative isImage flag', () => { + expect(isImageFile({ isImage: true })).toBe(true); + expect(isImageFile({ isImage: false, contentType: 'application/pdf', name: 'a.pdf' })).toBe( + false + ); + }); + + it('falls back to an image/* content type when isImage is absent', () => { + expect(isImageFile({ contentType: 'image/png' })).toBe(true); + expect(isImageFile({ contentType: 'IMAGE/PNG' })).toBe(true); + expect(isImageFile({ contentType: 'application/pdf' })).toBe(false); + }); + + it('does not infer image-ness from the file name alone', () => { + // Extension sniffing is intentionally avoided; name is not a trusted signal. + expect(isImageFile({ name: 'photo.png' })).toBe(false); + }); + + it('detects image metadata coming from a referenced dotAsset', () => { + // Shape of a dotAsset's assetMetaData resolved via getFileMetadata upstream. + expect(isImageFile({ isImage: true, contentType: 'image/jpeg', name: 'image 2.jpg' })).toBe( + true + ); + }); +}); diff --git a/core-web/libs/image-editor/src/lib/utils/is-image-file.util.ts b/core-web/libs/image-editor/src/lib/utils/is-image-file.util.ts new file mode 100644 index 000000000000..b8bd3826a988 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/utils/is-image-file.util.ts @@ -0,0 +1,26 @@ +import { DotFileMetadata } from '@dotcms/dotcms-models'; + +/** + * Determines whether a file's metadata describes an image. + * + * Trusts the authoritative server-computed `isImage` flag first, then falls back + * to the `image/*` content type. Both signals are reliably present on the + * metadata this gate runs against: the `assetMetaData` of the `dotAsset` + * referenced by Image/File fields, and the (enriched) metadata of Binary temp + * files. Extension sniffing is intentionally avoided — file names are unreliable + * and the mime type is always available here. + * + * @param metadata The resolved file metadata (may be partial, null or undefined). + * @returns true when the metadata describes an image. + */ +export function isImageFile(metadata: Partial | null | undefined): boolean { + if (!metadata) { + return false; + } + + if (metadata.isImage) { + return true; + } + + return !!metadata.contentType?.toLowerCase().startsWith('image/'); +} diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java index 616e6a6772d9..84bfc5838ba1 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategy.java @@ -253,6 +253,22 @@ private void addBinaries(final Contentlet contentlet, final Map : contentlet.getBinaryMetadata(FILE_ASSET).getName(); putBinaryLinks(FILE_ASSET, name, contentlet, map); + + // Surface the binary metadata (including the focal point from custom + // metadata) for the FileAsset's binary field, mirroring the dotAsset path + // below. Without this the image editor cannot re-seed the focal marker when + // reopening a File/Image field that references a legacy FileAsset. + final Metadata fileAssetMetadata = contentlet.getBinaryMetadata(FILE_ASSET); + if (null != fileAssetMetadata) { + final Map metaMap = new HashMap<>( + fileAssetMetadata.getMap()); + metaMap.remove("path"); + final String focalPoint = Try.of(() -> fileAssetMetadata.getCustomMeta() + .getOrDefault(FocalPointAPI.FOCAL_POINT, "0.0").toString()) + .getOrElse("0.0"); + metaMap.put(FocalPointAPI.FOCAL_POINT, focalPoint); + map.put(FILE_ASSET + "MetaData", metaMap); + } continue; } diff --git a/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategyTest.java b/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategyTest.java index e4a202d1356c..3823c46d3058 100644 --- a/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategyTest.java +++ b/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/DefaultTransformStrategyTest.java @@ -9,8 +9,11 @@ import com.dotcms.contenttype.model.field.Field; import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.storage.model.Metadata; +import com.dotmarketing.beans.Identifier; +import com.dotmarketing.business.IdentifierAPI; import com.dotmarketing.image.focalpoint.FocalPointAPI; import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.fileassets.business.FileAssetAPI; import java.io.Serializable; import java.lang.reflect.Method; import java.util.HashMap; @@ -114,4 +117,100 @@ public void testAddBinaries_whenCustomMetaHasNoFocalPoint_defaultsToZero() assertEquals("focalPoint must default to 0.0 when absent from custom metadata", "0.0", metaMap.get(FocalPointAPI.FOCAL_POINT)); } + + private static final String FILE_ASSET_META_KEY = FileAssetAPI.BINARY_FIELD + "MetaData"; + + /** + * Mocks an {@link APIProvider} whose {@code identifierAPI} resolves to an asset name, so the + * FileAsset branch of {@code addBinaries} can build its binary links. The field is injected via + * reflection because the real {@code APIProvider.Builder} eagerly resolves APILocator defaults, + * which is not available in a pure unit test. + */ + private APIProvider toolBoxResolvingAssetName(final String assetName) throws Exception { + final Identifier identifier = Mockito.mock(Identifier.class); + Mockito.when(identifier.getAssetName()).thenReturn(assetName); + final IdentifierAPI identifierAPI = Mockito.mock(IdentifierAPI.class); + Mockito.when(identifierAPI.find(Mockito.anyString())).thenReturn(identifier); + + final APIProvider toolBox = Mockito.mock(APIProvider.class); + final java.lang.reflect.Field field = APIProvider.class.getDeclaredField("identifierAPI"); + field.setAccessible(true); + field.set(toolBox, identifierAPI); + return toolBox; + } + + private Contentlet mockFileAssetWithBinary(final Metadata metadata) throws Exception { + final Field field = Mockito.mock(BinaryField.class); + Mockito.when(field.variable()).thenReturn(FileAssetAPI.BINARY_FIELD); + + final ContentType contentType = Mockito.mock(ContentType.class); + Mockito.when(contentType.fields(BinaryField.class)).thenReturn(List.of(field)); + + final Contentlet contentlet = Mockito.mock(Contentlet.class); + Mockito.when(contentlet.getContentType()).thenReturn(contentType); + Mockito.when(contentlet.isFileAsset()).thenReturn(true); + Mockito.when(contentlet.getIdentifier()).thenReturn("identifier-1"); + Mockito.when(contentlet.getInode()).thenReturn("inode-1"); + Mockito.when(contentlet.getBinaryMetadata(FileAssetAPI.BINARY_FIELD)).thenReturn(metadata); + return contentlet; + } + + /** + * A legacy FileAsset's {@code fileAsset} binary field must also surface its focal point under + * {@code fileAssetMetaData.focalPoint}, mirroring the dotAsset path, so the image editor can + * re-seed the marker when a File/Image field references a FileAsset. Regression coverage for + * #36363. + */ + @Test + public void testAddBinaries_fileAsset_whenCustomMetaHasFocalPoint_surfacesItInMetaDataMap() + throws Exception { + + final Map fieldsMeta = new HashMap<>(); + fieldsMeta.put("name", "image.png"); + fieldsMeta.put(CUSTOM_FOCAL_POINT_KEY, "0.76,0.13"); + final Metadata metadata = new Metadata(FileAssetAPI.BINARY_FIELD, fieldsMeta); + + final DefaultTransformStrategy strategy = + new DefaultTransformStrategy(toolBoxResolvingAssetName("image.png")); + final Contentlet contentlet = mockFileAssetWithBinary(metadata); + + final Map map = new HashMap<>(); + invokeAddBinaries(strategy, contentlet, map); + + assertTrue("Expected fileAssetMetaData to be surfaced for a FileAsset", + map.containsKey(FILE_ASSET_META_KEY)); + @SuppressWarnings("unchecked") + final Map metaMap = + (Map) map.get(FILE_ASSET_META_KEY); + assertEquals("Focal point must be exposed under the focalPoint key for a FileAsset", + "0.76,0.13", metaMap.get(FocalPointAPI.FOCAL_POINT)); + } + + /** + * A FileAsset with no focal point in its custom metadata still surfaces the + * {@code focalPoint} entry defaulting to "0.0", matching the dotAsset path. + */ + @Test + public void testAddBinaries_fileAsset_whenCustomMetaHasNoFocalPoint_defaultsToZero() + throws Exception { + + final Map fieldsMeta = new HashMap<>(); + fieldsMeta.put("name", "image.png"); + final Metadata metadata = new Metadata(FileAssetAPI.BINARY_FIELD, fieldsMeta); + + final DefaultTransformStrategy strategy = + new DefaultTransformStrategy(toolBoxResolvingAssetName("image.png")); + final Contentlet contentlet = mockFileAssetWithBinary(metadata); + + final Map map = new HashMap<>(); + invokeAddBinaries(strategy, contentlet, map); + + assertTrue("Expected fileAssetMetaData to be surfaced for a FileAsset", + map.containsKey(FILE_ASSET_META_KEY)); + @SuppressWarnings("unchecked") + final Map metaMap = + (Map) map.get(FILE_ASSET_META_KEY); + assertEquals("focalPoint must default to 0.0 when absent from custom metadata", + "0.0", metaMap.get(FocalPointAPI.FOCAL_POINT)); + } }