From 91794f518855c450469d4146e02a88f96623c757 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Fri, 5 Jun 2026 13:08:24 -0400 Subject: [PATCH 01/40] feat(edit-content): restructure command bar & side panel around Actions tab (#35892) Command bar (dot-edit-content-form): - Slim to [status tag] | Preview ... (overflow menu) [sidebar toggle], sticky on scroll. - Status rendered as a local p-tag; lock toggle and inline workflow actions removed. - New dot-edit-content-command-bar-actions overflow menu (Permissions, Rules for pages, View references) that owns the relocated dialog logic. - Preview now supports HTML pages via generatePageEditUrl and URL-mapped content. Side panel (dot-edit-content-sidebar): - Info tab becomes Actions (bolt icon); Settings tab removed. - Lock control relocated as a distinct outlined button with a lock icon, separated from the workflow actions by a divider. - Workflow actions render stacked full-width (new `stacked` mode on dot-workflow-actions), one above another, first action primary; firing stays in the form via layout wiring (workflowActionFired output -> DotEditContentLayoutComponent.onWorkflowActionFired). - Sections (Locales, Workflow, Details) are independently collapsible and persist their state via DotLocalstorageService; headers restyled to the design's uppercase treatment. - Information card drops the status chip and References card (moved to the command bar). Removed the now-orphaned sidebar permissions/rules wrapper components. --- ...content-command-bar-actions.component.html | 7 + ...tent-command-bar-actions.component.spec.ts | 295 ++++++++++++ ...t-content-command-bar-actions.component.ts | 217 +++++++++ .../dot-edit-content-form.component.html | 66 +-- .../dot-edit-content-form.component.scss | 9 + .../dot-edit-content-form.component.spec.ts | 404 ++++++++++------ .../dot-edit-content-form.component.ts | 100 ++-- .../dot-edit-content.layout.component.html | 17 +- .../dot-edit-content.layout.component.spec.ts | 37 +- .../dot-edit-content.layout.component.ts | 22 +- ...content-sidebar-information.component.html | 49 +- ...tent-sidebar-information.component.spec.ts | 150 +----- ...t-content-sidebar-information.component.ts | 71 +-- ...content-sidebar-permissions.component.html | 22 - ...tent-sidebar-permissions.component.spec.ts | 202 -------- ...t-content-sidebar-permissions.component.ts | 90 ---- ...-edit-content-sidebar-rules.component.html | 22 - ...it-content-sidebar-rules.component.spec.ts | 166 ------- ...ot-edit-content-sidebar-rules.component.ts | 70 --- ...dit-content-sidebar-section.component.html | 38 +- ...-content-sidebar-section.component.spec.ts | 120 ++++- ...-edit-content-sidebar-section.component.ts | 53 +- .../dot-edit-content-sidebar.component.html | 122 +++-- ...dot-edit-content-sidebar.component.spec.ts | 451 +++++------------- .../dot-edit-content-sidebar.component.ts | 16 +- .../src/lib/utils/functions.util.spec.ts | 28 ++ .../src/lib/utils/functions.util.ts | 24 + .../dot-workflow-actions.component.html | 22 + .../dot-workflow-actions.component.spec.ts | 72 +++ .../dot-workflow-actions.component.ts | 23 +- .../WEB-INF/messages/Language.properties | 2 + 31 files changed, 1511 insertions(+), 1476 deletions(-) create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.html create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.spec.ts create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.ts create mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss delete mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.html delete mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.spec.ts delete mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.ts delete mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.html delete mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.spec.ts delete mode 100644 core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.ts diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.html new file mode 100644 index 000000000000..311ac36bd7b3 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.html @@ -0,0 +1,7 @@ + + diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.spec.ts new file mode 100644 index 000000000000..46fdf4e5fb86 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.spec.ts @@ -0,0 +1,295 @@ +import { + byTestId, + createComponentFactory, + mockProvider, + Spectator, + SpyObject +} from '@ngneat/spectator/jest'; +import { Subject } from 'rxjs'; + +import { MenuItem } from 'primeng/api'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotPermissionsIframeDialogComponent } from '@dotcms/ui'; + +import { + CONTENTLET_PERMISSIONS_IFRAME_PATH, + DotEditContentCommandBarActionsComponent +} from './dot-edit-content-command-bar-actions.component'; + +import { DotEditContentSidebarReferencesDialogComponent } from '../../../dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-references-dialog/dot-edit-content-sidebar-references-dialog.component'; +import { DotRulesDialogComponent } from '../../../dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/components/rules-dialog/rules-dialog.component'; + +const findItem = (model: MenuItem[], testId: string): MenuItem | undefined => + model.find((item) => item.testId === testId); + +describe('DotEditContentCommandBarActionsComponent', () => { + let spectator: Spectator; + let dotMessageService: SpyObject; + let dialogOpenSpy: jest.Mock; + let mockDialogRef: DynamicDialogRef; + + const createComponent = createComponentFactory({ + component: DotEditContentCommandBarActionsComponent, + providers: [ + mockProvider(DotMessageService, { + get: jest.fn((key: string) => key) + }) + ], + // DialogService is provided at the component node (providers in the component + // decorator), so the mock must be supplied via componentProviders to override it. + // The open mock delegates to the per-test dialogOpenSpy so each test gets a fresh spy. + componentProviders: [ + { + provide: DialogService, + useValue: { open: (...args: unknown[]) => dialogOpenSpy(...args) } + } + ] + }); + + beforeEach(() => { + mockDialogRef = { + onClose: new Subject(), + close: jest.fn() + } as unknown as DynamicDialogRef; + dialogOpenSpy = jest.fn().mockReturnValue(mockDialogRef); + + spectator = createComponent({ + props: { + identifier: 'content-123', + languageId: 123 + } + }); + + dotMessageService = spectator.inject(DotMessageService); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + describe('Trigger button', () => { + it('should render the overflow trigger button', () => { + expect(spectator.query(byTestId('command-bar-actions-button'))).toBeTruthy(); + }); + }); + + describe('Menu model', () => { + it('should always include the Permissions action', () => { + const item = findItem(spectator.component.$model(), 'command-bar-action-permissions'); + expect(item).toBeTruthy(); + expect(item?.label).toBe('edit.content.sidebar.permissions.title'); + }); + + it('should NOT include the Rules action when isPage is false', () => { + spectator.setInput('isPage', false); + spectator.detectChanges(); + + expect( + findItem(spectator.component.$model(), 'command-bar-action-rules') + ).toBeUndefined(); + }); + + it('should include the Rules action only when isPage is true', () => { + spectator.setInput('isPage', true); + spectator.detectChanges(); + + const item = findItem(spectator.component.$model(), 'command-bar-action-rules'); + expect(item).toBeTruthy(); + expect(item?.label).toBe('edit.content.sidebar.rules.title'); + }); + + it('should include a separator', () => { + expect(spectator.component.$model().some((item) => item.separator === true)).toBe(true); + }); + + it('should include the References action', () => { + const item = findItem(spectator.component.$model(), 'command-bar-action-references'); + expect(item).toBeTruthy(); + expect(item?.label).toBe('edit.content.sidebar.command-bar.references'); + }); + + it('should disable the References action when hasReferences is false', () => { + spectator.setInput('hasReferences', false); + spectator.detectChanges(); + + const item = findItem(spectator.component.$model(), 'command-bar-action-references'); + expect(item?.disabled).toBe(true); + }); + + it('should enable the References action when hasReferences is true', () => { + spectator.setInput('hasReferences', true); + spectator.detectChanges(); + + const item = findItem(spectator.component.$model(), 'command-bar-action-references'); + expect(item?.disabled).toBe(false); + }); + }); + + describe('openPermissionsDialog', () => { + it('should open the permissions dialog with DotPermissionsIframeDialogComponent', () => { + spectator.setInput('identifier', 'content-789'); + spectator.setInput('languageId', 2); + spectator.detectChanges(); + + findItem(spectator.component.$model(), 'command-bar-action-permissions')?.command?.( + {} as never + ); + + expect(dialogOpenSpy).toHaveBeenCalledWith( + DotPermissionsIframeDialogComponent, + expect.objectContaining({ + header: 'edit.content.sidebar.permissions.title', + width: 'min(92vw, 75rem)', + contentStyle: { overflow: 'hidden' }, + modal: true, + appendTo: 'body', + closeOnEscape: false, + closable: true + }) + ); + }); + + it('should build the url with contentletId, languageId and popup', () => { + spectator.setInput('identifier', 'content-789'); + spectator.setInput('languageId', 2); + spectator.detectChanges(); + + spectator.component.openPermissionsDialog(); + + const callData = dialogOpenSpy.mock.calls[0][1].data; + expect(callData.url).toContain(CONTENTLET_PERMISSIONS_IFRAME_PATH); + expect(callData.url).toContain('contentletId=content-789'); + expect(callData.url).toContain('languageId=2'); + expect(callData.url).toContain('popup=true'); + }); + + it('should NOT open the dialog when identifier is empty', () => { + spectator.setInput('identifier', ''); + spectator.setInput('languageId', 1); + spectator.detectChanges(); + + spectator.component.openPermissionsDialog(); + + expect(dialogOpenSpy).not.toHaveBeenCalled(); + }); + + it('should NOT open the dialog when languageId is 0', () => { + spectator.setInput('identifier', 'content-ok'); + spectator.setInput('languageId', 0); + spectator.detectChanges(); + + spectator.component.openPermissionsDialog(); + + expect(dialogOpenSpy).not.toHaveBeenCalled(); + }); + + it('should NOT open a second permissions dialog while one is already open', () => { + spectator.component.openPermissionsDialog(); + spectator.component.openPermissionsDialog(); + + expect(dialogOpenSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('openRulesDialog', () => { + it('should open the rules dialog with DotRulesDialogComponent', () => { + spectator.setInput('identifier', 'page-1'); + spectator.detectChanges(); + + spectator.component.openRulesDialog(); + + expect(dialogOpenSpy).toHaveBeenCalledWith( + DotRulesDialogComponent, + expect.objectContaining({ + header: 'edit.content.sidebar.rules.title', + width: 'min(92vw, 75rem)', + data: { identifier: 'page-1' }, + modal: true, + appendTo: 'body' + }) + ); + }); + + it('should NOT open the dialog when identifier is empty', () => { + spectator.setInput('identifier', ''); + spectator.detectChanges(); + + spectator.component.openRulesDialog(); + + expect(dialogOpenSpy).not.toHaveBeenCalled(); + }); + }); + + describe('openReferencesDialog', () => { + it('should open the references dialog with DotEditContentSidebarReferencesDialogComponent', () => { + spectator.setInput('identifier', 'ref-1'); + spectator.setInput('title', 'My Content'); + spectator.detectChanges(); + + spectator.component.openReferencesDialog(); + + expect(dialogOpenSpy).toHaveBeenCalledWith( + DotEditContentSidebarReferencesDialogComponent, + expect.objectContaining({ + data: { identifier: 'ref-1' }, + modal: true, + appendTo: 'body', + closeOnEscape: true, + closable: true + }) + ); + }); + + it('should use the title input for the references dialog header', () => { + spectator.setInput('identifier', 'ref-1'); + spectator.setInput('title', 'My Content'); + spectator.detectChanges(); + + spectator.component.openReferencesDialog(); + + expect(dotMessageService.get).toHaveBeenCalledWith( + 'edit.content.sidebar.references.dialog.title', + 'My Content' + ); + }); + + it('should fall back to the contentlet title when the title input is empty', () => { + spectator.setInput('identifier', 'ref-1'); + spectator.setInput('title', ''); + spectator.setInput('contentlet', { title: 'Fallback Title' } as never); + spectator.detectChanges(); + + spectator.component.openReferencesDialog(); + + expect(dotMessageService.get).toHaveBeenCalledWith( + 'edit.content.sidebar.references.dialog.title', + 'Fallback Title' + ); + }); + + it('should NOT open the dialog when identifier is empty', () => { + spectator.setInput('identifier', ''); + spectator.detectChanges(); + + spectator.component.openReferencesDialog(); + + expect(dialogOpenSpy).not.toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + it('should close any open dialog on destroy', () => { + spectator.component.openPermissionsDialog(); + + spectator.fixture.destroy(); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it('should not throw when destroyed and no dialog was opened', () => { + expect(() => spectator.fixture.destroy()).not.toThrow(); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.ts new file mode 100644 index 000000000000..7c288e7f63c9 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component.ts @@ -0,0 +1,217 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + input +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +import { MenuItem } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { MenuModule } from 'primeng/menu'; + +import { DotMessageService } from '@dotcms/data-access'; +import { DotCMSContentlet } from '@dotcms/dotcms-models'; +import { DotPermissionsIframeDialogComponent, DotPermissionsIframeDialogData } from '@dotcms/ui'; + +import { DotReferencesDialogData } from '../../../../models/dot-edit-content.model'; +import { DotEditContentSidebarReferencesDialogComponent } from '../../../dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-references-dialog/dot-edit-content-sidebar-references-dialog.component'; +import { DotRulesDialogComponent } from '../../../dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/components/rules-dialog/rules-dialog.component'; + +export const CONTENTLET_PERMISSIONS_IFRAME_PATH = '/html/portlet/ext/contentlet/permissions.jsp'; + +/** + * Overflow ("...") menu for the edit-content command bar. + * + * Renders a single trigger button that toggles a popup menu with the secondary + * contentlet actions (Permissions, Rules, View references). Each action opens its + * own dialog; this component owns the dialog lifecycle and guards against opening + * duplicate instances. + */ +@Component({ + selector: 'dot-edit-content-command-bar-actions', + imports: [ButtonModule, MenuModule], + templateUrl: './dot-edit-content-command-bar-actions.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService] +}) +export class DotEditContentCommandBarActionsComponent { + readonly #dialogService = inject(DialogService); + readonly #dotMessageService = inject(DotMessageService); + readonly #destroyRef = inject(DestroyRef); + + #permissionsDialogRef: DynamicDialogRef | undefined; + #rulesDialogRef: DynamicDialogRef | undefined; + #referencesDialogRef: DynamicDialogRef | undefined; + + /** The contentlet the command bar acts on. Used as a fallback source for the title. */ + readonly contentlet = input(null); + + /** Contentlet identifier used by the permissions, rules and references dialogs. */ + readonly identifier = input(''); + + /** Contentlet language id used by the permissions dialog. */ + readonly languageId = input(0); + + /** Whether the contentlet is a page. Controls visibility of the Rules action. */ + readonly isPage = input(false); + + /** Whether the contentlet has at least one page reference. Disables the references action. */ + readonly hasReferences = input(false); + + /** Contentlet title used for the references dialog header. */ + readonly title = input(''); + + /** Menu model for the overflow popup. Rebuilt reactively from the inputs. */ + readonly $model = computed(() => { + const items: MenuItem[] = [ + { + label: this.#dotMessageService.get('edit.content.sidebar.permissions.title'), + testId: 'command-bar-action-permissions', + command: () => this.openPermissionsDialog() + } + ]; + + if (this.isPage()) { + items.push({ + label: this.#dotMessageService.get('edit.content.sidebar.rules.title'), + testId: 'command-bar-action-rules', + command: () => this.openRulesDialog() + }); + } + + items.push( + { separator: true }, + { + label: this.#dotMessageService.get('edit.content.sidebar.command-bar.references'), + testId: 'command-bar-action-references', + disabled: !this.hasReferences(), + command: () => this.openReferencesDialog() + } + ); + + return items; + }); + + constructor() { + this.#destroyRef.onDestroy(() => { + this.#permissionsDialogRef?.close(); + this.#rulesDialogRef?.close(); + this.#referencesDialogRef?.close(); + }); + } + + /** + * Opens the permissions dialog with an iframe for the current contentlet. + * Prevents opening multiple instances if the action is triggered repeatedly. + */ + openPermissionsDialog(): void { + if (this.#permissionsDialogRef) return; + + const id = this.identifier(); + const langId = this.languageId(); + if (!id || !langId) return; + + this.#permissionsDialogRef = this.#dialogService.open(DotPermissionsIframeDialogComponent, { + header: this.#dotMessageService.get('edit.content.sidebar.permissions.title'), + width: 'min(92vw, 75rem)', + contentStyle: { overflow: 'hidden' }, + data: { + url: this.#buildPermissionsUrl(id, langId) + } satisfies DotPermissionsIframeDialogData, + transitionOptions: null, + modal: true, + appendTo: 'body', + closeOnEscape: false, + closable: true, + draggable: false, + resizable: false, + position: 'center' + }); + + this.#permissionsDialogRef.onClose + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe(() => { + this.#permissionsDialogRef = undefined; + }); + } + + /** + * Opens the rules dialog for the current contentlet. + * Prevents opening multiple instances if the action is triggered repeatedly. + */ + openRulesDialog(): void { + if (this.#rulesDialogRef) return; + + const id = this.identifier(); + if (!id) return; + + const header = this.#dotMessageService.get('edit.content.sidebar.rules.title'); + this.#rulesDialogRef = this.#dialogService.open(DotRulesDialogComponent, { + header, + width: 'min(92vw, 75rem)', + data: { identifier: id }, + modal: true, + appendTo: 'body', + closeOnEscape: false, + closable: true, + draggable: false, + keepInViewport: false, + resizable: false, + position: 'center' + }); + + this.#rulesDialogRef.onClose.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe(() => { + this.#rulesDialogRef = undefined; + }); + } + + /** Opens the references dialog showing all pages that include this contentlet. */ + openReferencesDialog(): void { + if (this.#referencesDialogRef) return; + + const identifier = this.identifier(); + if (!identifier) return; + + this.#referencesDialogRef = this.#dialogService.open( + DotEditContentSidebarReferencesDialogComponent, + { + header: this.#dotMessageService.get( + 'edit.content.sidebar.references.dialog.title', + this.title() || this.contentlet()?.title || '' + ), + width: 'min(92vw, 60rem)', + contentStyle: { padding: '0', overflow: 'auto' }, + data: { identifier } satisfies DotReferencesDialogData, + modal: true, + appendTo: 'body', + closeOnEscape: true, + closable: true, + draggable: false, + resizable: false, + position: 'center' + } + ); + + this.#referencesDialogRef.onClose.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe({ + next: () => { + this.#referencesDialogRef = undefined; + }, + error: () => { + this.#referencesDialogRef = undefined; + } + }); + } + + #buildPermissionsUrl(identifier: string, languageId: number): string { + const params = new URLSearchParams({ + contentletId: identifier, + languageId: String(languageId), + popup: 'true' + }); + return `${CONTENTLET_PERMISSIONS_IFRAME_PATH}?${params.toString()}`; + } +} diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html index 0b128e8a4e1c..7b98c6669f16 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html @@ -1,19 +1,7 @@ @let contentType = $store.contentType(); @let contentlet = $store.contentlet(); @let showSidebar = $store.isSidebarOpen(); -@let actions = $store.getActions(); -@let showWorkflowActions = $store.showWorkflowActions(); @let activeIndex = $store.activeTab(); - - -@let currentLocale = $store.currentLocale(); -@let currentLocaleId = currentLocale ? currentLocale.id.toString() : ''; -@let currentIdentifier = $store.currentIdentifier(); - - -@let isContentLocked = $store.isContentLocked(); -@let canLock = $store.canLock(); -@let lockSwitchLabel = $store.lockSwitchLabel(); @let tabs = $tabs(); @@ -100,25 +88,6 @@ class="ml-auto flex min-h-[50px] w-full items-center gap-4" data-testId="edit-content-actions" [class.dot-edit-content-actions--sidebar-open]="showSidebar"> - - @if (!$store.isViewingHistoricalVersion()) { - @if (canLock) { -
- - -
- } - } - @if ($store.isViewingHistoricalVersion()) { } - @if (showWorkflowActions) { - + + @if (!$store.isNew()) { + } } diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss new file mode 100644 index 000000000000..9ff16de666e8 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.scss @@ -0,0 +1,9 @@ +// The form host is the overflow-auto scroll container, so the tab list (which +// carries the command bar) is pinned to the top while the fields scroll under it. +// An opaque background keeps the scrolling fields from showing through. +:host ::ng-deep .p-tablist { + position: sticky; + top: 0; + z-index: 10; + background-color: var(--p-tabs-tablist-background, var(--p-content-background, #ffffff)); +} diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts index 3d7963bfdb65..e82a639a2f9f 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts @@ -19,7 +19,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { ConfirmationService, MessageService } from 'primeng/api'; import { DialogService } from 'primeng/dynamicdialog'; import { Tab, Tabs } from 'primeng/tabs'; -import { ToggleSwitch, ToggleSwitchChangeEvent } from 'primeng/toggleswitch'; import { DotContentletService, @@ -37,6 +36,7 @@ import { DotWorkflowService } from '@dotcms/data-access'; import { + DotCMSBaseTypesContentTypes, DotCMSContentlet, DotCMSContentTypeField, DotCMSWorkflowAction, @@ -44,10 +44,8 @@ import { DotContentletDepths } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; -import { DotWorkflowActionsComponent } from '@dotcms/ui'; import { DotFormatDateServiceMock, - MOCK_MULTIPLE_WORKFLOW_ACTIONS, MOCK_SINGLE_WORKFLOW_ACTIONS, mockMatchMedia } from '@dotcms/utils-testing'; @@ -64,7 +62,7 @@ import { MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB, MOCK_WORKFLOW_STATUS } from '../../utils/edit-content.mock'; -import { generatePreviewUrl } from '../../utils/functions.util'; +import { generatePageEditUrl, generatePreviewUrl } from '../../utils/functions.util'; describe('DotFormComponent', () => { let spectator: Spectator; @@ -347,18 +345,26 @@ describe('DotFormComponent', () => { expect(appendArea).toBeTruthy(); }); - it('should render workflow actions and sidebar toggle in append area', () => { + it('should render the status tag, command bar actions and sidebar toggle in append area', () => { const sidebarToggle = spectator.query(byTestId('sidebar-toggle')); const sidebarButton = spectator.query(byTestId('sidebar-toggle-button')) ?? sidebarToggle?.querySelector('button'); - const workflowActions = spectator.query(DotWorkflowActionsComponent); + const statusTag = spectator.query(byTestId('content-status-tag')); + const commandBar = spectator.query(byTestId('command-bar-actions')); - expect(workflowActions).toBeTruthy(); + expect(statusTag).toBeTruthy(); + expect(commandBar).toBeTruthy(); expect(sidebarToggle).toBeTruthy(); expect(sidebarButton).toBeTruthy(); }); + it('should not render the lock toggle or workflow actions in the append area', () => { + expect(spectator.query(byTestId('content-lock-controls'))).toBeFalsy(); + expect(spectator.query(byTestId('content-lock-switch'))).toBeFalsy(); + expect(spectator.query(byTestId('workflow-actions'))).toBeFalsy(); + }); + it('should call toggleSidebar when sidebar button is clicked', () => { const sidebarToggle = spectator.query(byTestId('sidebar-toggle')); const sidebarButton = @@ -429,56 +435,39 @@ describe('DotFormComponent', () => { expect(editContentActions).toBeTruthy(); }); - describe('Workflow Actions Component', () => { - it('should show DotWorkflowActionsComponent when showWorkflowActions is true', () => { - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) // Single workflow actions trigger the show - ); - store.initializeExistingContent({ - inode: 'inode', - depth: DotContentletDepths.ONE - }); - spectator.detectChanges(); - - const workflowActions = spectator.query(DotWorkflowActionsComponent); - expect(store.showWorkflowActions()).toBe(true); - expect(workflowActions).toBeTruthy(); - }); + // The workflow actions UI now lives in the sidebar; the form keeps the public + // fireWorkflowAction() method (called by the layout). These tests exercise it + // directly to preserve the parameter, wizard and validation regression coverage. + describe('fireWorkflowAction (programmatic)', () => { + const baseParams = { + inode: 'inode', + contentType: 'TestMock', + languageId: '1', + identifier: 'identifier' + }; - it('should hide DotWorkflowActionsComponent when showWorkflowActions is false', () => { + beforeEach(() => { workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_MULTIPLE_WORKFLOW_ACTIONS) // Multiple workflow actions trigger the hide + of(MOCK_SINGLE_WORKFLOW_ACTIONS) ); - store.initializeExistingContent({ inode: 'inode', depth: DotContentletDepths.ONE }); spectator.detectChanges(); - - const workflowActions = spectator.query(DotWorkflowActionsComponent); - expect(store.showWorkflowActions()).toBe(false); - expect(workflowActions).toBeFalsy(); }); - it('should send the correct parameters when firing an action', () => { + it('should fire the action on the store when it has no inputs', () => { const spy = jest.spyOn(store, 'fireWorkflowAction'); - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - store.initializeExistingContent({ - inode: 'inode', - depth: DotContentletDepths.ONE + component.fireWorkflowAction({ + workflow: { id: '1' } as DotCMSWorkflowAction, + ...baseParams }); - spectator.detectChanges(); - - const workflowActions = spectator.query(DotWorkflowActionsComponent); - workflowActions.actionFired.emit({ id: '1' } as DotCMSWorkflowAction); expect(spy).toHaveBeenCalledWith({ actionId: '1', - inode: 'cc120e84-ae80-49d8-9473-36d183d0c1c9', + inode: 'inode', data: { contentlet: { contentType: 'TestMock', @@ -487,88 +476,101 @@ describe('DotFormComponent', () => { text2: 'content text 2', text3: 'default value modified', multiselect: 'A,B,C', - languageId: '', - identifier: null, + languageId: '1', + identifier: 'identifier', disabledWYSIWYG: ['wysiwygField1', 'wysiwygField2'] } } }); }); - it('should call the wizard service when the workflow action is fired', () => { + it('should call the wizard service when the workflow action has inputs', () => { const wizardService = spectator.inject(DotWizardService); - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - store.initializeExistingContent({ - inode: 'inode', - depth: DotContentletDepths.ONE + component.fireWorkflowAction({ + workflow: { + id: '1', + actionInputs: [{ id: 'move', body: {} }] + } as DotCMSWorkflowAction, + ...baseParams }); - spectator.detectChanges(); - - const workflowActions = spectator.query(DotWorkflowActionsComponent); - workflowActions.actionFired.emit({ - id: '1', - actionInputs: [{ id: 'move', body: {} }] - } as DotCMSWorkflowAction); expect(wizardService.open).toHaveBeenCalled(); }); + it('should validate and not fire when the form is invalid (regression)', () => { + const fireSpy = jest.spyOn(store, 'fireWorkflowAction'); + const setFormStatusSpy = jest.spyOn(store, 'setFormStatus'); + const markAllAsTouchedSpy = jest.spyOn(component.form, 'markAllAsTouched'); + + // Force the form invalid via a required control. + component.form.get('text1')?.setValidators(Validators.required); + component.form.get('text1')?.setValue(''); + component.form.get('text1')?.updateValueAndValidity(); + expect(component.form.invalid).toBe(true); + + component.fireWorkflowAction({ + workflow: { id: '1' } as DotCMSWorkflowAction, + ...baseParams + }); + + expect(markAllAsTouchedSpy).toHaveBeenCalled(); + expect(setFormStatusSpy).toHaveBeenCalledWith('invalid'); + expect(fireSpy).not.toHaveBeenCalled(); + }); + describe('commentable and assignable dialog', () => { let wizardService: DotWizardService; - let workflowActionsComponent: DotWorkflowActionsComponent; beforeEach(() => { - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - store.initializeExistingContent({ - inode: 'inode', - depth: DotContentletDepths.ONE - }); - spectator.detectChanges(); - wizardService = spectator.inject(DotWizardService); - workflowActionsComponent = spectator.query(DotWorkflowActionsComponent); (wizardService.open as jest.Mock).mockClear(); }); it('should open wizard when action has commentable input', () => { - workflowActionsComponent.actionFired.emit({ - id: '1', - actionInputs: [{ id: 'commentable', body: {} }] - } as DotCMSWorkflowAction); + component.fireWorkflowAction({ + workflow: { + id: '1', + actionInputs: [{ id: 'commentable', body: {} }] + } as DotCMSWorkflowAction, + ...baseParams + }); expect(wizardService.open).toHaveBeenCalled(); }); it('should open wizard when action has assignable input', () => { - workflowActionsComponent.actionFired.emit({ - id: '1', - actionInputs: [{ id: 'assignable', body: {} }] - } as DotCMSWorkflowAction); + component.fireWorkflowAction({ + workflow: { + id: '1', + actionInputs: [{ id: 'assignable', body: {} }] + } as DotCMSWorkflowAction, + ...baseParams + }); expect(wizardService.open).toHaveBeenCalled(); }); it('should open wizard when action has both commentable and assignable inputs', () => { - workflowActionsComponent.actionFired.emit({ - id: '1', - actionInputs: [ - { id: 'commentable', body: {} }, - { id: 'assignable', body: {} } - ] - } as DotCMSWorkflowAction); + component.fireWorkflowAction({ + workflow: { + id: '1', + actionInputs: [ + { id: 'commentable', body: {} }, + { id: 'assignable', body: {} } + ] + } as DotCMSWorkflowAction, + ...baseParams + }); expect(wizardService.open).toHaveBeenCalled(); }); it('should not open wizard when action has no inputs', () => { - workflowActionsComponent.actionFired.emit({ - id: '1' - } as DotCMSWorkflowAction); + component.fireWorkflowAction({ + workflow: { id: '1' } as DotCMSWorkflowAction, + ...baseParams + }); expect(wizardService.open).not.toHaveBeenCalled(); }); @@ -673,6 +675,141 @@ describe('DotFormComponent', () => { expect(previewButton).toBeFalsy(); }); }); + + describe('HTML Page', () => { + const pageContentlet = { + ...MOCK_CONTENTLET_1_OR_2_TABS, + baseType: DotCMSBaseTypesContentTypes.HTMLPAGE + } as DotCMSContentlet; + + beforeEach(() => { + windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + dotContentTypeService.getContentTypeWithRender.mockReturnValue( + of(MOCK_CONTENTTYPE_2_TABS) + ); + dotEditContentService.getContentById.mockReturnValue(of(pageContentlet)); + workflowActionsService.getByInode.mockReturnValue( + of(MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB) + ); + workflowActionsService.getWorkFlowActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); + dotContentletService.canLock.mockReturnValue( + of({ canLock: true } as DotContentletCanLock) + ); + + store.initializeExistingContent({ + inode: MOCK_CONTENTLET_1_OR_2_TABS.inode, + depth: DotContentletDepths.ONE + }); + spectator.detectChanges(); + }); + + it('should render the preview button for an HTML page', () => { + const previewButton = spectator.query(byTestId('preview-button')); + expect(previewButton).toBeTruthy(); + }); + + it('should use generatePageEditUrl when showPreview runs for an HTML page', () => { + const expectedUrl = generatePageEditUrl(pageContentlet); + expect(expectedUrl).toBeTruthy(); + + component.showPreview(); + + expect(windowOpenSpy).toHaveBeenCalledWith(expectedUrl, '_blank'); + }); + }); + + describe('New content', () => { + beforeEach(() => { + windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + + dotContentTypeService.getContentTypeWithRender.mockReturnValue( + of(MOCK_CONTENTTYPE_1_TAB) + ); + workflowActionsService.getDefaultActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + workflowActionsService.getWorkFlowActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + dotContentletService.canLock.mockReturnValue( + of({ canLock: true } as DotContentletCanLock) + ); + + store.initializeNewContent('TestMock'); + spectator.detectChanges(); + }); + + it('should not render the preview button for new content', () => { + const previewButton = spectator.query(byTestId('preview-button')); + expect(previewButton).toBeFalsy(); + }); + }); + }); + + describe('Command Bar', () => { + beforeEach(() => { + dotContentTypeService.getContentTypeWithRender.mockReturnValue( + of(MOCK_CONTENTTYPE_2_TABS) + ); + dotEditContentService.getContentById.mockReturnValue(of(MOCK_CONTENTLET_1_OR_2_TABS)); + workflowActionsService.getByInode.mockReturnValue( + of(MOCK_WORKFLOW_ACTIONS_NEW_ITEMNTTYPE_1_TAB) + ); + workflowActionsService.getDefaultActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + workflowActionsService.getWorkFlowActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); + dotContentletService.canLock.mockReturnValue( + of({ canLock: true } as DotContentletCanLock) + ); + }); + + it('should render the status tag for existing content', () => { + store.initializeExistingContent({ + inode: MOCK_CONTENTLET_1_OR_2_TABS.inode, + depth: DotContentletDepths.ONE + }); + spectator.detectChanges(); + + const statusTag = spectator.query(byTestId('content-status-tag')); + expect(statusTag).toBeTruthy(); + }); + + it('should render the command bar actions for existing content', () => { + store.initializeExistingContent({ + inode: MOCK_CONTENTLET_1_OR_2_TABS.inode, + depth: DotContentletDepths.ONE + }); + spectator.detectChanges(); + + const commandBar = spectator.query(byTestId('command-bar-actions')); + expect(commandBar).toBeTruthy(); + }); + + it('should not render the command bar actions for new content', () => { + store.initializeNewContent('TestMock'); + spectator.detectChanges(); + + const commandBar = spectator.query(byTestId('command-bar-actions')); + expect(commandBar).toBeFalsy(); + }); + + describe('statusSeverity', () => { + it('should map status labels to PrimeNG severities', () => { + expect(component.statusSeverity('Published')).toBe('success'); + expect(component.statusSeverity('Archived')).toBe('danger'); + expect(component.statusSeverity('Revision')).toBe('info'); + expect(component.statusSeverity('Draft')).toBe('warn'); + expect(component.statusSeverity('New')).toBe('warn'); + }); + }); }); describe('Lock functionality', () => { @@ -724,18 +861,16 @@ describe('DotFormComponent', () => { spectator.detectChanges(); }); - it('should call lockContent when switch is turned on', () => { - const lockSwitch = spectator.query(ToggleSwitch); - - lockSwitch.onChange.emit({ checked: true } as ToggleSwitchChangeEvent); + // The lock toggle UI moved out of this component; the form still reacts to + // lock-state changes coming from the store, so we drive those directly. + it('should call lockContent on the store when locking', () => { + store.lockContent(); expect(dotContentletService.lockContent).toHaveBeenCalled(); }); - it('should call unlockContent when switch is turned off', () => { - const lockSwitch = spectator.query(ToggleSwitch); - - lockSwitch.onChange.emit({ checked: false } as ToggleSwitchChangeEvent); + it('should call unlockContent on the store when unlocking', () => { + store.unlockContent(); expect(dotContentletService.unlockContent).toHaveBeenCalled(); }); @@ -746,8 +881,7 @@ describe('DotFormComponent', () => { 'initializeForm' ); - const lockSwitch = spectator.query(ToggleSwitch); - lockSwitch.onChange.emit({ checked: true } as ToggleSwitchChangeEvent); + store.lockContent(); spectator.detectChanges(); // identifier/inode/modDate did not change — form must not rebuild, @@ -756,10 +890,10 @@ describe('DotFormComponent', () => { }); }); - describe('cant lock', () => { + describe('lock controls UI', () => { beforeEach(() => { dotContentletService.canLock.mockReturnValue( - of({ canLock: false } as DotContentletCanLock) + of({ canLock: true } as DotContentletCanLock) ); store.initializeExistingContent({ @@ -770,9 +904,9 @@ describe('DotFormComponent', () => { spectator.detectChanges(); }); - it('should hide the lock switch when user can not lock', () => { - const lockSwitch = spectator.query(ToggleSwitch); - expect(lockSwitch).toBe(null); + it('should never render the lock toggle in the command bar', () => { + expect(spectator.query(byTestId('content-lock-controls'))).toBeFalsy(); + expect(spectator.query(byTestId('content-lock-switch'))).toBeFalsy(); }); }); @@ -860,7 +994,7 @@ describe('DotFormComponent', () => { flush(); })); - it('should not mark form dirty when the lock toggle emits onChange (AC2 regression guard)', fakeAsync(() => { + it('should not mark form dirty when the content is locked (AC2 regression guard)', fakeAsync(() => { store.initializeExistingContent({ inode: MOCK_CONTENTLET_1_OR_2_TABS.inode, depth: DotContentletDepths.ONE @@ -873,11 +1007,10 @@ describe('DotFormComponent', () => { expect(component.form.pristine).toBe(true); - const lockSwitch = spectator.query(ToggleSwitch); - lockSwitch.onChange.emit({ checked: true } as ToggleSwitchChangeEvent); + store.lockContent(); spectator.detectChanges(); - // After fix: the toggle is { standalone: true }, so it is not part of the form. + // Locking patches the contentlet reference but must not dirty the form. expect(dotContentletService.lockContent).toHaveBeenCalled(); expect(component.form.dirty).toBe(false); @@ -932,8 +1065,7 @@ describe('DotFormComponent', () => { expect(component.form.pristine).toBe(true); expect(component.form.dirty).toBe(false); - const lockSwitch = spectator.query(ToggleSwitch); - lockSwitch.onChange.emit({ checked: false } as ToggleSwitchChangeEvent); + store.unlockContent(); expect(dotContentletService.unlockContent).toHaveBeenCalled(); @@ -966,8 +1098,7 @@ describe('DotFormComponent', () => { const enableSpy = jest.spyOn(component.form, 'enable'); - const lockSwitch = spectator.query(ToggleSwitch); - lockSwitch.onChange.emit({ checked: true } as ToggleSwitchChangeEvent); + store.lockContent(); spectator.detectChanges(); expect(dotContentletService.lockContent).toHaveBeenCalled(); @@ -1002,8 +1133,7 @@ describe('DotFormComponent', () => { control?.markAsDirty(); expect(component.form.dirty).toBe(true); - const lockSwitch = spectator.query(ToggleSwitch); - lockSwitch.onChange.emit({ checked: true } as ToggleSwitchChangeEvent); + store.lockContent(); spectator.detectChanges(); // Locking must not clobber the user's real unsaved changes. @@ -1307,18 +1437,18 @@ describe('DotFormComponent', () => { }); describe('Historical Version UI Elements', () => { - it('should hide lock controls when viewing historical version', () => { - // Initially lock controls should be visible - const lockControls = spectator.query(byTestId('content-lock-controls')); - expect(lockControls).toBeTruthy(); + it('should hide the status tag and command bar when viewing historical version', () => { + // Initially the normal-view command bar should be visible + expect(spectator.query(byTestId('content-status-tag'))).toBeTruthy(); + expect(spectator.query(byTestId('command-bar-actions'))).toBeTruthy(); // Simulate loading a historical version using the store's public method store.loadVersionContent('historical-inode'); spectator.detectChanges(); - // Lock controls should be hidden - const lockControlsAfter = spectator.query(byTestId('content-lock-controls')); - expect(lockControlsAfter).toBeFalsy(); + // The status tag and command bar should be hidden + expect(spectator.query(byTestId('content-status-tag'))).toBeFalsy(); + expect(spectator.query(byTestId('command-bar-actions'))).toBeFalsy(); }); it('should show restore button when viewing historical version', () => { @@ -1338,20 +1468,6 @@ describe('DotFormComponent', () => { ); expect(restoreButtonAfter).toBeTruthy(); }); - - it('should hide workflow actions when viewing historical version', () => { - // Initially workflow actions should be visible - const workflowActions = spectator.query(byTestId('workflow-actions')); - expect(workflowActions).toBeTruthy(); - - // Simulate loading a historical version using the store's public method - store.loadVersionContent('historical-inode'); - spectator.detectChanges(); - - // Workflow actions should be hidden - const workflowActionsAfter = spectator.query(byTestId('workflow-actions')); - expect(workflowActionsAfter).toBeFalsy(); - }); }); describe('Restore Functionality', () => { @@ -1398,14 +1514,14 @@ describe('DotFormComponent', () => { expect(store.isViewingHistoricalVersion()).toBe(false); //TODO: enable this when all fields have disable state expect(component.form.enabled).toBe(true); - const lockControls = spectator.query(byTestId('content-lock-controls')); - const workflowActions = spectator.query(byTestId('workflow-actions')); + const statusTag = spectator.query(byTestId('content-status-tag')); + const commandBar = spectator.query(byTestId('command-bar-actions')); const restoreButton = spectator.query( byTestId('restore-historical-version-button') ); - expect(lockControls).toBeTruthy(); - expect(workflowActions).toBeTruthy(); + expect(statusTag).toBeTruthy(); + expect(commandBar).toBeTruthy(); expect(restoreButton).toBeFalsy(); // Simulate loading a historical version using the store's public method @@ -1415,14 +1531,14 @@ describe('DotFormComponent', () => { // Check historical view state //TODO: enable this when all fields have disable state expect(component.form.disabled).toBe(true); - const lockControlsAfter = spectator.query(byTestId('content-lock-controls')); - const workflowActionsAfter = spectator.query(byTestId('workflow-actions')); + const statusTagAfter = spectator.query(byTestId('content-status-tag')); + const commandBarAfter = spectator.query(byTestId('command-bar-actions')); const restoreButtonAfter = spectator.query( byTestId('restore-historical-version-button') ); - expect(lockControlsAfter).toBeFalsy(); - expect(workflowActionsAfter).toBeFalsy(); + expect(statusTagAfter).toBeFalsy(); + expect(commandBarAfter).toBeFalsy(); expect(restoreButtonAfter).toBeTruthy(); }); @@ -1441,14 +1557,14 @@ describe('DotFormComponent', () => { // Check normal view state expect(component.form.enabled).toBe(true); - const lockControls = spectator.query(byTestId('content-lock-controls')); - const workflowActions = spectator.query(byTestId('workflow-actions')); + const statusTag = spectator.query(byTestId('content-status-tag')); + const commandBar = spectator.query(byTestId('command-bar-actions')); const restoreButton = spectator.query( byTestId('restore-historical-version-button') ); - expect(lockControls).toBeTruthy(); - expect(workflowActions).toBeTruthy(); + expect(statusTag).toBeTruthy(); + expect(commandBar).toBeTruthy(); expect(restoreButton).toBeFalsy(); }); }); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts index 4d1f62389420..ae6d6ddd892e 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts @@ -21,7 +21,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormBuilder, FormGroup, - FormsModule, ReactiveFormsModule, ValidatorFn, Validators @@ -31,7 +30,7 @@ import { Router } from '@angular/router'; import { ButtonModule } from 'primeng/button'; import { MessageModule } from 'primeng/message'; import { TabsModule } from 'primeng/tabs'; -import { ToggleSwitchChangeEvent, ToggleSwitchModule } from 'primeng/toggleswitch'; +import { Tag, TagModule } from 'primeng/tag'; import { filter, take } from 'rxjs/operators'; @@ -41,14 +40,16 @@ import { DotWorkflowEventHandlerService } from '@dotcms/data-access'; import { + DotCMSBaseTypesContentTypes, DotCMSContentlet, DotCMSContentTypeField, DotCMSWorkflowAction, DotWorkflowPayload } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; -import { DotMessagePipe, DotWorkflowActionsComponent } from '@dotcms/ui'; +import { DotContentletStatusPipe, DotMessagePipe } from '@dotcms/ui'; +import { DotEditContentCommandBarActionsComponent } from './components/dot-edit-content-command-bar-actions/dot-edit-content-command-bar-actions.component'; import { resolutionValue } from './dot-edit-content-form-resolutions'; import { TabViewInsertDirective } from '../../directives/tab-view-insert/tab-view-insert.directive'; @@ -59,6 +60,7 @@ import { FormValues } from '../../models/dot-edit-content-form.interface'; import { DotWorkflowActionParams } from '../../models/dot-edit-content.model'; import { DotEditContentStore } from '../../store/edit-content.store'; import { + generatePageEditUrl, generatePreviewUrl, getFinalCastedValue, isFilteredType, @@ -80,7 +82,7 @@ import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit * - Custom field type handling (calendar fields, flattened fields) * - Workflow action integration with push publish support * - Form validation (required fields, regex patterns) - * - Content locking mechanism + * - Command bar with status, preview and overflow actions * - Preview functionality for content types * - Tab-based field organization * @@ -92,16 +94,17 @@ import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit @Component({ selector: 'dot-edit-content-form', templateUrl: './dot-edit-content-form.component.html', + styleUrl: './dot-edit-content-form.component.scss', imports: [ ReactiveFormsModule, DotEditContentFieldComponent, ButtonModule, TabsModule, - DotWorkflowActionsComponent, + TagModule, TabViewInsertDirective, DotMessagePipe, - ToggleSwitchModule, - FormsModule, + DotContentletStatusPipe, + DotEditContentCommandBarActionsComponent, MessageModule, NgTemplateOutlet ], @@ -155,12 +158,38 @@ export class DotEditContentFormComponent implements OnInit { /** * Computed property that determines if the preview link should be shown. * + * Shown for existing content that is either an HTML page or has a URL map. + * * @memberof DotEditContentFormComponent */ $showPreviewLink = computed(() => { const contentlet = this.$store.contentlet(); - return contentlet?.baseType === 'CONTENT' && !!contentlet.URL_MAP_FOR_CONTENT; + return ( + !this.$store.isNew() && + (contentlet?.baseType === DotCMSBaseTypesContentTypes.HTMLPAGE || + !!contentlet?.URL_MAP_FOR_CONTENT) + ); + }); + + /** + * Computed property that returns true when the current content type is an HTML Page. + * + * @memberof DotEditContentFormComponent + */ + $isPage = computed( + () => this.$store.contentType()?.baseType === DotCMSBaseTypesContentTypes.HTMLPAGE + ); + + /** + * Computed property that returns true when the contentlet has at least one page reference. + * + * @memberof DotEditContentFormComponent + */ + $hasReferences = computed(() => { + const relatedContent = this.$store.information.relatedContent(); + + return !!relatedContent && relatedContent !== '0'; }); /** @@ -215,19 +244,15 @@ export class DotEditContentFormComponent implements OnInit { return { $store: this.$store, showSidebar: this.$store.isSidebarOpen(), - canLock: this.$store.canLock(), - isContentLocked: this.$store.isContentLocked(), - lockSwitchLabel: this.$store.lockSwitchLabel(), - actions: this.$store.getActions(), $showPreviewLink: this.$showPreviewLink, - showWorkflowActions: this.$store.showWorkflowActions(), + $isPage: this.$isPage, + $hasReferences: this.$hasReferences, contentlet: this.$store.contentlet(), contentType: this.$store.contentType(), currentLocaleId: currentLocale ? currentLocale.id.toString() : '', currentIdentifier: this.$store.currentIdentifier(), - onContentLockChange: (e: ToggleSwitchChangeEvent) => this.onContentLockChange(e), - showPreview: () => this.showPreview(), - fireWorkflowAction: (e: DotWorkflowActionParams) => this.fireWorkflowAction(e) + statusSeverity: (status: string) => this.statusSeverity(status), + showPreview: () => this.showPreview() }; } @@ -717,14 +742,18 @@ export class DotEditContentFormComponent implements OnInit { /** * Opens the content preview in a new browser tab. * - * Generates a preview URL based on the current contentlet's URL_MAP_FOR_CONTENT - * and opens it in a new tab. Logs a warning if the URL cannot be generated. + * For HTML pages the edit-page URL is generated from the contentlet's `url`, + * otherwise the preview URL is generated from `URL_MAP_FOR_CONTENT`. + * Opens the resulting URL in a new tab. Logs a warning if the URL cannot be generated. * * @memberof DotEditContentFormComponent */ showPreview(): void { const contentlet = this.$store.contentlet(); - const realUrl = generatePreviewUrl(contentlet); + const realUrl = + contentlet?.baseType === DotCMSBaseTypesContentTypes.HTMLPAGE + ? generatePageEditUrl(contentlet) + : generatePreviewUrl(contentlet); if (!realUrl) { console.warn( @@ -737,6 +766,26 @@ export class DotEditContentFormComponent implements OnInit { window.open(realUrl, '_blank'); } + /** + * Maps a contentlet status string to a PrimeNG Tag severity. + * + * @param {string} status - The contentlet status label + * @returns {Tag['severity']} The matching PrimeNG Tag severity + * @memberof DotEditContentFormComponent + */ + statusSeverity(status: string): Tag['severity'] { + switch (status) { + case 'Published': + return 'success'; + case 'Archived': + return 'danger'; + case 'Revision': + return 'info'; + default: + return 'warn'; + } + } + /** * Updates the active tab index in the store. * @@ -754,19 +803,6 @@ export class DotEditContentFormComponent implements OnInit { this.$store.setActiveTab(numberValue); } - /** - * Handles the content lock toggle. - * - * This method is triggered when the user toggles the content lock switch. - * It updates the content lock state in the store based on the switch value. - * - * @param {ToggleSwitchChangeEvent} event - The switch change event containing the new checked state - * @memberof DotEditContentFormComponent - */ - onContentLockChange(event: ToggleSwitchChangeEvent) { - event.checked ? this.$store.lockContent() : this.$store.unlockContent(); - } - /** * Handles changes to the disabledWYSIWYG attribute from field components. * diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.html index 8ab1cc437cae..6c6ab4112dd3 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.html @@ -23,9 +23,9 @@ data-testId="edit-content-layout__beta-message">
- +
{{ 'edit.content.layout.back.to.old.edit.content' | dm }}
- +
@@ -76,8 +76,8 @@ data-testId="edit-content-layout__select-workflow-warning">
- -
+ +
- +
{{ 'edit.content.layout.invalid.message' | dm }}
@@ -129,6 +129,7 @@ [(showDialog)]="$showDialog" [attr.inert]="!showSidebar ? '' : null" [attr.aria-hidden]="!showSidebar" + (workflowActionFired)="onWorkflowActionFired($event)" data-testId="edit-content-layout__sidebar" class="edit-content-layout__sidebar min-w-0 overflow-x-hidden [grid-area:sidebar]" /> } diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.spec.ts index e6e5fcd5f749..4fca9dcf85c6 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.spec.ts @@ -33,7 +33,7 @@ import { DotWorkflowsActionsService, DotWorkflowService } from '@dotcms/data-access'; -import { DotLanguage } from '@dotcms/dotcms-models'; +import { DotCMSWorkflowAction, DotLanguage } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; import { DotMessagePipe } from '@dotcms/ui'; import { @@ -346,6 +346,41 @@ describe('EditContentLayoutComponent', () => { }); }); + describe('onWorkflowActionFired()', () => { + it('should delegate to the form with params built from the store', () => { + const fireWorkflowActionSpy = jest.fn(); + jest.spyOn(spectator.component, '$editContentForm').mockReturnValue({ + fireWorkflowAction: fireWorkflowActionSpy + } as unknown as DotEditContentFormComponent); + + jest.spyOn(store, 'currentLocale').mockReturnValue(MOCK_LANGUAGES[0]); + jest.spyOn(store, 'contentlet').mockReturnValue(MOCK_CONTENTLET_1_TAB); + jest.spyOn(store, 'contentType').mockReturnValue(CONTENT_TYPE_MOCK); + jest.spyOn(store, 'currentIdentifier').mockReturnValue( + MOCK_CONTENTLET_1_TAB.identifier + ); + + const workflow = { id: 'action-id' } as DotCMSWorkflowAction; + spectator.component.onWorkflowActionFired(workflow); + + expect(fireWorkflowActionSpy).toHaveBeenCalledWith({ + workflow, + inode: MOCK_CONTENTLET_1_TAB.inode, + contentType: CONTENT_TYPE_MOCK.variable, + languageId: MOCK_LANGUAGES[0].id.toString(), + identifier: MOCK_CONTENTLET_1_TAB.identifier + }); + }); + + it('should not throw when the form ref is undefined (compare view)', () => { + jest.spyOn(spectator.component, '$editContentForm').mockReturnValue(undefined); + + const workflow = { id: 'action-id' } as DotCMSWorkflowAction; + + expect(() => spectator.component.onWorkflowActionFired(workflow)).not.toThrow(); + }); + }); + describe('closeMessage()', () => { it('should call store.toggleBetaMessage when closing beta message', () => { const toggleBetaMessageSpy = jest.spyOn(store, 'toggleBetaMessage'); 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 832bbc314935..6d35295fe6c1 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 @@ -31,7 +31,7 @@ import { DotWorkflowsActionsService, DotWorkflowService } from '@dotcms/data-access'; -import { DotCMSContentlet } from '@dotcms/dotcms-models'; +import { DotCMSContentlet, DotCMSWorkflowAction } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { FormValues } from '../../models/dot-edit-content-form.interface'; @@ -356,6 +356,26 @@ export class DotEditContentLayoutComponent { this.$showDialog.set(true); } + /** + * Handles a workflow action fired from the sidebar by delegating to the form. + * + * Builds the workflow action params from the current store state and forwards + * them to the embedded form via the `$editContentForm` viewChild. The optional + * chaining guards the compare view, where the form is not rendered. + * + * @param workflow - The workflow action to execute + */ + onWorkflowActionFired(workflow: DotCMSWorkflowAction): void { + const currentLocale = this.$store.currentLocale(); + this.$editContentForm()?.fireWorkflowAction({ + workflow, + inode: this.$store.contentlet()?.inode, + contentType: this.$store.contentType().variable, + languageId: currentLocale ? currentLocale.id.toString() : '', + identifier: this.$store.currentIdentifier() + }); + } + /** * Handles form value changes and updates the store. * diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.html index e21708adaaf5..6336f25a5af6 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.html @@ -1,13 +1,10 @@ @let data = $data(); @let contentlet = data.contentlet; @let contentType = data.contentType; -@let referencesPageCount = data.referencesPageCount; -@let loading = data.loading;
- -@if ($hasReferences()) { -
- - {{ 'References' | dm }} - - @if (loading) { - - } @else { - - {{ - 'edit.content.sidebar.information.references-with.pages.tooltip' - | dm: [referencesPageCount] - }} - - } -
-} @else { -
- - {{ 'References' | dm }} - - @if (loading) { - - } @else { - - {{ 'edit.content.sidebar.information.references-with.pages.not.used' | dm }} - - } -
-} diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.spec.ts index a628cf289a8a..2b7dc768354c 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.spec.ts @@ -1,14 +1,12 @@ import { byTestId, createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest'; -import { Subject } from 'rxjs'; import { RouterTestingModule } from '@angular/router/testing'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { SkeletonModule } from 'primeng/skeleton'; import { TooltipModule } from 'primeng/tooltip'; import { DotFormatDateService, DotMessageService } from '@dotcms/data-access'; -import { DotContentletStatusChipComponent, DotMessagePipe, DotRelativeDatePipe } from '@dotcms/ui'; +import { DotMessagePipe, DotRelativeDatePipe } from '@dotcms/ui'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotEditContentSidebarInformationComponent } from './dot-edit-content-sidebar-information.component'; @@ -16,8 +14,6 @@ import { DotEditContentSidebarInformationComponent } from './dot-edit-content-si import { DotNameFormatPipe } from '../../../../pipes/name-format.pipe'; const messageServiceMock = new MockDotMessageService({ - 'edit.content.sidebar.information.references-with.pages.not.used': 'No References', - 'edit.content.sidebar.references.dialog.title': 'References for {0}', New: 'New', Published: 'Published' }); @@ -52,7 +48,6 @@ describe('DotEditContentSidebarInformationComponent', () => { SkeletonModule, TooltipModule, DotNameFormatPipe, - DotContentletStatusChipComponent, DotRelativeDatePipe, DotMessagePipe ], @@ -62,14 +57,6 @@ describe('DotEditContentSidebarInformationComponent', () => { useValue: messageServiceMock }, mockProvider(DotFormatDateService) - ], - componentProviders: [ - mockProvider(DialogService, { - open: jest.fn().mockReturnValue({ - onClose: new Subject(), - close: jest.fn() - } as unknown as DynamicDialogRef) - }) ] }); @@ -89,8 +76,8 @@ describe('DotEditContentSidebarInformationComponent', () => { spectator.detectChanges(); }); - it('should show contentlet status chip', () => { - expect(spectator.query('dot-contentlet-status-chip')).toBeTruthy(); + it('should NOT show contentlet status chip', () => { + expect(spectator.query('dot-contentlet-status-chip')).toBeFalsy(); }); it('should show json link', () => { @@ -119,9 +106,8 @@ describe('DotEditContentSidebarInformationComponent', () => { expect(publishedDate).toBeTruthy(); }); - it('should show references count', () => { - const referencesCount = spectator.query(byTestId('references-count')); - expect(referencesCount).toBeTruthy(); + it('should NOT show a references card', () => { + expect(spectator.query(byTestId('references-card'))).toBeFalsy(); }); }); @@ -136,10 +122,6 @@ describe('DotEditContentSidebarInformationComponent', () => { spectator.detectChanges(); }); - it('should show status chip when contentlet is null', () => { - expect(spectator.query('dot-contentlet-status-chip')).toBeTruthy(); - }); - it('should not show json link', () => { const jsonLink = spectator.query(byTestId('json-link')); expect(jsonLink).toBeFalsy(); @@ -150,127 +132,5 @@ describe('DotEditContentSidebarInformationComponent', () => { expect(contentTypeLink).toBeTruthy(); expect(contentTypeLink.textContent).toContain('Blog'); }); - - it('should show no references message', () => { - const referencesCount = spectator.query(byTestId('references-count')); - expect(referencesCount).toBeTruthy(); - }); - }); - - describe('loading state', () => { - beforeEach(() => { - spectator.setInput('data', { - contentlet: null, - contentType: null, - referencesPageCount: 0, - loading: true - }); - spectator.detectChanges(); - }); - - it('should show skeleton loader', () => { - const skeleton = spectator.query(byTestId('loading-skeleton')); - expect(skeleton).toBeTruthy(); - }); - }); - - describe('$hasReferences', () => { - it('should show the clickable references card when referencesPageCount is a non-zero string', () => { - spectator.setInput('data', { - contentlet: mockContentlet, - contentType: mockContentType, - referencesPageCount: '3', - loading: false - }); - spectator.detectChanges(); - - expect(spectator.query(byTestId('references-card'))).toBeTruthy(); - }); - - it('should hide the clickable references card when referencesPageCount is "0"', () => { - spectator.setInput('data', { - contentlet: mockContentlet, - contentType: mockContentType, - referencesPageCount: '0', - loading: false - }); - spectator.detectChanges(); - - expect(spectator.query(byTestId('references-card'))).toBeFalsy(); - }); - - it('should hide the clickable references card when referencesPageCount is an empty string', () => { - spectator.setInput('data', { - contentlet: mockContentlet, - contentType: mockContentType, - referencesPageCount: '', - loading: false - }); - spectator.detectChanges(); - - expect(spectator.query(byTestId('references-card'))).toBeFalsy(); - }); - }); - - describe('references card', () => { - describe('when contentlet has references', () => { - beforeEach(() => { - spectator.setInput('data', { - contentlet: { ...mockContentlet, identifier: 'abc-123', title: 'My Content' }, - contentType: mockContentType, - referencesPageCount: '5', - loading: false - }); - spectator.detectChanges(); - }); - - it('should render the clickable references card', () => { - expect(spectator.query(byTestId('references-card'))).toBeTruthy(); - }); - - it('should open the references dialog on click', () => { - const dialogService = spectator.inject(DialogService, true); - - spectator.click(byTestId('references-card')); - - expect(dialogService.open).toHaveBeenCalled(); - }); - - it('should open the dialog with closable and closeOnEscape enabled', () => { - const dialogService = spectator.inject(DialogService, true); - - spectator.click(byTestId('references-card')); - - expect(dialogService.open).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ closable: true, closeOnEscape: true }) - ); - }); - - it('should not open a second dialog if one is already open', () => { - const dialogService = spectator.inject(DialogService, true); - - spectator.click(byTestId('references-card')); - spectator.click(byTestId('references-card')); - - expect(dialogService.open).toHaveBeenCalledTimes(1); - }); - }); - - describe('when contentlet has no references', () => { - beforeEach(() => { - spectator.setInput('data', { - contentlet: mockContentlet, - contentType: mockContentType, - referencesPageCount: '0', - loading: false - }); - spectator.detectChanges(); - }); - - it('should not render the clickable references card', () => { - expect(spectator.query(byTestId('references-card'))).toBeFalsy(); - }); - }); }); }); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.ts index 3f3cbab48807..158b44770613 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component.ts @@ -1,26 +1,13 @@ import { DatePipe } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - computed, - inject, - input -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { RouterLink } from '@angular/router'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { SkeletonModule } from 'primeng/skeleton'; import { TooltipModule } from 'primeng/tooltip'; import { DotMessageService } from '@dotcms/data-access'; import { DotCMSContentlet, DotCMSContentType } from '@dotcms/dotcms-models'; -import { DotContentletStatusChipComponent, DotMessagePipe, DotRelativeDatePipe } from '@dotcms/ui'; +import { DotMessagePipe, DotRelativeDatePipe } from '@dotcms/ui'; -import { DotEditContentSidebarReferencesDialogComponent } from './dot-edit-content-sidebar-references-dialog/dot-edit-content-sidebar-references-dialog.component'; - -import { DotReferencesDialogData } from '../../../../models/dot-edit-content.model'; import { DotNameFormatPipe } from '../../../../pipes/name-format.pipe'; interface ContentSidebarInformation { @@ -38,8 +25,6 @@ interface ContentSidebarInformation { imports: [ RouterLink, TooltipModule, - SkeletonModule, - DotContentletStatusChipComponent, DotRelativeDatePipe, DotMessagePipe, DotNameFormatPipe, @@ -47,17 +32,12 @@ interface ContentSidebarInformation { ], templateUrl: './dot-edit-content-sidebar-information.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DialogService], host: { class: 'flex flex-col gap-2' } }) export class DotEditContentSidebarInformationComponent { readonly #dotMessageService = inject(DotMessageService); - readonly #dialogService = inject(DialogService); - readonly #destroyRef = inject(DestroyRef); - - #referencesDialogRef: DynamicDialogRef | undefined; /** The sidebar data including the contentlet, content type, loading state, and references count. */ readonly $data = input.required({ alias: 'data' }); @@ -75,51 +55,4 @@ export class DotEditContentSidebarInformationComponent { ? this.#dotMessageService.get('edit.content.sidebar.information.no.created.yet') : null; }); - - /** Whether the contentlet has at least one page reference. Controls the clickable card variant. */ - readonly $hasReferences = computed(() => { - const count = this.$data().referencesPageCount; - return !!count && count !== '0'; - }); - - constructor() { - this.#destroyRef.onDestroy(() => this.#referencesDialogRef?.close()); - } - - /** Opens the references dialog showing all pages that include this contentlet. */ - openReferencesDialog(): void { - if (this.#referencesDialogRef) return; - - const identifier = this.$data().contentlet?.identifier; - if (!identifier) return; - - this.#referencesDialogRef = this.#dialogService.open( - DotEditContentSidebarReferencesDialogComponent, - { - header: this.#dotMessageService.get( - 'edit.content.sidebar.references.dialog.title', - this.$data().contentlet.title - ), - width: 'min(92vw, 60rem)', - contentStyle: { padding: '0', overflow: 'auto' }, - data: { identifier } satisfies DotReferencesDialogData, - modal: true, - appendTo: 'body', - closeOnEscape: true, - closable: true, - draggable: false, - resizable: false, - position: 'center' - } - ); - - this.#referencesDialogRef.onClose.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe({ - next: () => { - this.#referencesDialogRef = undefined; - }, - error: () => { - this.#referencesDialogRef = undefined; - } - }); - } } diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.html deleted file mode 100644 index a897c855a23d..000000000000 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.html +++ /dev/null @@ -1,22 +0,0 @@ -
-
- -

- {{ 'edit.content.sidebar.permissions.setup' | dm }} -

-
-
-
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.spec.ts deleted file mode 100644 index dd0f947c9bf6..000000000000 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { - byTestId, - createComponentFactory, - mockProvider, - Spectator, - SpyObject -} from '@ngneat/spectator/jest'; -import { Subject } from 'rxjs'; - -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { DotMessageService } from '@dotcms/data-access'; -import { DotPermissionsIframeDialogComponent } from '@dotcms/ui'; - -import { - CONTENTLET_PERMISSIONS_IFRAME_PATH, - DotEditContentSidebarPermissionsComponent -} from './dot-edit-content-sidebar-permissions.component'; - -describe('DotEditContentSidebarPermissionsComponent', () => { - let spectator: Spectator; - let dotMessageService: SpyObject; - let dialogOpenSpy: jest.Mock; - let mockDialogRef: DynamicDialogRef; - - const createComponent = createComponentFactory({ - component: DotEditContentSidebarPermissionsComponent, - providers: [ - mockProvider(DotMessageService, { - get: jest.fn((key: string) => key) - }) - ] - }); - - beforeEach(() => { - mockDialogRef = { - onClose: new Subject(), - close: jest.fn() - } as unknown as DynamicDialogRef; - dialogOpenSpy = jest.fn().mockReturnValue(mockDialogRef); - - spectator = createComponent({ - props: { - identifier: 'content-123', - languageId: 123 - }, - providers: [ - { - provide: DialogService, - useValue: { open: dialogOpenSpy } - } - ] - }); - - dotMessageService = spectator.inject(DotMessageService); - }); - - it('should create', () => { - expect(spectator.component).toBeTruthy(); - }); - - describe('Elements by data-testId', () => { - it('should render permissions-card when identifier and languageId are set', () => { - spectator.setInput('identifier', 'content-456'); - spectator.setInput('languageId', 1); - spectator.detectChanges(); - - const card = spectator.query(byTestId('permissions-card')); - expect(card).toBeTruthy(); - }); - - it('should have permissions-card with role="button" and tabindex="0"', () => { - const card = spectator.query(byTestId('permissions-card')); - expect(card?.getAttribute('role')).toBe('button'); - expect(card?.getAttribute('tabindex')).toBe('0'); - }); - }); - - describe('openPermissionsDialog - Success', () => { - it('should open permissions dialog with DotPermissionsIframeDialogComponent', () => { - spectator.setInput('identifier', 'content-789'); - spectator.setInput('languageId', 2); - spectator.detectChanges(); - - spectator.click(byTestId('permissions-card')); - - expect(dialogOpenSpy).toHaveBeenCalledWith( - DotPermissionsIframeDialogComponent, - expect.objectContaining({ - header: 'edit.content.sidebar.permissions.title', - width: 'min(92vw, 75rem)', - contentStyle: { overflow: 'hidden' }, - modal: true, - appendTo: 'body', - closeOnEscape: false, - closable: true - }) - ); - }); - - it('should build url with contentletId, languageId and popup', () => { - spectator.setInput('identifier', 'content-789'); - spectator.setInput('languageId', 2); - spectator.detectChanges(); - - spectator.click(byTestId('permissions-card')); - - const callData = dialogOpenSpy.mock.calls[0][1].data; - expect(callData.url).toContain(CONTENTLET_PERMISSIONS_IFRAME_PATH); - expect(callData.url).toContain('contentletId=content-789'); - expect(callData.url).toContain('languageId=2'); - expect(callData.url).toContain('popup=true'); - }); - - it('should call DotMessageService.get for header when opening dialog', () => { - spectator.component.openPermissionsDialog(); - - expect(dotMessageService.get).toHaveBeenCalledWith( - 'edit.content.sidebar.permissions.title' - ); - }); - }); - - describe('openPermissionsDialog - Failure and Edge Cases', () => { - it('should NOT open dialog when identifier is empty string', () => { - spectator.setInput('identifier', ''); - spectator.setInput('languageId', 1); - spectator.detectChanges(); - - spectator.component.openPermissionsDialog(); - - expect(dialogOpenSpy).not.toHaveBeenCalled(); - }); - - it('should NOT open dialog when languageId is 0', () => { - spectator.setInput('identifier', 'content-ok'); - spectator.setInput('languageId', 0); - spectator.detectChanges(); - - spectator.component.openPermissionsDialog(); - - expect(dialogOpenSpy).not.toHaveBeenCalled(); - }); - - it('should NOT open dialog when identifier is undefined', () => { - spectator.setInput('identifier', undefined as never); - spectator.setInput('languageId', 1); - spectator.detectChanges(); - - spectator.component.openPermissionsDialog(); - - expect(dialogOpenSpy).not.toHaveBeenCalled(); - }); - - it('should NOT open dialog when languageId is undefined', () => { - spectator.setInput('identifier', 'content-ok'); - spectator.setInput('languageId', undefined as never); - spectator.detectChanges(); - - spectator.component.openPermissionsDialog(); - - expect(dialogOpenSpy).not.toHaveBeenCalled(); - }); - }); - - describe('Keyboard and accessibility', () => { - it('should open dialog on Enter key on permissions-card', () => { - spectator.setInput('identifier', 'k1'); - spectator.setInput('languageId', 1); - spectator.detectChanges(); - - spectator.dispatchKeyboardEvent(byTestId('permissions-card'), 'keydown', 'Enter'); - - expect(dialogOpenSpy).toHaveBeenCalled(); - }); - - it('should open dialog on Space key on permissions-card', () => { - spectator.setInput('identifier', 'k2'); - spectator.setInput('languageId', 1); - spectator.detectChanges(); - - spectator.dispatchKeyboardEvent(byTestId('permissions-card'), 'keydown', ' '); - spectator.detectChanges(); - - expect(dialogOpenSpy).toHaveBeenCalled(); - }); - }); - - describe('ngOnDestroy', () => { - it('should close dialog ref on destroy when dialog was opened', () => { - spectator.component.openPermissionsDialog(); - - spectator.fixture.destroy(); - - expect(mockDialogRef.close).toHaveBeenCalled(); - }); - - it('should not throw when destroy and dialog was never opened', () => { - expect(() => spectator.fixture.destroy()).not.toThrow(); - }); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.ts deleted file mode 100644 index 86cbaf5b375b..000000000000 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, inject, input } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; - -import { CardModule } from 'primeng/card'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { DotMessageService } from '@dotcms/data-access'; -import { - DotMessagePipe, - DotPermissionsIframeDialogComponent, - DotPermissionsIframeDialogData -} from '@dotcms/ui'; - -export const CONTENTLET_PERMISSIONS_IFRAME_PATH = '/html/portlet/ext/contentlet/permissions.jsp'; - -/** - * Tab content component for the Permissions section in the edit content sidebar. - * Renders a clickable card that opens the permissions modal. - */ -@Component({ - selector: 'dot-edit-content-sidebar-permissions', - imports: [CardModule, DotMessagePipe], - templateUrl: './dot-edit-content-sidebar-permissions.component.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DotEditContentSidebarPermissionsComponent { - readonly #dialogService = inject(DialogService); - readonly #dotMessageService = inject(DotMessageService); - readonly #destroyRef = inject(DestroyRef); - - #permissionsDialogRef: DynamicDialogRef | undefined; - - /** - * Contentlet identifier for the permissions iframe. - */ - readonly identifier = input(''); - - /** - * Contentlet language id for the permissions iframe. - */ - readonly languageId = input(0); - - constructor() { - this.#destroyRef.onDestroy(() => this.#permissionsDialogRef?.close()); - } - - /** - * Opens the permissions dialog with an iframe for the current contentlet. - * Prevents opening multiple instances if the user clicks the card repeatedly. - */ - openPermissionsDialog(): void { - if (this.#permissionsDialogRef) return; - - const id = this.identifier(); - const langId = this.languageId(); - if (!id || !langId) return; - - this.#permissionsDialogRef = this.#dialogService.open(DotPermissionsIframeDialogComponent, { - header: this.#dotMessageService.get('edit.content.sidebar.permissions.title'), - width: 'min(92vw, 75rem)', - contentStyle: { overflow: 'hidden' }, - data: { - url: this.#buildPermissionsUrl(id, langId) - } satisfies DotPermissionsIframeDialogData, - transitionOptions: null, - modal: true, - appendTo: 'body', - closeOnEscape: false, - closable: true, - draggable: false, - resizable: false, - position: 'center' - }); - - this.#permissionsDialogRef.onClose - .pipe(takeUntilDestroyed(this.#destroyRef)) - .subscribe(() => { - this.#permissionsDialogRef = undefined; - }); - } - - #buildPermissionsUrl(identifier: string, languageId: number): string { - const params = new URLSearchParams({ - contentletId: identifier, - languageId: String(languageId), - popup: 'true' - }); - return `${CONTENTLET_PERMISSIONS_IFRAME_PATH}?${params.toString()}`; - } -} diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.html deleted file mode 100644 index be19e28116a0..000000000000 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.html +++ /dev/null @@ -1,22 +0,0 @@ -
-
- -

- {{ 'edit.content.sidebar.rules.setup' | dm }} -

-
-
-
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.spec.ts deleted file mode 100644 index 9800e8ace2a6..000000000000 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { - byTestId, - createComponentFactory, - mockProvider, - Spectator, - SpyObject -} from '@ngneat/spectator/jest'; - -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { DotMessageService } from '@dotcms/data-access'; - -import { DotRulesDialogComponent } from './components/rules-dialog/rules-dialog.component'; -import { DotEditContentSidebarRulesComponent } from './dot-edit-content-sidebar-rules.component'; - -describe('DotEditContentSidebarRulesComponent', () => { - let spectator: Spectator; - let dotMessageService: SpyObject; - let dialogOpenSpy: jest.Mock; - let mockDialogRef: DynamicDialogRef; - - const createComponent = createComponentFactory({ - component: DotEditContentSidebarRulesComponent, - providers: [ - mockProvider(DotMessageService, { - get: jest.fn((key: string) => key) - }) - ] - }); - - beforeEach(() => { - mockDialogRef = { - onClose: { subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })) }, - close: jest.fn() - } as unknown as DynamicDialogRef; - dialogOpenSpy = jest.fn().mockReturnValue(mockDialogRef); - - spectator = createComponent({ - props: { - identifier: 'content-123' - }, - providers: [ - { - provide: DialogService, - useValue: { open: dialogOpenSpy } - } - ] - }); - - dotMessageService = spectator.inject(DotMessageService); - }); - - it('should create', () => { - expect(spectator.component).toBeTruthy(); - }); - - describe('Elements by data-testId', () => { - it('should render rules-card when identifier is set', () => { - spectator.setInput('identifier', 'content-456'); - spectator.detectChanges(); - - const card = spectator.query(byTestId('rules-card')); - expect(card).toBeTruthy(); - }); - - it('should have rules-card with role="button" and tabindex="0"', () => { - const card = spectator.query(byTestId('rules-card')); - expect(card?.getAttribute('role')).toBe('button'); - expect(card?.getAttribute('tabindex')).toBe('0'); - }); - }); - - describe('openRulesDialog - Success', () => { - it('should open rules dialog when card is clicked with valid identifier', () => { - spectator.setInput('identifier', 'content-789'); - spectator.detectChanges(); - - spectator.click(byTestId('rules-card')); - - expect(dialogOpenSpy).toHaveBeenCalledWith(DotRulesDialogComponent, { - header: 'edit.content.sidebar.rules.title', - width: 'min(92vw, 75rem)', - data: { identifier: 'content-789' }, - modal: true, - appendTo: 'body', - closeOnEscape: false, - closable: true, - draggable: false, - keepInViewport: false, - resizable: false, - position: 'center' - }); - }); - - it('should call DotMessageService.get for header when opening dialog', () => { - spectator.component.openRulesDialog(); - - expect(dotMessageService.get).toHaveBeenCalledWith('edit.content.sidebar.rules.title'); - }); - }); - - describe('openRulesDialog - Failure and Edge Cases', () => { - it('should NOT open dialog when identifier is empty string', () => { - spectator.setInput('identifier', ''); - spectator.detectChanges(); - - spectator.component.openRulesDialog(); - - expect(dialogOpenSpy).not.toHaveBeenCalled(); - }); - - it('should NOT open dialog when identifier is undefined', () => { - spectator.setInput('identifier', undefined as never); - spectator.detectChanges(); - - spectator.component.openRulesDialog(); - - expect(dialogOpenSpy).not.toHaveBeenCalled(); - }); - - it('should NOT open a second dialog when one is already open', () => { - spectator.setInput('identifier', 'content-123'); - spectator.detectChanges(); - - spectator.component.openRulesDialog(); - spectator.component.openRulesDialog(); - - expect(dialogOpenSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('Keyboard and accessibility', () => { - it('should open dialog on Enter key on rules-card', () => { - spectator.setInput('identifier', 'k1'); - spectator.detectChanges(); - - spectator.dispatchKeyboardEvent(byTestId('rules-card'), 'keydown', 'Enter'); - - expect(dialogOpenSpy).toHaveBeenCalled(); - }); - - it('should open dialog on Space key on rules-card', () => { - spectator.setInput('identifier', 'k2'); - spectator.detectChanges(); - - spectator.dispatchKeyboardEvent(byTestId('rules-card'), 'keydown', ' '); - spectator.detectChanges(); - - expect(dialogOpenSpy).toHaveBeenCalled(); - }); - }); - - describe('ngOnDestroy', () => { - it('should close dialog ref on destroy when dialog was opened', () => { - spectator.component.openRulesDialog(); - - spectator.fixture.destroy(); - - expect(mockDialogRef.close).toHaveBeenCalled(); - }); - - it('should not throw when destroy and dialog was never opened', () => { - expect(() => spectator.fixture.destroy()).not.toThrow(); - }); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.ts deleted file mode 100644 index 9fe2031598a2..000000000000 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Subscription } from 'rxjs'; - -import { ChangeDetectionStrategy, Component, inject, input, OnDestroy } from '@angular/core'; - -import { CardModule } from 'primeng/card'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; - -import { DotMessageService } from '@dotcms/data-access'; -import { DotMessagePipe } from '@dotcms/ui'; - -import { DotRulesDialogComponent } from './components/rules-dialog/rules-dialog.component'; - -/** - * Tab content component for the Rules section in the edit content sidebar. - * Renders a clickable card that opens the rules modal. - */ -@Component({ - selector: 'dot-edit-content-sidebar-rules', - imports: [CardModule, DotMessagePipe], - templateUrl: './dot-edit-content-sidebar-rules.component.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DotEditContentSidebarRulesComponent implements OnDestroy { - readonly #dialogService = inject(DialogService); - readonly #dotMessageService = inject(DotMessageService); - - #rulesDialogRef: DynamicDialogRef | undefined; - #closeSubscription: Subscription | undefined; - - /** - * Contentlet identifier for the rules engine. - */ - readonly identifier = input(''); - - ngOnDestroy(): void { - this.#closeSubscription?.unsubscribe(); - this.#rulesDialogRef?.close(); - } - - /** - * Opens the rules dialog for the current contentlet. - * Prevents opening multiple instances if the user clicks the card repeatedly. - */ - openRulesDialog(): void { - if (this.#rulesDialogRef) return; - - const id = this.identifier(); - if (!id) return; - - const header = this.#dotMessageService.get('edit.content.sidebar.rules.title'); - this.#rulesDialogRef = this.#dialogService.open(DotRulesDialogComponent, { - header, - width: 'min(92vw, 75rem)', - data: { identifier: id }, - modal: true, - appendTo: 'body', - closeOnEscape: false, - closable: true, - draggable: false, - keepInViewport: false, - resizable: false, - position: 'center' - }); - this.#closeSubscription = this.#rulesDialogRef.onClose.subscribe({ - next: () => { - this.#rulesDialogRef = undefined; - } - }); - } -} diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.html index aec650298f79..9edbb67e0709 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.html @@ -3,19 +3,39 @@ data-testid="dot-section"> @if ($title()) {
-

- {{ $title() }} -

+ class="sticky top-0 z-10 flex min-h-12 shrink-0 cursor-pointer items-center justify-between py-1" + role="button" + tabindex="0" + [attr.aria-expanded]="!$collapsed()" + data-testid="dot-section-toggle" + (click)="toggle()" + (keydown.enter)="toggle()" + (keydown.space)="$event.preventDefault(); toggle()"> +
+ +

+ {{ $title() }} +

+
@if (actionTemplate) { -
+
}
} -
- -
+ @if (!$collapsed()) { +
+ +
+ } diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.spec.ts index d03ef963b444..83f5e8ffad07 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.spec.ts @@ -1,16 +1,21 @@ -import { byTestId, createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { byTestId, createHostFactory, mockProvider, SpectatorHost } from '@ngneat/spectator/jest'; import { fakeAsync, tick } from '@angular/core/testing'; +import { DotLocalstorageService } from '@dotcms/data-access'; + import { DotEditContentSidebarSectionComponent } from './dot-edit-content-sidebar-section.component'; +const STORAGE_KEY = 'dot-edit-content.section.workflow'; + describe('DotEditContentSidebarSectionComponent', () => { let spectator: SpectatorHost; const createHost = createHostFactory({ component: DotEditContentSidebarSectionComponent, + providers: [mockProvider(DotLocalstorageService)], template: ` - +
Action Content
@@ -22,7 +27,8 @@ describe('DotEditContentSidebarSectionComponent', () => { beforeEach(() => { spectator = createHost(null, { hostProps: { - title: 'Test Section' + title: 'Test Section', + key: '' } }); }); @@ -38,7 +44,7 @@ describe('DotEditContentSidebarSectionComponent', () => { it('should render main structure and title', () => { const section = spectator.query(byTestId('dot-section')); - const header = spectator.query(byTestId('dot-section-header')); + const header = spectator.query(byTestId('dot-section-toggle')); const title = spectator.query(byTestId('dot-section-title')); expect(section).toBeTruthy(); @@ -47,6 +53,10 @@ describe('DotEditContentSidebarSectionComponent', () => { expect(title).toHaveText('Test Section'); }); + it('should render the chevron icon', () => { + expect(spectator.query(byTestId('dot-section-chevron'))).toBeTruthy(); + }); + it('should render action section', fakeAsync(() => { tick(); @@ -63,12 +73,12 @@ describe('DotEditContentSidebarSectionComponent', () => { beforeEach(() => { // Create fresh host with title: null to avoid ExpressionChangedAfterItHasBeenCheckedError spectator = createHost(null, { - hostProps: { title: null } + hostProps: { title: null, key: '' } }); }); it('should not render header section', () => { - const header = spectator.query(byTestId('dot-section-header')); + const header = spectator.query(byTestId('dot-section-toggle')); expect(header).toBeFalsy(); }); @@ -90,4 +100,102 @@ describe('DotEditContentSidebarSectionComponent', () => { expect(projectedContent).toBeTruthy(); expect(projectedContent).toHaveText('Projected Content'); }); + + describe('Without key (backward-compatible)', () => { + let localStorageService: jest.Mocked; + + beforeEach(() => { + localStorageService = spectator.inject(DotLocalstorageService); + }); + + it('should not read from storage on init', () => { + expect(localStorageService.getItem).not.toHaveBeenCalled(); + }); + + it('should collapse in-memory but not persist on toggle', () => { + // Starts expanded + expect(spectator.query(byTestId('dot-section-content'))).toBeTruthy(); + + // Collapses in-memory... + spectator.click(byTestId('dot-section-toggle')); + expect(spectator.query(byTestId('dot-section-content'))).toBeFalsy(); + + // ...but never writes to storage + expect(localStorageService.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('With key (collapsible + persistent)', () => { + let localStorageService: jest.Mocked; + + beforeEach(() => { + spectator = createHost(null, { + hostProps: { title: 'Workflow', key: 'workflow' }, + detectChanges: false + }); + localStorageService = spectator.inject(DotLocalstorageService); + }); + + it('should toggle content visibility on header click', () => { + localStorageService.getItem.mockReturnValue(false); + spectator.detectChanges(); + + // Starts expanded + expect(spectator.query(byTestId('dot-section-content'))).toBeTruthy(); + + // Collapse + spectator.click(byTestId('dot-section-toggle')); + expect(spectator.query(byTestId('dot-section-content'))).toBeFalsy(); + + // Expand again + spectator.click(byTestId('dot-section-toggle')); + expect(spectator.query(byTestId('dot-section-content'))).toBeTruthy(); + }); + + it('should toggle on Enter key', () => { + localStorageService.getItem.mockReturnValue(false); + spectator.detectChanges(); + + const header = spectator.query(byTestId('dot-section-toggle')) as HTMLElement; + header.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + spectator.detectChanges(); + + expect(spectator.query(byTestId('dot-section-content'))).toBeFalsy(); + }); + + it('should persist the collapsed state on toggle', () => { + localStorageService.getItem.mockReturnValue(false); + spectator.detectChanges(); + + spectator.click(byTestId('dot-section-toggle')); + + expect(localStorageService.setItem).toHaveBeenCalledWith(STORAGE_KEY, true); + }); + + it('should seed the initial collapsed state from storage', () => { + localStorageService.getItem.mockReturnValue(true); + spectator.detectChanges(); + + expect(localStorageService.getItem).toHaveBeenCalledWith(STORAGE_KEY); + expect(spectator.query(byTestId('dot-section-content'))).toBeFalsy(); + }); + + it('should rotate the chevron when collapsed', () => { + localStorageService.getItem.mockReturnValue(true); + spectator.detectChanges(); + + const chevron = spectator.query(byTestId('dot-section-chevron')); + expect(chevron).toHaveClass('rotate-180'); + }); + + it('should not collapse when clicking the action slot', () => { + localStorageService.getItem.mockReturnValue(false); + spectator.detectChanges(); + + spectator.click(byTestId('dot-section-action')); + + expect(spectator.query(byTestId('dot-section-content'))).toBeTruthy(); + expect(localStorageService.setItem).not.toHaveBeenCalled(); + }); + }); }); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.ts index cbbc074f46e6..73d7689663dd 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component.ts @@ -4,11 +4,25 @@ import { Component, ContentChild, TemplateRef, - input + inject, + input, + linkedSignal } from '@angular/core'; +import { DotLocalstorageService } from '@dotcms/data-access'; + +/** + * Prefix used to persist the collapsed state of each section in localstorage. + */ +const SECTION_STORAGE_PREFIX = 'dot-edit-content.section.'; + /** * Component that renders a section with a title and an optional action template. + * + * When a `key` is provided the section can be collapsed/expanded by clicking its + * header, and the collapsed state is persisted in localstorage under + * `dot-edit-content.section.`. When no `key` is provided the section stays + * expanded and no storage writes happen (backward-compatible behaviour). */ @Component({ selector: 'dot-edit-content-sidebar-section', @@ -20,14 +34,51 @@ import { } }) export class DotEditContentSidebarSectionComponent { + readonly #dotLocalstorageService = inject(DotLocalstorageService); + /** * The title of the section. */ $title = input(null, { alias: 'title' }); + /** + * Unique key used to persist the collapsed state. When empty the section is + * not collapsible-persistent and stays expanded with no storage writes. + */ + key = input(''); + + /** + * Writable signal holding the collapsed state of the section. + * + * Initialised reactively once the `key` input is bound: when a key is present + * it seeds from localstorage (default expanded when absent), otherwise it + * stays expanded in-memory. + */ + $collapsed = linkedSignal(() => { + const key = this.key(); + + return key + ? !!this.#dotLocalstorageService.getItem(SECTION_STORAGE_PREFIX + key) + : false; + }); + /** * The action template for the section. */ @ContentChild('sectionAction') actionTemplate: TemplateRef; + + /** + * Toggles the collapsed state of the section. When a `key` is present the new + * state is persisted to localstorage. + */ + toggle(): void { + const collapsed = !this.$collapsed(); + this.$collapsed.set(collapsed); + + const key = this.key(); + if (key) { + this.#dotLocalstorageService.setItem(SECTION_STORAGE_PREFIX + key, collapsed); + } + } } diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html index ecc6a6f22786..4ac55dac61ae 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html @@ -18,9 +18,9 @@ class="min-h-[53px] border-b border-(--p-inputtext-border-color) [&_.p-tab]:flex [&_.p-tab]:h-full [&_.p-tab]:flex-1 [&_.p-tab]:items-center [&_.p-tab]:justify-center [&_.p-tab]:p-0! [&_.p-tab_i]:-translate-y-px [&_.p-tablist-tab-list]:h-full"> - + - @if (!isNew) { - - - - } - +
- - - - @if (!$store.isNew() && !$store.isCopyingLocale()) { - - } - + + @if ( + !$store.isNew() && !$store.isViewingHistoricalVersion() && $store.canLock() + ) { +
+ +
+ } - -
+ + @if ($store.showWorkflowActions()) { + + } - + + key="actions.workflow" + [title]="'edit.content.sidebar.workflow.title' | dm"> + + + + + @if (!$store.isNew() && !$store.isCopyingLocale()) { + + } + + + +
@@ -124,24 +158,6 @@ (commentSubmitted)="onCommentSubmitted($event)" data-testId="activities" /> - @if (!isNew) { - - -
- - - - @if ($isPage()) { - - } -
-
- }
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts index bb84d389db2e..e587257bd561 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.spec.ts @@ -31,15 +31,23 @@ import { DotWorkflowsActionsService, DotWorkflowService } from '@dotcms/data-access'; -import { DotContentletCanLock, DotContentletDepths } from '@dotcms/dotcms-models'; -import { createFakeContentlet, MOCK_SINGLE_WORKFLOW_ACTIONS } from '@dotcms/utils-testing'; +import { + DotCMSWorkflowAction, + DotContentletCanLock, + DotContentletDepths +} from '@dotcms/dotcms-models'; +import { DotWorkflowActionsComponent } from '@dotcms/ui'; +import { + createFakeContentlet, + MOCK_SINGLE_WORKFLOW_ACTIONS, + mockWorkflowsActions +} from '@dotcms/utils-testing'; import { DotEditContentSidebarActivitiesComponent } from './components/dot-edit-content-sidebar-activities/dot-edit-content-sidebar-activities.component'; import { DotEditContentSidebarHistoryComponent } from './components/dot-edit-content-sidebar-history/dot-edit-content-sidebar-history.component'; import { DotEditContentSidebarInformationComponent } from './components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component'; import { DotEditContentSidebarLocalesComponent } from './components/dot-edit-content-sidebar-locales/dot-edit-content-sidebar-locales.component'; -import { DotEditContentSidebarPermissionsComponent } from './components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component'; -import { DotEditContentSidebarRulesComponent } from './components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component'; +import { DotEditContentSidebarSectionComponent } from './components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component'; import { DotEditContentSidebarWorkflowComponent } from './components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component'; import { DotEditContentSidebarComponent } from './dot-edit-content-sidebar.component'; @@ -50,11 +58,6 @@ import { MOCK_WORKFLOW_STATUS } from '../../utils/edit-content.mock'; import * as utils from '../../utils/functions.util'; import { CONTENT_TYPE_MOCK } from '../../utils/mocks'; -const HTMLPAGE_CONTENT_TYPE_MOCK = { - ...CONTENT_TYPE_MOCK, - baseType: 'HTMLPAGE' -}; - describe('DotEditContentSidebarComponent', () => { let spectator: Spectator; let dotEditContentService: SpyObject; @@ -71,9 +74,7 @@ describe('DotEditContentSidebarComponent', () => { imports: [ TabsModule, DotEditContentSidebarActivitiesComponent, - DotEditContentSidebarHistoryComponent, - DotEditContentSidebarPermissionsComponent, - DotEditContentSidebarRulesComponent + DotEditContentSidebarHistoryComponent ], // I need the real components to be rendered in the p-template="content" providers: [ DotEditContentStore, @@ -264,300 +265,124 @@ describe('DotEditContentSidebarComponent', () => { }); }); - describe('Permissions Tab Visibility', () => { - describe('when content is new (isNew = true)', () => { - it('should NOT render the permissions tab', () => { - expect(store.isNew()).toBe(true); - const permissionsElement = spectator.query(byTestId('permissions')); - expect(permissionsElement).toBeFalsy(); - }); - - it('should NOT include permissions tab in the tab list', () => { - const tabView = spectator.query(byTestId('sidebar-tabs')); - const tabs = tabView.querySelectorAll('[role="tab"]'); - expect(tabs.length).toBe(3); - }); - - it('should NOT render DotEditContentSidebarPermissionsComponent', () => { - const permissionsComponent = spectator.query( - DotEditContentSidebarPermissionsComponent - ); - expect(permissionsComponent).toBeFalsy(); - }); - }); - - describe('when content is in edit mode (isNew = false)', () => { - beforeEach(fakeAsync(() => { - const dotContentTypeService = spectator.inject(DotContentTypeService); - const workflowActionsService = spectator.inject(DotWorkflowsActionsService); - const dotWorkflowService = spectator.inject(DotWorkflowService); - const dotEditContentService = spectator.inject(DotEditContentService); - - const mockContentlet = createFakeContentlet({ - inode: '123', - contentType: 'testContentType', - identifier: '123-456', - title: 'Test Content' - }); + describe('Tabs', () => { + it('should render the first tab as the Actions tab with the bolt icon', () => { + const messageService = spectator.inject(DotMessageService); + const getSpy = jest.spyOn(messageService, 'get'); - dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); - dotContentTypeService.getContentTypeWithRender.mockReturnValue( - of(CONTENT_TYPE_MOCK) - ); - workflowActionsService.getByInode.mockReturnValue(of([])); - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); - dotContentletService.canLock.mockReturnValue( - of({ locked: false, canLock: true } as DotContentletCanLock) - ); + const tabView = spectator.query(byTestId('sidebar-tabs')); + const tabs = tabView.querySelectorAll('[role="tab"]'); - store.initializeExistingContent({ - inode: '123', - depth: DotContentletDepths.TWO - }); - tick(); - spectator.detectChanges(); - })); + // Only the three remaining tabs (actions, history, comments) + expect(tabs.length).toBe(3); - it('should render the permissions tab', fakeAsync(() => { - expect(store.isNew()).toBe(false); - store.setActiveSidebarTab(3); - tick(); - spectator.detectChanges(); - const permissionsElement = spectator.query(byTestId('permissions')); - expect(permissionsElement).toBeTruthy(); - })); + // The first tab swapped the old info-circle icon for the new bolt icon + expect(tabs[0].querySelector('i.pi.pi-bolt')).toBeTruthy(); + expect(tabs[0].querySelector('i.pi.pi-info-circle')).toBeFalsy(); - it('should include permissions tab in the tab list', () => { - const tabView = spectator.query(byTestId('sidebar-tabs')); - const tabs = tabView.querySelectorAll('[role="tab"]'); - expect(tabs.length).toBe(4); - }); + // The old "information" tooltip key is no longer requested + expect(getSpy).not.toHaveBeenCalledWith('edit.content.sidebar.tab.information'); + }); - it('should render DotEditContentSidebarPermissionsComponent when permissions tab is active', fakeAsync(() => { - store.setActiveSidebarTab(3); - tick(); - spectator.detectChanges(); - const permissionsComponent = spectator.query( - DotEditContentSidebarPermissionsComponent - ); - expect(permissionsComponent).toBeTruthy(); - })); + it('should NOT render a Settings tab', () => { + const tabView = spectator.query(byTestId('sidebar-tabs')); + const tabs = tabView.querySelectorAll('[role="tab"]'); + expect(tabs.length).toBe(3); + expect(tabs[0].querySelector('i.pi.pi-cog')).toBeFalsy(); + }); - it('should find permissions element by data-testId when permissions tab is active', fakeAsync(() => { - store.setActiveSidebarTab(3); - tick(); - spectator.detectChanges(); - const permissionsElement = spectator.query(byTestId('permissions')); - expect(permissionsElement).toBeTruthy(); - })); + it('should NOT render the permissions or rules components', () => { + expect(spectator.query(byTestId('permissions'))).toBeFalsy(); + expect(spectator.query(byTestId('rules'))).toBeFalsy(); }); + }); - describe('Edge Cases', () => { - it('should NOT render permissions when initialContentletState is new', () => { - expect(store.initialContentletState()).toBe('new'); - expect(spectator.query(byTestId('permissions'))).toBeFalsy(); + describe('Actions tab content', () => { + beforeEach(fakeAsync(() => { + const dotContentTypeService = spectator.inject(DotContentTypeService); + const workflowActionsService = spectator.inject(DotWorkflowsActionsService); + const dotWorkflowService = spectator.inject(DotWorkflowService); + const dotEditContentService = spectator.inject(DotEditContentService); + + const mockContentlet = createFakeContentlet({ + inode: '123', + contentType: 'testContentType', + identifier: '123-456', + title: 'Test Content' }); - it('should render permissions when initialContentletState is existing', fakeAsync(() => { - const dotContentTypeService = spectator.inject(DotContentTypeService); - const workflowActionsService = spectator.inject(DotWorkflowsActionsService); - const dotWorkflowService = spectator.inject(DotWorkflowService); - const dotEditContentService = spectator.inject(DotEditContentService); - - const mockContentlet = createFakeContentlet({ - inode: '456', - contentType: 'testContentType', - identifier: '456-789', - title: 'Existing Content' - }); + dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); + dotContentTypeService.getContentTypeWithRender.mockReturnValue(of(CONTENT_TYPE_MOCK)); + // Flat actions for this inode keyed under the single scheme so that + // showWorkflowActions/getActions resolve to a non-empty list. + workflowActionsService.getByInode.mockReturnValue(of(mockWorkflowsActions)); + workflowActionsService.getWorkFlowActions.mockReturnValue( + of(MOCK_SINGLE_WORKFLOW_ACTIONS) + ); + dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); + dotContentletService.canLock.mockReturnValue( + of({ locked: false, canLock: true } as DotContentletCanLock) + ); - dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); - dotContentTypeService.getContentTypeWithRender.mockReturnValue( - of(CONTENT_TYPE_MOCK) - ); - workflowActionsService.getByInode.mockReturnValue(of([])); - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); - dotContentletService.canLock.mockReturnValue( - of({ locked: false, canLock: true } as DotContentletCanLock) - ); + store.initializeExistingContent({ + inode: '123', + depth: DotContentletDepths.TWO + }); + tick(); + spectator.detectChanges(); + })); - store.initializeExistingContent({ - inode: '456', - depth: DotContentletDepths.TWO - }); - tick(); - spectator.detectChanges(); + describe('lock button', () => { + it('should render the lock button when the content can be locked', () => { + expect(store.canLock()).toBe(true); + expect(spectator.query(byTestId('sidebar-lock-button'))).toBeTruthy(); + }); - expect(store.initialContentletState()).not.toBe('new'); - store.setActiveSidebarTab(3); - tick(); + it('should call store.lockContent when clicking the button on unlocked content', () => { + const lockSpy = jest.spyOn(store, 'lockContent').mockImplementation(); + jest.spyOn(store, 'isContentLocked').mockReturnValue(false); spectator.detectChanges(); - expect(spectator.query(byTestId('permissions'))).toBeTruthy(); - })); - it('should render permissions when initialContentletState is reset (no workflow)', fakeAsync(() => { - const dotContentTypeService = spectator.inject(DotContentTypeService); - const workflowActionsService = spectator.inject(DotWorkflowsActionsService); - const dotWorkflowService = spectator.inject(DotWorkflowService); - const dotEditContentService = spectator.inject(DotEditContentService); - - const mockContentlet = createFakeContentlet({ - inode: '789', - contentType: 'testContentType', - identifier: '789-012', - title: 'Reset Content' - }); - - dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); - dotContentTypeService.getContentTypeWithRender.mockReturnValue( - of(CONTENT_TYPE_MOCK) - ); - workflowActionsService.getByInode.mockReturnValue(of([])); - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - dotWorkflowService.getWorkflowStatus.mockReturnValue( - of({ scheme: null, step: null, task: null, firstStep: null }) - ); - dotContentletService.canLock.mockReturnValue( - of({ locked: false, canLock: true } as DotContentletCanLock) - ); + spectator.click(byTestId('sidebar-lock-button')); - store.initializeExistingContent({ - inode: '789', - depth: DotContentletDepths.TWO - }); - tick(); - spectator.detectChanges(); + expect(lockSpy).toHaveBeenCalled(); + }); - expect(store.initialContentletState()).toBe('reset'); - expect(store.isNew()).toBe(false); - store.setActiveSidebarTab(3); - tick(); + it('should call store.unlockContent when clicking the button on locked content', () => { + const unlockSpy = jest.spyOn(store, 'unlockContent').mockImplementation(); + jest.spyOn(store, 'isContentLocked').mockReturnValue(true); spectator.detectChanges(); - expect(spectator.query(byTestId('permissions'))).toBeTruthy(); - })); - }); - }); - describe('Rules Tab Visibility', () => { - describe('when content is new (isNew = true)', () => { - it('should NOT render the rules tab', () => { - expect(store.isNew()).toBe(true); - const rulesElement = spectator.query(byTestId('rules')); - expect(rulesElement).toBeFalsy(); - }); + spectator.click(byTestId('sidebar-lock-button')); - it('should NOT render DotEditContentSidebarRulesComponent', () => { - const rulesComponent = spectator.query(DotEditContentSidebarRulesComponent); - expect(rulesComponent).toBeFalsy(); + expect(unlockSpy).toHaveBeenCalled(); }); }); - describe('when content is existing but NOT an HTMLPAGE', () => { - beforeEach(fakeAsync(() => { - const dotContentTypeService = spectator.inject(DotContentTypeService); - const workflowActionsService = spectator.inject(DotWorkflowsActionsService); - const dotWorkflowService = spectator.inject(DotWorkflowService); - const dotEditContentService = spectator.inject(DotEditContentService); - - const mockContentlet = createFakeContentlet({ - inode: '123', - contentType: 'testContentType', - identifier: '123-456', - title: 'Test Content' - }); - - dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); - dotContentTypeService.getContentTypeWithRender.mockReturnValue( - of(CONTENT_TYPE_MOCK) // baseType: 'CONTENT' - ); - workflowActionsService.getByInode.mockReturnValue(of([])); - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); - dotContentletService.canLock.mockReturnValue( - of({ locked: false, canLock: true } as DotContentletCanLock) - ); + describe('workflow actions', () => { + it('should render the dot-workflow-actions component when there are actions', () => { + expect(store.showWorkflowActions()).toBe(true); + expect(spectator.query(byTestId('sidebar-workflow-actions'))).toBeTruthy(); + }); - store.initializeExistingContent({ - inode: '123', - depth: DotContentletDepths.TWO - }); - tick(); - spectator.detectChanges(); - })); + it('should emit workflowActionFired when the actions component fires an action', () => { + const emitSpy = jest.spyOn(spectator.component.workflowActionFired, 'emit'); + const actionsComponent = spectator.query(DotWorkflowActionsComponent); + const action = { id: 'action-1' } as DotCMSWorkflowAction; - it('should NOT render the rules tab when content type is not HTMLPAGE', () => { - expect(store.isNew()).toBe(false); - const rulesElement = spectator.query(byTestId('rules')); - expect(rulesElement).toBeFalsy(); - }); + actionsComponent.actionFired.emit(action); - it('should NOT render DotEditContentSidebarRulesComponent for non-page content types', () => { - const rulesComponent = spectator.query(DotEditContentSidebarRulesComponent); - expect(rulesComponent).toBeFalsy(); + expect(emitSpy).toHaveBeenCalledWith(action); }); }); - describe('when content is an existing HTMLPAGE', () => { - beforeEach(fakeAsync(() => { - const dotContentTypeService = spectator.inject(DotContentTypeService); - const workflowActionsService = spectator.inject(DotWorkflowsActionsService); - const dotWorkflowService = spectator.inject(DotWorkflowService); - const dotEditContentService = spectator.inject(DotEditContentService); - - const mockContentlet = createFakeContentlet({ - inode: '123', - contentType: 'htmlpageType', - identifier: '123-456', - title: 'Test Page' - }); - - dotEditContentService.getContentById.mockReturnValue(of(mockContentlet)); - dotContentTypeService.getContentTypeWithRender.mockReturnValue( - of(HTMLPAGE_CONTENT_TYPE_MOCK) // baseType: 'HTMLPAGE' - ); - workflowActionsService.getByInode.mockReturnValue(of([])); - workflowActionsService.getWorkFlowActions.mockReturnValue( - of(MOCK_SINGLE_WORKFLOW_ACTIONS) - ); - dotWorkflowService.getWorkflowStatus.mockReturnValue(of(MOCK_WORKFLOW_STATUS)); - dotContentletService.canLock.mockReturnValue( - of({ locked: false, canLock: true } as DotContentletCanLock) - ); - - store.initializeExistingContent({ - inode: '123', - depth: DotContentletDepths.TWO - }); - tick(); - spectator.detectChanges(); - })); - - it('should render the rules component in the settings tab when content type is HTMLPAGE', fakeAsync(() => { - expect(store.isNew()).toBe(false); - store.setActiveSidebarTab(3); - tick(); - spectator.detectChanges(); - const rulesElement = spectator.query(byTestId('rules')); - expect(rulesElement).toBeTruthy(); - })); + describe('sections', () => { + it('should carry the expected persistence keys on the three sections', () => { + const sections = spectator.queryAll(DotEditContentSidebarSectionComponent); + const keys = sections.map((section) => section.key()); - it('should render DotEditContentSidebarRulesComponent inside the settings tab', fakeAsync(() => { - store.setActiveSidebarTab(3); - tick(); - spectator.detectChanges(); - const rulesComponent = spectator.query(DotEditContentSidebarRulesComponent); - expect(rulesComponent).toBeTruthy(); - })); + expect(keys).toEqual(['actions.locales', 'actions.workflow', 'actions.details']); + }); }); }); @@ -637,56 +462,6 @@ describe('DotEditContentSidebarComponent', () => { expect(activitiesComponent).toBeTruthy(); })); - it('should switch to permissions tab and render permissions content when clicking permissions tab', fakeAsync(() => { - spectator.detectChanges(); - tick(); - - const tabView = spectator.query(byTestId('sidebar-tabs')); - expect(tabView).toBeTruthy(); - - const tabs = tabView.querySelectorAll('[role="tab"]'); - expect(tabs.length).toBeGreaterThan(3); - - const permissionsTabLink = tabs[3]; // Permissions is the fourth tab - expect(permissionsTabLink).toBeTruthy(); - - const storeSpy = jest.spyOn(store, 'setActiveSidebarTab'); - - // Start on info tab (0), then click permissions tab - expect(store.activeSidebarTab()).toBe(0); - spectator.click(permissionsTabLink); - tick(); - spectator.detectChanges(); - - expect(storeSpy).toHaveBeenCalledWith(3); - expect(store.activeSidebarTab()).toBe(3); - - const permissionsComponent = spectator.query( - DotEditContentSidebarPermissionsComponent - ); - expect(permissionsComponent).toBeTruthy(); - })); - - it('should open permissions dialog when clicking permissions card in permissions tab', fakeAsync(() => { - spectator.detectChanges(); - tick(); - - // Switch to permissions tab first - store.setActiveSidebarTab(3); - tick(); - spectator.detectChanges(); - - const dialogService = spectator.inject(DialogService); - const openSpy = jest.spyOn(dialogService, 'open'); - - const permissionsCard = spectator.query(byTestId('permissions-card')); - expect(permissionsCard).toBeTruthy(); - spectator.click(permissionsCard); - tick(); - - expect(openSpy).toHaveBeenCalled(); - })); - it('should update store and render content when clicking history tab', fakeAsync(() => { spectator.detectChanges(); tick(); @@ -800,10 +575,10 @@ describe('DotEditContentSidebarComponent', () => { expect(storeSpy).toHaveBeenCalledWith(0); })); - it('should call setActiveSidebarTab with index 3 when permissions tab is selected', fakeAsync(() => { + it('should call setActiveSidebarTab with index 1 when history tab is selected', fakeAsync(() => { const storeSpy = jest.spyOn(store, 'setActiveSidebarTab'); - spectator.component.onActiveIndexChange(3); - expect(storeSpy).toHaveBeenCalledWith(3); + spectator.component.onActiveIndexChange(1); + expect(storeSpy).toHaveBeenCalledWith(1); })); it('should call setActiveSidebarTab with the exact index from the event', fakeAsync(() => { @@ -827,6 +602,16 @@ describe('DotEditContentSidebarComponent', () => { identifier: 'test-identifier' }); })); + + it('should call store.fireWorkflowAction when fireWorkflowAction (reset path) is invoked', fakeAsync(() => { + const storeSpy = jest.spyOn(store, 'fireWorkflowAction').mockImplementation(); + + spectator.component.fireWorkflowAction('reset-action-id'); + + expect(storeSpy).toHaveBeenCalledWith( + expect.objectContaining({ actionId: 'reset-action-id' }) + ); + })); }); describe('Event Handlers - Failure and Edge Cases', () => { @@ -909,14 +694,6 @@ describe('DotEditContentSidebarComponent', () => { expect(informationElement).toBeTruthy(); })); - it('should render permissions-card only when permissions tab (tab 3) is active', fakeAsync(() => { - store.setActiveSidebarTab(3); - tick(); - spectator.detectChanges(); - const permissionsCard = spectator.query(byTestId('permissions-card')); - expect(permissionsCard).toBeTruthy(); - })); - it('should render history section with data-testId when history tab is active', fakeAsync(() => { store.setActiveSidebarTab(1); tick(); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.ts index 074bb7347172..d7d3f512d6b6 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.ts @@ -5,6 +5,7 @@ import { effect, inject, model, + output, untracked } from '@angular/core'; @@ -16,15 +17,13 @@ import { SelectModule } from 'primeng/select'; import { TabsModule } from 'primeng/tabs'; import { TooltipModule } from 'primeng/tooltip'; -import { DotCMSBaseTypesContentTypes } from '@dotcms/dotcms-models'; -import { DotCopyButtonComponent, DotMessagePipe } from '@dotcms/ui'; +import { DotCMSBaseTypesContentTypes, DotCMSWorkflowAction } from '@dotcms/dotcms-models'; +import { DotCopyButtonComponent, DotMessagePipe, DotWorkflowActionsComponent } from '@dotcms/ui'; import { DotEditContentSidebarActivitiesComponent } from './components/dot-edit-content-sidebar-activities/dot-edit-content-sidebar-activities.component'; import { DotEditContentSidebarHistoryComponent } from './components/dot-edit-content-sidebar-history/dot-edit-content-sidebar-history.component'; import { DotEditContentSidebarInformationComponent } from './components/dot-edit-content-sidebar-information/dot-edit-content-sidebar-information.component'; import { DotEditContentSidebarLocalesComponent } from './components/dot-edit-content-sidebar-locales/dot-edit-content-sidebar-locales.component'; -import { DotEditContentSidebarPermissionsComponent } from './components/dot-edit-content-sidebar-permissions/dot-edit-content-sidebar-permissions.component'; -import { DotEditContentSidebarRulesComponent } from './components/dot-edit-content-sidebar-rules/dot-edit-content-sidebar-rules.component'; import { DotEditContentSidebarSectionComponent } from './components/dot-edit-content-sidebar-section/dot-edit-content-sidebar-section.component'; import { DotEditContentSidebarWorkflowComponent } from './components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component'; @@ -58,8 +57,7 @@ import { DotEditContentStore } from '../../store/edit-content.store'; DotEditContentSidebarLocalesComponent, DotEditContentSidebarActivitiesComponent, DotEditContentSidebarHistoryComponent, - DotEditContentSidebarPermissionsComponent, - DotEditContentSidebarRulesComponent + DotWorkflowActionsComponent ], changeDetection: ChangeDetectionStrategy.OnPush, host: { @@ -121,6 +119,12 @@ export class DotEditContentSidebarComponent { alias: 'showDialog' }); + /** + * Emits the selected workflow action when the user fires one from the Actions tab, + * so the parent layout can run it against the edit-content form. + */ + readonly workflowActionFired = output(); + /** * Effect that loads sidebar data (reference pages and activities) when the * sidebar is open and the contentlet identifier is available. diff --git a/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts b/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts index 55942eca0509..474ec1a175ea 100644 --- a/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts +++ b/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts @@ -16,6 +16,7 @@ import { createFakeContentlet } from '@dotcms/utils-testing'; import { MOCK_CONTENTTYPE_2_TABS, MOCK_FORM_CONTROL_FIELDS } from './edit-content.mock'; import * as functionsUtil from './functions.util'; import { + generatePageEditUrl, generatePreviewUrl, getFieldVariablesParsed, getStoredUIState, @@ -1362,6 +1363,33 @@ describe('Utils Functions', () => { }); }); + describe('generatePageEditUrl', () => { + it('should generate the correct edit page URL when all attributes are present', () => { + const contentlet = createFakeContentlet({ + contentType: 'htmlpageasset', + url: '/blog/index', + host: '48190c8c-42c4-46af-8d1a-0cd5db894797', + languageId: 1 + }); + + const expectedUrl = + 'http://localhost/dotAdmin/#/edit-page/content?url=%2Fblog%2Findex%3Fhost_id%3D48190c8c-42c4-46af-8d1a-0cd5db894797&language_id=1&com.dotmarketing.persona.id=modes.persona.no.persona&mode=EDIT_MODE'; + + expect(generatePageEditUrl(contentlet)).toBe(expectedUrl); + }); + + it('should return an empty string when required attributes are missing', () => { + const contentlet = createFakeContentlet({ + contentType: 'htmlpageasset', + url: undefined, + host: '48190c8c-42c4-46af-8d1a-0cd5db894797', + languageId: 1 + }); + + expect(generatePageEditUrl(contentlet)).toBe(''); + }); + }); + describe('prepareContentletForCopy', () => { it('should prepare a contentlet for copying by clearing inode, setting locked to false and removing lockedBy', () => { // Arrange diff --git a/core-web/libs/edit-content/src/lib/utils/functions.util.ts b/core-web/libs/edit-content/src/lib/utils/functions.util.ts index 770a3e32f079..3e69b6f4fbb4 100644 --- a/core-web/libs/edit-content/src/lib/utils/functions.util.ts +++ b/core-web/libs/edit-content/src/lib/utils/functions.util.ts @@ -382,6 +382,30 @@ export const generatePreviewUrl = (contentlet: DotCMSContentlet): string => { return `${baseUrl}?${params.toString()}`; }; +/** + * Generates an edit-page URL for a given page contentlet. + * + * @param {DotCMSContentlet} contentlet - The contentlet object containing the necessary data. + * @returns {string} The generated edit-page URL. + */ +export const generatePageEditUrl = (contentlet: DotCMSContentlet): string => { + if (!contentlet.url || !contentlet.host || contentlet.languageId === undefined) { + console.warn('Missing required contentlet attributes to generate edit page URL'); + + return ''; + } + + const baseUrl = `${window.location.origin}/dotAdmin/#/edit-page/content`; + const params = new URLSearchParams(); + + params.set('url', `${contentlet.url}?host_id=${contentlet.host}`); + params.set('language_id', contentlet.languageId.toString()); + params.set('com.dotmarketing.persona.id', 'modes.persona.no.persona'); + params.set('mode', UVE_MODE.EDIT); + + return `${baseUrl}?${params.toString()}`; +}; + /** * Gets the UI state from sessionStorage or returns the initial state if not found */ diff --git a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.html b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.html index 7bd8c52da8ca..7e714b7dabea 100644 --- a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.html +++ b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.html @@ -27,6 +27,28 @@ [label]="(loading() ? 'Loading' : 'edit.ema.page.no.workflow.action') | dm" data-testId="empty-button" /> } +} @else if (stacked()) { + @if ($flatActions().length === 0) { + + } @else { + @for (action of $flatActions(); track action.id; let idx = $index; let first = $first) { + + } + } } @else { @if ($flatActions().length === 0) { { }); }); + describe('stacked', () => { + beforeEach(() => { + spectator.setInput('stacked', true); + }); + + it('should render ALL actions as buttons with no overflow menu', () => { + setBreakpointMatch({ [Breakpoints.XSmall]: true }); // would otherwise force overflow + spectator.setInput('actions', mockWorkflowsActionsWithMove); + spectator.detectChanges(); + + expect(spectator.queryAll(Button).length).toBe(mockWorkflowsActionsWithMove.length); + expect(spectator.query(byTestId('overflow-button'))).toBeNull(); + expect(spectator.query(Menu)).toBeNull(); + }); + + it('should render the first action solid and the rest outlined', () => { + spectator.setInput('actions', mockWorkflowsActions); + spectator.detectChanges(); + + const buttons = spectator.queryAll(Button); + + expect(buttons[0].variant).toBeNull(); + expect(buttons[1].variant).toBe('outlined'); + expect(buttons[2].variant).toBe('outlined'); + }); + + it('should render every button full-width (w-full)', () => { + spectator.setInput('actions', mockWorkflowsActions); + spectator.detectChanges(); + + spectator.queryAll(Button).forEach((button) => { + expect(button.styleClass).toContain('w-full'); + }); + }); + + it('should stack the host in a column (no flex-row-reverse)', () => { + spectator.setInput('actions', mockWorkflowsActions); + spectator.detectChanges(); + + const host = spectator.element; + + expect(host.classList.contains('flex-col')).toBe(true); + expect(host.classList.contains('flex-row-reverse')).toBe(false); + }); + + it('should filter out SEPARATOR actions', () => { + spectator.setInput('actions', [ + mockWorkflowsActions[0], + SEPARATOR_ACTION, + mockWorkflowsActions[1] + ]); + spectator.detectChanges(); + + expect(spectator.queryAll(Button).length).toBe(2); + }); + + it('should emit actionFired when a stacked button is clicked', () => { + spectator.setInput('actions', mockWorkflowsActions); + spectator.detectChanges(); + + const spy = jest.spyOn(spectator.component.actionFired, 'emit'); + const action = mockWorkflowsActions[1]; + const btn = spectator + .query(byTestId(`action-button-${action.id}`)) + ?.querySelector('button'); + + spectator.click(btn); + + expect(spy).toHaveBeenCalledWith(action); + }); + }); + describe('groupActions', () => { const actionsWithSeparator = [ mockWorkflowsActions[0], diff --git a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.ts b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.ts index dacc844ccf3f..69b5dde25ecf 100644 --- a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.ts @@ -52,13 +52,26 @@ const MAX_INLINE_ACTIONS = 4; * @example * * + * + * ## Stacked mode (`stacked=true`) + * Renders ALL actions as full-width buttons stacked vertically (top to bottom), with no overflow + * menu and no breakpoint-based cap. The first action is the solid/primary button; the rest are + * outlined. Used in the narrow edit-content sidebar where actions must list one above another. + * + * @example + * + * */ @Component({ selector: 'dot-workflow-actions', imports: [ButtonModule, MenuModule, SplitButtonModule, DotMessagePipe], templateUrl: './dot-workflow-actions.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'flex flex-row-reverse gap-2' } + host: { + class: 'flex gap-2', + '[class.flex-col]': 'stacked()', + '[class.flex-row-reverse]': '!stacked()' + } }) export class DotWorkflowActionsComponent { /** @@ -110,6 +123,14 @@ export class DotWorkflowActionsComponent { */ groupActions = input(false); + /** + * When true, renders ALL actions as full-width buttons stacked vertically (top to bottom), + * with no overflow menu and no breakpoint-based cap. The first action is solid/primary, the + * rest are outlined. Takes precedence over the flat overflow layout; ignored when + * `groupActions` is true. Use this in the narrow edit-content sidebar. + */ + stacked = input(false); + /** * Button size passed through to PrimeNG. * 'normal' maps to PrimeNG's default (no size attribute). diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index d489113d25bf..959daf39841a 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6357,6 +6357,7 @@ edit.content.sidebar.tab.information=Information edit.content.sidebar.tab.history=History edit.content.sidebar.tab.comments=Comments edit.content.sidebar.tab.settings=Settings +edit.content.sidebar.tab.actions=Actions edit.content.sidebar.general.title=General edit.content.sidebar.workflow.dialog.title=Select the workflow you want to work on. edit.content.sidebar.workflow.dialog.dropdown.placeholder=Select a Workflow @@ -6442,6 +6443,7 @@ dot.permissions.iframe.dialog.no-asset=No asset selected. Permissions require a edit.content.sidebar.rules.title=Rules edit.content.sidebar.rules.setup=Set up edit.content.sidebar.rules.no.content=No content selected. Rules require a content item. +edit.content.sidebar.command-bar.references=View References edit.content.locked=Content Locked edit.content.unlocked=Content Unlocked From f040f893fba4822f09517bda74ed510ec8b35a9b Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Fri, 5 Jun 2026 13:30:43 -0400 Subject: [PATCH 02/40] style(ui): soften p-tag success to the design's green tones (#35892) Override the Tag `success` color scheme in CustomLaraPreset so it uses a tinted background with dark text ({green.100}/{green.700}) instead of Lara's default solid fill + white text. Applies globally to every p-tag success, including the Edit Content command-bar status; the primitives map 1:1 to the Claude Design green tones. --- core-web/libs/ui/src/lib/theme/theme.config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core-web/libs/ui/src/lib/theme/theme.config.ts b/core-web/libs/ui/src/lib/theme/theme.config.ts index 8964931df107..c2faa5e39059 100644 --- a/core-web/libs/ui/src/lib/theme/theme.config.ts +++ b/core-web/libs/ui/src/lib/theme/theme.config.ts @@ -56,6 +56,21 @@ export const CustomLaraPreset = definePreset(Lara, { } ` }, + tag: { + // Soft status tags per the dotCMS design spec: a tinted background with + // dark text instead of Lara's default solid fill + white text + // ({green.500}/{surface.0}). Overriding the token here applies globally to + // every p-tag success (e.g. the Edit Content command-bar status), and the + // {green.100}/{green.700} primitives map 1:1 to the design's green tones. + colorScheme: { + light: { + success: { + background: '{green.100}', + color: '{green.700}' + } + } + } + }, toolbar: { root: { borderRadius: '0', From 1c0ca6be31315866bedb134c3b0d620b404bf129 Mon Sep 17 00:00:00 2001 From: Arcadio Quintero Date: Fri, 5 Jun 2026 13:47:00 -0400 Subject: [PATCH 03/40] refactor(edit-content): pill tags globally, computed status severity, fluid buttons (#35892) - Make the status p-tag a fully-rounded pill globally via CustomLaraPreset (tag.root: 9999px radius, 4px 12px padding, 600 weight) instead of a per-instance class, so a forgotten class can never make a tag look different. - Replace the statusSeverity template method with memoized computeds ($contentStatus via DotContentletStatusPipe + $statusSeverity), backed by a pure exported contentStatusSeverity() so the mapping stays unit-testable. Avoids the per-CD method call. - Use the native PrimeNG [fluid] input for full-width stacked workflow actions and the lock button instead of styleClass (class does not reach p-button's inner element). --- .../dot-edit-content-form.component.html | 8 +-- .../dot-edit-content-form.component.spec.ts | 17 ++--- .../dot-edit-content-form.component.ts | 63 ++++++++++++------- .../dot-edit-content-sidebar.component.html | 2 +- .../dot-workflow-actions.component.html | 4 +- .../dot-workflow-actions.component.spec.ts | 4 +- .../libs/ui/src/lib/theme/theme.config.ts | 15 +++-- 7 files changed, 70 insertions(+), 43 deletions(-) diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html index 7b98c6669f16..4b1eae8c3f66 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html @@ -98,11 +98,11 @@ } @else { - - @let status = $store.isNew() ? 'New' : ($store.contentlet() | dotContentletStatus); + diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts index e82a639a2f9f..3348295fb25c 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.spec.ts @@ -50,7 +50,10 @@ import { mockMatchMedia } from '@dotcms/utils-testing'; -import { DotEditContentFormComponent } from './dot-edit-content-form.component'; +import { + contentStatusSeverity, + DotEditContentFormComponent +} from './dot-edit-content-form.component'; import { DotEditContentService } from '../../services/dot-edit-content.service'; import { DotEditContentStore } from '../../store/edit-content.store'; @@ -801,13 +804,13 @@ describe('DotFormComponent', () => { expect(commandBar).toBeFalsy(); }); - describe('statusSeverity', () => { + describe('contentStatusSeverity', () => { it('should map status labels to PrimeNG severities', () => { - expect(component.statusSeverity('Published')).toBe('success'); - expect(component.statusSeverity('Archived')).toBe('danger'); - expect(component.statusSeverity('Revision')).toBe('info'); - expect(component.statusSeverity('Draft')).toBe('warn'); - expect(component.statusSeverity('New')).toBe('warn'); + expect(contentStatusSeverity('Published')).toBe('success'); + expect(contentStatusSeverity('Archived')).toBe('danger'); + expect(contentStatusSeverity('Revision')).toBe('info'); + expect(contentStatusSeverity('Draft')).toBe('warn'); + expect(contentStatusSeverity('New')).toBe('warn'); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts index ae6d6ddd892e..78fcbe736eb2 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.ts @@ -69,6 +69,25 @@ import { import { blockEditorRequiredValidator } from '../../utils/validators'; import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit-content-field.component'; +/** + * Maps a contentlet status label to its PrimeNG Tag severity. + * + * Kept as a pure, exported function so the mapping stays unit-testable in isolation + * (no component/store needed) and is consumed by the `$statusSeverity` computed. + */ +export function contentStatusSeverity(status: string): Tag['severity'] { + switch (status) { + case 'Published': + return 'success'; + case 'Archived': + return 'danger'; + case 'Revision': + return 'info'; + default: + return 'warn'; + } +} + /** * DotEditContentFormComponent * @@ -103,11 +122,11 @@ import { DotEditContentFieldComponent } from '../dot-edit-content-field/dot-edit TagModule, TabViewInsertDirective, DotMessagePipe, - DotContentletStatusPipe, DotEditContentCommandBarActionsComponent, MessageModule, NgTemplateOutlet ], + providers: [DotContentletStatusPipe], changeDetection: ChangeDetectionStrategy.OnPush, animations: [ trigger('fadeIn', [ @@ -132,6 +151,7 @@ export class DotEditContentFormComponent implements OnInit { readonly #dotMessageService = inject(DotMessageService); readonly #document = inject(DOCUMENT); readonly #appRef = inject(ApplicationRef); + readonly #statusPipe = inject(DotContentletStatusPipe); /** * Output event emitter that informs when the form has changed. @@ -192,6 +212,24 @@ export class DotEditContentFormComponent implements OnInit { return !!relatedContent && relatedContent !== '0'; }); + /** + * Status label shown in the command-bar tag. A brand-new contentlet has no status yet, + * so it shows "New"; otherwise the contentlet state is mapped via DotContentletStatusPipe. + * + * @memberof DotEditContentFormComponent + */ + $contentStatus = computed(() => + this.$store.isNew() ? 'New' : this.#statusPipe.transform(this.$store.contentlet()) + ); + + /** + * PrimeNG Tag severity derived from the current status label. A computed (not a template + * method) so it is memoized and only recomputes when the status changes. + * + * @memberof DotEditContentFormComponent + */ + $statusSeverity = computed(() => contentStatusSeverity(this.$contentStatus())); + /** * FormGroup instance that contains the form controls for the fields in the content type * @@ -251,7 +289,8 @@ export class DotEditContentFormComponent implements OnInit { contentType: this.$store.contentType(), currentLocaleId: currentLocale ? currentLocale.id.toString() : '', currentIdentifier: this.$store.currentIdentifier(), - statusSeverity: (status: string) => this.statusSeverity(status), + $contentStatus: this.$contentStatus, + $statusSeverity: this.$statusSeverity, showPreview: () => this.showPreview() }; } @@ -766,26 +805,6 @@ export class DotEditContentFormComponent implements OnInit { window.open(realUrl, '_blank'); } - /** - * Maps a contentlet status string to a PrimeNG Tag severity. - * - * @param {string} status - The contentlet status label - * @returns {Tag['severity']} The matching PrimeNG Tag severity - * @memberof DotEditContentFormComponent - */ - statusSeverity(status: string): Tag['severity'] { - switch (status) { - case 'Published': - return 'success'; - case 'Archived': - return 'danger'; - case 'Revision': - return 'info'; - default: - return 'warn'; - } - } - /** * Updates the active tab index in the store. * diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html index 4ac55dac61ae..9156edcc6309 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.html @@ -53,7 +53,7 @@