Bulk Job
Async bulk-operation progress with a per-item failure list, retry-failed, download-report, and dismiss.
Preview
{{-- One snapshot of a running job; the host re-renders it as the job progresses. --}}
<div class="flex w-full max-w-md flex-col gap-4">
<x-ui.bulk-job :action="__('Exporting 120 invoices')" state="running" :done="72" :total="120" />
<x-ui.bulk-job :action="__('Archiving 40 orders')" state="done" :done="40" :total="40" download-url="#" report-url="#" />
</div>
Installation
php artisan ui:add bulk-job
Note
This component ships an Alpine behavior module at
resources/js/ui/bulk-job.js. Import it once from your bundle so it registers on alpine:init:
import './bulk-job.js';
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.
# Brok UI: Bulk Job (`bulk-job`)
Async bulk-operation progress with a per-item failure list, retry-failed, download-report, and dismiss.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add bulk-job
```
## Usage
```blade
{{-- One snapshot of a running job; the host re-renders it as the job progresses. --}}
<div class="flex w-full max-w-md flex-col gap-4">
<x-ui.bulk-job :action="__('Exporting 120 invoices')" state="running" :done="72" :total="120" />
<x-ui.bulk-job :action="__('Archiving 40 orders')" state="done" :done="40" :total="40" download-url="#" report-url="#" />
</div>
```
## Props
- `action` (string, default ``) — Short description of what is running, e.g. "Archiving 40 orders".
- `state` (running|done, default `running`) — Whether the job is still in progress or has finished (with or without failures).
- `done` (int, default `0`) — Items processed so far. Clamped to [0, total].
- `total` (int, default `0`) — Total items in the run.
- `failed` (array, default `[]`) — Per-item failures: each entry is ['id', 'reason']. Drives the failure list and the retry-failed form.
- `message` (string|null, default `null`) — Optional inline warning or informational line under the progress sentence.
- `retryUrl` (string|null, default `null`) — POST action that resubmits only the failed item ids. Hidden when there are no failures.
- `downloadUrl` (string|null, default `null`) — Link to download files produced by the run. Shown once the job is no longer running.
- `reportUrl` (string|null, default `null`) — Link to download a report of the run. Shown once the job is no longer running.
- `dismissible` (bool, default `true`) — Whether a dismiss control is rendered. The dismissed state is local to the browser tab.
- `client` (bool, default `false`) — Emit bulk-job:retry with the failed ids instead of posting retryUrl. For a host that owns the job state.
- `retryEvent` (string, default `bulk-job:retry`) — Event the client-driven retry dispatches, with the failed ids as payload. Named by the caller.
## Use when
- Use to show state, status, notification, or lightweight social feedback near the relevant object.
- Reporting the outcome of a bulk operation (archive, export, re-price, re-tag, …) run across a selection.
- Naming every failure with its own reason so the operator can act on exactly what did not go through.
## Avoid when
- Do not overload small status elements with explanations that should remain visible in text.
- The operation is synchronous and finishes before the next paint; use inline validation or a toast instead.
- Nothing has failed and there is no per-item detail to report; a plain <x-ui.progress> is enough.
## Anti-patterns
- Using color or icon alone to communicate state
## Rules
- Use the `<brok:bulk-job>` tag (or `<x-ui.bulk-job>`) 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/bulk-job
- Registry JSON (files, props, contract): https://brokui.dev/r/open/bulk-job.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
{{-- Client-state mode: retry is an event carrying the failed ids, not a POST. --}}
<div
x-data="{ last: null }"
x-on:bulk-job:retry="last = `retry ${$event.detail.ids.join(', ')}`"
class="flex flex-col gap-3"
>
<x-ui.bulk-job
client
:action="__('Archiving 40 orders')"
state="done"
:done="38"
:total="40"
:failed="[
['id' => '#1042', 'reason' => __('Already shipped')],
['id' => '#1043', 'reason' => __('Locked by another operator')],
]"
/>
<p class="text-xs text-muted-foreground">
{{ __('Host received:') }}
<span data-slot="preview-event-log" class="font-mono text-foreground" x-text="last ?? '—'"></span>
</p>
</div>
{{-- Slot-driven, client-state form: `done`/`total`/`failed` never exist as
PHP props here — a consumer whose job lives entirely in Alpine state
drives the sentence, the progress bar, and the failure rows straight off
`job`, with no server re-render. Shown next to the ordinary array-prop
form so both are visible side by side; "Simulate progress" advances the
client-state job with no navigation. --}}
<div class="flex flex-col gap-6">
<div>
<p class="mb-1.5 text-xs font-medium text-muted-foreground">{{ __('Server-array form (props)') }}</p>
<x-ui.bulk-job
:action="__('Archiving 40 orders')"
state="done"
:done="38"
:total="40"
:failed="[
['id' => '#1042', 'reason' => __('Already shipped')],
['id' => '#1043', 'reason' => __('Locked by another operator')],
]"
/>
</div>
<div
x-data="{
job: { done: 12, total: 40, failed: [] },
tick() {
if (this.job.done >= this.job.total) return;
this.job.done++;
if (this.job.done === 27) {
this.job.failed.push({ id: '#1042', reason: @js(__('Already shipped')) });
}
if (this.job.done === 33) {
this.job.failed.push({ id: '#1043', reason: @js(__('Locked by another operator')) });
}
},
}"
class="flex flex-col gap-3"
>
<div>
<p class="mb-1.5 text-xs font-medium text-muted-foreground">{{ __('Slot-driven form (client state)') }}</p>
<x-ui.bulk-job :action="__('Archiving 40 orders')" state="running">
<x-slot:summary>
<span
x-text="job.failed.length
? `${job.done} {{ __('of') }} ${job.total} {{ __('processed') }}, ${job.failed.length} {{ __('failed') }}`
: `${job.done} {{ __('of') }} ${job.total} {{ __('processed') }}`"
></span>
</x-slot:summary>
<x-slot:progress>
{{-- <x-ui.progress> computes its fill server-side from a
`value` prop, so it cannot be made reactive by
forwarding attributes onto its tag: the percentage
lives on a private child element the caller never
sees. A live bar reuses the primitive's own token
recipe directly instead, with real Alpine bindings on
elements the caller actually authors. --}}
<div
data-slot="progress"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
aria-label="{{ __('Archiving 40 orders') }}"
x-bind:aria-valuenow="Math.round((job.done / job.total) * 100)"
data-state="determinate"
data-tone="default"
class="relative mt-2.5 h-2 w-full overflow-hidden rounded-full bg-muted"
>
<div
data-slot="progress-indicator"
class="h-full rounded-full bg-primary transition-[width] duration-300 ease-out motion-reduce:transition-none"
x-bind:style="`width: ${Math.round((job.done / job.total) * 100)}%`"
></div>
</div>
</x-slot:progress>
<x-slot:failures>
<template x-if="job.failed.length">
<div data-slot="bulk-job-failures" class="mt-3 border-t border-border pt-3">
<div class="text-xs font-medium text-foreground">{{ __('Did not go through') }}</div>
<ul class="mt-1.5 space-y-1">
<template x-for="failure in job.failed" :key="failure.id">
<li class="flex items-baseline gap-2 text-xs">
<span class="font-mono text-info-text" x-text="failure.id"></span>
<span class="min-w-0 truncate text-muted-foreground" x-text="failure.reason"></span>
</li>
</template>
</ul>
<div class="mt-2.5">
<x-ui.button type="button" variant="outline" size="sm" x-on:click="job.failed = []">
{{ __('Retry failed') }}
</x-ui.button>
</div>
</div>
</template>
</x-slot:failures>
</x-ui.bulk-job>
</div>
<x-ui.button type="button" size="sm" class="self-start" x-on:click="tick()">
{{ __('Simulate progress tick') }}
</x-ui.button>
</div>
</div>
Long Content
<div class="max-w-xs">
<x-ui.bulk-job>
{{ __('A deliberately long example that verifies wrapping, overflow, and content expansion without clipping important information.') }}
</x-ui.bulk-job>
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| action | string | Short description of what is running, e.g. "Archiving 40 orders". | |
| state | running | done | running | Whether the job is still in progress or has finished (with or without failures). |
| done | int | 0 | Items processed so far. Clamped to [0, total]. |
| total | int | 0 | Total items in the run. |
| failed | array | [] | Per-item failures: each entry is ['id', 'reason']. Drives the failure list and the retry-failed form. |
| message | string | null | null | Optional inline warning or informational line under the progress sentence. |
| retryUrl | string | null | null | POST action that resubmits only the failed item ids. Hidden when there are no failures. |
| downloadUrl | string | null | null | Link to download files produced by the run. Shown once the job is no longer running. |
| reportUrl | string | null | null | Link to download a report of the run. Shown once the job is no longer running. |
| dismissible | bool | true | Whether a dismiss control is rendered. The dismissed state is local to the browser tab. |
| client | bool | false | Emit bulk-job:retry with the failed ids instead of posting retryUrl. For a host that owns the job state. |
| retryEvent | string | bulk-job:retry | Event the client-driven retry dispatches, with the failed ids as payload. Named by the caller. |
Slots
summary— Replaces the done/total/failed sentence. For a job whose counts live in client (Alpine) state rather than as PHP props — render your own x-text bound to that state instead of passing done/total/failed. Renders inside the component's role="status"/aria-live="polite" region, so x-text updates announce like any other change to the sentence.progress— Replaces the whole <x-ui.progress> bar, since its fill percentage is exactly as live as the summary sentence. Compose <x-ui.progress> yourself with your own reactive binding, or render a different indicator entirely.failures— Replaces the entire failure block — heading, rows, and retry action together, including the bulk-job-failures wrapper and its border/spacing. Takes priority over the failed prop when set. Only the caller's own x-if/x-show can know whether a client-side failures array is currently empty, so this slot owns that visibility too: pass nothing (or wrap it in your own x-if) to hide it, not a failed=[] prop.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Announces progress via role="status" and aria-live="polite", since a run usually finishes while the operator looks elsewhere.
- The fill transition on the underlying progress bar is disabled under prefers-reduced-motion.
- By default retry-failed and the download links are plain forms/links — no JavaScript is required to act on a finished job.
- Dismiss hides the card locally via Alpine state; it does not clear the underlying job.
- In client mode retry-failed becomes a button that dispatches bulk-job:retry with the failed ids, and needs no retryUrl; the download links stay links in both modes because they fetch a real file.
- summary/progress/failures are additive slots for a job whose state lives in client (Alpine) code rather than PHP props: unset, the component renders exactly as before, straight from done/total/failed.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Communicate concise object or system state.
Use when
- Use to show state, status, notification, or lightweight social feedback near the relevant object.
- Reporting the outcome of a bulk operation (archive, export, re-price, re-tag, …) run across a selection.
- Naming every failure with its own reason so the operator can act on exactly what did not go through.
Avoid when
- Do not overload small status elements with explanations that should remain visible in text.
- The operation is synchronous and finishes before the next paint; use inline validation or a toast instead.
- Nothing has failed and there is no per-item detail to report; a plain <x-ui.progress> is enough.
Use instead
- Persistent explanatory text
Anti-patterns
- Using color or icon alone to communicate state
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- Meet the WCAG 2.2 AA target declared in meta.a11y.
- The progress bar's accessible name is derived from the action, not a bare percentage.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="bulk-job-{{ $record->id }}">
{{-- One snapshot of a running job; the host re-renders it as the job progresses. --}}
<div class="flex w-full max-w-md flex-col gap-4">
<x-ui.bulk-job :action="__('Exporting 120 invoices')" state="running" :done="72" :total="120" />
<x-ui.bulk-job :action="__('Archiving 40 orders')" state="done" :done="40" :total="40" download-url="#" report-url="#" />
</div>
</div>
Source
The exact, editable file ui:add writes
into your app. Previews render this same code; there are no preview-only components.
{{--
Bulk Job — what a bulk run actually did.
"34 of 40 succeeded" is not a result anybody can act on. Every failure is
named with its own reason, and retry-failed only resubmits those, so
nobody has to re-run the whole batch and hope it goes better this time.
`role="status"` + `aria-live="polite"`: a bulk run usually finishes while
the operator is looking somewhere else. Sighted readers get the bar;
without the live region nobody else is told at all. Polite, because a run
can report progress every second and must never interrupt.
Retrying has two state models, matching the rest of the toolbar set. By
default `retryUrl` posts a real form carrying the failed ids, so a retry
survives a page with no JavaScript. Pass `client` when the host owns the
job's state: the retry button dispatches `bulk-job:retry` with those ids
instead, and no URL is needed. The two download links stay links in both
modes — they fetch a real file, which is a navigation, not state.
`done`/`total`/`failed` are PHP values snapshotted once at render time —
fine when a server re-renders the page, useless when the job lives in
Alpine state on the client and changes every tick with no navigation to
hang a re-render on. An Alpine *expression* prop (`doneExpression="job.done"`)
would be a string of someone else's code smuggled through a PHP prop —
unvalidatable, unescapable, and it still couldn't reach the retry button's
payload. So the live parts take slots instead: `summary` (the done/total/
failed sentence), `progress` (the whole `<x-ui.progress>` bar, since its
fill percentage is exactly as live as the sentence), and `failures` (the
*entire* failure block — heading, rows, and retry action together, because
only the caller's own `x-if`/`x-show` can know whether the client-side
array is empty right now). Slot content still renders inside this
component's `role="status"`/`aria-live="polite"` region, so `x-text`
updates and `x-for` row insertions inside a slot announce exactly like a
Livewire-rerendered sentence would — no wiring needed on either side,
because `aria-live` watches its whole subtree, not just the props this
component itself renders. Leave all three unset and it renders exactly as
before, straight from `done`/`total`/`failed`.
Generalised from `orders/bulk-job.blade.php` and `customers/bulk-job.blade.php`
(identical across ~19 admin modules bar the copy). The domain-specific i18n
keys and the page's own polling/store wiring are left out — this renders
one snapshot of job state from props; the host re-renders it as the job
progresses (Livewire polling, a dispatched update, or a page refresh).
--}}
@props([
// Short description of what is running, e.g. "Archiving 40 orders".
'action' => '',
// running|done — done covers both a clean finish and one with failures.
'state' => 'running',
'done' => 0,
'total' => 0,
// [['id' => '#1042', 'reason' => 'Already shipped'], ...]
'failed' => [],
// Optional inline warning/info line under the progress sentence.
'message' => null,
// Form action that resubmits only the failed items. Hidden with no failures.
'retryUrl' => null,
'downloadUrl' => null,
'reportUrl' => null,
'dismissible' => true,
// Emit `bulk-job:retry` instead of posting `retryUrl`, for a host that
// owns the job's state.
'client' => false,
// The event the client-driven retry dispatches. Named by the caller for the
// same reason record-search's is: our vocabulary should not be imposed on a
// host that already has one.
'retryEvent' => 'bulk-job:retry',
])
@php
$failed = array_values(array_filter((array) $failed, 'is_array'));
$client = (bool) $client;
$total = max(0, (int) $total);
$done = max(0, min((int) $done, $total));
$pct = $total > 0 ? (int) round($done / $total * 100) : 0;
$running = $state === 'running';
$tone = $failed !== [] ? 'warning' : ($running ? 'default' : 'success');
$sentence = $failed !== []
? __(':done of :total processed, :failed failed', ['done' => $done, 'total' => $total, 'failed' => count($failed)])
: __(':done of :total processed', ['done' => $done, 'total' => $total]);
$statusLabel = $running ? __('In progress') : ($failed !== [] ? __('Finished with errors') : __('Finished'));
$statusVariant = $running ? 'secondary' : ($failed !== [] ? 'soft-warning' : 'soft-success');
// Status glyph: a spinner while running, a check when clean, a warning triangle with failures.
$glyphWrap = match ($tone) {
'success' => 'bg-success-soft text-success-text',
'warning' => 'bg-warning-soft text-warning-text',
default => 'bg-muted text-muted-foreground',
};
@endphp
<div
@if ($dismissible) x-data="{ dismissed: false }" x-show="!dismissed" x-cloak
@elseif ($client) x-data="{}" @endif
data-slot="bulk-job"
data-state="{{ $state }}"
data-tone="{{ $tone }}"
role="status"
aria-live="polite"
{{ $attributes->merge(['class' => 'rounded-lg border border-border bg-card p-6 text-card-foreground']) }}
>
<div class="flex items-start gap-4">
<span data-slot="bulk-job-glyph" class="inline-flex size-8 shrink-0 items-center justify-center rounded-full {{ $glyphWrap }}" aria-hidden="true">
@if ($running)
<svg class="size-4 motion-safe:animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.6" /></svg>
@elseif ($failed !== [])
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3" /><path d="M12 9v4M12 17h.01" /></svg>
@else
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 5 5L20 7" /></svg>
@endif
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<div data-slot="bulk-job-title" class="truncate text-sm font-semibold text-foreground">{{ $action }}</div>
<x-ui.badge :variant="$statusVariant" size="sm">{{ $statusLabel }}</x-ui.badge>
</div>
<div data-slot="bulk-job-summary" class="mt-1 text-sm text-muted-foreground">
@isset($summary)
{{ $summary }}
@else
{{ $sentence }}
@endisset
</div>
@if ($message)
<div data-slot="bulk-job-message" class="mt-1 text-sm text-warning-text">{{ $message }}</div>
@endif
</div>
@if ($dismissible)
<button
type="button"
data-slot="bulk-job-dismiss"
x-on:click="dismissed = true"
aria-label="{{ __('Dismiss') }}"
class="-me-2 -mt-2 inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
>
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
@endif
</div>
<div class="mt-4 flex items-center gap-4">
@isset($progress)
<div class="min-w-0 flex-1">{{ $progress }}</div>
@else
<x-ui.progress
:value="$pct"
:tone="$tone"
:label="$action !== '' ? $action : __('Bulk job progress')"
class="min-w-0 flex-1"
/>
<span data-slot="bulk-job-percent" class="w-10 shrink-0 text-end text-xs font-medium tabular-nums text-muted-foreground">{{ $pct }}%</span>
@endisset
</div>
@if ($failed !== [] && ! isset($failures))
<div data-slot="bulk-job-failures" class="mt-4 rounded-md border border-border bg-muted/50 p-4">
<div class="flex items-center justify-between gap-2">
<div class="text-sm font-medium text-foreground">{{ __('Did not go through') }}</div>
<span class="text-xs tabular-nums text-muted-foreground">{{ count($failed) }}</span>
</div>
<ul class="mt-2 divide-y divide-border">
@foreach ($failed as $failure)
<li class="flex items-baseline gap-3 py-2 text-sm">
<span class="relative top-px inline-block size-2 shrink-0 rounded-full bg-destructive" aria-hidden="true"></span>
<span class="shrink-0 font-mono text-foreground">{{ $failure['id'] ?? '' }}</span>
<span class="min-w-0 truncate text-muted-foreground">{{ $failure['reason'] ?? '' }}</span>
</li>
@endforeach
</ul>
@if ($client)
<div class="mt-2">
<x-ui.button
type="button"
variant="outline"
size="sm"
x-on:click="$dispatch({{ Illuminate\Support\Js::from($retryEvent) }}, {{ Illuminate\Support\Js::from(['ids' => array_map(static fn (array $failure): string => (string) ($failure['id'] ?? ''), $failed)]) }})"
>
{{ __('Retry :count failed', ['count' => count($failed)]) }}
</x-ui.button>
</div>
@elseif ($retryUrl)
<form method="POST" action="{{ $retryUrl }}" class="mt-2">
@csrf
@foreach ($failed as $failure)
<input type="hidden" name="ids[]" value="{{ $failure['id'] ?? '' }}" />
@endforeach
<x-ui.button type="submit" variant="outline" size="sm">
{{ __('Retry :count failed', ['count' => count($failed)]) }}
</x-ui.button>
</form>
@endif
</div>
@endif
@isset($failures)
{{ $failures }}
@endisset
@if (! $running && ($downloadUrl || $reportUrl))
<div data-slot="bulk-job-links" class="mt-4 flex flex-wrap gap-2">
@if ($downloadUrl)
<x-ui.button as="a" href="{{ $downloadUrl }}" variant="outline" size="sm">
<x-slot:icon><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg></x-slot:icon>
{{ __('Download files') }}
</x-ui.button>
@endif
@if ($reportUrl)
<x-ui.button as="a" href="{{ $reportUrl }}" variant="outline" size="sm">
<x-slot:icon><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6M8 13h8M8 17h5" /></svg></x-slot:icon>
{{ __('Download report') }}
</x-ui.button>
@endif
</div>
@endif
</div>
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.3.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