{{ '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 (contentlet?.inode) {
-
- json
-
- }
-
-
+
+
+
@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