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..f801d0183727 --- /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,12 @@ + + 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..100a9eea365f 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(); @@ -97,28 +85,9 @@
- - @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 d024931d3dd2..fab4a2fc7a67 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,15 +44,16 @@ 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'; -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'; @@ -64,7 +65,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; @@ -348,18 +349,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 = @@ -430,56 +439,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', @@ -488,88 +480,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(); }); @@ -674,6 +679,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('contentStatusSeverity', () => { + it('should map status labels to PrimeNG severities', () => { + 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'); + }); + }); }); describe('Lock functionality', () => { @@ -725,18 +865,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(); }); @@ -747,8 +885,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, @@ -757,10 +894,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({ @@ -771,9 +908,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(); }); }); @@ -861,7 +998,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 @@ -874,11 +1011,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); @@ -933,8 +1069,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(); @@ -967,8 +1102,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(); @@ -1003,8 +1137,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. @@ -1308,18 +1441,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', () => { @@ -1339,20 +1472,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', () => { @@ -1399,14 +1518,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 @@ -1416,14 +1535,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(); }); @@ -1442,14 +1561,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..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 @@ -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, @@ -67,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 * @@ -80,7 +101,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,19 +113,20 @@ 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, + DotEditContentCommandBarActionsComponent, MessageModule, NgTemplateOutlet ], + providers: [DotContentletStatusPipe], changeDetection: ChangeDetectionStrategy.OnPush, animations: [ trigger('fadeIn', [ @@ -129,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. @@ -155,14 +178,58 @@ 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'; + }); + + /** + * 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 * @@ -215,19 +282,16 @@ 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) + $contentStatus: this.$contentStatus, + $statusSeverity: this.$statusSeverity, + showPreview: () => this.showPreview() }; } @@ -717,14 +781,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( @@ -754,19 +822,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.scss b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.scss index 6544cf7e0952..b1d66f236ef8 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.scss +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-layout/dot-edit-content.layout.component.scss @@ -25,6 +25,6 @@ transition: grid-template-columns 150ms ease-in-out; &.edit-content--with-sidebar { - grid-template-columns: minmax(0, 1fr) 21.875rem; + grid-template-columns: minmax(0, 1fr) 360px; } } 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..2f9f1921bc7a 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,154 +1,71 @@ @let data = $data(); @let contentlet = data.contentlet; @let contentType = data.contentType; -@let referencesPageCount = data.referencesPageCount; -@let loading = data.loading; -
- -
+
+ +
@if (contentType) { + {{ 'Content-Type' | dm }} - - {{ 'Content-Type' | dm }} - - - {{ contentType.name }} - + {{ contentType.name }} } -
-
- - {{ 'Created' | dm }} - + {{ 'Modified-By' | dm }} + + @if (contentlet?.modDate) { - {{ - contentlet?.creationDate - ? (contentlet?.ownerUserName | dotNameFormat) || - ('edit.content.sidebar.workflow.you' | dm) - : '-' - }} - - @if (contentlet?.creationDate) { - - {{ contentlet?.creationDate | dotRelativeDate: 'MM/dd/yyyy' : null }} - - } -
- -
- - {{ 'Modified' | dm }} - - - {{ contentlet?.modDate ? (contentlet?.modUserName | dotNameFormat) : '-' }} - - @if (contentlet?.modDate) { - - {{ contentlet?.modDate | dotRelativeDate: 'MM/dd/yyyy' : null }} - - } -
- -
- - {{ 'Published' | dm }} - - - {{ - contentlet?.publishDate - ? (contentlet?.publishUserName | dotNameFormat) - : '-' - }} + {{ $modifiedByInitials() }} - @if (contentlet?.publishDate) { - - {{ contentlet?.publishDate | dotRelativeDate: 'MM/dd/yyyy' : null }} - - } -
-
-
-
- -@if ($hasReferences()) { -
- - {{ 'References' | dm }} + {{ contentlet?.modUserName | dotNameFormat }} + } @else { + - + } - @if (loading) { - - } @else { - - {{ - 'edit.content.sidebar.information.references-with.pages.tooltip' - | dm: [referencesPageCount] - }} + + {{ 'Modified' | dm }} + @if (contentlet?.modDate) { + + {{ contentlet?.modDate | dotRelativeDate: 'MM/dd/yyyy' : null }} - } -
-} @else { -
- - {{ 'References' | dm }} - - @if (loading) { - } @else { - - {{ 'edit.content.sidebar.information.references-with.pages.not.used' | dm }} - + - }
-} + + + @if (contentlet?.inode) { +
+ + } +
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..24499e886c51 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', () => { @@ -104,24 +91,27 @@ describe('DotEditContentSidebarInformationComponent', () => { expect(contentTypeLink.textContent).toContain('Blog'); }); - it('should show created information', () => { - const createdDate = spectator.query(byTestId('created-date')); - expect(createdDate).toBeTruthy(); - }); - it('should show modified information', () => { const modifiedDate = spectator.query(byTestId('modified-date')); expect(modifiedDate).toBeTruthy(); }); - it('should show published information', () => { - const publishedDate = spectator.query(byTestId('published-date')); - expect(publishedDate).toBeTruthy(); + it('should show the modified-by row with an initials avatar', () => { + const modifiedBy = spectator.query(byTestId('modified-by')); + expect(modifiedBy).toBeTruthy(); + expect(modifiedBy.textContent).toContain('E'); + }); + + it('should show the copy identifier button in the footer', () => { + expect(spectator.query(byTestId('copy-id-button'))).toBeTruthy(); }); - it('should show references count', () => { - const referencesCount = spectator.query(byTestId('references-count')); - expect(referencesCount).toBeTruthy(); + it('should show the view-as-json link in the footer', () => { + expect(spectator.query(byTestId('json-link'))).toBeTruthy(); + }); + + it('should NOT show a references card', () => { + expect(spectator.query(byTestId('references-card'))).toBeFalsy(); }); }); @@ -136,10 +126,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 +136,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..b3ad2a2af9a4 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,12 @@ 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, 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 { DotCopyButtonComponent, 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,27 +24,19 @@ interface ContentSidebarInformation { imports: [ RouterLink, TooltipModule, - SkeletonModule, - DotContentletStatusChipComponent, DotRelativeDatePipe, DotMessagePipe, DotNameFormatPipe, + DotCopyButtonComponent, DatePipe ], 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' }); @@ -67,59 +45,17 @@ export class DotEditContentSidebarInformationComponent { () => `/api/v1/content/${this.$data().contentlet?.identifier ?? ''}` ); - /** Tooltip message shown when the contentlet has no creation date yet. */ - readonly $createdTooltipMessage = computed(() => { - const { contentlet } = this.$data(); + /** Initials of the last modifier, shown in the "Modified by" avatar chip. */ + readonly $modifiedByInitials = computed(() => { + const name = String(this.$data().contentlet?.modUserName ?? '').trim(); + const parts = name.split(/\s+/).filter(Boolean); + if (!parts.length) { + return '?'; + } - return !contentlet?.creationDate - ? this.#dotMessageService.get('edit.content.sidebar.information.no.created.yet') - : null; - }); + const first = parts[0].charAt(0); + const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : ''; - /** 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'; + return (first + last).toUpperCase(); }); - - 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..533ceb940813 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 @@ -1,21 +1,48 @@
@if ($title()) {
-

+ class="sticky top-0 z-10 flex min-h-12 shrink-0 cursor-pointer items-center justify-between rounded-md px-2 py-1 transition-colors hover:bg-surface-50" + 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 (actionTemplate) { +
+ +
+ } + +
} -
- + +
+
+
+ +
+
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..b10950b50eae 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,110 @@ 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.component.$collapsed()).toBe(false); + + // Collapses in-memory... + spectator.click(byTestId('dot-section-toggle')); + expect(spectator.component.$collapsed()).toBe(true); + + // ...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 the collapsed state on header click', () => { + localStorageService.getItem.mockReturnValue(false); + spectator.detectChanges(); + + // Starts expanded + expect(spectator.component.$collapsed()).toBe(false); + + // Collapse + spectator.click(byTestId('dot-section-toggle')); + expect(spectator.component.$collapsed()).toBe(true); + + // Expand again + spectator.click(byTestId('dot-section-toggle')); + expect(spectator.component.$collapsed()).toBe(false); + }); + + 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.component.$collapsed()).toBe(true); + }); + + 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.component.$collapsed()).toBe(true); + }); + + it('should rotate the chevron up when expanded (collapsed has it pointing down)', () => { + localStorageService.getItem.mockReturnValue(false); + spectator.detectChanges(); + + const chevron = spectator.query(byTestId('dot-section-chevron')); + expect(chevron).toHaveClass('rotate-180'); + }); + + it('should not rotate the chevron when collapsed', () => { + localStorageService.getItem.mockReturnValue(true); + spectator.detectChanges(); + + const chevron = spectator.query(byTestId('dot-section-chevron')); + expect(chevron).not.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.component.$collapsed()).toBe(false); + 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/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.html index 7ddaa7b5a764..aed027456958 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.html @@ -2,74 +2,66 @@ @let isLoading = $isLoading(); @let workflowSelection = $workflowSelection(); -
-
{{ 'Workflow' | dm }}
-
- @if (isLoading) { - - } @else if (workflowSelection.isWorkflowSelected) { - - {{ 'edit.content.sidebar.workflow.select.workflow' | dm }} - - } @else { - {{ workflow.scheme?.name }} - - @if ($showWorkflowSelection()) { - - } - - @if (workflow.resetAction) { - - } - } -
- - @if (!workflowSelection.isWorkflowSelected) { -
- {{ 'Step' | dm }} -
-
+
+ +
+ {{ 'Workflow' | dm }} + @if (isLoading) { + } @else if (workflowSelection.isWorkflowSelected) { + + {{ 'edit.content.sidebar.workflow.select.workflow' | dm }} + } @else { - {{ workflow.step?.name }} + {{ workflow.scheme?.name }} + + @if ($showWorkflowSelection()) { + + } + + @if (workflow.resetAction) { + + } } -
+ - @if (workflow.task) { -
{{ 'Assignee' | dm }}
-
+ @if (!workflowSelection.isWorkflowSelected) { + {{ 'Step' | dm }} + @if (isLoading) { } @else { - {{ workflow.task.assignedTo }} + {{ workflow.step?.name }} } -
+ + + @if (workflow.task) { + {{ 'Assignee' | dm }} + + @if (isLoading) { + + } @else { + {{ workflow.task.assignedTo }} + } + + } } - } -
+
+
@defer (when $showDialog()) { diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.scss b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.scss index d1ca427a0df2..560f8b0d3f9e 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.scss +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/components/dot-edit-content-sidebar-workflow/dot-edit-content-sidebar-workflow.component.scss @@ -1,39 +1,8 @@ @use "variables" as *; @use "mixins"; -@use "../../../../../../../dotcms-scss/shared/colors"; -@use "../../../../../../../dotcms-scss/shared/fonts"; -@use "../../../../../../../dotcms-scss/shared/spacing"; $skeleton-height: 14px; -.workflow-list { - display: grid; - grid-template-columns: 96px 1fr; - - margin: 0; - - dt { - font-weight: fonts.$font-weight-medium-bold; - color: colors.$black; - - min-height: 2.286rem; - display: flex; - align-content: center; - flex-wrap: wrap; - } - - dd { - margin: 0; - color: colors.$color-palette-gray-800; - font-weight: fonts.$font-weight-regular-bold; - } -} -.workflow-column__description { - display: flex; - align-items: center; - gap: spacing.$spacing-0; -} - .select-workflow-link { cursor: pointer; } 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..dce7e29e1509 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 @@ -15,12 +15,9 @@ [pt]="tabsPt" data-testId="sidebar-tabs"> - - + class="min-h-[53px] border-b border-surface-200 [&_.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) { - - - - } + class="flex h-full min-h-0 flex-1 flex-col bg-white! p-0! [&_.p-tabpanel]:p-0!"> - -
- - - - @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"> + + + + +
@@ -124,24 +147,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.scss b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.scss index 049f809194b2..40c349e1b100 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.scss +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-sidebar/dot-edit-content-sidebar.component.scss @@ -7,3 +7,21 @@ :host ::ng-deep .p-card { border: none !important; } + +/* Tab content transition: each panel plays a subtle fade + slide as it becomes + * visible. PrimeNG keeps all panels mounted and only toggles `[hidden]`; a CSS + * animation restarts whenever an element goes from display:none to displayed, so + * this fires on every tab switch. */ +:host ::ng-deep .p-tabpanel { + animation: ec-tab-fade-in 180ms ease-out; +} + +@keyframes ec-tab-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} 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..3fb6a7cda990 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 { 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'; @@ -50,7 +49,6 @@ import { DotEditContentStore } from '../../store/edit-content.store'; TabsModule, TooltipModule, DotEditContentSidebarSectionComponent, - DotCopyButtonComponent, ConfirmDialogModule, DialogModule, SelectModule, @@ -58,12 +56,11 @@ import { DotEditContentStore } from '../../store/edit-content.store'; DotEditContentSidebarLocalesComponent, DotEditContentSidebarActivitiesComponent, DotEditContentSidebarHistoryComponent, - DotEditContentSidebarPermissionsComponent, - DotEditContentSidebarRulesComponent + DotWorkflowActionsComponent ], changeDetection: ChangeDetectionStrategy.OnPush, host: { - class: 'flex w-[21.875rem] h-full flex-col items-start border-l border-[var(--gray-400)] [&:not([inert])]:shadow-[-4px_0_12px_rgba(0,0,0,0.08)] relative min-w-0 overflow-x-hidden' + class: 'flex w-[360px] h-full flex-col items-start border-l border-surface-200 relative min-w-0 overflow-x-hidden' } }) export class DotEditContentSidebarComponent { @@ -121,6 +118,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..49514b72ddb0 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 (fluid)', () => { + spectator.setInput('actions', mockWorkflowsActions); + spectator.detectChanges(); + + spectator.queryAll(Button).forEach((button) => { + expect(button.fluid()).toBe(true); + }); + }); + + 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/core-web/libs/ui/src/lib/theme/theme.config.ts b/core-web/libs/ui/src/lib/theme/theme.config.ts index 8964931df107..4efaf32eba79 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,49 @@ export const CustomLaraPreset = definePreset(Lara, { } ` }, + tag: { + // Status tags follow the dotCMS design spec globally (not per-instance, so a + // forgotten class can never make one look different): a fully-rounded pill with + // a tinted background + dark text instead of Lara's default small-radius solid + // fill + white text. Soft per-severity colors use PrimeNG palette tokens + // ({green.100}/{green.700} map 1:1 to the design); shape/typography are expressed + // with Tailwind theme variables — same mechanism as `chip` — so there are no magic + // numbers. `calc(infinity * 1px)` is exactly what Tailwind's `rounded-full` emits; + // there is no --radius-full token. + css: ` + .p-tag { + border-radius: calc(infinity * 1px); + padding: var(--spacing) calc(var(--spacing) * 3); /* 0.25rem 0.75rem */ + font-weight: var(--font-weight-semibold); /* 600 */ + } + `, + colorScheme: { + light: { + success: { + background: '{green.100}', + color: '{green.700}' + } + } + } + }, + tabs: { + // Underline-style tabs per the design: the active indicator sits on the BOTTOM + // border (Lara defaults to a 2px TOP border) and tabs have no static background + // (Lara fills inactive tabs with surface-50). The active state still reads via the + // primary bottom border + primary text (tab.activeBorderColor / activeColor). + tab: { + borderWidth: '0 0 2px 0' + }, + colorScheme: { + light: { + tab: { + background: 'transparent', + hoverBackground: 'transparent', + activeBackground: 'transparent' + } + } + } + }, toolbar: { root: { borderRadius: '0', diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 4544e83ff10b..1e17b0e1ca18 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6356,6 +6356,7 @@ edit.content.sidebar.tab.history=History edit.content.sidebar.tab.comments=Comments edit.content.sidebar.tab.settings=Settings edit.content.sidebar.general.title=General +edit.content.sidebar.details.view.as.json=View as JSON edit.content.sidebar.workflow.dialog.title=Select the workflow you want to work on. edit.content.sidebar.workflow.dialog.dropdown.placeholder=Select a Workflow edit.content.sidebar.workflow.select.workflow=Select Workflow @@ -6440,6 +6441,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