Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
fb99410
checkpoint(content-drive): edit content side panel POC + UI (working)
adrianjm-dotCMS Jul 22, 2026
bc2cc12
feat(content-drive): add support for shareable edit content links via…
adrianjm-dotCMS Jul 22, 2026
9cd7dc1
feat(edit-content): implement unsaved changes guard for editor close …
adrianjm-dotCMS Jul 22, 2026
1c82d89
feat(edit-content): introduce side panel for editing content
adrianjm-dotCMS Jul 23, 2026
559ce3c
feat(edit-content): enhance side panel functionality and testing
adrianjm-dotCMS Jul 23, 2026
a0c0014
feat(edit-content): enhance side panel functionality and testing
adrianjm-dotCMS Jul 27, 2026
fbc75f9
refactor(dot-query-tool): clean up import statements in dot-query-too…
adrianjm-dotCMS Jul 27, 2026
a1cd539
fix(edit-content): scope side-panel ESC to top of stack and i18n labels
adrianjm-dotCMS Jul 27, 2026
d22ec21
refactor(edit-content): update side panel component and improve testing
adrianjm-dotCMS Jul 27, 2026
7793004
fix(edit-content): clean up HTML formatting in side panel component
adrianjm-dotCMS Jul 27, 2026
711597c
fix(edit-content): correct class syntax in side panel component HTML
adrianjm-dotCMS Jul 27, 2026
6c02066
feat(edit-content): enhance user interaction detection in edit conten…
adrianjm-dotCMS Jul 27, 2026
622d944
refactor(edit-content): update tests for side panel interactions
adrianjm-dotCMS Jul 28, 2026
739ced5
Merge branch 'main' into issue-36631-edit-content-slide-in-panel
adrianjm-dotCMS Jul 28, 2026
ccacdaa
refactor(edit-content): enhance user interaction detection in DotEdit…
adrianjm-dotCMS Jul 28, 2026
46c96ba
refactor(edit-content): improve formatting of event listener registra…
adrianjm-dotCMS Jul 28, 2026
cd9c643
Merge branch 'main' into issue-36631-edit-content-slide-in-panel
adrianjm-dotCMS Jul 28, 2026
028393f
refactor(edit-content): enhance close handling in DotEditContentSideP…
adrianjm-dotCMS Jul 29, 2026
b274dbc
refactor(edit-content): enhance interaction handling in DotEditConten…
adrianjm-dotCMS Jul 29, 2026
35de273
Merge branch 'issue-36631-edit-content-slide-in-panel' of https://git…
adrianjm-dotCMS Jul 29, 2026
917826c
feat(dot-content-drive): implement popstate re-hydration for browser …
adrianjm-dotCMS Jul 29, 2026
9799d5e
Merge branch 'main' into issue-36631-edit-content-slide-in-panel
adrianjm-dotCMS Jul 29, 2026
6e7efa9
feat(dot-content-drive): integrate feature flags into DotContentDrive…
adrianjm-dotCMS Jul 30, 2026
bfd3a58
Merge branch 'issue-36631-edit-content-slide-in-panel' of https://git…
adrianjm-dotCMS Jul 30, 2026
cf8222f
Merge branch 'main' into issue-36631-edit-content-slide-in-panel
adrianjm-dotCMS Jul 30, 2026
fd1de51
refactor(edit-content): integrate feature flags for side panel behavior
adrianjm-dotCMS Jul 30, 2026
17e8d2f
refactor(dot-content-drive): relocate NEW_CONTENT_MARKER constant
adrianjm-dotCMS Jul 30, 2026
fdd14de
Merge branch 'issue-36631-edit-content-slide-in-panel' of https://git…
adrianjm-dotCMS Jul 30, 2026
1a9a7d5
refactor(dot-query-tool): enhance side panel flag handling in content…
adrianjm-dotCMS Jul 30, 2026
cd92179
Merge branch 'main' of https://github.com/dotCMS/core into issue-3663…
adrianjm-dotCMS Jul 30, 2026
919f803
refactor(edit-ema): enhance feature flag handling and remove deprecat…
adrianjm-dotCMS Jul 30, 2026
ac7ec02
feat(edit-ema): enhance routing and editor integration for content ed…
adrianjm-dotCMS Jul 31, 2026
2b20f2d
Merge branch 'main' into issue-36631-edit-content-slide-in-panel
adrianjm-dotCMS Jul 31, 2026
91b63fc
feat(uve): introduce feature flag handling in UVE state management
adrianjm-dotCMS Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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();
});
});
Expand Down
3 changes: 2 additions & 1 deletion core-web/libs/dotcms-models/src/lib/shared-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions core-web/libs/edit-content/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}));
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DestroyRef,
DOCUMENT,
effect,
ElementRef,
inject,
OnInit,
output,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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)
*
Expand Down Expand Up @@ -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();
}
});
}

Expand Down Expand Up @@ -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) {
Comment thread
adrianjm-dotCMS marked this conversation as resolved.
this.form.markAsPristine();
}

this.onFormChange(value);
});
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading