Skip to content
Brok UI

Loading…

No results

Approval Workflow

Open source

A server-driven approval surface with explicit states, allowlisted actions, per-action comment rules, idempotency and record-version hooks, and an immutable history slot.

Version
v1.1.2
Stability
stable
License
MIT
Related
Form
Status History

Preview

Approval

Pending
Review request REQ-1042 before you make a decision.
  1. Submitted

    Completed
previews.components.approval-workflow.default.blade.php Blade
@php
    $actions = [
        ['key' => 'approve', 'label' => __('Approve')],
        ['key' => 'reject', 'label' => __('Reject'), 'destructive' => true, 'commentRequired' => true, 'commentPlaceholder' => __('Explain why this request is rejected.')],
    ];
@endphp
<x-ui.approval-workflow state="pending" :actions="$actions" action-url="#" idempotency-key="preview-intent" record-version="7">
    {{ __('Review request REQ-1042 before you make a decision.') }}
    <x-slot:history>
        <x-ui.status-history :events="[['state' => 'completed', 'label' => __('Submitted'), 'at' => now()->subDay()]]" />
    </x-slot:history>
</x-ui.approval-workflow>

Installation

terminal
php artisan ui:add approval-workflow

Registry contract

php artisan ui:add approval-workflow writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/approval-workflow.blade.php
Registry dependencies
card badge form field textarea button alert
Packages
composer: jml/brok:^0.2

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

approval-workflow.md
# Brok UI: Approval Workflow (`approval-workflow`)

A server-driven approval surface with explicit states, allowlisted actions, per-action comment rules, idempotency and record-version hooks, and an immutable history slot.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add approval-workflow
```

## Usage

```blade
@php
    $actions = [
        ['key' => 'approve', 'label' => __('Approve')],
        ['key' => 'reject', 'label' => __('Reject'), 'destructive' => true, 'commentRequired' => true, 'commentPlaceholder' => __('Explain why this request is rejected.')],
    ];
@endphp
<x-ui.approval-workflow state="pending" :actions="$actions" action-url="#" idempotency-key="preview-intent" record-version="7">
    {{ __('Review request REQ-1042 before you make a decision.') }}
    <x-slot:history>
        <x-ui.status-history :events="[['state' => 'completed', 'label' => __('Submitted'), 'at' => now()->subDay()]]" />
    </x-slot:history>
</x-ui.approval-workflow>
```

## Props

- `state` (pending|approved|rejected|cancelled, default `pending`) — Server-owned workflow state.
- `actions` (list<ApprovalAction>, default `[]`) — Up to eight authorized actions: key, label, destructive (reject/cancel by default), primary (approve/accept by default) and commentRequired. The pressed button posts its key as `decision`.
- `actionUrl` (url|null, default `null`) — Application workflow endpoint.
- `idempotencyKey` (string|null, default `null`) — Key that makes the decision submission idempotent; the server pairs it with the posted decision.
- `recordVersion` (string|null, default `null`) — Optimistic concurrency value checked by the server.
- `commentLabel` (string, default `Comment`) — Localized comment label.
- `historyLabel` (string, default `Approval history`) — Accessible history label.
- `error` (string|null, default `null`) — Server error message.

## Use when

- Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
- Present actions and state for an approval workflow that the application owns.

## Avoid when

- Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
- Do not use this component as a workflow database or authorization engine. Use audit-log to display the resulting history once decided.

## Anti-patterns

- Using cards for dense cross-item comparison

## Rules

- Use the `<brok:approval-workflow>` tag (or `<x-ui.approval-workflow>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/approval-workflow
- Registry JSON (files, props, contract): https://brokui.dev/r/open/approval-workflow.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

error.blade.php Blade
<x-ui.approval-workflow state="pending" error="{{ __('This request changed. Reload it before you submit another decision.') }}" />
long-content.blade.php Blade
<div class="max-w-sm">
    <x-ui.approval-workflow state="pending" :actions="[['key' => 'request_changes', 'label' => __('Request changes with a deliberately long translated action label'), 'commentRequired' => true]]">
        {{ __('A deliberately long example that verifies wrapping, overflow, and content expansion without clipping important information.') }}
    </x-ui.approval-workflow>
</div>
states.blade.php Blade
<div class="grid gap-4 md:grid-cols-3">
    <x-ui.approval-workflow state="approved"><p class="text-sm">{{ __('This request was approved.') }}</p></x-ui.approval-workflow>
    <x-ui.approval-workflow state="rejected"><p class="text-sm">{{ __('This request was rejected.') }}</p></x-ui.approval-workflow>
    <x-ui.approval-workflow state="cancelled"><p class="text-sm">{{ __('This request was cancelled.') }}</p></x-ui.approval-workflow>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
state pending | approved | rejected | cancelled pending Server-owned workflow state.
actions list<ApprovalAction> [] Up to eight authorized actions: key, label, destructive (reject/cancel by default), primary (approve/accept by default) and commentRequired. The pressed button posts its key as `decision`.
actionUrl url | null null Application workflow endpoint.
idempotencyKey string | null null Key that makes the decision submission idempotent; the server pairs it with the posted decision.
recordVersion string | null null Optimistic concurrency value checked by the server.
commentLabel string Comment Localized comment label.
historyLabel string Approval history Accessible history label.
error string | null null Server error message.

Slots

  • default — Immutable approval context.
  • history — Immutable history supplied by the application.

Data slots

Stable hooks for CSS overrides and browser tests.

approval-workflow approval-workflow-actions approval-workflow-context

Behavior

  • Renders actions only while pending.
  • One ordinary server form: a single comment field and one submit button per decision (name=decision). Choosing an action with commentRequired makes the comment required before the browser submits.
  • Declares registry capability flags: a11y, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Card and content widget

Present a self-contained content object with local context.

Use when

  • Use when self-contained content objects benefit from a recognizable card-like presentation with local actions or metadata.
  • Present actions and state for an approval workflow that the application owns.

Avoid when

  • Do not use card widgets where a simpler list, table, or plain text block would make comparison faster.
  • Do not use this component as a workflow database or authorization engine. Use audit-log to display the resulting history once decided.

Use instead

  • List for compact scanning
  • Table for exact comparison

Anti-patterns

  • Using cards for dense cross-item comparison
Anatomy
approval-workflow
Theming hooks
Uses semantic card, badge, field, textarea, and button tokens.

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • State and actions use visible text.
  • Required comments use native required semantics and Laravel validation errors.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="approval-workflow-{{ $record->id }}">
    @php
        $actions = [
            ['key' => 'approve', 'label' => __('Approve')],
            ['key' => 'reject', 'label' => __('Reject'), 'destructive' => true, 'commentRequired' => true, 'commentPlaceholder' => __('Explain why this request is rejected.')],
        ];
    @endphp
    <x-ui.approval-workflow state="pending" :actions="$actions" action-url="#" idempotency-key="preview-intent" record-version="7">
        {{ __('Review request REQ-1042 before you make a decision.') }}
        <x-slot:history>
            <x-ui.status-history :events="[['state' => 'completed', 'label' => __('Submitted'), 'at' => now()->subDay()]]" />
        </x-slot:history>
    </x-ui.approval-workflow>
</div>

Source

The exact, editable file ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/approval-workflow.blade.php Blade
@props([
    'state' => 'pending',
    'actions' => [],
    'actionUrl' => null,
    'idempotencyKey' => null,
    'recordVersion' => null,
    'commentLabel' => 'Comment',
    'historyLabel' => 'Approval history',
    'error' => null,
])

@php
    $states = [
        'pending' => ['variant' => 'warning', 'label' => 'Pending'],
        'approved' => ['variant' => 'success', 'label' => 'Approved'],
        'rejected' => ['variant' => 'destructive', 'label' => 'Rejected'],
        'cancelled' => ['variant' => 'secondary', 'label' => 'Cancelled'],
    ];
    $resolvedState = array_key_exists($state, $states) ? $state : 'pending';
    $actions = array_slice(array_values(array_filter(
        (array) $actions,
        fn ($action): bool => is_array($action) && filled($action['key'] ?? null),
    )), 0, 8);
@endphp

<x-ui.card data-slot="approval-workflow" data-state="{{ $resolvedState }}" {{ $attributes }}>
    <x-ui.card.header class="flex-row flex-wrap items-center justify-between gap-3">
        <x-ui.card.title>{{ __('Approval') }}</x-ui.card.title>
        <x-ui.badge :variant="$states[$resolvedState]['variant']">{{ __($states[$resolvedState]['label']) }}</x-ui.badge>
    </x-ui.card.header>
    <x-ui.card.content class="space-y-5">
        @if (filled($error))
            <x-ui.alert variant="destructive" role="alert">{{ $error }}</x-ui.alert>
        @endif
        @if (! $slot->isEmpty())
            {{-- Wrapped so plain text in the slot still takes the stack spacing. --}}
            <div data-slot="approval-workflow-context" class="text-sm text-muted-foreground">{{ $slot }}</div>
        @endif

        @if ($resolvedState === 'pending' && $actions !== [])
            @php
                // One comment, one form: the pressed button names the decision.
                // Actions that need a comment make the field required the moment
                // they are chosen, so the browser blocks an empty rejection.
                $requiring = array_values(array_map(
                    static fn (array $a): string => preg_replace('/[^a-z0-9_-]/i', '', (string) $a['key']),
                    array_filter($actions, static fn (array $a): bool => (bool) ($a['commentRequired'] ?? false)),
                ));
                $placeholder = $requiring !== []
                    ? __('Add a note for the requester (required to :actions).', ['actions' => mb_strtolower(implode(' / ', array_map(static fn (array $a) => $a['label'] ?? ucfirst((string) $a['key']), array_filter($actions, static fn (array $a): bool => (bool) ($a['commentRequired'] ?? false)))))])
                    : __('Add a note for the requester.');
            @endphp
            <x-ui.form
                :action="$actionUrl"
                method="POST"
                :idempotency-key="$idempotencyKey"
                :record-version="$recordVersion"
                data-slot="approval-workflow-actions"
                class="space-y-4"
                x-data="{
                    decision: null,
                    requiring: {{ \Illuminate\Support\Js::from($requiring) }},
                    // The pressed decision also names the idempotency key
                    // (base:decision), so each decision is its own intent.
                    choose(key, intent) {
                        this.decision = key;
                        const input = this.$root.querySelector('input[name=idempotency_key]');
                        if (input && intent) input.value = intent;
                    },
                }"
            >
                <x-ui.field name="comment" :label="$commentLabel">
                    <x-ui.textarea
                        name="comment"
                        rows="3"
                        :placeholder="$placeholder"
                        x-bind:required="requiring.includes(decision)"
                        x-bind:aria-required="requiring.includes(decision) ? 'true' : 'false'"
                    />
                </x-ui.field>
                <div class="flex flex-wrap items-center gap-2">
                    @foreach ($actions as $action)
                        @php
                            $key = preg_replace('/[^a-z0-9_-]/i', '', (string) $action['key']);
                            $destructive = (bool) ($action['destructive'] ?? in_array($key, ['reject', 'cancel'], true));
                            $primary = (bool) ($action['primary'] ?? in_array($key, ['approve', 'accept'], true));
                        @endphp
                        @if ($key !== '')
                            <x-ui.button
                                type="submit"
                                name="decision"
                                value="{{ $key }}"
                                :variant="$destructive ? 'destructive' : ($primary ? 'default' : 'outline')"
                                class="h-auto min-h-10 py-2 [&_[data-slot=button-label]]:whitespace-normal"
                                :data-idempotency-key="$idempotencyKey ? $idempotencyKey.':'.$key : null"
                                @click="choose({{ \Illuminate\Support\Js::from($key) }}, $el.dataset.idempotencyKey)"
                            >
                                {{ $action['label'] ?? ucfirst($key) }}
                            </x-ui.button>
                        @endif
                    @endforeach
                </div>
            </x-ui.form>
        @endif

        @isset($history)
            <section aria-label="{{ __($historyLabel) }}" class="border-t border-border pt-4">
                {{ $history }}
            </section>
        @endisset
    </x-ui.card.content>
</x-ui.card>

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0