-
Notifications
You must be signed in to change notification settings - Fork 480
feat(image-editor): add Angular image editor #36236
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
356b5e9
e1f629a
ab9ce62
28e5ed9
d2c12b8
eba92bd
31c5410
2724912
5f438ee
31f2c90
2ed9d81
c4d75ee
7294651
c34cd70
074acb8
fe603b0
13be039
a49fa48
71dc266
e308cf0
171bd28
2611d71
d27f70d
5acbe47
fe49dea
403a665
efca8b2
1978d22
b3e24d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,7 @@ import { | |
| DotCMSContentTypeField, | ||
| DotCMSContentTypeFieldVariable, | ||
| DotCMSTempFile, | ||
| DotFileMetadata, | ||
| DotGeneratedAIImage | ||
| } from '@dotcms/dotcms-models'; | ||
| import { | ||
|
|
@@ -58,10 +59,11 @@ import { BinaryFieldMode, BinaryFieldStatus } from './interfaces'; | |
| import { DotBinaryFieldEditImageService } from './service/dot-binary-field-edit-image/dot-binary-field-edit-image.service'; | ||
| import { DotBinaryFieldValidatorService } from './service/dot-binary-field-validator/dot-binary-field-validator.service'; | ||
| import { DotBinaryFieldStore } from './store/binary-field.store'; | ||
| import { getUiMessage } from './utils/binary-field-utils'; | ||
| import { getFileMetadata, getUiMessage } from './utils/binary-field-utils'; | ||
|
|
||
| import { DEFAULT_MONACO_CONFIG } from '../../models/dot-edit-content-field.constant'; | ||
| import { getFieldVariablesParsed, stringToJson } from '../../utils/functions.util'; | ||
| import { IMAGE_EDITOR_LAUNCHER } from '../shared/image-editor-launcher'; | ||
|
|
||
| export const DEFAULT_BINARY_FIELD_MONACO_CONFIG: MonacoEditorConstructionOptions = { | ||
| ...DEFAULT_MONACO_CONFIG, | ||
|
|
@@ -118,6 +120,10 @@ export class DotEditContentBinaryFieldComponent | |
| readonly #dotAiService = inject(DotAiService); | ||
| readonly #dialogService = inject(DialogService); | ||
| readonly #destroyRef = inject(DestroyRef); | ||
| // Optional: the launcher is provided by the Angular edit-content shell, so the new | ||
| // image editor only activates there. When absent (e.g. a non-Angular host), or when | ||
| // isAvailable() is false, `onEditImage()` safely no-ops. | ||
| readonly #imageEditorLauncher = inject(IMAGE_EDITOR_LAUNCHER, { optional: true }); | ||
|
|
||
| $isAIPluginInstalled = toSignal(this.#dotAiService.checkPluginInstallation(), { | ||
| initialValue: false | ||
|
|
@@ -389,14 +395,54 @@ export class DotEditContentBinaryFieldComponent | |
| /** | ||
| * Open Image Editor | ||
| * | ||
| * Launches the editor through the {@link IMAGE_EDITOR_LAUNCHER} seam and applies | ||
| * the edited image back to the field via the binary field store. | ||
| * | ||
| * @memberof DotEditContentBinaryFieldComponent | ||
| */ | ||
| onEditImage() { | ||
| this.#dotBinaryFieldEditImageService.openImageEditor({ | ||
| inode: this.contentlet?.inode, | ||
| tempId: this.tempId, | ||
| variable: this.variable | ||
| }); | ||
| const launcher = this.#imageEditorLauncher; | ||
|
|
||
| // The new Angular editor is gated by FEATURE_FLAG_NEW_IMAGE_EDITOR (via the | ||
| // launcher's `isAvailable()`). When it's off — or no launcher is provided in | ||
| // this context — fall back to the legacy Dojo image editor. | ||
| if (!launcher?.isAvailable()) { | ||
| this.#dotBinaryFieldEditImageService.openImageEditor({ | ||
| inode: this.contentlet?.inode, | ||
| tempId: this.tempId, | ||
| variable: this.variable | ||
| }); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| const inode = this.contentlet?.inode; | ||
| const metadata = this.contentlet | ||
| ? (getFileMetadata(this.contentlet) as Partial<DotFileMetadata>) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| : null; | ||
|
|
||
| launcher | ||
| .open({ | ||
| inode, | ||
| tempId: this.tempId, | ||
| variable: this.variable, | ||
| fieldName: this.$field()?.name, | ||
| byInode: !!inode, | ||
| fileName: this.contentlet?.fileName ?? metadata?.name, | ||
| mimeType: metadata?.contentType | ||
| }) | ||
| .pipe( | ||
| filter((tempFile): tempFile is DotCMSTempFile => !!tempFile), | ||
| takeUntilDestroyed(this.#destroyRef) | ||
| ) | ||
| .subscribe({ | ||
| next: (tempFile) => this.#dotBinaryFieldStore.setFileFromTemp(tempFile), | ||
| // The dialog stream isn't expected to error, but guard it so an | ||
| // unexpected failure surfaces in the console instead of being swallowed | ||
| // by the global handler with the edited image silently never applied. | ||
| error: (error) => | ||
| console.error('Image editor failed to apply the edited image', error) | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { expect } from '@jest/globals'; | ||
| import { createServiceFactory, mockProvider, SpectatorService } from '@ngneat/spectator/jest'; | ||
| import { BehaviorSubject, Subject } from 'rxjs'; | ||
|
|
||
| import { DialogService } from 'primeng/dynamicdialog'; | ||
|
|
||
| import { DotPropertiesService } from '@dotcms/data-access'; | ||
| import { DotCMSTempFile, FeaturedFlags } from '@dotcms/dotcms-models'; | ||
| import { DotImageEditorComponent, ImageEditorOpenParams } from '@dotcms/image-editor'; | ||
|
|
||
| import { AngularImageEditorLauncher } from './angular-image-editor.launcher'; | ||
|
|
||
| describe('AngularImageEditorLauncher', () => { | ||
| let spectator: SpectatorService<AngularImageEditorLauncher>; | ||
| let onClose: Subject<DotCMSTempFile | undefined>; | ||
| // Drives the new-image-editor flag; `next()` flows through `toSignal` so the same | ||
| // service instance reflects on/off without re-creating it. | ||
| const featureFlag$ = new BehaviorSubject<boolean>(true); | ||
| const getFeatureFlag = jest.fn(() => featureFlag$); | ||
|
|
||
| const params: ImageEditorOpenParams = { | ||
| inode: 'inode-1', | ||
| variable: 'binaryField', | ||
| fieldName: 'binary' | ||
| }; | ||
|
|
||
| const createService = createServiceFactory({ | ||
| service: AngularImageEditorLauncher, | ||
| providers: [mockProvider(DotPropertiesService, { getFeatureFlag })], | ||
| mocks: [DialogService] | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| // Default the flag ON so the open() tests below run the Angular path; the | ||
| // gating itself is covered by the dedicated tests. | ||
| featureFlag$.next(true); | ||
| onClose = new Subject<DotCMSTempFile | undefined>(); | ||
| spectator = createService(); | ||
| spectator.inject(DialogService).open.mockReturnValue({ onClose }); | ||
| }); | ||
|
|
||
| it('should be available when FEATURE_FLAG_NEW_IMAGE_EDITOR is on', () => { | ||
| expect(spectator.service.isAvailable()).toBe(true); | ||
| expect(getFeatureFlag).toHaveBeenCalledWith(FeaturedFlags.FEATURE_FLAG_NEW_IMAGE_EDITOR); | ||
| }); | ||
|
|
||
| it('should NOT be available when the feature flag is off', () => { | ||
| featureFlag$.next(false); | ||
|
|
||
| expect(spectator.service.isAvailable()).toBe(false); | ||
| }); | ||
|
|
||
| it('should open the DotImageEditorComponent with a headerless, closable, escapable dialog', () => { | ||
| spectator.service.open(params).subscribe(); | ||
|
|
||
| expect(spectator.inject(DialogService).open).toHaveBeenCalledWith( | ||
| DotImageEditorComponent, | ||
| expect.objectContaining({ | ||
| data: params, | ||
| modal: true, | ||
| // The editor renders its own header; PrimeNG's chrome header is hidden. | ||
| showHeader: false, | ||
| closable: true, | ||
| closeOnEscape: true | ||
| }) | ||
| ); | ||
| }); | ||
|
|
||
| it('should resolve the temp file emitted on close', () => { | ||
| const tempFile = { id: 'temp-123' } as DotCMSTempFile; | ||
| let result: DotCMSTempFile | null | undefined; | ||
|
|
||
| spectator.service.open(params).subscribe((value) => (result = value)); | ||
| onClose.next(tempFile); | ||
|
|
||
| expect(result).toEqual(tempFile); | ||
| }); | ||
|
|
||
| it('should resolve null when the dialog closes without a value', () => { | ||
| let result: DotCMSTempFile | null | undefined; | ||
|
|
||
| spectator.service.open(params).subscribe((value) => (result = value)); | ||
| onClose.next(undefined); | ||
|
|
||
| expect(result).toBeNull(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This adds a new member to a
const enum. TYPESCRIPT_STANDARDS.md prohibits enums in favor ofas constobjects. The enum is pre-existing, so this isn't a new violation on its own, but it extends a prohibited pattern — worth tracking the migration to anas constmap.