Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<dot-workflow-actions
(actionFired)="handleActionTrigger($event)"
[size]="'small'"
[groupActions]="true"
[loading]="loading()"
[disabled]="!canEdit()"
[actions]="actions()" />
Original file line number Diff line number Diff line change
@@ -1,30 +1,61 @@
@if ($flatActions().length === 0) {
<p-button
[loading]="loading()"
[disabled]="!loading()"
[size]="getButtonSize()"
[label]="(loading() ? 'Loading' : 'edit.ema.page.no.workflow.action') | dm"
data-testId="empty-button" />
} @else {
@if ($overflowActions().length) {
<p-menu #overflowMenu [model]="$overflowActions()" [popup]="true" appendTo="body" />
@if (groupActions()) {
@for (action of $groupedActions(); track $index; let first = $first) {
@let mainAction = action.mainAction;
@let subActions = action.subActions;
@if (subActions.length) {
<p-splitButton
(onClick)="mainAction.command?.({ originalEvent: $event })"
[disabled]="loading() || disabled()"
[size]="getButtonSize()"
[model]="subActions"
[outlined]="!first"
[label]="mainAction.label" />
} @else {
<p-button
(onClick)="mainAction.command?.({ originalEvent: $event })"
[loading]="loading()"
[disabled]="disabled()"
Comment thread
adrianjm-dotCMS marked this conversation as resolved.
[size]="getButtonSize()"
[variant]="!first ? 'outlined' : undefined"
[label]="mainAction.label" />
}
} @empty {
<p-button
(onClick)="overflowMenu.toggle($event)"
[rounded]="true"
[loading]="loading()"
[disabled]="!loading()"
[size]="getButtonSize()"
[disabled]="loading() || disabled()"
icon="pi pi-ellipsis-v"
[variant]="'outlined'"
data-testId="overflow-button" />
[label]="(loading() ? 'Loading' : 'edit.ema.page.no.workflow.action') | dm"
data-testId="empty-button" />
}
@for (action of $visibleActions(); track action.id; let idx = $index; let first = $first) {
} @else {
@if ($flatActions().length === 0) {
<p-button
(onClick)="fireAction(action)"
[loading]="first && loading()"
[disabled]="loading() || disabled()"
[loading]="loading()"
[disabled]="!loading()"
[size]="getButtonSize()"
[variant]="getVariant(idx)"
[label]="action.name"
[attr.data-testId]="'action-button-' + action.id" />
[label]="(loading() ? 'Loading' : 'edit.ema.page.no.workflow.action') | dm"
data-testId="empty-button" />
} @else {
@if ($overflowActions().length) {
<p-menu #overflowMenu [model]="$overflowActions()" [popup]="true" appendTo="body" />
<p-button
(onClick)="overflowMenu.toggle($event)"
[rounded]="true"
[size]="getButtonSize()"
[disabled]="loading() || disabled()"
icon="pi pi-ellipsis-v"
[variant]="'outlined'"
data-testId="overflow-button" />
}
@for (action of $visibleActions(); track action.id; let idx = $index; let first = $first) {
<p-button
(onClick)="fireAction(action)"
[loading]="first && loading()"
[disabled]="loading() || disabled()"
[size]="getButtonSize()"
[variant]="getVariant(idx)"
[label]="action.name"
[attr.data-testId]="'action-button-' + action.id" />
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { BreakpointObserver, Breakpoints, BreakpointState } from '@angular/cdk/l

import { Button } from 'primeng/button';
import { Menu } from 'primeng/menu';
import { SplitButton } from 'primeng/splitbutton';

import { DotMessageService } from '@dotcms/data-access';
import {
Expand Down Expand Up @@ -370,4 +371,104 @@ describe('DotWorkflowActionsComponent', () => {
expect(spectator.query(byTestId('overflow-button'))).toBeNull();
});
});

describe('groupActions', () => {
const actionsWithSeparator = [
mockWorkflowsActions[0],
SEPARATOR_ACTION,
mockWorkflowsActions[1],
mockWorkflowsActions[2]
];

beforeEach(() => {
spectator.setInput('groupActions', true);
});

it('should render a p-splitButton when the group has sub-actions', () => {
spectator.setInput('actions', mockWorkflowsActions);
spectator.detectChanges();

const splitButtons = spectator.queryAll(SplitButton);

expect(splitButtons.length).toBe(1);
expect(splitButtons[0].label).toBe(mockWorkflowsActions[0].name);
expect(splitButtons[0].model.length).toBe(2);
});

it('should put sub-actions in the splitButton model with correct labels', () => {
spectator.setInput('actions', mockWorkflowsActions);
spectator.detectChanges();

const [splitButton] = spectator.queryAll(SplitButton);

expect(splitButton.model[0].label).toBe(mockWorkflowsActions[1].name);
expect(splitButton.model[1].label).toBe(mockWorkflowsActions[2].name);
});

it('should emit actionFired for the main action when the splitButton primary button is clicked', () => {
spectator.setInput('actions', mockWorkflowsActions);
spectator.detectChanges();

const spy = jest.spyOn(spectator.component.actionFired, 'emit');
const [splitButton] = spectator.queryAll(SplitButton);
splitButton.onClick.emit({});

expect(spy).toHaveBeenCalledWith(mockWorkflowsActions[0]);
});

it('should emit actionFired when a sub-action command is invoked', () => {
spectator.setInput('actions', mockWorkflowsActions);
spectator.detectChanges();

const spy = jest.spyOn(spectator.component.actionFired, 'emit');
const [splitButton] = spectator.queryAll(SplitButton);
splitButton.model[0].command({});

expect(spy).toHaveBeenCalledWith(mockWorkflowsActions[1]);
});

it('should render one p-splitButton per separator-delimited group', () => {
spectator.setInput('actions', actionsWithSeparator);
spectator.detectChanges();

// Group 1: [action0] → p-button (no sub-actions)
// Group 2: [action1, action2] → p-splitButton
expect(spectator.queryAll(SplitButton).length).toBe(1);
expect(spectator.queryAll(Button).length).toBe(1);
});

it('should render a plain p-button for a single-action group', () => {
spectator.setInput('actions', [mockWorkflowsActions[0]]);
spectator.detectChanges();

expect(spectator.queryAll(SplitButton).length).toBe(0);
expect(spectator.queryAll(Button).length).toBe(1);
});

it('should show empty-button and no split-button when actions list is empty', () => {
spectator.setInput('actions', []);
spectator.detectChanges();

expect(spectator.queryAll(SplitButton).length).toBe(0);
expect(spectator.query(byTestId('empty-button'))).toBeTruthy();
});

it('should not render overflow menu — flat path is inactive when groupActions is true', () => {
spectator.setInput('actions', mockWorkflowsActions);
spectator.detectChanges();

expect(spectator.query(byTestId('overflow-button'))).toBeNull();
expect(spectator.query(Menu)).toBeNull();
});

it('should use breakpoint-based inline cap when groupActions is false', () => {
setBreakpointMatch({});
spectator.setInput('groupActions', false);
spectator.setInput('actions', mockWorkflowsActionsWithMove);
spectator.detectChanges();

expect(spectator.queryAll(Button).length).toBe(4);
expect(spectator.query(byTestId('overflow-button'))).toBeNull();
});
});
Comment thread
adrianjm-dotCMS marked this conversation as resolved.
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { MenuItem } from 'primeng/api';
import { ButtonModule } from 'primeng/button';
import { MenuModule } from 'primeng/menu';
import { SplitButtonModule } from 'primeng/splitbutton';

import { map } from 'rxjs/operators';

Expand All @@ -14,6 +15,11 @@ 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}.
Expand All @@ -23,26 +29,33 @@ const MAX_INLINE_ACTIONS = 4;
/**
* Displays workflow actions as a command bar.
*
* ## Flat mode (default, `groupActions=false`)
* 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)
* - 2nd+ actions: outlined buttons
*
* 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,
* The inline cap follows CDK {@link Breakpoints}: XSmall → 0, Small → 1,
* Medium → 2, Large → 3, XLarge and wider → {@link MAX_INLINE_ACTIONS} (4).
*
* SEPARATOR actions are always filtered out before rendering.
*
* ## Grouped mode (`groupActions=true`)
* Actions are grouped by SEPARATOR entries. Each group renders as a `p-splitButton`:
* the first action in the group is the main button; the rest appear in its dropdown.
* Groups with a single action render as a plain `p-button`.
* Use this mode in constrained UIs like the UVE toolbar to preserve the classic split-button layout.
*
* @example
* <dot-workflow-actions
* [actions]="workflowActions"
* [loading]="isSaving"
* (actionFired)="onAction($event)" />
* <!-- Flat mode (edit-content) -->
* <dot-workflow-actions [actions]="workflowActions" (actionFired)="onAction($event)" />
*
* @example
* <!-- Grouped/split-button mode (UVE) -->
* <dot-workflow-actions [actions]="workflowActions" [groupActions]="true" (actionFired)="onAction($event)" />
*/
@Component({
selector: 'dot-workflow-actions',
imports: [ButtonModule, MenuModule, DotMessagePipe],
imports: [ButtonModule, MenuModule, SplitButtonModule, DotMessagePipe],
templateUrl: './dot-workflow-actions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'flex flex-row-reverse gap-2' }
Expand All @@ -57,6 +70,7 @@ export class DotWorkflowActionsComponent {
/**
* 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.
* Only used in flat mode (`groupActions=false`).
*/
readonly #inlineCap = toSignal(
this.#breakpointObserver
Expand All @@ -76,7 +90,6 @@ export class DotWorkflowActionsComponent {

/**
* List of workflow actions to display.
* SEPARATOR actions are filtered out automatically.
*/
actions = input.required<DotCMSWorkflowAction[]>();

Expand All @@ -90,20 +103,64 @@ export class DotWorkflowActionsComponent {
*/
disabled = input<boolean>(false);

/**
* When true, renders actions as `p-splitButton` groups separated by SEPARATOR actions.
* Use this in the UVE toolbar to preserve the classic split-button layout.
* When false (default), renders actions as inline buttons with an overflow menu.
*/
groupActions = input<boolean>(false);

/**
* Button size passed through to PrimeNG.
* 'normal' maps to PrimeNG's default (no size attribute).
*/
size = input<ButtonSize>('normal');

/**
* Emits the selected {@link DotCMSWorkflowAction} when the user clicks any action,
* including actions inside the overflow menu.
* Emits the selected {@link DotCMSWorkflowAction} when the user clicks any action.
*/
actionFired = output<DotCMSWorkflowAction>();

// --- Grouped mode (p-splitButton, groupActions=true) ---

/**
* Actions grouped by SEPARATOR entries, each mapped to a mainAction + subActions pair.
* Empty array when `groupActions=false`.
*/
protected $groupedActions = computed((): WorkflowActionsGroup[] => {
if (!this.groupActions()) return [];

return this.actions()
.reduce<DotCMSWorkflowAction[][]>(
(acc, action) => {
if (action?.metadata?.subtype === DotCMSActionSubtype.SEPARATOR) {
acc.push([]);
} else {
acc[acc.length - 1].push(action);
}

return acc;
},
[[]]
)
.filter((group) => group.length > 0)
.map(([first, ...rest]) => ({
mainAction: {
label: first.name,
command: () => this.actionFired.emit(first)
},
subActions: rest.map((action) => ({
label: action.name,
command: () => this.actionFired.emit(action)
}))
}));
});

// --- Flat mode (inline buttons + overflow menu, groupActions=false) ---

/**
* All actions with SEPARATOR entries removed.
* Used only in flat mode (`groupActions=false`).
*/
protected $flatActions = computed(() =>
this.actions().filter(
Expand Down Expand Up @@ -147,9 +204,7 @@ export class DotWorkflowActionsComponent {
* so PrimeNG receives no variant and renders its default button style.
*/
protected getVariant(index: number): 'outlined' | null {
if (index > 0) return 'outlined';

return null;
return index > 0 ? 'outlined' : null;
}

protected fireAction(action: DotCMSWorkflowAction): void {
Expand Down
Loading