diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-select.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-select.spec.ts index e18cecc4ea10..9e58271d31c5 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-select.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-select.spec.ts @@ -332,28 +332,29 @@ test.describe('Create New Inline', () => { const relationshipField = new RelationshipField(adminPage); await relationshipField.clickCreateNew(); - const createDialog = adminPage.locator('.p-dialog-create-content .p-dialog'); - await expect(createDialog).toBeVisible({ timeout: 10000 }); + // With the side-panel feature flag on, "New content" opens the editor in a slide-in + // panel (p-drawer, teleported to body), not the centered create dialog. + const sidePanel = adminPage.locator('.p-drawer'); + await expect(sidePanel).toBeVisible({ timeout: 10000 }); - const titleInput = createDialog.getByTestId('title').first(); + const titleInput = sidePanel.getByTestId('title').first(); await titleInput.waitFor({ state: 'visible', timeout: 10000 }); await titleInput.fill(`Inline Author ${testSuffix}`); const responsePromise = adminPage.waitForResponse((response) => response.url().includes('/api/v1/workflow/actions/') ); - const saveButton = createDialog.getByRole('button', { name: /Save/ }); + const saveButton = sidePanel.getByRole('button', { name: /Save/ }); await saveButton.waitFor({ state: 'visible', timeout: 5000 }); await saveButton.click(); await responsePromise; - // Dialog stays open after save — close via X button - const closeButton = createDialog.locator( - '.p-dialog-header-close, button[aria-label="Close"]' - ); + // Panel stays open after save — close via the header X. Closing fires onContentSaved, + // which adds the newly created content to the relationship. + const closeButton = sidePanel.getByTestId('side-panel-close'); await closeButton.waitFor({ state: 'visible', timeout: 5000 }); await closeButton.click(); - await expect(createDialog).toBeHidden({ timeout: 10000 }); + await expect(sidePanel).toBeHidden({ timeout: 10000 }); await relationshipField.expectRowCount(1); }); @@ -371,34 +372,32 @@ test.describe('Create New Inline', () => { const relationshipField = new RelationshipField(adminPage); await relationshipField.clickCreateNew(); - const createDialog = adminPage.locator('.p-dialog-create-content .p-dialog'); - await expect(createDialog).toBeVisible({ timeout: 10000 }); + const sidePanel = adminPage.locator('.p-drawer'); + await expect(sidePanel).toBeVisible({ timeout: 10000 }); await adminPage.keyboard.press('Escape'); - await expect(createDialog).toBeHidden({ timeout: 5000 }); + await expect(sidePanel).toBeHidden({ timeout: 5000 }); const textField = adminPage.getByTestId('title'); await expect(textField).toHaveValue(outerTitle); await relationshipField.expectEmpty(); }); - test('dismiss create dialog via X button @smoke', async ({ adminPage }) => { + test('dismiss create panel via X button @smoke', async ({ adminPage }) => { const formPage = new NewEditContentFormPage(adminPage); await formPage.goToNew(blogTypeVariable); const relationshipField = new RelationshipField(adminPage); await relationshipField.clickCreateNew(); - const createDialog = adminPage.locator('.p-dialog-create-content .p-dialog'); - await expect(createDialog).toBeVisible({ timeout: 10000 }); + const sidePanel = adminPage.locator('.p-drawer'); + await expect(sidePanel).toBeVisible({ timeout: 10000 }); - const closeButton = createDialog.locator( - '.p-dialog-header-close, button[aria-label="Close"]' - ); + const closeButton = sidePanel.getByTestId('side-panel-close'); await expect(closeButton).toBeVisible({ timeout: 5000 }); await closeButton.click(); - await expect(createDialog).toBeHidden({ timeout: 5000 }); + await expect(sidePanel).toBeHidden({ timeout: 5000 }); await relationshipField.expectEmpty(); }); }); diff --git a/core-web/libs/dotcms-models/src/lib/shared-models.ts b/core-web/libs/dotcms-models/src/lib/shared-models.ts index 2da10347ece1..c20e2416fa58 100644 --- a/core-web/libs/dotcms-models/src/lib/shared-models.ts +++ b/core-web/libs/dotcms-models/src/lib/shared-models.ts @@ -36,7 +36,8 @@ export const enum FeaturedFlags { FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION = 'FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION', FEATURE_FLAG_NEW_BLOCK_EDITOR = 'FEATURE_FLAG_NEW_BLOCK_EDITOR', FEATURE_FLAG_REPORT_ISSUE_ENABLED = 'FEATURE_FLAG_REPORT_ISSUE_ENABLED', - FEATURE_FLAG_LOCALE_SELECTOR_V2 = 'FEATURE_FLAG_LOCALE_SELECTOR_V2' + FEATURE_FLAG_LOCALE_SELECTOR_V2 = 'FEATURE_FLAG_LOCALE_SELECTOR_V2', + FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL = 'FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL' } export const enum DotConfigurationVariables { diff --git a/core-web/libs/edit-content/src/index.ts b/core-web/libs/edit-content/src/index.ts index 6a55298fc4e8..1c4b79194f74 100644 --- a/core-web/libs/edit-content/src/index.ts +++ b/core-web/libs/edit-content/src/index.ts @@ -5,6 +5,8 @@ export * from './lib/fields/dot-edit-content-file-field/components/dot-file-fiel export * from './lib/fields/dot-edit-content-tag-field/components/tag-field/tag-field.component'; export * from './lib/models/dot-edit-content-dialog.interface'; export * from './lib/services/dot-edit-content.service'; +export * from './lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component'; +export { DotSidePanelNavController } from './lib/services/dot-side-panel-nav.service'; 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-edit-content-form/dot-edit-content-form.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts index 78b5340f1d7c..267c09c4471c 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts @@ -1233,6 +1233,68 @@ describe('DotFormComponent', () => { flush(); })); + + it('treats a native `drop` as a genuine interaction, even as the very first one (drag-and-drop from the OS has no preceding pointerdown)', fakeAsync(() => { + store.initializeExistingContent({ + inode: MOCK_CONTENTLET_1_OR_2_TABS.inode, + depth: DotContentletDepths.ONE + }); + + spectator.detectChanges(); + tick(PRISTINE_RESET_DEBOUNCE_MS); + spectator.detectChanges(); + + expect(component.form.pristine).toBe(true); + + // Dragging a file in from the OS file manager: the drag starts outside the window, + // so no `pointerdown` precedes it inside the host — only this native `drop` event + // (dispatched on the host, where the capture-phase listener lives) does. + spectator.element.dispatchEvent(new Event('drop', { bubbles: true })); + + // Simulate the file field's CVA writing the dropped file's value — same as any + // CVA-driven onChange, Angular's forms wiring marks the control dirty and emits + // valueChanges, which `initializeFormListener` reacts to. + component.form.markAsDirty(); + component.form.updateValueAndValidity(); + spectator.detectChanges(); + + // #userTouched (latched by the drop) must stop the listener from resetting to + // pristine, the way it would for untouched async-CVA populate noise. + expect(component.form.dirty).toBe(true); + + flush(); + })); + + it('does not wipe a real edit made within the isStable/500ms fallback window (#scheduleMarkPristineAfterInit is gated on #userTouched too)', fakeAsync(() => { + store.initializeExistingContent({ + inode: MOCK_CONTENTLET_1_OR_2_TABS.inode, + depth: DotContentletDepths.ONE + }); + + spectator.detectChanges(); + // A microtask flush (not a meaningful time advance) is needed for the field to + // actually render — without it `text2` isn't in the DOM yet. This costs nothing + // against the 500ms fallback window the test is about. + tick(0); + spectator.detectChanges(); + + // A real edit lands before the fallback timer fires (e.g. the user starts typing + // immediately, before ApplicationRef settles or the 500ms safety window elapses). + const input = spectator.query(byTestId('text2')) as HTMLInputElement; + spectator.typeInElement('edited before settle', input); + spectator.detectChanges(); + expect(component.form.dirty).toBe(true); + + // Drain #scheduleMarkPristineAfterInit's fallback timer. + tick(PRISTINE_RESET_DEBOUNCE_MS); + spectator.detectChanges(); + + // Previously this timer called markAsPristine() unconditionally, silently + // discarding the edit the moment the window elapsed. + expect(component.form.dirty).toBe(true); + + flush(); + })); }); }); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts index f11f74671261..f9af769c91c4 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts @@ -11,6 +11,7 @@ import { DestroyRef, DOCUMENT, effect, + ElementRef, inject, OnInit, output, @@ -127,6 +128,7 @@ export class DotEditContentFormComponent implements OnInit { readonly $store = inject(DotEditContentStore); readonly #router = inject(Router); readonly #destroyRef = inject(DestroyRef); + readonly #elementRef = inject(ElementRef); readonly #fb = inject(FormBuilder); readonly #dotWorkflowEventHandlerService = inject(DotWorkflowEventHandlerService); readonly #dotWizardService = inject(DotWizardService); @@ -206,6 +208,14 @@ export class DotEditContentFormComponent implements OnInit { protected readonly $shouldRenderFields = signal(true); protected readonly $shouldRenderPreservedFields = signal(true); + /** + * True once the user has genuinely interacted with a field (see the capture-phase listeners set + * up in the constructor). While false, any form value change is async-CVA populate noise, so + * {@link initializeFormListener} re-marks the form pristine — decoupling "the user edited + * something" from load timing. Reset on each (re)build of the form in {@link initializeForm}. + */ + #userTouched = false; + /** * Subscription for form value changes - using this to manage the listener lifecycle * @@ -291,6 +301,30 @@ export class DotEditContentFormComponent implements OnInit { } constructor() { + // Detect the first REAL user interaction (pointer/keyboard/input from any field) in the + // CAPTURE phase, so `#userTouched` is set BEFORE the field's value-accessor emits + // `valueChanges` (which runs in the target/bubble phase) — only then can + // initializeFormListener tell a genuine edit apart from async-CVA populate noise. + // Programmatic `writeValue` on load never dispatches these DOM events, so it never trips it. + const host = this.#elementRef.nativeElement as HTMLElement; + const markTouched = () => { + this.#userTouched = true; + }; + // `drop` is included because dragging a file in from the OS (e.g. onto the binary/file + // field) sets the control value programmatically via `(fileDropped)` — the drag starts + // outside the window, so no `pointerdown` precedes it. Without latching here, that first + // interaction would look like async-CVA populate and the edit would be marked pristine. + const interactionEvents = ['pointerdown', 'keydown', 'input', 'drop'] as const; + const listenerOptions: AddEventListenerOptions = { capture: true }; + interactionEvents.forEach((type) => + host.addEventListener(type, markTouched, listenerOptions) + ); + this.#destroyRef.onDestroy(() => + interactionEvents.forEach((type) => + host.removeEventListener(type, markTouched, listenerOptions) + ) + ); + /** * Effect that reinitializes the form when contentlet changes (e.g., when viewing historical versions) * @@ -424,7 +458,12 @@ export class DotEditContentFormComponent implements OnInit { race(this.#appRef.isStable.pipe(filter(Boolean)), timer(500)) .pipe(take(1), takeUntilDestroyed(this.#destroyRef)) .subscribe(() => { - this.form?.markAsPristine(); + // Gate on `#userTouched` for the same reason `initializeFormListener` does: if the + // user genuinely edited a field within the isStable/500ms window, clearing dirty + // here would silently drop that edit. Only clear load-time (untouched) CVA noise. + if (!this.#userTouched) { + this.form?.markAsPristine(); + } }); } @@ -454,6 +493,13 @@ export class DotEditContentFormComponent implements OnInit { this.formValueSubscription = this.form.valueChanges .pipe(takeUntilDestroyed(this.#destroyRef)) .subscribe((value) => { + // Until the user has actually interacted, a value change is async-CVA populate, + // not an edit — keep the form pristine so the unsaved-changes guard never fires + // for load-time noise (independent of how slow the fields load). + if (!this.#userTouched) { + this.form.markAsPristine(); + } + this.onFormChange(value); }); } @@ -624,6 +670,10 @@ export class DotEditContentFormComponent implements OnInit { * @private */ private initializeForm() { + // A fresh (re)build starts a new populate window: until the user interacts, changes are + // programmatic CVA noise, not edits. + this.#userTouched = false; + const controls = this.$formFields().reduce( (acc, field) => ({ ...acc, diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.spec.ts index 99c1ab5fbe77..961887cd1aac 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.spec.ts @@ -360,6 +360,37 @@ describe('EditContentLayoutComponent', () => { }); }); + describe('confirmClose (chrome-agnostic close guard)', () => { + it('bypasses the prompt while the editor is loading/saving (form disabled, nothing to discard)', () => { + jest.spyOn(spectator.component, 'hasUnsavedChanges').mockReturnValue(true); + jest.spyOn(store, 'workflowActionSuccess').mockReturnValue(null); + jest.spyOn(store, 'isLoading').mockReturnValue(true); + const onProceed = jest.fn(); + + spectator.component.confirmClose(onProceed); + + expect(onProceed).toHaveBeenCalledTimes(1); + }); + + it('does NOT bypass the prompt once loading has settled, even if the sidebar has not (isFullyLoaded no longer gates this)', () => { + const confirmationService = spectator.inject(ConfirmationService, true); + jest.spyOn(spectator.component, 'hasUnsavedChanges').mockReturnValue(true); + jest.spyOn(store, 'workflowActionSuccess').mockReturnValue(null); + jest.spyOn(store, 'isLoading').mockReturnValue(false); + // A real edit made while `isFullyLoaded()` was still false (sidebar still settling) + // must still prompt once loading has finished — the previous `!isFullyLoaded()` bypass + // would have discarded it silently instead. + jest.spyOn(store, 'isFullyLoaded').mockReturnValue(false); + const confirmSpy = jest.spyOn(confirmationService, 'confirm'); + const onProceed = jest.fn(); + + spectator.component.confirmClose(onProceed); + + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(onProceed).not.toHaveBeenCalled(); + }); + }); + // Isolated to its own describe so that no other component mounted earlier // in the file can race the `window:beforeunload` listener registered via // the host metadata. The outer `spectator` fixture is destroyed and a diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.ts index 058f6dc07629..b111e0b0d32b 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.ts @@ -405,6 +405,37 @@ export class DotEditContentLayoutComponent { this.$editContentForm()?.form?.markAsPristine(); } + /** + * Chrome-agnostic close guard. Any presentation that can close the editor (side panel, + * dialog, etc.) should route its close through here so the unsaved-changes prompt is enforced + * consistently — instead of each chrome wiring its own check. Runs `onProceed` immediately when + * closing is safe — the form is clean, a save just succeeded, or the editor is still + * loading/saving (the form is disabled then, so nothing could have been edited) — otherwise it + * prompts and only proceeds on "Discard". + * + * @param onProceed Runs when it is safe to close (clean form, a save just succeeded, the editor + * is loading/saving, or the user discarded changes). + */ + confirmClose(onProceed: () => void): void { + // While the content is still loading/saving the form is disabled, so the user cannot have + // edited anything — close without prompting. (Async field CVAs that transiently mark the + // form dirty during load are already handled by the form's `#userTouched` gate, which keeps + // it pristine until a real interaction.) Gated on `isLoading()` — NOT `isFullyLoaded()`, + // which also waits on the sidebar and would leave a window where the form is visible and + // editable but the guard is still bypassed, discarding a real edit silently. + if ( + !this.hasUnsavedChanges() || + this.$store.workflowActionSuccess() || + this.$store.isLoading() + ) { + onProceed(); + + return; + } + + this.#confirmIfDirty(onProceed, () => undefined); + } + /** * Triggers the browser's native unload-confirmation dialog when the form has * unsaved changes. Covers cases the Angular `CanDeactivate` guard cannot diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.html new file mode 100644 index 000000000000..071ae1239717 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.html @@ -0,0 +1,65 @@ + + + +
+ + {{ data()?.title }} + +
+ + + + {{ $expanded() ? 'close_fullscreen' : 'open_in_full' }} + + + + + + close + + +
+
+
+ @for (item of $items(); track item.contentletInode ?? item.contentTypeId) { + + } +
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.spec.ts new file mode 100644 index 000000000000..cff68740354b --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.spec.ts @@ -0,0 +1,378 @@ +import { createComponentFactory, Spectator, byTestId } from '@openng/spectator/jest'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { Subject } from 'rxjs'; + +import { ButtonModule } from 'primeng/button'; +import { DrawerModule } from 'primeng/drawer'; + +import { DotCMSContentlet } from '@dotcms/dotcms-models'; +import { DotMessagePipe } from '@dotcms/ui'; + +import { DotEditContentSidePanelComponent } from './dot-edit-content-side-panel.component'; + +import { EditContentDialogData } from '../../models/dot-edit-content-dialog.interface'; +import { DotSidePanelNavController } from '../../services/dot-side-panel-nav.service'; +import { OverlayEditContentHost } from '../../services/host/overlay-edit-content-host'; +import { DotEditContentLayoutComponent } from '../dot-edit-content-layout/dot-edit-content.layout.component'; + +describe('DotEditContentSidePanelComponent', () => { + let spectator: Spectator; + let saved$: Subject; + let mockHost: Pick; + + const EDIT_DATA: EditContentDialogData = { + mode: 'edit', + contentletInode: 'inode-1', + identifier: 'id-1', + title: 'My Content' + }; + + const createComponent = createComponentFactory({ + component: DotEditContentSidePanelComponent, + // Swap the heavy editor for a stub; feed a mock host so we control `saved$`. + overrideComponents: [ + [ + DotEditContentSidePanelComponent, + { + set: { + imports: [ + DrawerModule, + ButtonModule, + MockComponent(DotEditContentLayoutComponent), + MockPipe(DotMessagePipe, (key: string) => key) + ], + providers: [{ provide: OverlayEditContentHost, useValue: undefined }] + } + } + ] + ] + }); + + beforeEach(() => { + // Isolate the persisted expanded preference between tests. + localStorage.clear(); + saved$ = new Subject(); + mockHost = { saved$: saved$.asObservable() }; + + spectator = createComponent({ + providers: [ + { provide: OverlayEditContentHost, useValue: mockHost }, + // Stub so the component doesn't pull the real controller (and GlobalStore) in tests. + { + provide: DotSidePanelNavController, + useValue: { + acquire: jest.fn(), + release: jest.fn(), + isTop: jest.fn().mockReturnValue(true) + } + } + ], + detectChanges: false + }); + }); + + it('should create', () => { + spectator.detectChanges(); + expect(spectator.component).toBeTruthy(); + }); + + it('should render the content title in the header', () => { + spectator.setInput('data', EDIT_DATA); + spectator.detectChanges(); + + // `appendTo="body"` teleports the drawer content out of the fixture into `document.body`, + // so byTestId (a DOM/CSS query) must search from the document root. + expect( + spectator.query(byTestId('side-panel-title'), { root: true })?.textContent?.trim() + ).toBe('My Content'); + }); + + it('should render the editor only when data is set', () => { + spectator.setInput('data', null); + spectator.detectChanges(); + expect(spectator.query(DotEditContentLayoutComponent)).toBeNull(); + + spectator.setInput('data', EDIT_DATA); + spectator.detectChanges(); + expect(spectator.query(DotEditContentLayoutComponent)).not.toBeNull(); + }); + + /** + * PrimeNG `p-button` renders its clickable `