From fb994105808634f640dbea29f17933c6aecb6f48 Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Wed, 22 Jul 2026 10:56:51 -0400 Subject: [PATCH 01/25] checkpoint(content-drive): edit content side panel POC + UI (working) Restore point before adding shareable-via-query-param support. Co-Authored-By: Claude Opus 4.8 --- core-web/libs/edit-content/src/index.ts | 1 + .../dot-edit-content-side-panel.component.ts | 153 ++++++++++++++++++ .../dot-edit-content-dialog.interface.ts | 13 ++ .../host/overlay-edit-content-host.ts | 3 +- .../dot-content-drive-shell.component.html | 10 ++ .../dot-content-drive-shell.component.ts | 17 +- ...t-content-drive-navigation.service.spec.ts | 32 ++-- .../dot-content-drive-navigation.service.ts | 40 +++-- 8 files changed, 248 insertions(+), 21 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts diff --git a/core-web/libs/edit-content/src/index.ts b/core-web/libs/edit-content/src/index.ts index 6a55298fc4e8..4b4cae1469f4 100644 --- a/core-web/libs/edit-content/src/index.ts +++ b/core-web/libs/edit-content/src/index.ts @@ -5,6 +5,7 @@ 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 * 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-side-panel/dot-edit-content-side-panel.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts new file mode 100644 index 000000000000..27043df7ecf7 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts @@ -0,0 +1,153 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + Injector, + OnDestroy, + afterNextRender, + computed, + forwardRef, + inject, + input, + output, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { ButtonModule } from 'primeng/button'; +import { DrawerModule } from 'primeng/drawer'; +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; + +import { DotCMSContentlet } from '@dotcms/dotcms-models'; +import { popFormBridge, pushFormBridge } from '@dotcms/edit-content-bridge'; + +import { EditContentDialogData } from '../../models/dot-edit-content-dialog.interface'; +import { EDIT_CONTENT_HOST } from '../../services/host/edit-content-host.model'; +import { OverlayEditContentHost } from '../../services/host/overlay-edit-content-host'; +import { DotEditContentLayoutComponent } from '../dot-edit-content-layout/dot-edit-content.layout.component'; + +/** + * Renders the new Edit Content editor inside a right-to-left slide-in panel (`p-drawer`), as an + * alternative to the full-screen route or the centered dialog. + * + * It reuses the overlay editor plumbing: it provides {@link OverlayEditContentHost} (identity from + * the dialog config, in-place navigation, chrome no-ops) and, since it is not opened through + * `DialogService`, supplies the {@link DynamicDialogConfig} the host reads identity from — built + * from the {@link data} input. The header shows the content title plus an expand toggle (70% ↔ + * full width) and a close button. + */ +@Component({ + selector: 'dot-edit-content-side-panel', + standalone: true, + imports: [DrawerModule, ButtonModule, DotEditContentLayoutComponent], + providers: [ + OverlayEditContentHost, + { provide: EDIT_CONTENT_HOST, useExisting: OverlayEditContentHost }, + { + // The overlay host reads the content identity from the dialog config; this panel is not + // opened through DialogService, so feed it from the `data` input. The `data` getter is + // lazy on purpose: it defers reading the input until the host actually resolves the + // identity, by which point Angular has applied the input. + provide: DynamicDialogConfig, + useFactory: (panel: DotEditContentSidePanelComponent) => ({ + get data() { + return panel.data(); + } + }), + deps: [forwardRef(() => DotEditContentSidePanelComponent)] + } + ], + template: ` + + +
+ + {{ data()?.title }} + +
+ + +
+
+
+ @for (item of $items(); track item.contentletInode ?? item.contentTypeId) { + + } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotEditContentSidePanelComponent implements OnDestroy { + readonly #injector = inject(Injector); + readonly #destroyRef = inject(DestroyRef); + + /** Identity (and header title) of the content to create/edit, or `null` when closed. */ + readonly data = input(null); + + /** Emitted when the user closes the panel, so the opener can clear its request. */ + readonly closed = output(); + + /** Emitted on each successful save, so the opener can refresh its view. */ + readonly saved = output(); + + /** Whether the panel is expanded to the full viewport width (vs the default ~70%). */ + protected readonly $expanded = signal(false); + + /** + * `@for` source: a single-item list. Rendering the editor through `@for` (instead of directly) + * defers its creation until the input has a value — the editor resolves its identity + * synchronously on construction, so it must not be created before `data` is applied. + */ + protected readonly $items = computed(() => { + const data = this.data(); + + return data ? [data] : []; + }); + + constructor() { + // Give the editor a clean form-bridge slot; restore the previous one on close. + pushFormBridge(); + + // Forward each save to the opener so it can refresh its view. The overlay host is resolved + // AFTER construction (afterNextRender) on purpose: resolving it in the constructor would + // cycle through its `DynamicDialogConfig` factory, which depends on this component. + afterNextRender(() => { + this.#injector + .get(OverlayEditContentHost) + .saved$.pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe((contentlet) => this.saved.emit(contentlet)); + }); + } + + ngOnDestroy(): void { + popFormBridge(); + } +} diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-dialog.interface.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-dialog.interface.ts index 6cab0a553655..1b8083cda9d7 100644 --- a/core-web/libs/edit-content/src/lib/models/dot-edit-content-dialog.interface.ts +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-dialog.interface.ts @@ -24,6 +24,19 @@ export interface EditContentDialogData { */ depth?: number; + /** + * Optional header label for the side panel (e.g. the content title when editing, or the + * content type name when creating). Shown in the panel header; ignored by the dialog. + */ + title?: string; + + /** + * For new content: pre-fills a Host-or-Folder field with this `hostname/path`, so content + * created from a folder context (e.g. Content Drive) lands in that folder. Mirrors the + * `folderPath` query param the full-screen editor reads. + */ + folderPath?: string; + /** * Optional relationship information when creating content for relationships */ diff --git a/core-web/libs/edit-content/src/lib/services/host/overlay-edit-content-host.ts b/core-web/libs/edit-content/src/lib/services/host/overlay-edit-content-host.ts index 2e5362332049..2c5b248a22f8 100644 --- a/core-web/libs/edit-content/src/lib/services/host/overlay-edit-content-host.ts +++ b/core-web/libs/edit-content/src/lib/services/host/overlay-edit-content-host.ts @@ -64,7 +64,8 @@ export class OverlayEditContentHost implements EditContentHost, OnDestroy { return { inode: data?.contentletInode, - contentTypeId: data?.contentTypeId + contentTypeId: data?.contentTypeId, + folderPath: data?.folderPath }; } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html index f0baa7632636..d6175377546c 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html @@ -115,3 +115,13 @@ + + +@if ($editPanelRequest(); as data) { + +} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 6db432db5802..ab1cf6c70045 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -35,6 +35,7 @@ import { DotContentDriveItem, DotContentDrivePaginateEvent } from '@dotcms/dotcms-models'; +import { DotEditContentSidePanelComponent } from '@dotcms/edit-content'; import { DotFolderListViewComponent, DotContentDriveUploadFiles, @@ -87,7 +88,8 @@ import { encodeFilters, isFolder } from '../utils/functions'; MessageModule, DotMessagePipe, DotContentDriveDropzoneComponent, - DotSeverityIconComponent + DotSeverityIconComponent, + DotEditContentSidePanelComponent ], providers: [DotContentDriveStore, DotWorkflowsActionsService, MessageService, DotFolderService], templateUrl: './dot-content-drive-shell.component.html', @@ -109,6 +111,9 @@ export class DotContentDriveShellComponent { readonly #fileService = inject(DotUploadFileService); readonly #dotWorkflowActionsFireService = inject(DotWorkflowActionsFireService); + /** Edit Content side panel request, driven by the navigation service; read by the template. */ + protected readonly $editPanelRequest = this.#navigationService.editPanelRequest; + readonly $items = this.#store.items; readonly $status = this.#store.status; readonly $treeExpanded = this.#store.isTreeExpanded; @@ -359,6 +364,16 @@ export class DotContentDriveShellComponent { this.$activeDialog.set(undefined); } + /** Closes the Edit Content side panel. */ + protected onEditPanelClosed() { + this.#navigationService.closeEditPanel(); + } + + /** A save in the side panel can create or change an item, so refresh the list. */ + protected onEditPanelSaved() { + this.#store.reloadContentDrive(); + } + /** * Upload-button flow: prompt for the asset type first; the OS file picker opens later, once the * user confirms a type in {@link onUploadTypeSelected}. diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.spec.ts index d12e3ce8fd5f..49d7a78731c2 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.spec.ts @@ -97,10 +97,11 @@ describe('DotContentDriveNavigationService', () => { }); }); - it('should navigate to new content editor when feature flag is enabled', () => { + it('should open the new editor side panel when feature flag is enabled', () => { const mockContentlet = createFakeContentlet({ contentType: 'blog', - inode: 'test-inode-123' + inode: 'test-inode-123', + title: 'My Blog Post' }); const mockContentType = createFakeContentType({ @@ -114,7 +115,12 @@ describe('DotContentDriveNavigationService', () => { service.editContent(mockContentlet); expect(contentTypeService.getContentType).toHaveBeenCalledWith('blog'); - expect(router.navigate).toHaveBeenCalledWith(['content/test-inode-123']); + expect(service.editPanelRequest()).toEqual({ + mode: 'edit', + contentletInode: 'test-inode-123', + title: 'My Blog Post' + }); + expect(router.navigate).not.toHaveBeenCalled(); }); it('should navigate to old content editor when feature flag is disabled', () => { @@ -225,7 +231,7 @@ describe('DotContentDriveNavigationService', () => { }); describe('createContent', () => { - it('should navigate to new content editor (empty query params) when feature flag is enabled and no folder given', () => { + it('should open the new editor side panel when feature flag is enabled and no folder given', () => { const mockContentType = createFakeContentType({ id: 'blog', name: 'Blog', @@ -237,12 +243,16 @@ describe('DotContentDriveNavigationService', () => { service.createContent('blog'); expect(contentTypeService.getContentType).toHaveBeenCalledWith('blog'); - expect(router.navigate).toHaveBeenCalledWith(['content/new/blog'], { - queryParams: {} + expect(service.editPanelRequest()).toEqual({ + mode: 'new', + contentTypeId: 'blog', + folderPath: undefined, + title: 'Blog' }); + expect(router.navigate).not.toHaveBeenCalled(); }); - it('should forward folderPath to the new content editor so it is created in the current folder', () => { + it('should forward folderPath to the new editor side panel so it is created in the current folder', () => { const mockContentType = createFakeContentType({ id: 'blog', name: 'Blog', @@ -253,9 +263,13 @@ describe('DotContentDriveNavigationService', () => { service.createContent('blog', { folderPath: 'demo.dotcms.com/about-us/' }); - expect(router.navigate).toHaveBeenCalledWith(['content/new/blog'], { - queryParams: { folderPath: 'demo.dotcms.com/about-us/' } + expect(service.editPanelRequest()).toEqual({ + mode: 'new', + contentTypeId: 'blog', + folderPath: 'demo.dotcms.com/about-us/', + title: 'Blog' }); + expect(router.navigate).not.toHaveBeenCalled(); }); it('should navigate to legacy content editor with mapped CD_ params when feature flag is disabled', () => { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.ts index efa05dac488b..993aa3693067 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.ts @@ -2,7 +2,7 @@ import { EMPTY } from 'rxjs'; import { Location } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; +import { Injectable, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; import { catchError, take } from 'rxjs/operators'; @@ -17,6 +17,7 @@ import { DotCMSContentlet, FeaturedFlags } from '@dotcms/dotcms-models'; +import { EditContentDialogData } from '@dotcms/edit-content'; import { mapQueryParamsToCDParams } from '@dotcms/utils'; @Injectable({ @@ -28,6 +29,15 @@ export class DotContentDriveNavigationService { readonly #dotContentTypeService = inject(DotContentTypeService); readonly #dotRouterService = inject(DotRouterService); readonly #httpErrorManager = inject(DotHttpErrorManagerService); + + readonly #editPanelRequest = signal(null); + + /** + * The content to show in the Edit Content side panel, or `null` when it is closed. Set when + * the new editor should open for a content type/inode (instead of navigating to the + * full-screen route); the shell renders the panel while this is set. + */ + readonly editPanelRequest = this.#editPanelRequest.asReadonly(); /** * Navigates to the appropriate editor based on the content type. * Routes to the page editor for HTML pages, or the contentlet editor for other types. @@ -106,16 +116,22 @@ export class DotContentDriveNavigationService { return; } - // The new content editor owns its own close/back navigation, so — like the edit - // flow (#editContentlet) — it does not need the CD_-prefixed return params that - // only the legacy editor's onClose consumes. It pre-selects the Host/Folder field - // from the `folderPath` query param (see hostFolderResolutionFn in edit-content). - this.#router.navigate([`content/new/${contentTypeVariable}`], { - queryParams: folder.folderPath ? { folderPath: folder.folderPath } : {} + // New editor: open it in a side panel over Content Drive instead of navigating. + // Forward `folderPath` so the content is created in the folder being browsed. + this.#editPanelRequest.set({ + mode: 'new', + contentTypeId: contentTypeVariable, + folderPath: folder.folderPath, + title: contentType.name }); }); } + /** Closes the Edit Content side panel. */ + closeEditPanel(): void { + this.#editPanelRequest.set(null); + } + /** * Navigates to the contentlet editor. * Determines whether to use the new or legacy content editor based on @@ -142,16 +158,20 @@ export class DotContentDriveNavigationService { const shouldRedirectToOldContentEditor = !contentType?.metadata?.[FeaturedFlags.FEATURE_FLAG_CONTENT_EDITOR2_ENABLED]; - const mappedQueryParams = mapQueryParamsToCDParams(currentQueryParams); - if (shouldRedirectToOldContentEditor) { + const mappedQueryParams = mapQueryParamsToCDParams(currentQueryParams); this.#router.navigate([`c/content/${contentlet.inode}`], { queryParams: mappedQueryParams }); return; } - this.#router.navigate([`content/${contentlet.inode}`]); + // New editor: open it in a side panel over Content Drive instead of navigating. + this.#editPanelRequest.set({ + mode: 'edit', + contentletInode: contentlet.inode, + title: contentlet.title + }); }); } } From bc2cc12d2c7f2819a34ba49603640ed09d6d37bf Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Wed, 22 Jul 2026 13:27:13 -0400 Subject: [PATCH 02/25] feat(content-drive): add support for shareable edit content links via identifier - Introduced a new `identifier` field in the `EditContentDialogData` interface to serve as a stable identifier for content, enabling the construction of shareable URLs. - Enhanced the `DotContentDriveShellComponent` to handle the `editContent` query parameter, allowing the edit panel to open directly to specific content based on its identifier. - Updated the `DotContentDriveNavigationService` to resolve identifiers to their corresponding content inode, facilitating the opening of the edit panel for shared content links. - Added unit tests to ensure the new functionality works as expected. This change improves the user experience by allowing users to share direct links to editable content. --- .../dot-edit-content-dialog.interface.ts | 6 +++ .../dot-content-drive-shell.component.spec.ts | 15 ++++-- .../dot-content-drive-shell.component.ts | 17 ++++++- ...t-content-drive-navigation.service.spec.ts | 50 +++++++++++++++++++ .../dot-content-drive-navigation.service.ts | 43 ++++++++++++++++ 5 files changed, 126 insertions(+), 5 deletions(-) diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-dialog.interface.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-dialog.interface.ts index 1b8083cda9d7..ee21ec92d72e 100644 --- a/core-web/libs/edit-content/src/lib/models/dot-edit-content-dialog.interface.ts +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-dialog.interface.ts @@ -19,6 +19,12 @@ export interface EditContentDialogData { */ contentletInode?: string; + /** + * For edit content: the stable identifier of the content (does not change across saves/ + * versions, unlike the inode). Used to build a shareable URL that reopens this content. + */ + identifier?: string; + /** * Depth for loading existing content (defaults to TWO) */ diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 9f242359c56d..d4c43b875edc 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -111,7 +111,11 @@ describe('DotContentDriveShellComponent', () => { get: jest.fn().mockImplementation((key: string) => key) }), mockProvider(DotContentDriveNavigationService, { - editContent: jest.fn() + editContent: jest.fn(), + createContent: jest.fn(), + closeEditPanel: jest.fn(), + openEditByIdentifier: jest.fn(), + editPanelRequest: signal(null) }), LoggerService, StringUtils, @@ -248,7 +252,8 @@ describe('DotContentDriveShellComponent', () => { queryParams: { isTreeExpanded: 'false', path: '/another/path', - filters: 'contentType:Blog;baseType:1,2,3' + filters: 'contentType:Blog;baseType:1,2,3', + editContent: null }, queryParamsHandling: 'merge' }); @@ -269,7 +274,8 @@ describe('DotContentDriveShellComponent', () => { queryParams: { isTreeExpanded: 'false', path: '/another/path', - filters: 'contentType:Blog;baseType:1,2,3' + filters: 'contentType:Blog;baseType:1,2,3', + editContent: null }, queryParamsHandling: 'merge' }); @@ -284,7 +290,8 @@ describe('DotContentDriveShellComponent', () => { queryParams: { isTreeExpanded: 'false', path: '/another/path', - filters: null // With merge, null removes the param + filters: null, // With merge, null removes the param + editContent: null }, queryParamsHandling: 'merge' }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index ab1cf6c70045..c9c4bc06c7cf 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -13,7 +13,7 @@ import { untracked, viewChild } from '@angular/core'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { MessageService, SortEvent } from 'primeng/api'; import { DialogModule } from 'primeng/dialog'; @@ -102,6 +102,7 @@ export class DotContentDriveShellComponent { readonly #store = inject(DotContentDriveStore); readonly #router = inject(Router); + readonly #route = inject(ActivatedRoute); readonly #location = inject(Location); readonly #navigationService = inject(DotContentDriveNavigationService); @@ -199,6 +200,13 @@ export class DotContentDriveShellComponent { constructor() { this.#syncDialog(this.#store.dialog); + + // Shareable deep-link: `?editContent=` reopens the edit panel on load. Read + // once from the snapshot (the portlet is not re-created on in-session query-param changes). + const editContent = this.#route.snapshot.queryParams['editContent']; + if (editContent) { + this.#navigationService.openEditByIdentifier(editContent); + } } readonly $offset = computed(() => this.#store.pagination().offset, { @@ -246,6 +254,13 @@ export class DotContentDriveShellComponent { queryParams['filters'] = null; } + // Reflect the open edit panel in a shareable `editContent=` param (edit only; + // creating is not shareable). Cleared when the panel is closed. Written here — via + // Location.go — so it does not trigger a navigation or a content reload. + const editRequest = this.$editPanelRequest(); + queryParams['editContent'] = + editRequest?.mode === 'edit' ? (editRequest.identifier ?? null) : null; + const urlTree = this.#router.createUrlTree([], { queryParams, queryParamsHandling: 'merge' diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.spec.ts index 49d7a78731c2..c4e93c28d0d1 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.spec.ts @@ -12,6 +12,7 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; import { + DotContentSearchService, DotContentTypeService, DotHttpErrorManagerService, DotRouterService @@ -29,6 +30,7 @@ describe('DotContentDriveNavigationService', () => { let dotRouterService: jest.Mocked; let location: SpyObject; let httpErrorManager: SpyObject; + let contentSearch: jest.Mocked; const createService = createServiceFactory({ service: DotContentDriveNavigationService, @@ -47,6 +49,9 @@ describe('DotContentDriveNavigationService', () => { }), mockProvider(DotHttpErrorManagerService, { handle: jest.fn().mockReturnValue(of({})) + }), + mockProvider(DotContentSearchService, { + get: jest.fn() }) ] }); @@ -59,6 +64,7 @@ describe('DotContentDriveNavigationService', () => { dotRouterService = spectator.inject(DotRouterService); location = spectator.inject(Location); httpErrorManager = spectator.inject(DotHttpErrorManagerService); + contentSearch = spectator.inject(DotContentSearchService); }); afterEach(() => { @@ -101,6 +107,7 @@ describe('DotContentDriveNavigationService', () => { const mockContentlet = createFakeContentlet({ contentType: 'blog', inode: 'test-inode-123', + identifier: 'test-identifier-123', title: 'My Blog Post' }); @@ -118,6 +125,7 @@ describe('DotContentDriveNavigationService', () => { expect(service.editPanelRequest()).toEqual({ mode: 'edit', contentletInode: 'test-inode-123', + identifier: 'test-identifier-123', title: 'My Blog Post' }); expect(router.navigate).not.toHaveBeenCalled(); @@ -445,4 +453,46 @@ describe('DotContentDriveNavigationService', () => { }); }); }); + + describe('openEditByIdentifier', () => { + it('should resolve the identifier to its working inode and open the edit panel', () => { + const resolved = createFakeContentlet({ + inode: 'working-inode-1', + identifier: 'shared-identifier', + title: 'Shared Content' + }); + contentSearch.get.mockReturnValue(of({ jsonObjectView: { contentlets: [resolved] } })); + + service.openEditByIdentifier('shared-identifier'); + + expect(contentSearch.get).toHaveBeenCalledWith({ + query: '+identifier:shared-identifier +working:true', + limit: 1 + }); + expect(service.editPanelRequest()).toEqual({ + mode: 'edit', + contentletInode: 'working-inode-1', + identifier: 'shared-identifier', + title: 'Shared Content' + }); + }); + + it('should not open the panel when the identifier resolves to nothing', () => { + contentSearch.get.mockReturnValue(of({ jsonObjectView: { contentlets: [] } })); + + service.openEditByIdentifier('missing-identifier'); + + expect(service.editPanelRequest()).toBeNull(); + }); + + it('should surface the error and not open the panel when the search fails', () => { + const error = new HttpErrorResponse({ status: 500 }); + contentSearch.get.mockReturnValue(throwError(() => error)); + + service.openEditByIdentifier('shared-identifier'); + + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(service.editPanelRequest()).toBeNull(); + }); + }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.ts index 993aa3693067..5524dc8ac51f 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/services/dot-content-drive-navigation.service.ts @@ -8,6 +8,7 @@ import { Router } from '@angular/router'; import { catchError, take } from 'rxjs/operators'; import { + DotContentSearchService, DotContentTypeService, DotHttpErrorManagerService, DotRouterService @@ -20,6 +21,11 @@ import { import { EditContentDialogData } from '@dotcms/edit-content'; import { mapQueryParamsToCDParams } from '@dotcms/utils'; +/** Shape of the `/api/content/_search` entity we read the resolved contentlet from. */ +interface ContentSearchEntity { + jsonObjectView: { contentlets: DotCMSContentlet[] }; +} + @Injectable({ providedIn: 'root' }) @@ -29,6 +35,7 @@ export class DotContentDriveNavigationService { readonly #dotContentTypeService = inject(DotContentTypeService); readonly #dotRouterService = inject(DotRouterService); readonly #httpErrorManager = inject(DotHttpErrorManagerService); + readonly #contentSearch = inject(DotContentSearchService); readonly #editPanelRequest = signal(null); @@ -170,6 +177,42 @@ export class DotContentDriveNavigationService { this.#editPanelRequest.set({ mode: 'edit', contentletInode: contentlet.inode, + identifier: contentlet.identifier, + title: contentlet.title + }); + }); + } + + /** + * Opens the Edit Content side panel for a content addressed by its stable `identifier` + * (e.g. from a shared `?editContent=` URL). Resolves the identifier to its + * current working inode — the editor loads by inode — and opens the panel. No-op when the + * content can't be resolved (deleted, no permission, bad id). + */ + openEditByIdentifier(identifier: string): void { + this.#contentSearch + .get({ + query: `+identifier:${identifier} +working:true`, + limit: 1 + }) + .pipe( + take(1), + catchError((error: HttpErrorResponse) => { + this.#httpErrorManager.handle(error); + + return EMPTY; + }) + ) + .subscribe((entity) => { + const contentlet = entity?.jsonObjectView?.contentlets?.[0]; + if (!contentlet?.inode) { + return; + } + + this.#editPanelRequest.set({ + mode: 'edit', + contentletInode: contentlet.inode, + identifier, title: contentlet.title }); }); From 9cd7dc17d785ab9e1892d83a27b1ff8c3af33b21 Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Wed, 22 Jul 2026 14:54:15 -0400 Subject: [PATCH 03/25] feat(edit-content): implement unsaved changes guard for editor close actions - Added a `confirmClose` method in `DotEditContentLayoutComponent` to enforce unsaved changes prompts consistently across different editor presentations. - Updated `DotEditContentSidePanelComponent` to route close requests through the new `confirmClose` method, ensuring users are prompted when there are unsaved changes. - Enhanced the side panel's close behavior to handle both the close button and the Escape key, improving user experience when closing the editor. This change ensures that users are consistently warned about unsaved changes, preventing accidental data loss. --- .../dot-edit-content.layout.component.ts | 18 ++++++++++++ .../dot-edit-content-side-panel.component.ts | 28 ++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) 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..86b9b38a3b8e 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,24 @@ 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 + * the form is clean (or a save just succeeded); otherwise prompts and only proceeds on "Discard". + * + * @param onProceed Runs when it is safe to close (clean form, or the user discarded changes). + */ + confirmClose(onProceed: () => void): void { + if (!this.hasUnsavedChanges() || this.$store.workflowActionSuccess()) { + 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.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts index 27043df7ecf7..2ee1b593e665 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.ts @@ -10,7 +10,8 @@ import { inject, input, output, - signal + signal, + viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -60,10 +61,11 @@ import { DotEditContentLayoutComponent } from '../dot-edit-content-layout/dot-ed template: ` + position="right" + (keydown.escape)="requestClose()">
@@ -93,7 +96,7 @@ import { DotEditContentLayoutComponent } from '../dot-edit-content-layout/dot-ed severity="secondary" icon="pi pi-times" aria-label="Close panel" - (onClick)="closed.emit()" + (onClick)="requestClose()" data-testId="side-panel-close" />
@@ -109,6 +112,9 @@ export class DotEditContentSidePanelComponent implements OnDestroy { readonly #injector = inject(Injector); readonly #destroyRef = inject(DestroyRef); + /** The hosted editor; used to run its unsaved-changes guard before closing. */ + protected readonly $layout = viewChild(DotEditContentLayoutComponent); + /** Identity (and header title) of the content to create/edit, or `null` when closed. */ readonly data = input(null); @@ -147,6 +153,20 @@ export class DotEditContentSidePanelComponent implements OnDestroy { }); } + /** + * Close intent (X button or ESC). Routes through the editor's unsaved-changes guard so the + * user is prompted when the form is dirty; only closes (emits `closed`) once it is safe. + */ + protected requestClose(): void { + const layout = this.$layout(); + + if (layout) { + layout.confirmClose(() => this.closed.emit()); + } else { + this.closed.emit(); + } + } + ngOnDestroy(): void { popFormBridge(); } From 1c82d893705f3fc3ba89567b66117b558f1b27bb Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Thu, 23 Jul 2026 10:31:36 -0400 Subject: [PATCH 04/25] feat(edit-content): introduce side panel for editing content - Added a new feature flag `FEATURE_FLAG_EDIT_CONTENT_SIDE_PANEL` to control the display of the Edit Content panel. - Updated `DotEditContentLayoutComponent` and `DotEditContentSidePanelComponent` to support the new side panel interface for editing content, enhancing user experience by allowing in-place editing. - Modified `DotContentDriveNavigationService` to conditionally open the editor in a side panel or full-screen based on the feature flag. - Updated related components and templates to integrate the side panel functionality, ensuring seamless transitions between editing modes. - Enhanced unit tests to cover the new side panel behavior and its interactions with existing features. This change improves the editing workflow by providing a more flexible and user-friendly interface for content management. --- .../dotcms-models/src/lib/shared-models.ts | 3 +- .../dot-edit-content.layout.component.ts | 10 +- ...dot-edit-content-side-panel.component.html | 46 +++++ ...-edit-content-side-panel.component.spec.ts | 186 ++++++++++++++++++ .../dot-edit-content-side-panel.component.ts | 81 +++----- ...t-content-drive-navigation.service.spec.ts | 66 +++++++ .../dot-content-drive-navigation.service.ts | 63 ++++-- .../edit-ema-editor.component.html | 9 + .../edit-ema-editor.component.ts | 43 +++- .../dotcms/featureflag/FeatureFlagName.java | 7 + .../api/v1/system/ConfigurationResource.java | 2 + .../resources/dotmarketing-config.properties | 4 + 12 files changed, 448 insertions(+), 72 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.html create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-side-panel/dot-edit-content-side-panel.component.spec.ts 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/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 86b9b38a3b8e..94cb923cc308 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 @@ -414,7 +414,15 @@ export class DotEditContentLayoutComponent { * @param onProceed Runs when it is safe to close (clean form, or the user discarded changes). */ confirmClose(onProceed: () => void): void { - if (!this.hasUnsavedChanges() || this.$store.workflowActionSuccess()) { + // While the content is still loading, async field CVAs (e.g. the Block Editor) populate + // their controls and transiently mark the form dirty before the user has touched anything, + // so a close during that window would prompt falsely. The editor isn't fully loaded, so + // there is nothing the user could have changed — close without prompting. + if ( + !this.hasUnsavedChanges() || + this.$store.workflowActionSuccess() || + !this.$store.isFullyLoaded() + ) { onProceed(); return; 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..96690f4dbe36 --- /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,46 @@ + + +
+ + {{ data()?.title }} + +
+ + +
+
+
+ @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..a1324a759d65 --- /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,186 @@ +import { createComponentFactory, Spectator, byTestId } from '@openng/spectator/jest'; +import { MockComponent } from 'ng-mocks'; +import { Subject } from 'rxjs'; + +import { ButtonModule } from 'primeng/button'; +import { DrawerModule } from 'primeng/drawer'; + +import { DotCMSContentlet } from '@dotcms/dotcms-models'; + +import { DotEditContentSidePanelComponent } from './dot-edit-content-side-panel.component'; + +import { EditContentDialogData } from '../../models/dot-edit-content-dialog.interface'; +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) + ], + providers: [{ provide: OverlayEditContentHost, useValue: undefined }] + } + } + ] + ] + }); + + beforeEach(() => { + saved$ = new Subject(); + mockHost = { saved$: saved$.asObservable() }; + + spectator = createComponent({ + providers: [{ provide: OverlayEditContentHost, useValue: mockHost }], + 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(); + + expect(spectator.query(byTestId('side-panel-title'))?.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 `