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 @@
+
+
+
+