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); } }