diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/newEditContentForm.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/newEditContentForm.page.ts
index 29dcd883fb49..fee2b66fce19 100644
--- a/core-web/apps/dotcms-ui-e2e/src/pages/newEditContentForm.page.ts
+++ b/core-web/apps/dotcms-ui-e2e/src/pages/newEditContentForm.page.ts
@@ -67,12 +67,15 @@ export class NewEditContentFormPage {
}
/**
- * Clicks the primary workflow action button (Save or Publish) and waits for the API response.
- * New content shows "Save", existing content shows "Publish".
+ * Clicks the workflow action that persists the content (Save or Publish) and waits for the API response.
+ * Prefer "Save" when both Save and Publish are visible — the new command bar can show multiple
+ * workflow buttons at once, so a single regex locator would match 2+ roles and break strict mode.
*/
async save() {
- // Match either Save or Publish — the primary action button
- const actionButton = this.page.getByRole('button', { name: /^(Save|Publish)$/ });
+ const saveButton = this.page.getByRole('button', { name: 'Save' });
+ const publishButton = this.page.getByRole('button', { name: 'Publish' });
+ const actionButton = (await saveButton.isVisible()) ? saveButton : publishButton;
+
await expect(actionButton).toBeVisible();
const responsePromise = this.page.waitForResponse((response) => {
diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html
index d91fc333accf..f7cc5cfde26d 100644
--- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html
+++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form.component.html
@@ -139,8 +139,7 @@
identifier: currentIdentifier
})
"
- [actions]="actions"
- [groupActions]="true" />
+ [actions]="actions" />
}
}
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 8f7858d71ad6..28a3a7894fc7 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
@@ -1,28 +1,30 @@
-@for (action of $groupedActions(); track $index) {
- @let mainAction = action.mainAction;
- @let subActions = action.subActions;
- @if (subActions.length) {
-
- } @else {
-
- }
-} @empty {
+@if ($flatActions().length === 0) {
+} @else {
+ @if ($overflowActions().length) {
+
+
+ }
+ @for (action of $visibleActions(); track action.id; let idx = $index; let first = $first) {
+
+ }
}
diff --git a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.spec.ts
index 2541c4b4739e..4cc795de4730 100644
--- a/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.spec.ts
+++ b/core-web/libs/ui/src/lib/components/dot-workflow-actions/dot-workflow-actions.component.spec.ts
@@ -1,69 +1,70 @@
-import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
+import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest';
+import { BehaviorSubject } from 'rxjs';
+
+import { BreakpointObserver, Breakpoints, BreakpointState } from '@angular/cdk/layout';
import { Button } from 'primeng/button';
-import { SplitButton, SplitButtonModule } from 'primeng/splitbutton';
-import { ToolbarModule } from 'primeng/toolbar';
+import { Menu } from 'primeng/menu';
import { DotMessageService } from '@dotcms/data-access';
-import { DotCMSWorkflowAction } from '@dotcms/dotcms-models';
-import { MockDotMessageService, mockWorkflowsActions } from '@dotcms/utils-testing';
+import {
+ MockDotMessageService,
+ mockWorkflowsActions,
+ mockWorkflowsActionsWithMove
+} from '@dotcms/utils-testing';
import { DotWorkflowActionsComponent } from './dot-workflow-actions.component';
import { DotMessagePipe } from '../../dot-message/dot-message.pipe';
-import { DotClipboardUtil } from '../../services/clipboard/ClipboardUtil';
-const WORKFLOW_ACTIONS_SEPARATOR_MOCK: DotCMSWorkflowAction = {
- assignable: true,
- commentable: true,
+// mockWorkflowsActions → 3 actions (Assign Workflow, Save, Save/Publish)
+// mockWorkflowsActionsWithMove → 4 actions (above + Move)
+
+const SEPARATOR_ACTION = {
+ assignable: false,
+ commentable: false,
condition: '',
- icon: 'workflowIcon',
- id: '44d4d4cd-c812-49db-adb1-1030be73e69a',
+ icon: '',
+ id: 'separator-id',
name: 'SEPARATOR',
- nextAssign: 'db0d2bca-5da5-4c18-b5d7-87f02ba58eb6',
- nextStep: '43e16aac-5799-46d0-945c-83753af39426',
+ nextAssign: '',
+ nextStep: '',
nextStepCurrentStep: false,
order: 0,
- owner: null,
- roleHierarchyForAssign: true,
- schemeId: '85c1515c-c4f3-463c-bac2-860b8fcacc34',
- showOn: ['UNLOCKED', 'LOCKED'],
- metadata: {
- subtype: 'SEPARATOR'
- },
- actionInputs: [
- {
- body: {},
- id: 'assignable'
- },
- {
- body: {},
- id: 'commentable'
- },
- {
- body: {},
- id: 'pushPublish'
- },
- { body: {}, id: 'moveable' }
- ]
+ roleHierarchyForAssign: false,
+ schemeId: '',
+ showOn: [],
+ actionInputs: [],
+ metadata: { subtype: 'SEPARATOR' }
};
-const WORKFLOW_ACTIONS_MOCK = [
- ...mockWorkflowsActions,
- WORKFLOW_ACTIONS_SEPARATOR_MOCK,
- ...mockWorkflowsActions
-];
-
const messageServiceMock = new MockDotMessageService({
'edit.ema.page.no.workflow.action': 'no workflow action',
Loading: 'loading'
});
-const getComponents = (spectator: Spectator) => {
- return {
- button: spectator.query(Button),
- splitButton: spectator.query(SplitButton)
- };
+/** Shared mock so responsive tests can drive {@link BreakpointObserver} without a second TestBed module. */
+const breakpointState$ = new BehaviorSubject({ matches: true, breakpoints: {} });
+const breakpointMatchMap: Record = {};
+
+const resetBreakpointMock = (): void => {
+ Object.keys(breakpointMatchMap).forEach((k) => delete breakpointMatchMap[k]);
+ breakpointState$.next({ matches: true, breakpoints: {} });
+};
+
+const setBreakpointMatch = (partial: Record): void => {
+ resetBreakpointMock();
+ Object.assign(breakpointMatchMap, partial);
+ breakpointState$.next({ matches: true, breakpoints: {} });
+};
+
+const breakpointObserverMock: Pick = {
+ observe: jest.fn(() => breakpointState$.asObservable()),
+ isMatched: jest.fn((query: string | readonly string[]) => {
+ const key = typeof query === 'string' ? query : query[0];
+
+ return !!breakpointMatchMap[key];
+ })
};
describe('DotWorkflowActionsComponent', () => {
@@ -71,36 +72,25 @@ describe('DotWorkflowActionsComponent', () => {
const createComponent = createComponentFactory({
component: DotWorkflowActionsComponent,
- imports: [ToolbarModule, SplitButtonModule, DotMessagePipe],
+ imports: [DotMessagePipe],
providers: [
- {
- provide: DotMessageService,
- useValue: messageServiceMock
- },
- DotClipboardUtil
+ { provide: DotMessageService, useValue: messageServiceMock },
+ { provide: BreakpointObserver, useValue: breakpointObserverMock }
],
detectChanges: false
});
beforeEach(() => {
- spectator = createComponent({
- props: {
- actions: WORKFLOW_ACTIONS_MOCK,
- groupActions: true,
- loading: false,
- size: 'normal'
- }
- });
- spectator.detectChanges();
+ resetBreakpointMock();
+ spectator = createComponent({ props: { actions: [] } });
});
- describe('without actions', () => {
+ describe('empty state', () => {
beforeEach(() => {
- spectator.setInput('actions', []);
spectator.detectChanges();
});
- it('should render the empty button with loading', () => {
+ it('should show loading spinner and loading label when loading is true', () => {
spectator.setInput('loading', true);
spectator.detectChanges();
@@ -111,10 +101,7 @@ describe('DotWorkflowActionsComponent', () => {
expect(button.label).toBe('loading');
});
- it('should render the empty button with disabled', () => {
- spectator.setInput('loading', false);
- spectator.detectChanges();
-
+ it('should show disabled button with no-workflow label when not loading', () => {
const button = spectator.query(Button);
expect(button.disabled).toBeTruthy();
@@ -123,140 +110,264 @@ describe('DotWorkflowActionsComponent', () => {
});
});
- describe('group action', () => {
- it('should render an extra split button for each `SEPARATOR` Action', () => {
- const splitButtons = spectator.queryAll(SplitButton);
- spectator.detectComponentChanges();
- expect(splitButtons.length).toBe(2);
+ describe('inline buttons', () => {
+ it('should render 1 primary button for 1 action', () => {
+ spectator.setInput('actions', [mockWorkflowsActions[0]]);
+ spectator.detectChanges();
+
+ const buttons = spectator.queryAll(Button);
+
+ expect(buttons.length).toBe(1);
+ expect(buttons[0].variant).toBeNull();
});
- it('should emit the action when click on a split button', () => {
- const spy = jest.spyOn(spectator.component.actionFired, 'emit');
- const splitButton = spectator.query('.p-splitbutton > button');
- splitButton.dispatchEvent(new Event('click'));
+ it('should render primary and outlined buttons for 2 actions', () => {
+ spectator.setInput('actions', mockWorkflowsActions.slice(0, 2));
+ spectator.detectChanges();
+
+ const buttons = spectator.queryAll(Button);
+
+ expect(buttons.length).toBe(2);
+ expect(buttons[0].variant).toBeNull();
+ expect(buttons[1].variant).toBe('outlined');
+ });
+
+ it('should render primary and outlined buttons for 3 actions', () => {
+ spectator.setInput('actions', mockWorkflowsActions);
+ spectator.detectChanges();
+
+ const buttons = spectator.queryAll(Button);
- expect(spy).toHaveBeenCalledWith(WORKFLOW_ACTIONS_MOCK[0]);
+ expect(buttons.length).toBe(3);
+ expect(buttons[0].variant).toBeNull();
+ expect(buttons[1].variant).toBe('outlined');
+ expect(buttons[2].variant).toBe('outlined');
});
- it('should render a normal button is a group has only one action', () => {
- spectator.setInput('actions', [WORKFLOW_ACTIONS_MOCK[0]]);
+ it('should render labels matching the action names', () => {
+ spectator.setInput('actions', mockWorkflowsActions);
spectator.detectChanges();
- const button = spectator.query('.p-button');
- expect(button).not.toBeNull();
+ const buttons = spectator.queryAll(Button);
+
+ mockWorkflowsActions.forEach((action, idx) => {
+ expect(buttons[idx].label).toBe(action.name);
+ });
+ });
+
+ it('should filter out SEPARATOR actions', () => {
+ spectator.setInput('actions', [
+ mockWorkflowsActions[0],
+ SEPARATOR_ACTION,
+ mockWorkflowsActions[1]
+ ]);
+ spectator.detectChanges();
+
+ const buttons = spectator.queryAll(Button);
+
+ expect(buttons.length).toBe(2);
});
});
- describe('not group action', () => {
- beforeEach(() => {
- spectator.setInput('groupActions', false);
- spectator.detectComponentChanges();
+ describe('overflow', () => {
+ it('should not show overflow button when actions are 3 or fewer', () => {
+ spectator.setInput('actions', mockWorkflowsActions);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('overflow-button'))).toBeNull();
+ expect(spectator.query(Menu)).toBeNull();
});
- it('should render one split button and remove the `SEPARATOR` Action', () => {
- const splitButtons = spectator.queryAll(SplitButton);
- const amountOfItems = WORKFLOW_ACTIONS_MOCK.length - 2; // Less the `SEPARATOR` Action and the First actions which is the default one
- expect(splitButtons.length).toBe(1);
- expect(splitButtons[0].model.length).toBe(amountOfItems);
+ it('should show overflow button when actions exceed the inline cap', () => {
+ setBreakpointMatch({ [Breakpoints.Large]: true });
+ spectator.setInput('actions', mockWorkflowsActionsWithMove);
+ spectator.detectChanges();
+
+ expect(spectator.query(byTestId('overflow-button'))).toBeTruthy();
});
- it('should render a normal button and remove the `SEPARATOR` Action', () => {
- const action = WORKFLOW_ACTIONS_MOCK[0];
+ it('should put actions beyond the cap into the overflow menu model', () => {
+ setBreakpointMatch({ [Breakpoints.Large]: true });
+ spectator.setInput('actions', mockWorkflowsActionsWithMove);
+ spectator.detectChanges();
+
+ const menu = spectator.query(Menu);
+ const overflowAction = mockWorkflowsActionsWithMove[3];
- spectator.setInput('actions', [action, WORKFLOW_ACTIONS_SEPARATOR_MOCK]);
+ expect(menu.model.length).toBe(1);
+ expect(menu.model[0].label).toBe(overflowAction.name);
+ });
+
+ it('should emit actionFired when an overflow menu item command is invoked', () => {
+ setBreakpointMatch({ [Breakpoints.Large]: true });
+ spectator.setInput('actions', mockWorkflowsActionsWithMove);
spectator.detectChanges();
- const buttons = spectator.queryAll(Button);
- expect(buttons.length).toBe(1);
- expect(buttons[0].label.trim()).toBe(action.name.trim());
+ const spy = jest.spyOn(spectator.component.actionFired, 'emit');
+ const menu = spectator.query(Menu);
+ menu.model[0].command({});
+
+ expect(spy).toHaveBeenCalledWith(mockWorkflowsActionsWithMove[3]);
+ });
+ });
+
+ describe('actionFired', () => {
+ beforeEach(() => {
+ spectator.setInput('actions', mockWorkflowsActions);
+ spectator.detectChanges();
+ });
+
+ it('should emit the action when an inline button is clicked', () => {
+ const spy = jest.spyOn(spectator.component.actionFired, 'emit');
+ const action = mockWorkflowsActions[0];
+ const btn = spectator
+ .query(byTestId(`action-button-${action.id}`))
+ ?.querySelector('button');
+
+ spectator.click(btn);
+
+ expect(spy).toHaveBeenCalledWith(action);
});
});
describe('loading', () => {
beforeEach(() => {
+ spectator.setInput('actions', mockWorkflowsActions);
spectator.setInput('loading', true);
- spectator.setInput('actions', [
- ...WORKFLOW_ACTIONS_MOCK,
- WORKFLOW_ACTIONS_SEPARATOR_MOCK,
- WORKFLOW_ACTIONS_MOCK[0]
- ]);
- spectator.detectComponentChanges();
+ spectator.detectChanges();
});
- it('should disabled split buttons and set normal buttons loading', () => {
- const button = spectator.query(Button);
- const splitButton = spectator.query(SplitButton);
+ it('should show loading spinner only on the primary (first) button', () => {
+ const buttons = spectator.queryAll(Button);
- expect(button.loading).toBeTruthy();
- expect(splitButton.disabled).toBeTruthy();
+ expect(buttons[0].loading).toBeTruthy();
+ expect(buttons[1].loading).toBeFalsy();
+ expect(buttons[2].loading).toBeFalsy();
+ });
+
+ it('should disable all buttons while loading', () => {
+ spectator.queryAll(Button).forEach((button) => {
+ expect(button.disabled).toBeTruthy();
+ });
});
});
describe('disabled', () => {
beforeEach(() => {
- spectator.setInput('actions', [
- ...WORKFLOW_ACTIONS_MOCK,
- WORKFLOW_ACTIONS_SEPARATOR_MOCK,
- WORKFLOW_ACTIONS_MOCK[0]
- ]);
+ spectator.setInput('actions', mockWorkflowsActions);
spectator.detectChanges();
});
- it('should disable the button', () => {
- const button = spectator.query(Button);
- expect(button.disabled).toBeFalsy();
-
+ it('should disable all buttons when disabled is true', () => {
spectator.setInput('disabled', true);
spectator.detectChanges();
- expect(button.disabled).toBeTruthy();
+ spectator.queryAll(Button).forEach((button) => {
+ expect(button.disabled).toBeTruthy();
+ });
});
- it('should disabled split buttons ', () => {
- const splitButton = spectator.query(SplitButton);
- expect(splitButton.disabled).toBeFalsy();
+ it('should have all buttons enabled by default', () => {
+ spectator.queryAll(Button).forEach((button) => {
+ expect(button.disabled).toBeFalsy();
+ });
+ });
+ });
- spectator.setInput('disabled', true);
+ describe('size', () => {
+ beforeEach(() => {
+ spectator.setInput('actions', mockWorkflowsActions);
+ });
+
+ it('should use PrimeNG default size when size is normal', () => {
+ spectator.setInput('size', 'normal');
+ spectator.detectChanges();
+
+ spectator.queryAll(Button).forEach((button) => {
+ expect(button.size).toBeUndefined();
+ });
+ });
+
+ it('should set size to small on all buttons', () => {
+ spectator.setInput('size', 'small');
+ spectator.detectChanges();
+
+ spectator.queryAll(Button).forEach((button) => {
+ expect(button.size).toBe('small');
+ });
+ });
+
+ it('should set size to large on all buttons', () => {
+ spectator.setInput('size', 'large');
spectator.detectChanges();
- expect(splitButton.disabled).toBeTruthy();
+ spectator.queryAll(Button).forEach((button) => {
+ expect(button.size).toBe('large');
+ });
});
});
- describe('size', () => {
- beforeEach(() => {
- spectator.setInput('actions', [
- mockWorkflowsActions[0],
- WORKFLOW_ACTIONS_SEPARATOR_MOCK,
- ...mockWorkflowsActions
- ]);
+ /**
+ * {@link DotWorkflowActionsComponent} derives inline vs overflow from CDK {@link BreakpointObserver}.
+ * These tests drive the shared mock so each breakpoint branch runs without relying on viewport size.
+ */
+ describe('responsive inline cap', () => {
+ it('should put all actions in overflow when XSmall matches (0 inline)', () => {
+ setBreakpointMatch({ [Breakpoints.XSmall]: true });
+ spectator.setInput('actions', mockWorkflowsActions);
spectator.detectChanges();
+
+ expect(spectator.queryAll(Button).length).toBe(1);
+ expect(spectator.query(byTestId('overflow-button'))).toBeTruthy();
+ expect(spectator.query(Menu).model.length).toBe(3);
});
- it('should have default size', () => {
- const { button, splitButton } = getComponents(spectator);
- expect(button.size).toBeUndefined();
- expect(splitButton.size).toBeUndefined();
+ it('should show one inline button when Small matches (cap 1)', () => {
+ setBreakpointMatch({ [Breakpoints.Small]: true });
+ spectator.setInput('actions', mockWorkflowsActions);
+ spectator.detectChanges();
+
+ expect(spectator.queryAll(Button).length).toBe(2);
+ expect(spectator.query(byTestId('overflow-button'))).toBeTruthy();
+ expect(spectator.query(Menu).model.length).toBe(2);
});
- it('should set size to small', () => {
- spectator.setInput('size', 'small');
+ it('should show two inline buttons when Medium matches (cap 2)', () => {
+ setBreakpointMatch({ [Breakpoints.Medium]: true });
+ spectator.setInput('actions', mockWorkflowsActions);
spectator.detectChanges();
- const { button, splitButton } = getComponents(spectator);
+ expect(spectator.queryAll(Button).length).toBe(3);
+ expect(spectator.query(byTestId('overflow-button'))).toBeTruthy();
+ expect(spectator.query(Menu).model.length).toBe(1);
+ });
- expect(splitButton.size).toBe('small');
- expect(button.size).toBe('small');
+ it('should show three inline buttons when Large matches (cap 3)', () => {
+ setBreakpointMatch({ [Breakpoints.Large]: true });
+ spectator.setInput('actions', mockWorkflowsActions);
+ spectator.detectChanges();
+
+ expect(spectator.queryAll(Button).length).toBe(3);
+ expect(spectator.query(byTestId('overflow-button'))).toBeNull();
});
- it('should set size to large', () => {
- spectator.setInput('size', 'large');
+ it('should put one action in overflow when Large matches and there are four actions (cap 3)', () => {
+ setBreakpointMatch({ [Breakpoints.Large]: true });
+ spectator.setInput('actions', mockWorkflowsActionsWithMove);
spectator.detectChanges();
- const { button, splitButton } = getComponents(spectator);
+ expect(spectator.queryAll(Button).length).toBe(4); // 3 inline + overflow button
+ expect(spectator.query(byTestId('overflow-button'))).toBeTruthy();
+ expect(spectator.query(Menu).model.length).toBe(1);
+ });
+
+ it('should show all four inline buttons when no CDK breakpoint matches (XLarge fallback, cap 4)', () => {
+ setBreakpointMatch({});
+ spectator.setInput('actions', mockWorkflowsActionsWithMove);
+ spectator.detectChanges();
- expect(button.size).toBe('large');
- expect(splitButton.size).toBe('large');
+ expect(spectator.queryAll(Button).length).toBe(4);
+ expect(spectator.query(byTestId('overflow-button'))).toBeNull();
});
});
});
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 4eb4da1bc0ea..86e5d6618c22 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
@@ -1,15 +1,12 @@
-import {
- ChangeDetectionStrategy,
- Component,
- input,
- OnChanges,
- output,
- signal
-} from '@angular/core';
+import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
+import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
import { MenuItem } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
-import { SplitButtonModule } from 'primeng/splitbutton';
+import { MenuModule } from 'primeng/menu';
+
+import { map } from 'rxjs/operators';
import { DotCMSActionSubtype, DotCMSWorkflowAction } from '@dotcms/dotcms-models';
@@ -17,149 +14,145 @@ import { DotMessagePipe } from '../../dot-message/dot-message.pipe';
type ButtonSize = 'normal' | 'small' | 'large';
-interface WorkflowActionsGroup {
- mainAction: MenuItem;
- subActions: MenuItem[];
-}
-
+/**
+ * Maximum number of workflow actions rendered as inline buttons on a wide viewport.
+ * Narrower screens use a lower cap via {@link DotWorkflowActionsComponent.#inlineCap}.
+ */
+const MAX_INLINE_ACTIONS = 4;
+
+/**
+ * Displays workflow actions as a command bar.
+ *
+ * Up to four actions are shown inline when the viewport allows, ordered right to left:
+ * - 1st action (rightmost): default solid button (no variant)
+ * - 2nd action: outlined button (border, transparent background)
+ * - 3rd action: outlined (same tier as 2nd in current styling)
+ *
+ * When there are more actions than the inline cap, the rest go to an overflow menu (···).
+ * The inline cap follows CDK {@link Breakpoints} in {@link #inlineCap}: XSmall → 0, Small → 1,
+ * Medium → 2, Large → 3, XLarge and wider → {@link MAX_INLINE_ACTIONS} (4).
+ *
+ * SEPARATOR actions are always filtered out before rendering.
+ *
+ * @example
+ *
+ */
@Component({
selector: 'dot-workflow-actions',
- imports: [ButtonModule, SplitButtonModule, DotMessagePipe],
+ imports: [ButtonModule, MenuModule, DotMessagePipe],
templateUrl: './dot-workflow-actions.component.html',
- changeDetection: ChangeDetectionStrategy.OnPush
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: { class: 'flex flex-row-reverse gap-2' }
})
-export class DotWorkflowActionsComponent implements OnChanges {
+export class DotWorkflowActionsComponent {
/**
- * List of actions to display
- *
- * @type {DotCMSWorkflowAction[]}
- * @memberof DotWorkflowActionsComponent
+ * CDK helper that listens to viewport media queries; used to derive {@link #inlineCap}
+ * without manual `matchMedia` subscriptions.
+ */
+ readonly #breakpointObserver = inject(BreakpointObserver);
+
+ /**
+ * How many workflow actions render as inline buttons (0–4) for the current viewport.
+ * XSmall → 0, Small → 1, Medium → 2, Large → 3, XLarge and wider → 4.
+ */
+ readonly #inlineCap = toSignal(
+ this.#breakpointObserver
+ .observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium, Breakpoints.Large])
+ .pipe(
+ map(() => {
+ if (this.#breakpointObserver.isMatched(Breakpoints.XSmall)) return 0;
+ if (this.#breakpointObserver.isMatched(Breakpoints.Small)) return 1;
+ if (this.#breakpointObserver.isMatched(Breakpoints.Medium)) return 2;
+ if (this.#breakpointObserver.isMatched(Breakpoints.Large)) return 3;
+
+ return MAX_INLINE_ACTIONS;
+ })
+ ),
+ { initialValue: MAX_INLINE_ACTIONS }
+ );
+
+ /**
+ * List of workflow actions to display.
+ * SEPARATOR actions are filtered out automatically.
*/
actions = input.required();
+
/**
- * Show a loading button spinner
- *
- * @memberof DotWorkflowActionsComponent
+ * Shows a loading spinner on the primary (first) action button.
*/
loading = input(false);
+
/**
- * Disable the actions
- *
- * @memberof DotWorkflowActionsComponent
+ * Disables all action buttons.
*/
disabled = input(false);
+
/**
- * Group the actions by separator
- *
- * @memberof DotWorkflowActionsComponent
- */
- groupActions = input(false);
- /**
- * Button size
- *
- * @type {ButtonSize}
- * @memberof DotWorkflowActionsComponent
+ * Button size passed through to PrimeNG.
+ * 'normal' maps to PrimeNG's default (no size attribute).
*/
size = input('normal');
+
/**
- * Emits when an action is selected
- *
- * @memberof DotWorkflowActionsComponent
+ * Emits the selected {@link DotCMSWorkflowAction} when the user clicks any action,
+ * including actions inside the overflow menu.
*/
actionFired = output();
- protected $groupedActions = signal([]);
-
/**
- * Get the size for PrimeNG button component
- * PrimeNG only accepts 'small' | 'large', not 'normal'
- *
- * @protected
- * @return {*} {('small' | 'large' | undefined)}
- * @memberof DotWorkflowActionsComponent
+ * All actions with SEPARATOR entries removed.
*/
- protected getButtonSize(): 'small' | 'large' | undefined {
- const currentSize = this.size();
- return currentSize === 'normal' ? undefined : currentSize;
- }
-
- ngOnChanges(): void {
- if (!this.actions().length) {
- this.$groupedActions.set([]);
-
- return;
- }
+ protected $flatActions = computed(() =>
+ this.actions().filter(
+ (action) => action?.metadata?.subtype !== DotCMSActionSubtype.SEPARATOR
+ )
+ );
- this.setActions();
- }
+ /**
+ * Actions rendered as inline buttons — slice length follows {@link #inlineCap} (can be 0).
+ */
+ protected $visibleActions = computed(() => this.$flatActions().slice(0, this.#inlineCap()));
/**
- * Set the actions to display
- *
- * @private
- * @memberof DotWorkflowActionsComponent
+ * Actions not shown inline, mapped to PrimeNG {@link MenuItem} for the overflow popup menu.
*/
- private setActions(): void {
- const groups = this.createGroups(this.actions());
- const actions = groups.map((group) => {
- const [first, ...rest] = group;
- const mainAction = this.createActions(first);
- const subActions = rest.map((action) => this.createActions(action));
+ protected $overflowActions = computed((): MenuItem[] =>
+ this.$flatActions()
+ .slice(this.#inlineCap())
+ .map((action) => ({
+ label: action.name,
+ command: () => this.actionFired.emit(action)
+ }))
+ );
- return { mainAction, subActions };
- });
+ /**
+ * Maps the component's ButtonSize to PrimeNG's accepted size values.
+ * PrimeNG does not accept 'normal', so it is converted to undefined (default).
+ */
+ protected getButtonSize(): 'small' | 'large' | undefined {
+ const s = this.size();
- this.$groupedActions.set(actions);
+ return s === 'normal' ? undefined : s;
}
/**
- * Create the groups of actions
- * Each group is create when a separator is found
+ * Returns the PrimeNG button variant for a given position index among visible inline buttons.
+ * - 0 → null (no variant — default solid button)
+ * - 1+ → 'outlined'
*
- * @private
- * @param {DotCMSWorkflowAction[]} actions
- * @return {*} {DotCMSWorkflowAction[][]}
- * @memberof DotWorkflowActionsComponent
+ * null is intentional for index 0: Angular drops null bindings entirely,
+ * so PrimeNG receives no variant and renders its default button style.
*/
- private createGroups(actions: DotCMSWorkflowAction[]): DotCMSWorkflowAction[][] {
- if (!this.groupActions()) {
- // Remove the separator from the actions and return the actions grouped
- const formatActions = actions.filter(
- (action) => action?.metadata?.subtype !== DotCMSActionSubtype.SEPARATOR
- );
-
- return [formatActions].filter((group) => !!group.length);
- }
-
- // Create a new group every time we find a separator
- return actions
- .reduce(
- (acc, action) => {
- if (action?.metadata?.subtype === DotCMSActionSubtype.SEPARATOR) {
- acc.push([]);
- } else {
- acc[acc.length - 1].push(action);
- }
-
- return acc;
- },
- [[]]
- )
- .filter((group) => !!group.length);
+ protected getVariant(index: number): 'outlined' | null {
+ if (index > 0) return 'outlined';
+
+ return null;
}
- /**
- * Create the action menu item
- *
- * @private
- * @param {DotCMSWorkflowAction} action
- * @return {*} {MenuItem}
- * @memberof DotWorkflowActionsComponent
- */
- private createActions(action: DotCMSWorkflowAction): MenuItem {
- return {
- label: action.name,
- command: () => this.actionFired.emit(action)
- };
+ protected fireAction(action: DotCMSWorkflowAction): void {
+ this.actionFired.emit(action);
}
}