@if ($canRead()) {
-
+
} @else {
@if ($errorDisplay()?.code === 401) {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts
index 49178a82898f..26b68f109d82 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.spec.ts
@@ -623,6 +623,41 @@ describe('DotEmaShellComponent', () => {
angularCurrentPortlet: 'edit-page'
});
});
+
+ it('routes the properties click through the active editor (new editor/side panel) when the content route is mounted, instead of the legacy dialog', () => {
+ const openContentForEdit = jest.fn();
+ spectator.component.onRouteActivate({ openContentForEdit });
+ const dialogSpy = jest.spyOn(spectator.component.dialog, 'editContentlet');
+
+ const navBar = spectator.debugElement.query(By.css('[data-testid="ema-nav-bar"]'));
+ spectator.triggerEventHandler(navBar, 'action', 'properties');
+
+ expect(openContentForEdit).toHaveBeenCalledWith(
+ expect.objectContaining({ inode: '123', identifier: '123' })
+ );
+ expect(dialogSpy).not.toHaveBeenCalled();
+ });
+
+ it('falls back to the legacy dialog when a sibling route (layout/rules/experiments) is active', () => {
+ spectator.component.onRouteActivate({ openContentForEdit: jest.fn() });
+ spectator.component.onRouteDeactivate();
+ const dialogSpy = jest.spyOn(spectator.component.dialog, 'editContentlet');
+
+ const navBar = spectator.debugElement.query(By.css('[data-testid="ema-nav-bar"]'));
+ spectator.triggerEventHandler(navBar, 'action', 'properties');
+
+ expect(dialogSpy).toHaveBeenCalled();
+ });
+
+ it('ignores an activated component that does not expose openContentForEdit (a sibling route)', () => {
+ spectator.component.onRouteActivate({ someOtherMethod: jest.fn() });
+ const dialogSpy = jest.spyOn(spectator.component.dialog, 'editContentlet');
+
+ const navBar = spectator.debugElement.query(By.css('[data-testid="ema-nav-bar"]'));
+ spectator.triggerEventHandler(navBar, 'action', 'properties');
+
+ expect(dialogSpy).toHaveBeenCalled();
+ });
});
describe('Page Params', () => {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts
index 2706cec7c713..263d7e65af3c 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts
@@ -26,7 +26,12 @@ import { filter } from 'rxjs/operators';
import { DotMessageService } from '@dotcms/data-access';
import { SiteService } from '@dotcms/dotcms-js';
-import { DEFAULT_VARIANT_ID, DotPageToolUrlParams, FeaturedFlags } from '@dotcms/dotcms-models';
+import {
+ DEFAULT_VARIANT_ID,
+ DotCMSContentlet,
+ DotPageToolUrlParams,
+ FeaturedFlags
+} from '@dotcms/dotcms-models';
import {
DotPageScannerReportComponent,
DotPageToolsSeoComponent,
@@ -55,6 +60,23 @@ import {
shouldNavigate
} from '../utils';
+/** Structural shape of `EditEmaEditorComponent.openContentForEdit` (the 'content' child route). */
+interface RouteWithOpenContentForEdit {
+ openContentForEdit(contentlet: DotCMSContentlet): void;
+}
+
+/**
+ * Duck-typed guard instead of `instanceof EditEmaEditorComponent`: that class is lazy-loaded via
+ * `loadComponent` in `lib.routes.ts`, and a value import here (required by `instanceof`) would pull
+ * it into this shell's eager chunk, defeating the code-split.
+ */
+function hasOpenContentForEdit(component: unknown): component is RouteWithOpenContentForEdit {
+ return (
+ !!component &&
+ typeof (component as RouteWithOpenContentForEdit).openContentForEdit === 'function'
+ );
+}
+
@Component({
selector: 'dot-ema-shell',
templateUrl: './dot-ema-shell.component.html',
@@ -81,6 +103,13 @@ export class DotEmaShellComponent implements OnInit, OnDestroy {
@ViewChild('pageTools') pageTools!: DotPageToolsSeoComponent;
@ViewChild('pageScanner') pageScanner!: DotPageScannerReportComponent;
+ /**
+ * The active child route's component, when it's the 'content' route (`EditEmaEditorComponent`)
+ * — captured via the router-outlet `(activate)`/`(deactivate)` below. `null` while on a sibling
+ * route ('layout', 'rules', 'experiments') that doesn't expose `openContentForEdit`.
+ */
+ #activeEditor: RouteWithOpenContentForEdit | null = null;
+
readonly uveStore = inject(UVEStore);
readonly destroyRef = inject(DestroyRef);
readonly #activatedRoute = inject(ActivatedRoute);
@@ -326,6 +355,20 @@ export class DotEmaShellComponent implements OnInit, OnDestroy {
this.uveStore.pageReload();
}
+ /**
+ * Tracks the active child route's component (bound to the `router-outlet` in the template) so
+ * "Properties" can route through the new editor's side panel when it's mounted (the 'content'
+ * route). See {@link RouteWithOpenContentForEdit} for why this isn't `instanceof`-based.
+ */
+ onRouteActivate(component: unknown): void {
+ this.#activeEditor = hasOpenContentForEdit(component) ? component : null;
+ }
+
+ /** Clears the active-editor reference so a sibling route (layout/rules/experiments) falls back. */
+ onRouteDeactivate(): void {
+ this.#activeEditor = null;
+ }
+
/**
* Handle actions from nav bar
*
@@ -341,6 +384,16 @@ export class DotEmaShellComponent implements OnInit, OnDestroy {
return;
}
+ // Editing the page's own properties is editing its contentlet — route it through the
+ // same feature-flag-aware entry point as every other edit flow (new editor/side panel
+ // when enabled for the page's content type, legacy dialog otherwise). Only available
+ // while the 'content' child route is mounted; on 'layout'/'rules'/'experiments' fall
+ // back to this shell's own legacy dialog (previous, route-independent behavior).
+ if (this.#activeEditor) {
+ this.#activeEditor.openContentForEdit(page as unknown as DotCMSContentlet);
+ return;
+ }
+
this.dialog.editContentlet({
inode: page.inode,
title: page.title,
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 a4601fb8fc44..392fd35688f0 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
@@ -294,6 +294,28 @@
(onSaved)="reloadPage()" />
}
+
+@defer (when $editContentPanel()) {
+ @if ($editContentPanel(); as data) {
+
+ }
+} @loading {
+
+}
+
{
});
});
+ describe('handleEditVTL', () => {
+ const VTL_FILE_MOCK: VTLFile = { inode: 'vtl-inode-123', name: 'my-template.vtl' };
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should open the legacy VTL dialog when the contentlet lookup fails', () => {
+ const dotContentletService =
+ spectator.debugElement.injector.get(DotContentletService);
+ jest.spyOn(dotContentletService, 'getContentletByInode').mockReturnValue(
+ throwError(() => new Error('network error'))
+ );
+ const dialogSpy = jest.spyOn(spectator.component.dialog, 'editVTLContentlet');
+
+ spectator.component.handleEditVTL(VTL_FILE_MOCK);
+ spectator.detectChanges();
+
+ expect(dialogSpy).toHaveBeenCalledWith(VTL_FILE_MOCK);
+ });
+
+ it('should open the legacy dialog when the resolved content type does not enable the new editor', () => {
+ const dotContentletService =
+ spectator.debugElement.injector.get(DotContentletService);
+ jest.spyOn(dotContentletService, 'getContentletByInode').mockReturnValue(
+ of({ ...URL_MAP_CONTENTLET, inode: 'vtl-inode-123', contentType: 'test' })
+ );
+ const dotContentTypeService =
+ spectator.debugElement.injector.get(DotContentTypeService);
+ jest.spyOn(dotContentTypeService, 'getContentType').mockReturnValue(
+ of(
+ createFakeContentType({
+ variable: 'test',
+ name: 'Test',
+ metadata: {
+ [FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]: false
+ }
+ })
+ )
+ );
+ const dialogSpy = jest.spyOn(spectator.component.dialog, 'editContentlet');
+ const dialogServiceOpenSpy = jest.spyOn(
+ spectator.inject(DialogService),
+ 'open'
+ );
+
+ spectator.component.handleEditVTL(VTL_FILE_MOCK);
+ spectator.detectChanges();
+
+ expect(dialogSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ inode: 'vtl-inode-123' })
+ );
+ expect(dialogServiceOpenSpy).not.toHaveBeenCalled();
+ });
+
+ it('should open the new edit content flow when CONTENT_EDITOR2_ENABLED is true on the resolved content type', async () => {
+ const dotContentletService =
+ spectator.debugElement.injector.get(DotContentletService);
+ jest.spyOn(dotContentletService, 'getContentletByInode').mockReturnValue(
+ of({ ...URL_MAP_CONTENTLET, inode: 'vtl-inode-123', contentType: 'test' })
+ );
+ const dotContentTypeService =
+ spectator.debugElement.injector.get(DotContentTypeService);
+ jest.spyOn(dotContentTypeService, 'getContentType').mockReturnValue(
+ of(
+ createFakeContentType({
+ variable: 'test',
+ name: 'Test',
+ metadata: {
+ [FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]: true
+ }
+ })
+ )
+ );
+ const dialogSpy = jest.spyOn(spectator.component.dialog, 'editContentlet');
+ const dialogRefMock = {
+ onClose: new Subject(),
+ close: jest.fn()
+ };
+ const dialogServiceOpenSpy = jest
+ .spyOn(spectator.inject(DialogService), 'open')
+ .mockReturnValue(dialogRefMock as unknown as DynamicDialogRef);
+
+ spectator.component.handleEditVTL(VTL_FILE_MOCK);
+ spectator.detectChanges();
+
+ await spectator.fixture.whenStable();
+
+ expect(dialogSpy).not.toHaveBeenCalled();
+ expect(dialogServiceOpenSpy).toHaveBeenCalled();
+ const [, config] = dialogServiceOpenSpy.mock.calls[0];
+ expect(config.data).toEqual(
+ expect.objectContaining({
+ mode: 'edit',
+ contentletInode: 'vtl-inode-123'
+ })
+ );
+ });
+ });
+
describe('handleEditWithCopyDecision', () => {
const MULTI_PAGE_PAYLOAD: ActionPayload = {
...EDIT_ACTION_PAYLOAD_MOCK,
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 22f7e6c36920..2a50d446fe68 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
@@ -32,6 +32,7 @@ import { InputGroupModule } from 'primeng/inputgroup';
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
import { PopoverModule } from 'primeng/popover';
import { ProgressBarModule } from 'primeng/progressbar';
+import { ProgressSpinnerModule } from 'primeng/progressspinner';
import { TabsModule } from 'primeng/tabs';
import { ToolbarModule } from 'primeng/toolbar';
import { TooltipModule } from 'primeng/tooltip';
@@ -59,7 +60,11 @@ import {
SeoMetaTags,
SeoMetaTagsResult
} from '@dotcms/dotcms-models';
-import { DotEditContentDialogComponent, EditContentDialogData } from '@dotcms/edit-content';
+import {
+ DotEditContentDialogComponent,
+ DotEditContentSidePanelComponent,
+ EditContentDialogData
+} from '@dotcms/edit-content';
import { DotPaletteListStore, DotResultsSeoToolComponent } from '@dotcms/portlets/dot-ema/ui';
import { GlobalStore } from '@dotcms/store';
import { DotCMSPage, DotCMSURLContentMap, DotCMSUVEAction, UVE_MODE } from '@dotcms/types';
@@ -172,7 +177,9 @@ const MESSAGE_KEY = {
PopoverModule,
TooltipModule,
DotMessagePipe,
- DotUveDeviceControlsComponent
+ DotUveDeviceControlsComponent,
+ DotEditContentSidePanelComponent,
+ ProgressSpinnerModule
],
providers: [
DotPaletteListStore,
@@ -274,6 +281,22 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit {
readonly host = '*';
readonly $ogTags: WritableSignal = signal(undefined);
+ /**
+ * Drives the Edit Content side panel: the content to open (create/edit) or `null` when closed.
+ * Only used when {@link $sidePanelEnabled} is on; the template renders the panel while set.
+ */
+ protected readonly $editContentPanel = 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
+ * init, degrades to `false` on a failed config read) — defaults to `false` until it resolves, so
+ * the dialog is used meanwhile.
+ */
+ protected readonly $sidePanelEnabled = computed(
+ () => this.uveStore.flags()[FeaturedFlags.FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL] ?? false
+ );
+
// Component builds its own editor props locally
protected readonly $showDialogs = computed(() => {
const canEditPage = this.uveStore.editorCanEditContent();
@@ -1272,7 +1295,7 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit {
return;
}
- this.#openContentForEdit(contentlet);
+ this.openContentForEdit(contentlet);
}
/**
@@ -1319,10 +1342,12 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit {
}
/**
- * Opens the new Angular editor if the content type has the flag enabled, otherwise the legacy dialog.
- * Single entry point used by handleOpenFullEditor and handleEditWithCopyDecision.
+ * Opens the new Angular editor if the content type has the flag enabled, otherwise the legacy
+ * dialog. Single entry point used by handleOpenFullEditor and handleEditWithCopyDecision — and,
+ * since it's public, also by DotEmaShellComponent for the "Properties" nav action (editing the
+ * page's own contentlet), captured via the router-outlet `(activate)` reference to this component.
*/
- #openContentForEdit(contentlet: DotCMSContentlet): void {
+ openContentForEdit(contentlet: DotCMSContentlet): void {
const contentTypeVariable = contentlet.contentType;
if (!contentTypeVariable) {
this.dialog?.editContentlet(contentlet);
@@ -1381,9 +1406,20 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit {
}
/**
- * Opens the DotEditContentDialogComponent shell with the given header and dialog data.
+ * Opens the new Edit Content editor with the given header and dialog data — in the side panel
+ * when the feature flag is on, otherwise in the centered dialog (previous behavior).
*/
#openDotEditContentShell(header: string, dialogData: EditContentDialogData): void {
+ if (this.$sidePanelEnabled()) {
+ // Side panel: shows `title` in its header (the dialog used `header`) and fires
+ // `dialogData.onContentSaved`/`onCancel` on close — so palette-drop / edit flows work
+ // unchanged.
+ this.$editContentPanel.set({ ...dialogData, title: header });
+
+ return;
+ }
+
+ // Side panel disabled: open the centered dialog (previous behavior).
this.dialogService.open(DotEditContentDialogComponent, {
appendTo: 'body',
baseZIndex: 10000,
@@ -1424,7 +1460,7 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit {
const onMultiplePages = Number(contentlet.onNumberOfPages ?? 1) > 1;
if (!onMultiplePages) {
- this.#openContentForEdit(contentlet as unknown as DotCMSContentlet);
+ this.openContentForEdit(contentlet as unknown as DotCMSContentlet);
return;
}
@@ -1447,7 +1483,7 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit {
this.uveStore.pageReload();
}
- this.#openContentForEdit(target);
+ this.openContentForEdit(target);
},
error: (error: HttpErrorResponse) => {
this.dotHttpErrorManagerService.handle(error);
@@ -1460,13 +1496,31 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit {
}
/**
- * Handles the edit of a VTL file.
+ * 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.
*
* @param {VTLFile} vtlFile - The VTL file to be edited.
* @memberof EditEmaEditorComponent
*/
handleEditVTL(vtlFile: VTLFile) {
- this.dialog.editVTLContentlet(vtlFile);
+ this.dotContentletService
+ .getContentletByInode(vtlFile.inode)
+ .pipe(
+ take(1),
+ takeUntilDestroyed(this.destroyRef),
+ catchError(() => of(null))
+ )
+ .subscribe((contentlet) => {
+ if (!contentlet) {
+ this.dialog?.editVTLContentlet(vtlFile);
+ return;
+ }
+
+ this.openContentForEdit(contentlet);
+ });
}
/**
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/consts.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/consts.ts
index aaab0e78db9d..369480f173f6 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/consts.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/shared/consts.ts
@@ -1,4 +1,5 @@
import { DotDeviceListItem, FeaturedFlags } from '@dotcms/dotcms-models';
+import { DotFeatureFlags } from '@dotcms/store';
import { DotCMSViewAsPersona } from '@dotcms/types';
import { StyleEditorFieldType } from '@dotcms/types/internal';
@@ -114,8 +115,18 @@ export const UVE_FEATURE_FLAGS = [
FeaturedFlags.FEATURE_FLAG_UVE_TOGGLE_LOCK,
FeaturedFlags.FEATURE_FLAG_UVE_STYLE_EDITOR,
FeaturedFlags.FEATURE_FLAG_PAGE_SCANNER,
- FeaturedFlags.FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION
-];
+ FeaturedFlags.FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION,
+ // Gates the Edit Content side panel (create/edit contentlet in a slide-in over the editor).
+ FeaturedFlags.FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL
+] as const;
+
+/**
+ * Type of the `flags` slice `withFlags(UVE_FEATURE_FLAGS)` contributes to the store — derived from
+ * the list above so it can never drift from the flags actually fetched. Features that read
+ * individual flags (not just contribute to the list) extend their `state` type constraint with
+ * this, since `UVEState` itself does not declare `flags` (`withFlags` owns that slice).
+ */
+export type UVEFeatureFlags = DotFeatureFlags<(typeof UVE_FEATURE_FLAGS)[number]>;
export const DEFAULT_DEVICE: DotDeviceListItem = {
icon: 'pi pi-desktop',
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/CLAUDE.md b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/CLAUDE.md
index d27fa343dab2..e10e1e89b586 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/CLAUDE.md
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/CLAUDE.md
@@ -15,7 +15,6 @@ store/
│ ├── withLock.ts # Lock management
│ ├── save/withSave.ts # Save operations
│ └── toolbar/withUVEToolbar.ts
- ├── flags/withFlags.ts # Feature flag signals
├── layout/withLayout.ts # Layout tab computeds
├── track/withTrack.ts # Analytics tracking
├── workflow/withWorkflow.ts
@@ -163,7 +162,7 @@ withSave → withLoad → withClient
`withLoad` uses two `withMethods` calls: the first adds `updatePageParams` (no DI needed), the second injects all services. This is a workaround for ngrx/signals requiring methods to be available before they are used inside other methods. Do not collapse them into one block.
### Feature flag signals as a typed map
-`withFlags` fetches all feature flags from `DotPropertiesService` once and exposes them as a `flags()` signal: a strongly-typed record `UVEFlags`. Consuming features read individual flags via `flags().FEATURE_FLAG_*`. Never inject `DotPropertiesService` inside other features to read flags — always go through `flags()`.
+`withFlags` fetches all feature flags from `DotPropertiesService` once and exposes them as a `flags()` signal, typed from `UVE_FEATURE_FLAGS` (must be declared `as const` for the narrowing to apply). Consuming features read individual flags via `flags()[FeaturedFlags.FEATURE_FLAG_*]`. Never inject `DotPropertiesService` inside other features to read flags — always go through `flags()`.
### Debounced analytics
`withTrack` wraps tracking calls in `DEBOUNCE_FOR_TRACKING` (5000ms) to avoid noise on rapid state changes. Always apply this wrapper to new tracking methods — raw analytics events on every signal change will flood the analytics backend.
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts
index 7e069f9710b5..c5a943192538 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/dot-uve.store.ts
@@ -1,12 +1,12 @@
import { patchState, signalStore, withFeature, withMethods, withState } from '@ngrx/signals';
+import { withFlags } from '@dotcms/store';
import { DotCMSPageAsset } from '@dotcms/types';
import { withContentTypeCache } from './features/content-type-cache/withContentTypeCache';
import { withView } from './features/editor/toolbar/withView';
import { withEditor } from './features/editor/withEditor';
import { withSelectionAnchor } from './features/editor/withSelectionAnchor';
-import { withFlags } from './features/flags/withFlags';
import { withLayout } from './features/layout/withLayout';
import { withPage } from './features/page/withPage';
import { withPageApi } from './features/page-api/withPageApi';
@@ -24,8 +24,9 @@ const initialState: UVEState = {
// UVE system state (managed by withUve)
uveStatus: UVE_STATUS.LOADING,
uveCurrentUser: null,
- // Flags (managed by withFlags)
- flags: {}, // Will be populated by withFlags feature
+ // Flags — withFlags populates this on init; see the note on UVEState.flags for why the slice
+ // is also declared here rather than left entirely to the feature.
+ flags: {},
// Page state (managed by withPage)
pageParams: null,
pageLanguages: [],
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/toolbar/withView.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/toolbar/withView.spec.ts
index d499dff73460..1331f2116672 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/toolbar/withView.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/toolbar/withView.spec.ts
@@ -8,6 +8,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { DotPropertiesService } from '@dotcms/data-access';
import { DEFAULT_VARIANT_ID, DEFAULT_VARIANT_NAME, DotDevice } from '@dotcms/dotcms-models';
+import { withFlags } from '@dotcms/store';
import { UVE_MODE } from '@dotcms/types';
import { getRunningExperimentMock, mockDotDevices } from '@dotcms/utils-testing';
@@ -25,7 +26,6 @@ import {
import { MOCK_RESPONSE_HEADLESS, mockCurrentUser } from '../../../../shared/mocks';
import { Orientation, UVEState } from '../../../models';
import { createInitialUVEState } from '../../../testing/mocks';
-import { withFlags } from '../../flags/withFlags';
import { withPage } from '../../page/withPage';
const pageParams = {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts
index 679de076ed5f..b3ef426240e5 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/editor/withEditor.spec.ts
@@ -12,6 +12,7 @@ import {
DotWorkflowsActionsService
} from '@dotcms/data-access';
import { DEFAULT_VARIANT_ID } from '@dotcms/dotcms-models';
+import { withFlags } from '@dotcms/store';
import { UVE_MODE } from '@dotcms/types';
import { WINDOW } from '@dotcms/utils';
import { DotLanguagesServiceMock, mockWorkflowsActions } from '@dotcms/utils-testing';
@@ -39,7 +40,6 @@ import { ActionPayload, SelectedContentlet } from '../../../shared/models';
import { getPersonalization, mapContainerStructureToArrayOfContainers } from '../../../utils';
import { PageType, UVEState } from '../../models';
import { createInitialUVEState } from '../../testing/mocks';
-import { withFlags } from '../flags/withFlags';
import { withPage } from '../page/withPage';
import { withWorkflow } from '../workflow/withWorkflow';
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/flags/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/flags/models.ts
deleted file mode 100644
index 2a57837ed18e..000000000000
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/flags/models.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { FeaturedFlags } from '@dotcms/dotcms-models';
-
-type UVEFlagKeys =
- | FeaturedFlags.FEATURE_FLAG_UVE_TOGGLE_LOCK
- | FeaturedFlags.FEATURE_FLAG_UVE_STYLE_EDITOR
- | FeaturedFlags.FEATURE_FLAG_PAGE_SCANNER
- | FeaturedFlags.FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION;
-
-export type UVEFlags = { [K in UVEFlagKeys]?: boolean };
-
-export interface WithFlagsState {
- flags: UVEFlags;
-}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/flags/withFlags.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/flags/withFlags.spec.ts
deleted file mode 100644
index 2cc40a98baa4..000000000000
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/flags/withFlags.spec.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import { describe } from '@jest/globals';
-import { signalStore, withState } from '@ngrx/signals';
-import { createServiceFactory, SpectatorService } from '@openng/spectator/jest';
-import { of } from 'rxjs';
-
-import { DotPropertiesService } from '@dotcms/data-access';
-import { FEATURE_FLAG_NOT_FOUND, FeaturedFlags } from '@dotcms/dotcms-models';
-
-import { withFlags } from './withFlags';
-
-import { UVEState } from '../../models';
-import { createInitialUVEState } from '../../testing/mocks';
-
-const initialState = createInitialUVEState();
-
-const MOCK_UVE_FEATURE_FLAGS = [FeaturedFlags.FEATURE_FLAG_UVE_PREVIEW_MODE];
-
-export const uveStoreMock = signalStore(
- withState(initialState),
- withFlags(MOCK_UVE_FEATURE_FLAGS)
-);
-
-const MOCK_RESPONSE = MOCK_UVE_FEATURE_FLAGS.reduce((acc, flag) => {
- acc[flag] = true;
-
- return acc;
-}, {});
-
-describe('withFlags', () => {
- describe('onInit', () => {
- let spectator: SpectatorService>;
- let store: InstanceType;
-
- const createService = createServiceFactory({
- service: uveStoreMock,
- providers: [
- {
- provide: DotPropertiesService,
- useValue: {
- getFeatureFlags: jest.fn().mockReturnValue(of(MOCK_RESPONSE))
- }
- }
- ]
- });
-
- beforeEach(() => {
- spectator = createService();
- store = spectator.service;
- });
-
- it('should call propertiesService.getFeatureFlags with flags', () => {
- const propertiesService = spectator.inject(DotPropertiesService);
-
- expect(propertiesService.getFeatureFlags).toHaveBeenCalledWith(MOCK_UVE_FEATURE_FLAGS);
- });
-
- it('should patch state with flags', () => {
- expect(store.flags()).toEqual(MOCK_RESPONSE);
- });
- });
-
- describe('flag normalization', () => {
- const flag = FeaturedFlags.FEATURE_FLAG_UVE_PREVIEW_MODE;
- const propertiesServiceMock = { getFeatureFlags: jest.fn() };
-
- const createService = createServiceFactory({
- service: uveStoreMock,
- providers: [{ provide: DotPropertiesService, useValue: propertiesServiceMock }]
- });
-
- it('should normalize NOT_FOUND to true (flag not configured on server)', () => {
- propertiesServiceMock.getFeatureFlags.mockReturnValue(
- of({ [flag]: FEATURE_FLAG_NOT_FOUND })
- );
- const s = createService();
- expect(s.service.flags()[flag]).toBe(true);
- });
-
- it('should keep boolean true as true (flag explicitly enabled)', () => {
- propertiesServiceMock.getFeatureFlags.mockReturnValue(of({ [flag]: true }));
- const s = createService();
- expect(s.service.flags()[flag]).toBe(true);
- });
-
- it('should keep boolean false as false (flag explicitly disabled)', () => {
- propertiesServiceMock.getFeatureFlags.mockReturnValue(of({ [flag]: false }));
- const s = createService();
- expect(s.service.flags()[flag]).toBe(false);
- });
-
- it('should normalize any unknown string value to false', () => {
- propertiesServiceMock.getFeatureFlags.mockReturnValue(
- of({ [flag]: 'FF_NOT_AVAILABLE' })
- );
- const s = createService();
- expect(s.service.flags()[flag]).toBe(false);
- });
- });
-});
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/flags/withFlags.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/flags/withFlags.ts
deleted file mode 100644
index 1641f2bef0ff..000000000000
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/flags/withFlags.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { patchState, signalStoreFeature, type, withHooks, withState } from '@ngrx/signals';
-
-import { inject } from '@angular/core';
-
-import { map, take } from 'rxjs/operators';
-
-import { DotPropertiesService } from '@dotcms/data-access';
-import { FEATURE_FLAG_NOT_FOUND, FeaturedFlags } from '@dotcms/dotcms-models';
-
-import { WithFlagsState } from './models';
-
-import { UVEState } from '../../models';
-
-/**
- * @description Fetches feature flags on store init and patches them into state.
- *
- * Flag values come from {@link DotPropertiesService.getFeatureFlags}, which returns
- * booleans for defined flags and maps `FEATURE_FLAG_NOT_FOUND` (flag not set on the
- * server) to `true` — meaning undefined flags are treated as enabled by default.
- */
-export function withFlags(flags: FeaturedFlags[]) {
- return signalStoreFeature(
- { state: type() },
- withState({ flags: {} }),
- withHooks({
- onInit: (store) => {
- const propertiesService = inject(DotPropertiesService);
- propertiesService
- .getFeatureFlags(flags)
- .pipe(
- take(1),
- // Normalize to boolean: true or NOT_FOUND (flag absent on server) → enabled.
- // Mirrors the single-flag getFeatureFlag() default (dot-properties.service.ts:88).
- // Any other value, including explicit false, disables the flag.
- map((rawFlags) =>
- Object.fromEntries(
- Object.entries(rawFlags).map(([key, value]) => [
- key,
- value === true || value === FEATURE_FLAG_NOT_FOUND
- ])
- )
- )
- )
- .subscribe((flags) => {
- patchState(store, { flags });
- });
- }
- })
- );
-}
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/layout/wihtLayout.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/layout/wihtLayout.spec.ts
index b538f0ebff17..89f9b676ab1f 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/layout/wihtLayout.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/layout/wihtLayout.spec.ts
@@ -6,6 +6,7 @@ import { of } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { DotPropertiesService } from '@dotcms/data-access';
+import { withFlags } from '@dotcms/store';
import { withLayout } from './withLayout';
@@ -14,7 +15,6 @@ import { MOCK_RESPONSE_HEADLESS } from '../../../shared/mocks';
import { mapContainerStructureToDotContainerMap } from '../../../utils';
import { UVEState } from '../../models';
import { createInitialUVEState } from '../../testing/mocks';
-import { withFlags } from '../flags/withFlags';
import { withPage } from '../page/withPage';
const initialState = createInitialUVEState();
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page-api/withPageApi.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page-api/withPageApi.spec.ts
index 68c78beaa3c6..8ec3d413df02 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page-api/withPageApi.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page-api/withPageApi.spec.ts
@@ -13,6 +13,7 @@ import {
DotWorkflowActionsFireService
} from '@dotcms/data-access';
import { DEFAULT_VARIANT_ID, DotLanguage } from '@dotcms/dotcms-models';
+import { withFlags } from '@dotcms/store';
import { DotPageAssetLayoutRow, UVE_MODE } from '@dotcms/types';
import { WINDOW } from '@dotcms/utils';
@@ -25,7 +26,6 @@ import { UVE_STATUS } from '../../../shared/enums';
import { MOCK_RESPONSE_HEADLESS, ACTION_PAYLOAD_MOCK } from '../../../shared/mocks';
import { IframeAccessMode, UVEState } from '../../models';
import { createInitialUVEState } from '../../testing/mocks';
-import { withFlags } from '../flags/withFlags';
import { withPage } from '../page/withPage';
const pageParamsBase = {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page/withPage.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page/withPage.spec.ts
index adf131b32ff6..7d376271716e 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page/withPage.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/page/withPage.spec.ts
@@ -7,6 +7,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { DotPropertiesService } from '@dotcms/data-access';
import { DotLanguage } from '@dotcms/dotcms-models';
+import { withFlags } from '@dotcms/store';
import { UVE_MODE } from '@dotcms/types';
import { withPage } from './withPage';
@@ -19,7 +20,6 @@ import { PERSONA_KEY } from '../../../shared/consts';
import { MOCK_RESPONSE_HEADLESS } from '../../../shared/mocks';
import { UVEState } from '../../models';
import { createInitialUVEState } from '../../testing/mocks';
-import { withFlags } from '../flags/withFlags';
const initialState = createInitialUVEState();
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.spec.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.spec.ts
index 86188d5fc081..56f602322cb8 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.spec.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/features/workflow/withWorkflow.spec.ts
@@ -17,6 +17,7 @@ import {
DotWorkflowsActionsService
} from '@dotcms/data-access';
import { DotLanguage } from '@dotcms/dotcms-models';
+import { withFlags } from '@dotcms/store';
import { DotCMSPageAsset, UVE_MODE } from '@dotcms/types';
import { DotLanguagesServiceMock, mockWorkflowsActions } from '@dotcms/utils-testing';
@@ -27,7 +28,6 @@ import { PERSONA_KEY } from '../../../shared/consts';
import { MOCK_RESPONSE_HEADLESS, mockCurrentUser } from '../../../shared/mocks';
import { UVEState } from '../../models';
import { createInitialUVEState } from '../../testing/mocks';
-import { withFlags } from '../flags/withFlags';
import { withPage } from '../page/withPage';
const pageParams = {
diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts
index fb8a1d8f123c..92d6dfaced79 100644
--- a/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts
+++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/store/models.ts
@@ -10,13 +10,12 @@ import {
import { DotCMSPage } from '@dotcms/types';
import { StyleEditorFormSchema } from '@dotcms/types/internal';
-import { UVEFlags } from './features/flags/models';
-
import {
Container,
ContentletArea,
EmaDragItem
} from '../edit-ema-editor/components/ema-page-dropzone/types';
+import { UVEFeatureFlags } from '../shared/consts';
import { EDITOR_STATE, UVE_STATUS } from '../shared/enums';
import { DotPageAssetParams, SelectedContentlet } from '../shared/models';
@@ -78,8 +77,15 @@ export interface UVEState {
uveCurrentUser: CurrentUser | null;
// ============ FLAGS (withFlags) ============
- // Note: flags added by withFlags feature - kept optional for backwards compatibility
- flags?: UVEFlags;
+ /**
+ * Feature-flag slice. `withFlags` owns it at runtime (it declares and populates it); this
+ * declaration exists purely so features constrained on `UVEState` can READ `flags()` without a
+ * cast — a `signalStoreFeature` returned from a generic function (which `withFlags` is) loses
+ * its state contribution during composition in ngrx/signals, so the slice is invisible to
+ * consumers otherwise. The type is derived from `UVE_FEATURE_FLAGS`, so it cannot drift from
+ * the flags actually fetched.
+ */
+ flags: UVEFeatureFlags;
// ============ PAGE DOMAIN (withPage) ============
pageParams: DotPageAssetParams | null;
diff --git a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java
index 449302b4036e..8bf1b13ddb86 100644
--- a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java
+++ b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java
@@ -75,6 +75,14 @@ public interface FeatureFlagName {
String FEATURE_FLAG_LOCALE_SELECTOR_V2 = "FEATURE_FLAG_LOCALE_SELECTOR_V2";
+ /**
+ * Opens the new content editor (Edit Content v2) in a right slide-in side panel instead of
+ * navigating full-screen (Content Drive) or a centered dialog (UVE). On by default
+ * ({@code dotmarketing-config.properties} sets {@code FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL=true}).
+ * Frontend equivalent: {@code FeaturedFlags.FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL}.
+ */
+ String FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL = "FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL";
+
/**
* libvips image-engine toggle (off by default; the legacy Java2D engine is used
* otherwise). The new image editor reads this through the configuration endpoint
diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java
index ef5af4f764fa..3b72863e0c97 100644
--- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java
+++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/system/ConfigurationResource.java
@@ -93,6 +93,7 @@ public class ConfigurationResource implements Serializable {
FeatureFlagName.FEATURE_FLAG_NEW_BLOCK_EDITOR,
FeatureFlagName.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED,
FeatureFlagName.FEATURE_FLAG_LOCALE_SELECTOR_V2,
+ FeatureFlagName.FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL,
// libvips engine toggle: the new image editor reads it to gate AVIF output.
FeatureFlagName.IMAGE_API_USE_LIBVIPS);
@@ -111,6 +112,7 @@ public class ConfigurationResource implements Serializable {
REPORT_ISSUE_INCLUDE_USER_PII,
FeatureFlagName.FEATURE_FLAG_REPORT_ISSUE_ENABLED,
FeatureFlagName.FEATURE_FLAG_LOCALE_SELECTOR_V2,
+ FeatureFlagName.FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL,
// libvips engine toggle: the new image editor reads it to gate AVIF output.
FeatureFlagName.IMAGE_API_USE_LIBVIPS }));
diff --git a/dotCMS/src/main/resources/dotmarketing-config.properties b/dotCMS/src/main/resources/dotmarketing-config.properties
index b8ef743572fc..fe138ceaa53a 100644
--- a/dotCMS/src/main/resources/dotmarketing-config.properties
+++ b/dotCMS/src/main/resources/dotmarketing-config.properties
@@ -879,6 +879,10 @@ FEATURE_FLAG_UVE_LEGACY_SCRIPT_INJECTION=false
## Enhanced locale selector v2 in the edit-content sidebar
FEATURE_FLAG_LOCALE_SELECTOR_V2=true
+## Opens the new content editor (Edit Content v2) in a right slide-in side panel instead
+## of navigating full-screen (Content Drive) or a centered dialog (UVE).
+FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL=true
+
## libvips image engine toggle. On by default; the engine still requires the native
## libvips library to be present, otherwise it falls back to the legacy Java2D engine
## at runtime. Set to false to force the legacy engine. The new image editor reads this
diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
index 6c979ff2ddc7..3b830907a426 100644
--- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
+++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
@@ -6536,6 +6536,9 @@ edit.content.layout.select.workflow.warning.switch=Select a Workflow
edit.content.layout.select.workflow.warning.subtitle=to take action on this content.
edit.content.sidebar.open=Open sidebar
edit.content.sidebar.close=Close sidebar
+edit.content.side-panel.expand=Expand panel
+edit.content.side-panel.collapse=Collapse panel
+edit.content.side-panel.close=Close panel
edit.content.sidebar.tab.information=Information
edit.content.sidebar.tab.history=History
edit.content.sidebar.tab.comments=Comments