diff --git a/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.spec.ts b/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.spec.ts index 05f958d63755..aa1ed3439d25 100644 --- a/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-workflow-actions-fire/dot-workflow-actions-fire.service.spec.ts @@ -194,6 +194,29 @@ describe('DotWorkflowActionsFireService', () => { }); })); + it('should EDIT by identifier in the given language and return the saved contentlet', () => + new Promise((done) => { + const mockResult = { identifier: 'abc', inode: '456' }; + + spectator.service + .saveContentletByIdentifier({ identifier: 'abc', fileAsset: 'temp_1' }, 2) + .subscribe((res) => { + expect(res).toEqual(mockResult); + done(); + }); + + const req = spectator.expectOne( + '/api/v1/workflow/actions/default/fire/EDIT?language=2', + HttpMethod.PUT + ); + + expect(req.request.body).toEqual({ + contentlet: { identifier: 'abc', fileAsset: 'temp_1' } + }); + + req.flush({ entity: mockResult }); + })); + it('should DESTROY and return the deleted contentlet', () => new Promise((done) => { const mockResult = { 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 f97839c27edf..1cc081861093 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 @@ -205,6 +205,28 @@ export class DotWorkflowActionsFireService { }); } + /** + * Fire the default EDIT (Save) action against an existing contentlet, resolved by the + * `identifier` in `data` and the given language. The fields in `data` replace the stored ones; + * the rest are kept. Saves a new working version without publishing it. + * + * @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 + */ + saveContentletByIdentifier( + data: { [key: string]: string }, + language?: string | number + ): Observable { + return this.request({ + data, + action: ActionToFire.EDIT, + language + }); + } + /** * 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 diff --git a/core-web/libs/edit-content/src/index.ts b/core-web/libs/edit-content/src/index.ts index c086c8c9b4e1..66c59e67bef0 100644 --- a/core-web/libs/edit-content/src/index.ts +++ b/core-web/libs/edit-content/src/index.ts @@ -13,6 +13,11 @@ export type { DotSidePanelChrome, DotSidePanelChromeOptions } from './lib/services/dot-side-panel-chrome'; +// Edit Source: a file's source in Monaco, in a side panel. Opened from Content Drive's row menu and +// from the page editor's VTL menu, both deciding with `toSourceEditorRequest`. +export { DotSourceEditorSidePanelComponent } from './lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component'; +export type { DotSourceEditorRequest } from './lib/models/dot-source-editor.model'; +export { toSourceEditorRequest } from './lib/utils/source-editor.util'; export * from './lib/utils/functions.util'; export * from './lib/models/dot-edit-content-field.constant'; diff --git a/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component.html b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component.html new file mode 100644 index 000000000000..231b29df43a6 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component.html @@ -0,0 +1,115 @@ + + + +
+ + {{ request()?.title }} + +
+ + + + {{ $expanded() ? 'close_fullscreen' : 'open_in_full' }} + + + + + + close + + +
+
+
+ + @switch ($status()) { + @case (ComponentStatus.LOADING) { +
+ +
+ } + @case (ComponentStatus.ERROR) { +
+ {{ 'edit.content.source-editor.load-error' | dm }} +
+ } + @default { + + + } + } + + +
+ + +
+
+
+ + + diff --git a/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component.spec.ts new file mode 100644 index 000000000000..6f69db08ee0a --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component.spec.ts @@ -0,0 +1,456 @@ +import { MonacoEditorLoaderService } from '@materia-ui/ngx-monaco-editor'; +import { + byTestId, + createComponentFactory, + mockProvider, + Spectator +} from '@openng/spectator/vitest'; +import { MockPipe } from 'ng-mocks'; +import { BehaviorSubject, NEVER, Observable, of, throwError } from 'rxjs'; +import { Mock, vi } from 'vitest'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { AfterViewInit, Component, forwardRef, input, output } from '@angular/core'; +import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { Confirmation, ConfirmationService, ConfirmEventType } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { DrawerModule } from 'primeng/drawer'; + +import { DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; +import { DOT_VELOCITY_LANGUAGE_ID, DotMessagePipe } from '@dotcms/ui'; + +import { DotSourceEditorSidePanelComponent } from './dot-source-editor-side-panel.component'; +import { DotSourceEditorFile, DotSourceEditorService } from './dot-source-editor.service'; + +import { DotSourceEditorRequest } from '../../models/dot-source-editor.model'; +import { DotSidePanelNavController } from '../../services/dot-side-panel-nav.service'; + +/** + * Stands in for `ngx-monaco-editor`: a value accessor like the real one, without Monaco's AMD + * loader. `type` does what typing in the editor does. + */ +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector -- must match the real element + selector: 'ngx-monaco-editor', + template: '', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MonacoEditorStubComponent), + multi: true + } + ] +}) +class MonacoEditorStubComponent implements ControlValueAccessor, AfterViewInit { + readonly options = input>(); + readonly init = output<{ focus: () => void }>(); + + readonly focus = vi.fn(); + value = ''; + #onChange: (value: string) => void = () => undefined; + + ngAfterViewInit(): void { + this.init.emit({ focus: this.focus }); + } + + writeValue(value: string): void { + this.value = value ?? ''; + } + + registerOnChange(fn: (value: string) => void): void { + this.#onChange = fn; + } + + registerOnTouched(): void { + // Not read by the panel. + } + + type(value: string): void { + this.value = value; + this.#onChange(value); + } +} + +const REQUEST: DotSourceEditorRequest = { + inode: 'vtl-inode', + identifier: 'vtl-id', + languageId: 2, + title: 'header.vtl', + language: DOT_VELOCITY_LANGUAGE_ID +}; + +const FILE: DotSourceEditorFile = { + fileName: 'header.vtl', + source: '#set($title = "Hello")' +}; + +describe('DotSourceEditorSidePanelComponent', () => { + let spectator: Spectator; + let load: Mock<(inode: string) => Observable>; + let save: Mock<() => Observable>; + let navController: { acquire: Mock; release: Mock; isTop: Mock }; + /** Whether Monaco's AMD loader has finished; tests fire it to simulate the load. */ + let monacoLoaded$: BehaviorSubject; + + const createComponent = createComponentFactory({ + component: DotSourceEditorSidePanelComponent, + overrideComponents: [ + [ + DotSourceEditorSidePanelComponent, + { + set: { + imports: [ + DrawerModule, + ButtonModule, + ConfirmDialogModule, + FormsModule, + MonacoEditorStubComponent, + MockPipe(DotMessagePipe, (key: string) => key) + ] + } + } + ] + ], + providers: [ + mockProvider(DotMessageService, { get: vi.fn((key: string) => key) }), + mockProvider(DotHttpErrorManagerService, { handle: vi.fn() }), + { + provide: MonacoEditorLoaderService, + useFactory: () => ({ isMonacoLoaded$: monacoLoaded$ }) + } + ], + // Replaces the panel's own providers, so its prompt service is listed again. + componentProviders: [ + ConfirmationService, + // Read per creation, so each test's `load` and `save` reach the panel. + { provide: DotSourceEditorService, useFactory: () => ({ load, save }) } + ], + detectChanges: false + }); + + beforeEach(() => { + localStorage.clear(); + monacoLoaded$ = new BehaviorSubject(false); + load = vi.fn(() => of(FILE)); + save = vi.fn(() => of({})); + navController = { + acquire: vi.fn(), + release: vi.fn(), + isTop: vi.fn().mockReturnValue(true) + }; + + spectator = createComponent({ + providers: [{ provide: DotSidePanelNavController, useValue: navController }] + }); + }); + + afterEach(() => { + delete (globalThis as { monaco?: unknown }).monaco; + // The factory's mocks are shared across tests: drop their calls, and undo any spy. + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + /** Opens the panel on a file and lets the load and the editor's model write settle. */ + const open = async (request: DotSourceEditorRequest = REQUEST) => { + spectator.setInput('request', request); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + spectator.detectChanges(); + }; + + /** `appendTo="body"` teleports the drawer out of the fixture, so queries search the document. */ + const editor = (): MonacoEditorStubComponent | null => + spectator.query(MonacoEditorStubComponent); + + const button = (testId: string): HTMLButtonElement => + spectator + .query(byTestId(testId), { root: true }) + ?.querySelector('button') as HTMLButtonElement; + + /** Types in the editor and renders the result. */ + const typeInEditor = (value: string) => { + editor()?.type(value); + spectator.detectChanges(); + }; + + const pressKey = (init: KeyboardEventInit): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); + document.dispatchEvent(event); + spectator.detectChanges(); + + return event; + }; + + /** The panel's own confirmation service, the one its `` renders. */ + const panelConfirmation = (): ConfirmationService => + spectator.inject(ConfirmationService, true); + + /** Answers the next prompt the way the author would. */ + const answerPrompt = (answer: 'keep' | 'discard' | 'dismiss') => + vi.spyOn(panelConfirmation(), 'confirm').mockImplementation((options: Confirmation) => { + if (answer === 'keep') { + options.accept?.(); + } else { + options.reject?.( + answer === 'discard' ? ConfirmEventType.REJECT : ConfirmEventType.CANCEL + ); + } + + return panelConfirmation(); + }); + + describe('loading', () => { + it('opens straight on the source of the requested version', async () => { + await open(); + + expect(load).toHaveBeenCalledWith('vtl-inode'); + expect(editor()?.value).toBe(FILE.source); + expect(editor()?.options()).toEqual( + expect.objectContaining({ language: DOT_VELOCITY_LANGUAGE_ID, readOnly: false }) + ); + }); + + it('puts the cursor in the editor', async () => { + await open(); + + expect(editor()?.focus).toHaveBeenCalled(); + }); + + it('shows a spinner until the source arrives', async () => { + load.mockReturnValue(NEVER); + + await open(); + + expect( + spectator.query(byTestId('source-editor-loading'), { root: true }) + ).not.toBeNull(); + expect(editor()).toBeNull(); + }); + + it('reports a failed load and shows no editor', async () => { + const error = new HttpErrorResponse({ status: 404 }); + load.mockReturnValue(throwError(() => error)); + + await open(); + + expect(spectator.inject(DotHttpErrorManagerService).handle).toHaveBeenCalledWith(error); + expect(spectator.query(byTestId('source-editor-error'), { root: true })).not.toBeNull(); + expect(editor()).toBeNull(); + }); + + it('shows the load error without the HTTP handler when the failure is not a server error', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + load.mockReturnValue(throwError(() => new Error('No file to edit'))); + + await open(); + + expect(spectator.inject(DotHttpErrorManagerService).handle).not.toHaveBeenCalled(); + expect(spectator.query(byTestId('source-editor-error'), { root: true })).not.toBeNull(); + }); + + it('registers Velocity with Monaco as soon as Monaco loads', () => { + const register = vi.fn(); + (globalThis as { monaco?: unknown }).monaco = { + languages: { getLanguages: () => [], register, setMonarchTokensProvider: vi.fn() } + }; + + monacoLoaded$.next(true); + + expect(register).toHaveBeenCalledWith( + expect.objectContaining({ id: DOT_VELOCITY_LANGUAGE_ID }) + ); + }); + }); + + describe('saving', () => { + it('keeps Save disabled until the source changes', async () => { + await open(); + + expect(button('source-editor-save').disabled).toBe(true); + + typeInEditor('#set($title = "Bye")'); + + expect(button('source-editor-save').disabled).toBe(false); + }); + + it('saves the edited source into the opened file and language, keeping its name', async () => { + await open(); + typeInEditor('#set($title = "Bye")'); + + spectator.click(button('source-editor-save')); + + expect(save).toHaveBeenCalledWith({ + identifier: 'vtl-id', + languageId: 2, + fileName: 'header.vtl', + source: '#set($title = "Bye")' + }); + }); + + it('reports the save, then closes without asking', async () => { + await open(); + const events: string[] = []; + spectator.output('saved').subscribe(() => events.push('saved')); + spectator.output('closed').subscribe(() => events.push('closed')); + const confirm = vi.spyOn(panelConfirmation(), 'confirm'); + typeInEditor('#set($title = "Bye")'); + + spectator.click(button('source-editor-save')); + + // Saved first, so the opener refreshes before it clears the panel. + expect(events).toEqual(['saved', 'closed']); + expect(confirm).not.toHaveBeenCalled(); + }); + + it('stays open with the edits and reports the error when the save fails', async () => { + const error = new HttpErrorResponse({ status: 400 }); + save.mockReturnValue(throwError(() => error)); + await open(); + const saved = vi.fn(); + const closed = vi.fn(); + spectator.output('saved').subscribe(saved); + spectator.output('closed').subscribe(closed); + typeInEditor('#set($title = "Bye")'); + + spectator.click(button('source-editor-save')); + spectator.detectChanges(); + + expect(spectator.inject(DotHttpErrorManagerService).handle).toHaveBeenCalledWith(error); + expect(saved).not.toHaveBeenCalled(); + expect(closed).not.toHaveBeenCalled(); + expect(editor()?.value).toBe('#set($title = "Bye")'); + expect(button('source-editor-save').disabled).toBe(false); + }); + + it('makes the editor read-only while the save runs', async () => { + save.mockReturnValue(NEVER); + await open(); + typeInEditor('#set($title = "Bye")'); + + spectator.click(button('source-editor-save')); + spectator.detectChanges(); + + expect(editor()?.options()).toEqual(expect.objectContaining({ readOnly: true })); + }); + + it('saves with Cmd/Ctrl + S and keeps the browser from opening its own Save', async () => { + await open(); + typeInEditor('#set($title = "Bye")'); + + const event = pressKey({ key: 's', metaKey: true }); + + expect(save).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + }); + + it('swallows Cmd/Ctrl + S with nothing to save, without saving', async () => { + await open(); + + const event = pressKey({ key: 's', ctrlKey: true }); + + expect(save).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + }); + }); + + describe('closing', () => { + it('closes at once when nothing changed', async () => { + await open(); + const confirm = vi.spyOn(panelConfirmation(), 'confirm'); + const closed = vi.fn(); + spectator.output('closed').subscribe(closed); + + spectator.click(button('source-editor-cancel')); + + expect(confirm).not.toHaveBeenCalled(); + expect(closed).toHaveBeenCalledTimes(1); + }); + + it("asks with the Edit Content side panel's wording before discarding changes", async () => { + await open(); + typeInEditor('#set($title = "Bye")'); + const confirm = vi.spyOn(panelConfirmation(), 'confirm'); + + spectator.component.requestClose(); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + header: 'edit.content.unsaved.changes.title', + message: 'edit.content.unsaved.changes.message', + acceptLabel: 'edit.content.unsaved.changes.keep', + rejectLabel: 'edit.content.unsaved.changes.discard' + }) + ); + }); + + it.each([ + ['Keep editing', 'keep', 0], + ['Discard', 'discard', 1], + ['dismissing the prompt', 'dismiss', 0] + ] as const)('closes with changes only on Discard: %s', async (_label, answer, closes) => { + await open(); + typeInEditor('#set($title = "Bye")'); + answerPrompt(answer); + const closed = vi.fn(); + spectator.output('closed').subscribe(closed); + + spectator.component.requestClose(); + + expect(closed).toHaveBeenCalledTimes(closes); + }); + + it('does not close while a save runs', async () => { + save.mockReturnValue(NEVER); + await open(); + typeInEditor('#set($title = "Bye")'); + spectator.click(button('source-editor-save')); + const closed = vi.fn(); + spectator.output('closed').subscribe(closed); + + spectator.component.requestClose(); + + expect(closed).not.toHaveBeenCalled(); + }); + + it('closes on Escape, through the same guard', async () => { + await open(); + const closed = vi.fn(); + spectator.output('closed').subscribe(closed); + + pressKey({ key: 'Escape' }); + + expect(closed).toHaveBeenCalledTimes(1); + }); + + it('leaves Escape alone when another side panel is in front', async () => { + navController.isTop.mockReturnValue(false); + await open(); + const closed = vi.fn(); + spectator.output('closed').subscribe(closed); + + pressKey({ key: 'Escape' }); + + expect(closed).not.toHaveBeenCalled(); + }); + + it('leaves the side-panel stack and gives the shortcuts back when destroyed', async () => { + await open(); + + spectator.fixture.destroy(); + + const event = new KeyboardEvent('keydown', { + key: 's', + metaKey: true, + bubbles: true, + cancelable: true + }); + document.dispatchEvent(event); + + expect(navController.release).toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component.ts b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component.ts new file mode 100644 index 000000000000..a137f18b3545 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor-side-panel.component.ts @@ -0,0 +1,279 @@ +import { MonacoEditorLoaderService, MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; +import { EMPTY } from 'rxjs'; + +import { HttpErrorResponse } from '@angular/common/http'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + input, + output, + signal, + viewChild +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; + +import { ConfirmationService, ConfirmEventType } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { Drawer, DrawerModule } from 'primeng/drawer'; + +import { catchError, filter, switchMap, take, tap } from 'rxjs/operators'; + +import { DotHttpErrorManagerService, DotMessageService } from '@dotcms/data-access'; +import { ComponentStatus } from '@dotcms/dotcms-models'; +import { + DOT_MONACO_BASE_OPTIONS, + DotKeyboardShortcutService, + DotMessagePipe, + ensureDotVelocityLanguageRegistered +} from '@dotcms/ui'; + +import { DotSourceEditorService } from './dot-source-editor.service'; + +import { DotSourceEditorRequest } from '../../models/dot-source-editor.model'; +import { injectSidePanelChrome } from '../../services/dot-side-panel-chrome'; + +/** The minimal Monaco editor surface this panel uses. */ +interface DotSourceEditorInstance { + focus(): void; +} + +/** + * Edits a file's source in Monaco, in a side panel over the screen that opened it (Content Drive, + * the page editor). A developer tool: it opens straight on the code, and saves and closes with the + * button or `Cmd/Ctrl + S`. + * + * It looks and closes like the other side panels: the same drawer, and the same width toggle, mask + * and Escape rules from `injectSidePanelChrome`, with the Edit Content panel's unsaved-changes + * prompt. Escape typed inside the editor stays with Monaco, which marks every Escape handled; as in + * any IDE, Escape there never closes the file. + * + * It loads and saves the source itself, so a failed save leaves the edits on screen. It reports a + * save and a close through its outputs, and the opener decides what they mean for its own view. + */ +@Component({ + selector: 'dot-source-editor-side-panel', + imports: [ + DrawerModule, + ButtonModule, + ConfirmDialogModule, + FormsModule, + MonacoEditorModule, + DotMessagePipe + ], + // Its own prompt instance, so the prompt renders in this panel's ``. + providers: [ConfirmationService, DotSourceEditorService], + templateUrl: './dot-source-editor-side-panel.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotSourceEditorSidePanelComponent { + readonly #confirmation = inject(ConfirmationService); + readonly #messages = inject(DotMessageService); + readonly #httpErrorManager = inject(DotHttpErrorManagerService); + readonly #sourceEditor = inject(DotSourceEditorService); + readonly #destroyRef = inject(DestroyRef); + + /** The file to open, or `null` when the panel is closed. */ + readonly request = input(null); + + /** A save landed: the opener refreshes its view. `closed` follows straight after. */ + readonly saved = output(); + + /** The panel closed, after a save or without one: the opener clears its request. */ + readonly closed = output(); + + /** This panel's own drawer: its mask and container are what the shared chrome checks. */ + protected readonly $drawer = viewChild(Drawer); + + /** Escape, a click on the mask, the side-panel stack and the full-width toggle. */ + readonly #chrome = injectSidePanelChrome({ + panel: this, + drawer: this.$drawer, + requestClose: () => this.requestClose(), + escapeLabel: 'edit.content.source-editor.shortcut.close' + }); + + /** Whether the panel is expanded to the full width (vs 80%), seeded from the stored choice. */ + protected readonly $expanded = this.#chrome.expanded; + + protected readonly $status = signal(ComponentStatus.LOADING); + + /** The source as it stands in the editor. */ + protected readonly $source = signal(''); + + /** The source as it was last loaded or saved; the editor differs from it when dirty. */ + readonly #savedSource = signal(''); + + /** The file's name, kept on save so the asset is not renamed. Known once the file loads. */ + #fileName = ''; + + /** Whether the editor holds changes that have not been saved. */ + protected readonly $dirty = computed(() => this.$source() !== this.#savedSource()); + + /** Whether Save would do anything: a loaded file, with changes, and no save already running. */ + protected readonly $canSave = computed( + () => this.$status() === ComponentStatus.LOADED && this.$dirty() + ); + + protected readonly $editorOptions = computed(() => ({ + ...DOT_MONACO_BASE_OPTIONS, + language: this.request()?.language ?? 'plaintext', + // Read-only while saving, so nothing typed during the request is silently left unsaved. + readOnly: this.$status() === ComponentStatus.SAVING + })); + + protected readonly ComponentStatus = ComponentStatus; + + constructor() { + // Claimed for the panel's lifetime, through the same registry as Escape, so the browser's + // "Save page" never opens over the editor, even when there is nothing to save. + const withdrawSave = inject(DotKeyboardShortcutService).register({ + combination: 'mod+s', + label: 'edit.content.source-editor.shortcut.save', + handler: () => this.save() + }); + this.#destroyRef.onDestroy(withdrawSave); + + // Velocity has to be registered before the editor creates its model, or the file opens as + // plain text. Subscribed here, before any editor exists, so it runs first once Monaco loads. + inject(MonacoEditorLoaderService) + .isMonacoLoaded$.pipe(filter(Boolean), take(1), takeUntilDestroyed()) + .subscribe(() => ensureDotVelocityLanguageRegistered()); + + toObservable(this.request) + .pipe( + filter((request): request is DotSourceEditorRequest => !!request), + tap(() => this.$status.set(ComponentStatus.LOADING)), + switchMap(({ inode }) => + this.#sourceEditor.load(inode).pipe( + catchError((error: unknown) => { + this.#reportServerError(error); + this.$status.set(ComponentStatus.ERROR); + + return EMPTY; + }) + ) + ), + takeUntilDestroyed() + ) + .subscribe(({ fileName, source }) => { + this.#fileName = fileName; + this.#savedSource.set(source); + this.$source.set(source); + this.$status.set(ComponentStatus.LOADED); + }); + } + + /** + * Saves the source as a new working version, then closes the panel. A no-op without changes or + * while a save runs. + * + * On failure the panel stays open with the edits, and a server error goes through the shared + * handler. + */ + save(): void { + const request = this.request(); + + if (!request || !this.$canSave()) { + return; + } + + const source = this.$source(); + + this.$status.set(ComponentStatus.SAVING); + + this.#sourceEditor + .save({ + identifier: request.identifier, + languageId: request.languageId, + fileName: this.#fileName, + source + }) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe({ + next: () => { + this.#savedSource.set(source); + this.$status.set(ComponentStatus.LOADED); + this.saved.emit(); + // Nothing is left unsaved, so no prompt: straight out. + this.closed.emit(); + }, + error: (error: unknown) => { + this.$status.set(ComponentStatus.LOADED); + this.#reportServerError(error); + } + }); + } + + /** + * Close intent: the X button, Cancel, Escape, or a click on the mask. + * + * Ignored while a save runs, since leaving would abandon a request whose outcome the author + * then never sees. With unsaved changes it asks first, with the Edit Content panel's wording. + */ + requestClose(): void { + if (this.$status() === ComponentStatus.SAVING) { + return; + } + + if (!this.$dirty()) { + this.closed.emit(); + + return; + } + + this.#confirmation.confirm({ + header: this.#messages.get('edit.content.unsaved.changes.title'), + message: this.#messages.get('edit.content.unsaved.changes.message'), + acceptLabel: this.#messages.get('edit.content.unsaved.changes.keep'), + rejectLabel: this.#messages.get('edit.content.unsaved.changes.discard'), + acceptIcon: 'hidden', + rejectIcon: 'hidden', + rejectButtonStyleClass: 'p-button-outlined', + // "Keep editing", or X / ESC on the prompt itself: stay open with the edits. + accept: () => undefined, + reject: (type?: ConfirmEventType) => { + if (type === ConfirmEventType.REJECT) { + this.closed.emit(); + } + } + }); + } + + /** + * Puts the cursor in the editor as soon as it exists, so the author can type straight away. + * + * @param editor the Monaco editor instance emitted by `ngx-monaco-editor` + */ + protected onEditorInit(editor: DotSourceEditorInstance): void { + editor.focus(); + } + + /** Toggles the full-width state and remembers it for the next panel. */ + protected toggleExpanded(): void { + this.#chrome.toggleExpanded(); + } + + /** + * Hands a server error to the shared handler, which explains it to the author. + * + * Anything else (a version with no file to load, say) has no status for the handler to explain, + * so it is only logged: the panel already shows the failure itself, as the load error or as a + * Save button that is enabled again. + * + * @param error what the request failed with + */ + #reportServerError(error: unknown): void { + if (error instanceof HttpErrorResponse) { + this.#httpErrorManager.handle(error); + + return; + } + + console.error('Edit Source request failed', error); + } +} diff --git a/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor.service.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor.service.spec.ts new file mode 100644 index 000000000000..a03c13934283 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor.service.spec.ts @@ -0,0 +1,114 @@ +import { + createHttpFactory, + HttpMethod, + mockProvider, + SpectatorHttp +} from '@openng/spectator/vitest'; +import { of } from 'rxjs'; +import { vi } from 'vitest'; + +import { DotContentletService, DotWorkflowActionsFireService } from '@dotcms/data-access'; +import { DotCMSContentlet } from '@dotcms/dotcms-models'; + +import { DotSourceEditorService } from './dot-source-editor.service'; + +const FILE_VERSION_URL = '/dA/vtl-inode/fileAsset/header.vtl'; + +const fileContentlet = (overrides: Partial = {}) => + ({ + identifier: 'vtl-id', + inode: 'vtl-inode', + fileName: 'header.vtl', + fileAssetVersion: FILE_VERSION_URL, + ...overrides + }) as unknown as DotCMSContentlet; + +describe('DotSourceEditorService', () => { + let spectator: SpectatorHttp; + + const createHttp = createHttpFactory({ + service: DotSourceEditorService, + providers: [ + mockProvider(DotContentletService, { + getContentletByInode: vi.fn(() => of(fileContentlet())) + }), + mockProvider(DotWorkflowActionsFireService, { + saveContentletByIdentifier: vi.fn(() => of(fileContentlet({ inode: 'new-inode' }))) + }) + ] + }); + + beforeEach(() => (spectator = createHttp())); + + describe('load', () => { + it("downloads the version's own file as text, with the name the save must keep", () => { + const result = vi.fn(); + + spectator.service.load('vtl-inode').subscribe(result); + + const req = spectator.expectOne(FILE_VERSION_URL, HttpMethod.GET); + expect(req.request.responseType).toBe('text'); + req.flush('#set($x = 1)'); + + expect( + spectator.inject(DotContentletService).getContentletByInode + ).toHaveBeenCalledWith('vtl-inode'); + expect(result).toHaveBeenCalledWith({ fileName: 'header.vtl', source: '#set($x = 1)' }); + }); + + it('falls back to the file name the server adds to file rows', () => { + spectator + .inject(DotContentletService) + .getContentletByInode.mockReturnValue( + of(fileContentlet({ fileName: undefined, name: 'footer.vtl' })) + ); + const result = vi.fn(); + + spectator.service.load('vtl-inode').subscribe(result); + spectator.expectOne(FILE_VERSION_URL, HttpMethod.GET).flush(''); + + expect(result).toHaveBeenCalledWith({ fileName: 'footer.vtl', source: '' }); + }); + + it('fails when the version has no file to download', () => { + spectator + .inject(DotContentletService) + .getContentletByInode.mockReturnValue( + of(fileContentlet({ fileAssetVersion: undefined })) + ); + const error = vi.fn(); + + spectator.service.load('vtl-inode').subscribe({ error }); + + expect(error).toHaveBeenCalled(); + }); + }); + + describe('save', () => { + it('stages the source as a temp file under its own name, then saves it into the file', async () => { + const result = vi.fn(); + + spectator.service + .save({ + identifier: 'vtl-id', + languageId: 2, + fileName: 'header.vtl', + source: '#set($x = 2)' + }) + .subscribe(result); + + const req = spectator.expectOne('/api/v1/temp', HttpMethod.POST); + const file = (req.request.body as FormData).get('file') as File; + + expect(file.name).toBe('header.vtl'); + expect(await file.text()).toBe('#set($x = 2)'); + + req.flush({ tempFiles: [{ id: 'temp_123' }] }); + + expect( + spectator.inject(DotWorkflowActionsFireService).saveContentletByIdentifier + ).toHaveBeenCalledWith({ identifier: 'vtl-id', fileAsset: 'temp_123' }, 2); + expect(result).toHaveBeenCalledWith(expect.objectContaining({ inode: 'new-inode' })); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor.service.ts b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor.service.ts new file mode 100644 index 000000000000..df49af0a7463 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-source-editor-side-panel/dot-source-editor.service.ts @@ -0,0 +1,96 @@ +import { Observable, throwError } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { map, switchMap } from 'rxjs/operators'; + +import { DotContentletService, DotWorkflowActionsFireService } from '@dotcms/data-access'; +import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; +import { getFileVersion } from '@dotcms/utils'; + +import { fileNameOf, SOURCE_EDITOR_BINARY_FIELD } from '../../utils/source-editor.util'; + +/** A file version's source, and the name the save has to keep. */ +export interface DotSourceEditorFile { + fileName: string; + source: string; +} + +/** What a save writes: the edited source, into the file and language it was opened from. */ +export interface DotSourceEditorSave { + identifier: string; + languageId: number; + fileName: string; + source: string; +} + +/** + * Reads and writes a File Asset's source for the Edit Source panel. + * + * Provided on the panel rather than in root: nothing else edits source this way yet. + */ +@Injectable() +export class DotSourceEditorService { + readonly #http = inject(HttpClient); + readonly #contentletService = inject(DotContentletService); + readonly #workflowActionsFire = inject(DotWorkflowActionsFireService); + + /** + * Loads one file version and its source. + * + * The source is downloaded from the version's own file URL rather than through + * `DotContentletService.getContentletByInodeWithContent`, which only downloads files the server + * flags as editable text. That flag follows the MIME type the server detected, and a `.vtl` is + * not guaranteed to be detected as `text/*`. + * + * @param inode the version to open + */ + load(inode: string): Observable { + return this.#contentletService.getContentletByInode(inode).pipe( + switchMap((contentlet) => { + const url = getFileVersion(contentlet) as string | null; + const fileName = fileNameOf(contentlet); + + if (!url || !fileName) { + return throwError( + () => new Error(`File ${inode} has no file to edit the source of`) + ); + } + + return this.#http + .get(url, { responseType: 'text' }) + .pipe(map((source) => ({ fileName, source }))); + }) + ); + } + + /** + * Saves the source as a new working version of the file, without publishing it. + * + * The source is staged as a temp file under the file's own name, so the asset keeps its name, + * and then set as the file's binary through the default Save action. + * + * @param save the source and where it goes + */ + save({ + identifier, + languageId, + fileName, + source + }: DotSourceEditorSave): Observable { + const formData = new FormData(); + formData.append('file', new File([source], fileName)); + + return this.#http + .post<{ tempFiles: DotCMSTempFile[] }>('/api/v1/temp', formData) + .pipe( + switchMap(({ tempFiles }) => + this.#workflowActionsFire.saveContentletByIdentifier( + { identifier, [SOURCE_EDITOR_BINARY_FIELD]: tempFiles[0].id }, + languageId + ) + ) + ); + } +} diff --git a/core-web/libs/edit-content/src/lib/models/dot-source-editor.model.ts b/core-web/libs/edit-content/src/lib/models/dot-source-editor.model.ts new file mode 100644 index 000000000000..1b6eabe98993 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/models/dot-source-editor.model.ts @@ -0,0 +1,15 @@ +/** + * What the Edit Source panel is asked to open: one file's source, in a code editor. + */ +export interface DotSourceEditorRequest { + /** The version whose source is loaded: the row's own, which is the working one. */ + inode: string; + /** The file the save writes to, as a new working version. */ + identifier: string; + /** Language of the version, so a multilingual file saves into the one that was opened. */ + languageId: number; + /** Drawer header. */ + title: string; + /** Monaco language id the source is highlighted with. */ + language: string; +} diff --git a/core-web/libs/edit-content/src/lib/utils/source-editor.util.spec.ts b/core-web/libs/edit-content/src/lib/utils/source-editor.util.spec.ts new file mode 100644 index 000000000000..96142017b27e --- /dev/null +++ b/core-web/libs/edit-content/src/lib/utils/source-editor.util.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import { + DotCMSBaseTypesContentTypes, + DotCMSContentlet, + DotContentletCanLock +} from '@dotcms/dotcms-models'; +import { DOT_VELOCITY_LANGUAGE_ID } from '@dotcms/ui'; +import { createFakeContentlet } from '@dotcms/utils-testing'; + +import { toSourceEditorRequest } from './source-editor.util'; + +const fileRow = (overrides: Partial = {}) => + createFakeContentlet({ + baseType: DotCMSBaseTypesContentTypes.FILEASSET, + extension: 'vtl', + fileName: 'header.vtl', + ...overrides + }); + +const lock = (canLock: boolean, locked = false): DotContentletCanLock => ({ + canLock, + locked, + id: 'identifier', + inode: 'inode', + lockedBy: locked ? 'someone@dotcms.com' : '' +}); + +const CAN_LOCK = lock(true); + +describe('toSourceEditorRequest', () => { + it('opens a Velocity file the user can lock, named by its file name', () => { + const file = fileRow({ title: 'Site header' }); + + expect(toSourceEditorRequest(file, CAN_LOCK)).toEqual({ + inode: file.inode, + identifier: file.identifier, + languageId: file.languageId, + title: 'header.vtl', + language: DOT_VELOCITY_LANGUAGE_ID + }); + }); + + it('opens a file the user already holds the lock on', () => { + expect(toSourceEditorRequest(fileRow(), lock(true, true))).not.toBeNull(); + }); + + it('does not open a file the user cannot lock: no edit permission, or locked by someone else', () => { + expect(toSourceEditorRequest(fileRow(), lock(false))).toBeNull(); + expect(toSourceEditorRequest(fileRow(), lock(false, true))).toBeNull(); + }); + + it('reads the extension case-insensitively', () => { + expect(toSourceEditorRequest(fileRow({ extension: 'VTL' }), CAN_LOCK)?.language).toBe( + DOT_VELOCITY_LANGUAGE_ID + ); + }); + + it('falls back to the file name when the row carries no extension', () => { + expect(toSourceEditorRequest(fileRow({ extension: undefined }), CAN_LOCK)?.language).toBe( + DOT_VELOCITY_LANGUAGE_ID + ); + }); + + it('falls back to the title when the row carries no file name', () => { + const file = fileRow({ fileName: undefined, title: 'header.vtl' }); + + expect(toSourceEditorRequest(file, CAN_LOCK)?.title).toBe('header.vtl'); + }); + + it('does not open a file named after the extension but without one', () => { + expect( + toSourceEditorRequest(fileRow({ extension: undefined, fileName: 'vtl' }), CAN_LOCK) + ).toBeNull(); + }); + + it('does not open file types it does not handle yet', () => { + expect(toSourceEditorRequest(fileRow({ extension: 'png' }), CAN_LOCK)).toBeNull(); + }); + + it('does not resolve an extension that names an object member', () => { + expect(toSourceEditorRequest(fileRow({ extension: 'constructor' }), CAN_LOCK)).toBeNull(); + }); + + it('does not open content that is not a File Asset, even with a .vtl name', () => { + expect( + toSourceEditorRequest( + fileRow({ baseType: DotCMSBaseTypesContentTypes.DOTASSET }), + CAN_LOCK + ) + ).toBeNull(); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/utils/source-editor.util.ts b/core-web/libs/edit-content/src/lib/utils/source-editor.util.ts new file mode 100644 index 000000000000..409a68854217 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/utils/source-editor.util.ts @@ -0,0 +1,102 @@ +import { + DotCMSBaseTypesContentTypes, + DotCMSContentlet, + DotContentletCanLock +} from '@dotcms/dotcms-models'; +import { DOT_VELOCITY_LANGUAGE_ID } from '@dotcms/ui'; + +import { DotSourceEditorRequest } from '../models/dot-source-editor.model'; + +/** + * File extensions Edit Source opens, each with the Monaco language that highlights it. + * + * Velocity only for now. Every other code file dotCMS stores (css, js, html, json, scss…) is one + * more entry here: Monaco ships those languages, so nothing else has to change. A `Map` rather than + * an object literal, so an extension such as `constructor` can never resolve to a prototype member. + */ +const SOURCE_EDITOR_LANGUAGES: ReadonlyMap = new Map([ + ['vtl', DOT_VELOCITY_LANGUAGE_ID] +]); + +/** + * The binary field the save writes the edited source to. File Assets keep their file in + * `fileAsset`, which is why only they qualify; a dotAsset (`asset`) would need its own entry. + */ +export const SOURCE_EDITOR_BINARY_FIELD = 'fileAsset'; + +/** + * What to open a file in Edit Source with, or `null` when Edit Source does not apply to it. + * + * The one rule every opener follows (Content Drive's row menu, the page editor's VTL menu), so they + * cannot disagree about who may edit what: + * + * - the file is a File Asset of a type the editor handles, and + * - the user can lock it. The server answers that from the two things a save needs: edit permission + * on the file, and no lock held by someone else (`ContentletAPI.canLock`; admins always can). + * + * @param contentlet the file + * @param lock the server's answer for that file, from `DotContentletService.canLock` + */ +export function toSourceEditorRequest( + contentlet: DotCMSContentlet, + lock: DotContentletCanLock +): DotSourceEditorRequest | null { + const language = sourceEditorLanguageOf(contentlet); + + if (!language || !lock.canLock) { + return null; + } + + return { + inode: contentlet.inode, + identifier: contentlet.identifier, + languageId: contentlet.languageId, + title: fileNameOf(contentlet) ?? contentlet.title, + language + }; +} + +/** + * The file's name: the `fileName` field on a File Asset, else the `name` the server adds to file + * rows. `undefined` when the contentlet carries neither. + * + * @param contentlet the file + */ +export function fileNameOf(contentlet: DotCMSContentlet): string | undefined { + return ( + (contentlet['fileName'] as string | undefined) || (contentlet['name'] as string | undefined) + ); +} + +/** + * The Monaco language to edit a file's source in, or `null` when it is not a File Asset of a type + * the editor handles. + * + * @param contentlet the file + */ +function sourceEditorLanguageOf(contentlet: DotCMSContentlet): string | null { + if (contentlet.baseType !== DotCMSBaseTypesContentTypes.FILEASSET) { + return null; + } + + return SOURCE_EDITOR_LANGUAGES.get(fileExtensionOf(contentlet)) ?? null; +} + +/** + * The file's extension, lower-cased and without the dot. File rows carry `extension` from the + * server; the file name is the fallback for a row that does not. + * + * @param contentlet the file + */ +function fileExtensionOf(contentlet: DotCMSContentlet): string { + const extension = contentlet['extension'] as string | undefined; + + if (extension) { + return extension.toLowerCase(); + } + + const fileName = fileNameOf(contentlet) ?? ''; + const dot = fileName.lastIndexOf('.'); + + return dot === -1 ? '' : fileName.slice(dot + 1).toLowerCase(); +} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-folder-list-context-menu/dot-folder-list-context-menu.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-folder-list-context-menu/dot-folder-list-context-menu.component.spec.ts index fcd6927440ae..899bccc812a8 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-folder-list-context-menu/dot-folder-list-context-menu.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-folder-list-context-menu/dot-folder-list-context-menu.component.spec.ts @@ -42,7 +42,7 @@ import { DotContentDriveItem, PERMISSIONS_TYPE } from '@dotcms/dotcms-models'; -import { DotJspIframeDialogComponent } from '@dotcms/ui'; +import { DOT_VELOCITY_LANGUAGE_ID, DotJspIframeDialogComponent } from '@dotcms/ui'; import { createFakeContentlet, DOT_SYSTEM_CONFIG_SERVICE_MOCK, @@ -547,6 +547,85 @@ describe('DotFolderListViewContextMenuComponent', () => { }); }); + describe('edit source', () => { + const everything = [1, 2, 4, 8, 16] as unknown as DotCMSContentlet['permissions']; + const velocityFile = (overrides: Partial = {}) => + createFakeContentlet({ + baseType: DotCMSBaseTypesContentTypes.FILEASSET, + extension: 'vtl', + fileName: 'header.vtl', + permissions: everything, + ...overrides + }); + const openMenuFor = async (contentlet: DotCMSContentlet) => { + await component.getMenuItems({ + triggeredEvent: mockEvent, + contentlet, + showAddToBundle: false + }); + + return labels(component.$items()); + }; + + it('should offer Edit Source right after Edit Content on a Velocity file', async () => { + const items = await openMenuFor(velocityFile()); + + expect(items.slice(1, 3)).toEqual([ + 'content-drive.context-menu.edit-content', + 'content-drive.context-menu.edit-source' + ]); + }); + + it('should open the file in the source editor', async () => { + const file = velocityFile(); + await openMenuFor(file); + + invoke(component.$items(), 'content-drive.context-menu.edit-source'); + + expect(store.sourceEditor()).toEqual({ + inode: file.inode, + identifier: file.identifier, + languageId: file.languageId, + title: 'header.vtl', + language: DOT_VELOCITY_LANGUAGE_ID + }); + }); + + it('should not offer Edit Source on a file type it does not edit yet', async () => { + const items = await openMenuFor(velocityFile({ extension: 'png' })); + + expect(items).not.toContain('content-drive.context-menu.edit-source'); + }); + + it('should not offer Edit Source on content that is not a file', async () => { + const items = await openMenuFor(createFakeContentlet({ permissions: everything })); + + expect(items).not.toContain('content-drive.context-menu.edit-source'); + }); + + // Edit permission is the server's half of `canLock`, so the menu reads it from there, + // the same answer the page editor gets. + it('should not offer Edit Source when the user cannot edit the file', async () => { + dotContentletService.canLock.mockReturnValueOnce( + of(createMockCanLock(false, false)) + ); + + const items = await openMenuFor(velocityFile()); + + expect(items).not.toContain('content-drive.context-menu.edit-source'); + }); + + it('should not offer Edit Source on a file another user has locked', async () => { + dotContentletService.canLock.mockReturnValueOnce( + of(createMockCanLock(false, true)) + ); + + const items = await openMenuFor(velocityFile()); + + expect(items).not.toContain('content-drive.context-menu.edit-source'); + }); + }); + describe('folder handling', () => { const mockFolder: DotContentDriveFolder = { __icon__: 'folderIcon', diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-folder-list-context-menu/dot-folder-list-context-menu.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-folder-list-context-menu/dot-folder-list-context-menu.component.ts index 05a69931c6e3..9fb65012f102 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-folder-list-context-menu/dot-folder-list-context-menu.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-folder-list-context-menu/dot-folder-list-context-menu.component.ts @@ -39,6 +39,7 @@ import { DotWorkflowPayload, PERMISSIONS_TYPE } from '@dotcms/dotcms-models'; +import { toSourceEditorRequest } from '@dotcms/edit-content'; import { DotJspIframeDialogComponent, DotJspIframeDialogData } from '@dotcms/ui'; import { @@ -409,6 +410,17 @@ export class DotFolderListViewContextMenuComponent { }); } + // The file's source in a code editor, for developers. Same rule as the page editor's VTL + // menu, from `toSourceEditorRequest`: a file type it handles, and a lock the user can take. + const sourceEditorRequest = toSourceEditorRequest(contentlet, canLockData); + + if (sourceEditorRequest) { + actionsMenu.push({ + label: this.#dotMessageService.get('content-drive.context-menu.edit-source'), + command: () => this.#store.openSourceEditor(sourceEditorRequest) + }); + } + if (canLockData.canLock) { actionsMenu.push({ label: canLockData.locked diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html index 4e654707c047..3de828df266d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html @@ -289,3 +289,21 @@ (versionRestored)="onLegacyPanelRestored()" (languageChanged)="onLegacyPanelLanguage($event)" /> } + + +@defer (when $sourceEditorRequest()) { + @if ($sourceEditorRequest(); as request) { + + } +} @loading { +
+ +
+} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 93b096c6ca95..26e1772651be 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -57,6 +57,8 @@ import { import { DotEditContentSidePanelComponent, DotSidePanelNavController, + DotSourceEditorRequest, + DotSourceEditorSidePanelComponent, EditContentDialogData } from '@dotcms/edit-content'; import { @@ -132,6 +134,8 @@ const dialogServiceOpen = vi.fn(() => permissionsDialogRef); // Backs the mock's `$legacyPanelRequest`: the legacy-editor panel the shell must render (#37759). const legacyPanelRequestSignal: WritableSignal = signal(null); +// Backs the mock's `sourceEditor`: the file the Edit Source panel shows. +const sourceEditorSignal: WritableSignal = signal(null); // Module scope: both store mocks in this file read it, and they live in describes that do not // share a `beforeEach`. Reset per test rather than re-created, so neither mock captures a stale one. /** Nothing refused by default; the refusal tests set it. Module-scoped like its siblings, @@ -266,14 +270,24 @@ describe('DotContentDriveShellComponent', () => { // be provided the same way the shell provides it in production. provideContentDriveFieldFilterHost() ], - // The legacy panel hosts a JSP iframe and has its own spec; the shell only needs its - // inputs, outputs and `requestClose()`. + // The legacy panel hosts a JSP iframe and the source panel hosts Monaco; both have their + // own specs, and the shell only needs their inputs, outputs and `requestClose()`. overrideComponents: [ [ DotContentDriveShellComponent, { - remove: { imports: [DotLegacyEditorSidePanelComponent] }, - add: { imports: [MockComponent(DotLegacyEditorSidePanelComponent)] } + remove: { + imports: [ + DotLegacyEditorSidePanelComponent, + DotSourceEditorSidePanelComponent + ] + }, + add: { + imports: [ + MockComponent(DotLegacyEditorSidePanelComponent), + MockComponent(DotSourceEditorSidePanelComponent) + ] + } } ] ], @@ -302,6 +316,7 @@ describe('DotContentDriveShellComponent', () => { editPanelRequestSignal.set(null); panelLocationSignal.set(null); legacyPanelRequestSignal.set(null); + sourceEditorSignal.set(null); permissionsDialogClose$ = new Subject(); dialogServiceOpen.mockClear(); permissionsDialogRef.close.mockClear(); @@ -408,6 +423,8 @@ describe('DotContentDriveShellComponent', () => { selectSystemHost: vi.fn(), sidebarLoading: vi.fn(), closeDialog: vi.fn(), + sourceEditor: sourceEditorSignal, + closeSourceEditor: vi.fn(), patchContextMenu: vi.fn(), resetContextMenu: vi.fn(), setDragItems: vi.fn(), @@ -987,6 +1004,20 @@ describe('DotContentDriveShellComponent', () => { expect(store.loadItems).toHaveBeenCalledWith({ quiet: true }); }); + it('should hold the reload while the Edit Source panel is open, and run it when it closes', () => { + sourceEditorSignal.set({} as DotSourceEditorRequest); + spectator.detectChanges(); + + settle(backgrounded); + + expect(store.loadItems).not.toHaveBeenCalled(); + + sourceEditorSignal.set(null); + spectator.detectChanges(); + + expect(store.loadItems).toHaveBeenCalledWith({ quiet: true }); + }); + it('should keep a held reload when a later outcome for another folder arrives', () => { // Both are held while the author is mid-task. The later one replacing the earlier // would leave this folder's reload behind, and its changes would never show. @@ -5639,6 +5670,60 @@ describe('DotContentDriveShellComponent', () => { }); }); + describe('Edit Source panel', () => { + const SOURCE_PANEL = 'dot-source-editor-side-panel'; + const SOURCE_REQUEST: DotSourceEditorRequest = { + inode: 'vtl-inode', + identifier: 'vtl-id', + languageId: 1, + title: 'header.vtl', + language: 'velocity' + }; + + /** Opens the panel and lets its `@defer` block resolve. */ + const openSourcePanel = async () => { + sourceEditorSignal.set(SOURCE_REQUEST); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + spectator.detectChanges(); + }; + + it('renders the panel with the requested file', async () => { + await openSourcePanel(); + + const panel = spectator.debugElement.query(By.css(SOURCE_PANEL)); + + expect(panel).not.toBeNull(); + // Read through ng-mocks: a mocked component holds a signal input as a plain value. + expect(ngMocks.input(panel, 'request')).toEqual(SOURCE_REQUEST); + }); + + it('renders no panel while no file is open', async () => { + spectator.detectChanges(); + await spectator.fixture.whenStable(); + + expect(spectator.query(SOURCE_PANEL)).toBeNull(); + }); + + // The panel follows a save with its own `closed`, so the save itself only refreshes. + it('refreshes the list quietly on save', async () => { + await openSourcePanel(); + + spectator.triggerEventHandler(SOURCE_PANEL, 'saved', undefined); + + expect(store.reloadContentDrive).toHaveBeenCalledWith({ quiet: true }); + expect(store.closeSourceEditor).not.toHaveBeenCalled(); + }); + + it('clears the request when the panel closes', async () => { + await openSourcePanel(); + + spectator.triggerEventHandler(SOURCE_PANEL, 'closed', undefined); + + expect(store.closeSourceEditor).toHaveBeenCalledTimes(1); + }); + }); + describe('extra columns (Show In List)', () => { beforeEach(() => spectator.detectChanges()); @@ -5951,14 +6036,24 @@ describe('DotContentDriveShellComponent — editContent deep link', () => { // be provided the same way the shell provides it in production. provideContentDriveFieldFilterHost() ], - // The legacy panel hosts a JSP iframe and has its own spec; the shell only needs its - // inputs, outputs and `requestClose()`. + // The legacy panel hosts a JSP iframe and the source panel hosts Monaco; both have their + // own specs, and the shell only needs their inputs, outputs and `requestClose()`. overrideComponents: [ [ DotContentDriveShellComponent, { - remove: { imports: [DotLegacyEditorSidePanelComponent] }, - add: { imports: [MockComponent(DotLegacyEditorSidePanelComponent)] } + remove: { + imports: [ + DotLegacyEditorSidePanelComponent, + DotSourceEditorSidePanelComponent + ] + }, + add: { + imports: [ + MockComponent(DotLegacyEditorSidePanelComponent), + MockComponent(DotSourceEditorSidePanelComponent) + ] + } } ] ], @@ -6072,6 +6167,8 @@ describe('DotContentDriveShellComponent — editContent deep link', () => { sidebarLoading, defaultLanguageLoaded, closeDialog: vi.fn(), + sourceEditor: signal(null), + closeSourceEditor: vi.fn(), patchContextMenu: vi.fn(), resetContextMenu: vi.fn(), setDragItems: vi.fn(), diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 4301faec0866..2efabb49dc85 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -58,7 +58,11 @@ import { isActionableBrowseItem, DotCMSContentlet } from '@dotcms/dotcms-models'; -import { DotEditContentSidePanelComponent, DotSidePanelNavController } from '@dotcms/edit-content'; +import { + DotEditContentSidePanelComponent, + DotSidePanelNavController, + DotSourceEditorSidePanelComponent +} from '@dotcms/edit-content'; import { DotContentDriveUploadFiles, DotFolderTreeNodeData, @@ -184,6 +188,7 @@ type DotFolderUserAccess = { canEdit: boolean; canEditPermissions: boolean }; DotEditContentSidePanelComponent, // Remove with the legacy editor. DotLegacyEditorSidePanelComponent, + DotSourceEditorSidePanelComponent, ProgressSpinnerModule, DotContentDriveActionCenterComponent, DotContentDriveScopeBarComponent, @@ -354,9 +359,15 @@ export class DotContentDriveShellComponent implements OnDestroy { */ protected readonly $legacyPanelRequest = this.#navigationService.$legacyPanelRequest; - /** Whether either side panel is open. */ + /** The file open in the Edit Source panel, from the store; read by the template. */ + protected readonly $sourceEditorRequest = this.#store.sourceEditor; + + /** Whether any side panel is open. */ readonly #anyPanelOpen = computed( - () => !!this.$editPanelRequest() || !!this.$legacyPanelRequest() + () => + !!this.$editPanelRequest() || + !!this.$legacyPanelRequest() || + !!this.$sourceEditorRequest() ); /** @@ -2091,6 +2102,19 @@ export class DotContentDriveShellComponent implements OnDestroy { this.#dotRouterService.goToEditPage({ url, language_id: languageId }); } + /** + * A save in the Edit Source panel changed the file's version and modification date: refresh the + * list quietly. The panel closes itself right after, through `closed`. + */ + protected onSourceEditorSaved() { + this.#store.reloadContentDrive({ quiet: true }); + } + + /** The Edit Source panel closed: clear its request. */ + protected onSourceEditorClosed() { + this.#store.closeSourceEditor(); + } + /** * Upload-button flow. When the current folder pins a base type (`defaultBaseType`), skip the * menu and open the OS file picker straight away; otherwise open the type menu anchored to the diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index 15b65ad4464f..d2898dff3012 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -44,6 +44,7 @@ import { withFolderDeleteRuns } from './features/folder-delete-runs/with-folder- import { withPushPublishEnvironments } from './features/push-publish-environments/withPushPublishEnvironments'; import { withSidebar } from './features/sidebar/withSidebar'; import { withSitePermissions } from './features/site-permissions/withSitePermissions'; +import { withSourceEditor } from './features/source-editor/withSourceEditor'; import { DEFAULT_PAGE, @@ -771,6 +772,7 @@ export const DotContentDriveStore = signalStore( }), withContextMenu(), withDialog(), + withSourceEditor(), withSidebar(), withDragging(), withActionExecution(), diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/source-editor/withSourceEditor.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/source-editor/withSourceEditor.spec.ts new file mode 100644 index 000000000000..87d78fa66ba8 --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/source-editor/withSourceEditor.spec.ts @@ -0,0 +1,67 @@ +import { signalStore, withState } from '@ngrx/signals'; +import { createServiceFactory, SpectatorService } from '@openng/spectator/vitest'; +import { describe, expect, it } from 'vitest'; + +import { DotSourceEditorRequest } from '@dotcms/edit-content'; + +import { withSourceEditor } from './withSourceEditor'; + +import { + DotContentDriveSortOrder, + DotContentDriveState, + DotContentDriveStatus +} from '../../../shared/models'; + +const initialState = { + currentSite: null, + path: '', + filters: {}, + items: [], + selectedItems: [], + status: DotContentDriveStatus.LOADING, + pagination: { limit: 40, offset: 0 }, + sort: { field: 'modDate', order: DotContentDriveSortOrder.ASC }, + isTreeExpanded: true +} as unknown as DotContentDriveState; + +const sourceEditorStoreMock = signalStore( + withState(initialState), + withSourceEditor() +); + +const request: DotSourceEditorRequest = { + inode: 'inode-1', + identifier: 'identifier-1', + languageId: 1, + title: 'header.vtl', + language: 'velocity' +}; + +describe('withSourceEditor', () => { + let spectator: SpectatorService>; + let store: InstanceType; + + const createService = createServiceFactory({ service: sourceEditorStoreMock }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + }); + + it('should start with the panel closed', () => { + expect(store.sourceEditor()).toBeNull(); + }); + + it('should open the panel on the requested file', () => { + store.openSourceEditor(request); + + expect(store.sourceEditor()).toEqual(request); + }); + + it('should close the panel', () => { + store.openSourceEditor(request); + store.closeSourceEditor(); + + expect(store.sourceEditor()).toBeNull(); + }); +}); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/source-editor/withSourceEditor.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/source-editor/withSourceEditor.ts new file mode 100644 index 000000000000..c279d357e2f5 --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/source-editor/withSourceEditor.ts @@ -0,0 +1,33 @@ +import { patchState, signalStoreFeature, type, withMethods, withState } from '@ngrx/signals'; + +import { DotSourceEditorRequest } from '@dotcms/edit-content'; + +import { DotContentDriveState } from '../../../shared/models'; + +interface WithSourceEditorState { + /** The file open in the Edit Source panel, or `null` when the panel is closed. */ + sourceEditor: DotSourceEditorRequest | null; +} + +/** + * Which file the Edit Source panel shows. Data only: the panel loads and saves the source itself, + * because a failed save has to leave the author's edits on screen. + */ +export function withSourceEditor() { + return signalStoreFeature( + { + state: type() + }, + withState({ + sourceEditor: null + }), + withMethods((store) => ({ + openSourceEditor: (sourceEditor: DotSourceEditorRequest) => { + patchState(store, { sourceEditor }); + }, + closeSourceEditor: () => { + patchState(store, { sourceEditor: null }); + } + })) + ); +} diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html index 938d3c305eb2..7a3bd93138bb 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.html @@ -63,21 +63,8 @@ [style.top.px]="hoverTopClipOffset()" [style.transform]="hoverTopClipOffset() ? 'translate(0, 0)' : null" data-testId="hover-actions"> - @if (hasVtlFiles()) { - - - } + + @if (hasVtlFiles()) { + + + } { expect(editVtlButton).toBeFalsy(); }); + it('should order the toolbar: Edit, Styles, Full Editor, Code, then Remove', () => { + spectator.setInput('showStyleEditorOption', true); + spectator.detectChanges(); + + const toolbar = spectator.query(byTestId('hover-actions')) as HTMLElement; + const order = Array.from(toolbar.querySelectorAll(':scope > p-button')).map((button) => + button.getAttribute('data-testid') + ); + + expect(order).toEqual([ + 'hover-quick-edit-button', + 'hover-palette-button', + 'hover-edit-button', + 'hover-edit-vtl-button', + 'hover-delete-button' + ]); + }); + + it('should order the collapsed menu the same way as the toolbar', () => { + spectator.setInput('showStyleEditorOption', true); + spectator.detectChanges(); + + expect(spectator.component.actionsMenuItems().map((item) => item.label)).toEqual([ + 'uve.tooltip.edit.quick', + 'uve.tooltip.edit.style', + 'uve.tooltip.edit.full', + 'uve.tooltip.edit.vtl', + 'uve.tooltip.remove' + ]); + }); + it('should render drag button', () => { const dragButton = spectator.query(byTestId('hover-drag-button')); expect(dragButton).toBeTruthy(); diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts index 2f97ccb5e63d..d3d6959ed827 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-contentlet-tools/dot-uve-contentlet-tools.component.ts @@ -309,24 +309,16 @@ export class DotUveContentletToolsComponent { /** * Menu items for the collapsed actions toolbar (small contentlets). - * Mirrors the icon-row buttons one-for-one. The drag button is NOT - * included — it lives outside `.actions` (left-center of the border) - * and stays visible at all sizes. VTL is a nested submenu (PrimeNG - * `` honors `items` on a MenuItem). + * Mirrors the icon-row buttons one-for-one, in the same order: Edit (quick + * edit), Styles, Full Editor, Code, then Remove. The drag button is NOT + * included — it lives outside `.actions` (left-center of the border) and + * stays visible at all sizes. Code is a nested submenu of the + * contentlet's VTL files (PrimeNG `` honors `items` on a MenuItem). */ readonly actionsMenuItems = computed(() => { const context = this.contentContext(); const items: MenuItem[] = []; - const vtlSubmenu = this.vtlMenuItems(); - if (vtlSubmenu?.length) { - items.push({ - label: this.#dotMessageService.get('uve.tooltip.edit.vtl'), - icon: 'pi pi-code', - items: vtlSubmenu - }); - } - items.push({ label: this.#dotMessageService.get('uve.tooltip.edit.quick'), icon: 'pi pi-bolt', @@ -356,6 +348,15 @@ export class DotUveContentletToolsComponent { command: () => this.openFullEditor.emit(context) }); + const vtlSubmenu = this.vtlMenuItems(); + if (vtlSubmenu.length) { + items.push({ + label: this.#dotMessageService.get('uve.tooltip.edit.vtl'), + icon: 'pi pi-code', + items: vtlSubmenu + }); + } + items.push({ label: this.#dotMessageService.get('uve.tooltip.remove'), icon: 'pi pi-times', diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html index c27c2468302e..1150b494214b 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.html @@ -333,6 +333,25 @@ } + +@defer (when $sourceEditorPanel()) { + @if ($sourceEditorPanel(); as request) { + + } +} @loading { +
+ +
+} +
[ EditEmaEditorComponent, { + // The source editor hosts Monaco and has its own spec; the editor only needs + // its input and outputs. remove: { - imports: [DotUveToolbarComponent, DotUvePaletteComponent] + imports: [ + DotUveToolbarComponent, + DotUvePaletteComponent, + DotSourceEditorSidePanelComponent + ] }, add: { - imports: [DotUveToolbarStubComponent, DotUvePaletteStubComponent] + imports: [ + DotUveToolbarStubComponent, + DotUvePaletteStubComponent, + MockComponent(DotSourceEditorSidePanelComponent) + ] } } ] @@ -278,7 +289,10 @@ const createRouting = () => { provide: DotContentletService, useValue: { - getContentletByInode: () => of(URL_MAP_CONTENTLET) + getContentletByInode: () => of(URL_MAP_CONTENTLET), + // The VTL menu asks before opening Edit Source; by default the user may edit. + canLock: () => + of({ canLock: true, locked: false, id: '', inode: '', lockedBy: '' }) } }, { @@ -2770,6 +2784,106 @@ describe('EditEmaEditorComponent', () => { }) ); }); + + describe('Edit Source panel', () => { + const SOURCE_PANEL = 'dot-source-editor-side-panel'; + const VELOCITY_FILE = { + ...URL_MAP_CONTENTLET, + inode: 'vtl-inode-123', + identifier: 'vtl-identifier-123', + languageId: 2, + baseType: 'FILEASSET', + extension: 'vtl', + fileName: 'my-template.vtl' + } as DotCMSContentlet; + + /** Clicks the file in the VTL menu and lets the deferred panel render. */ + const openVelocityFile = async () => { + vi.spyOn( + spectator.debugElement.injector.get(DotContentletService), + 'getContentletByInode' + ).mockReturnValue(of(VELOCITY_FILE)); + + spectator.component.handleEditVTL(VTL_FILE_MOCK); + spectator.detectChanges(); + await spectator.fixture.whenStable(); + spectator.detectChanges(); + }; + + it("should open a Velocity file's source instead of the content editor", async () => { + const dialogSpy = vi.spyOn(spectator.component.dialog, 'editContentlet'); + const dialogServiceOpenSpy = vi.spyOn( + spectator.inject(DialogService), + 'open' + ); + + await openVelocityFile(); + + const panel = spectator.debugElement.query(By.css(SOURCE_PANEL)); + + // Read through ng-mocks: a mocked component holds a signal input as a + // plain value. + expect(ngMocks.input(panel, 'request')).toEqual({ + inode: 'vtl-inode-123', + identifier: 'vtl-identifier-123', + languageId: 2, + title: 'my-template.vtl', + language: DOT_VELOCITY_LANGUAGE_ID + }); + expect(dialogSpy).not.toHaveBeenCalled(); + expect(dialogServiceOpenSpy).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'the user cannot edit it', + of({ + canLock: false, + locked: true, + id: '', + inode: '', + lockedBy: 'someone' + }) + ], + ['the lock check fails', throwError(() => new Error('network'))] + ])( + 'should open the content editor instead when %s', + async (_label, lockAnswer) => { + vi.spyOn( + spectator.debugElement.injector.get(DotContentletService), + 'canLock' + ).mockReturnValue( + lockAnswer as ReturnType + ); + const openContentForEdit = vi + .spyOn(spectator.component, 'openContentForEdit') + .mockImplementation(() => undefined); + + await openVelocityFile(); + + expect(openContentForEdit).toHaveBeenCalledWith(VELOCITY_FILE); + expect(spectator.query(SOURCE_PANEL)).toBeNull(); + } + ); + + it('should reload the page when the source is saved', async () => { + await openVelocityFile(); + const pageReloadSpy = vi.spyOn(pageApi(), 'pageReload'); + + spectator.triggerEventHandler(SOURCE_PANEL, 'saved', undefined); + + expect(pageReloadSpy).toHaveBeenCalled(); + }); + + it('should remove the panel when it closes', async () => { + await openVelocityFile(); + + spectator.triggerEventHandler(SOURCE_PANEL, 'closed', undefined); + spectator.detectChanges(); + + expect(spectator.query(SOURCE_PANEL)).toBeNull(); + }); + }); }); describe('handleEditWithCopyDecision', () => { diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index a8b945d4dee8..1d49cba0b2a2 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -1,5 +1,5 @@ import { patchState, signalState } from '@ngrx/signals'; -import { EMPTY, Observable, fromEvent, of } from 'rxjs'; +import { EMPTY, Observable, forkJoin, fromEvent, of } from 'rxjs'; import { ClipboardModule } from '@angular/cdk/clipboard'; import { NgClass, NgStyle } from '@angular/common'; @@ -64,7 +64,10 @@ import { import { DotEditContentDialogComponent, DotEditContentSidePanelComponent, - EditContentDialogData + DotSourceEditorRequest, + DotSourceEditorSidePanelComponent, + EditContentDialogData, + toSourceEditorRequest } from '@dotcms/edit-content'; import { DotPaletteListStore, DotResultsSeoToolComponent } from '@dotcms/portlets/dot-ema/ui'; import { GlobalStore } from '@dotcms/store'; @@ -183,6 +186,7 @@ const MESSAGE_KEY = { DotMessagePipe, DotUveDeviceControlsComponent, DotEditContentSidePanelComponent, + DotSourceEditorSidePanelComponent, ProgressSpinnerModule ], providers: [ @@ -310,6 +314,12 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit { */ protected readonly $editContentPanel = signal(null); + /** + * Drives the Edit Source side panel: the VTL file whose source is open in Monaco, or `null` + * when closed. Set from the contentlet tools' VTL menu (see {@link handleEditVTL}). + */ + protected readonly $sourceEditorPanel = signal(null); + /** * Feature flag: when on, the editor opens in the side panel; when off, it opens in the centered * dialog (previous behavior). Read from the UVE store's `withFlags` slice (batch-fetched once on @@ -1696,30 +1706,47 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit { } /** - * Handles the edit of a VTL file. `VTLFile` only carries `inode`/`name` (it comes from the - * client's postMessage payload), not `contentType`, so `openContentForEdit`'s flag check can't - * run on it directly — the full contentlet is resolved by inode first. Falls back to the legacy - * dialog if that lookup fails (network/permissions), matching this codebase's established - * "swallow the error, keep editing working via the legacy editor" fallback pattern. + * Handles the edit of a VTL file: opens its source in Monaco, in the Edit Source side panel. + * + * `VTLFile` only carries `inode`/`name` (it comes from the client's postMessage payload), so the + * full contentlet is resolved by inode first: the save needs its identifier and language. Who + * may edit the source is decided before opening, by the same rule as Content Drive's row menu + * (`toSourceEditorRequest`), from the server's lock answer fetched alongside. + * + * A file the source editor does not handle, or one the user cannot edit, goes to the content + * editor as before, which shows what the user may see. Falls back to the legacy dialog if the + * file lookup fails (network/permissions), matching this codebase's established "swallow the + * error, keep editing working via the legacy editor" fallback pattern. * * @param {VTLFile} vtlFile - The VTL file to be edited. * @memberof EditEmaEditorComponent */ handleEditVTL(vtlFile: VTLFile) { - this.dotContentletService - .getContentletByInode(vtlFile.inode) + forkJoin({ + contentlet: this.dotContentletService.getContentletByInode(vtlFile.inode), + // A failed lock check is an unknown answer, so it reads as "cannot edit the source". + lock: this.dotContentletService.canLock(vtlFile.inode).pipe(catchError(() => of(null))) + }) .pipe( take(1), takeUntilDestroyed(this.destroyRef), catchError(() => of(null)) ) - .subscribe((contentlet) => { - if (!contentlet) { + .subscribe((result) => { + if (!result) { this.dialog?.editVTLContentlet(vtlFile); return; } - this.openContentForEdit(contentlet); + const { contentlet, lock } = result; + const request = lock ? toSourceEditorRequest(contentlet, lock) : null; + + if (!request) { + this.openContentForEdit(contentlet); + return; + } + + this.$sourceEditorPanel.set(request); }); } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index b63f98f6aabf..df9de658a706 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6796,6 +6796,9 @@ edit.content.side-panel.expand=Expand panel edit.content.side-panel.collapse=Collapse panel edit.content.side-panel.shortcut.close=Close the content panel edit.content.side-panel.close=Close panel +edit.content.source-editor.load-error=Couldn’t load this file’s source. +edit.content.source-editor.shortcut.close=Close the source editor +edit.content.source-editor.shortcut.save=Save the file’s source edit.content.sidebar.tab.information=Information edit.content.sidebar.tab.history=History edit.content.sidebar.tab.comments=Comments @@ -7027,10 +7030,10 @@ uve.preview.mode.search.engine.subheader=Search Engine uve.disable.delete.button.on.personalization=Personalization requires a minimum of one content item. uve.contentlet.no.edit.permission=You don't have permission to edit this content. uve.tooltip.move=Move -uve.tooltip.edit.vtl=Edit VTL -uve.tooltip.edit.quick=Quick Edit -uve.tooltip.edit.style=Edit Styles -uve.tooltip.edit.full=Edit +uve.tooltip.edit.vtl=Code +uve.tooltip.edit.quick=Edit +uve.tooltip.edit.style=Styles +uve.tooltip.edit.full=Full Editor uve.tooltip.remove=Remove uve.personalize.empty.page.error=Personalization requires page content. Add some first. uve.editor.mode.draft=Draft @@ -7315,6 +7318,7 @@ content-drive.field-filter.binary.exclude=Exclude "{0}" content-drive.field-filter.apply=Apply content-drive.field-filter.relationship.unavailable=Filtering by related content is not available here content-drive.context-menu.edit-content=Edit Content +content-drive.context-menu.edit-source=Edit Source content-drive.context-menu.edit-page=Edit Page content-drive.context-menu.edit-folder=Folder Settings content-drive.context-menu.view-content=View Content