Component contract
One React component renders the entire job-admin surface. Job kind is parameterized — same component shape covers reports, mailings, imports, syncs, exports, bulk-resends. Branch stories declare which kind via prop.
jobKind: JobKind— discriminator: "report" | "mailing" | "import" | "sync" | "export" | "bulk-resend"fetchJobs: (filters, page) => Promise<JobsPage>— returns paginated job rowsfetchJobStatus: (jobId) => Promise<JobStatus>— per-row tracker uses thisjobTypes: JobTypeDef[]— types creatable from this surface (e.g., "guest-list-summary", "activity-log" for reports)onCreateJob?: (type, params) => Promise<Job>— when omitted, "Create new" is hiddenonCancelJob?: (jobId) => Promise— per-row cancel button visible when providedonRetryJob?: (jobId) => Promise— per-row retry button visible when providedfilterFields?: FilterField[]— passed to the embedded ui-search-with-filterspermissions: { canCreate, canCancel, canRetry, canDelete }— gates UI affordancesidempotencyKeyPrefix: string— for create + retry idempotency
Composition
Internally constructs:
ui-search-with-filters— search + filter chips at the top; debounced 300msui-date-range-picker— embedded in the filter row when filterFields includes a date-rangeui-data-table— the job list itself; columns: name, type, status (ui-status-pill), created-by, created-at, duration, actionsui-pagination— bottom of the list; page-size + total + status textui-async-job-tracker— embedded per-row when a row is in {queued, running} state, AND optionally as the primary surface in a drill-in modalui-status-pill— per row's status columnui-destructive-confirmation— for cancel + delete (when provided)ui-toast— for action confirmations (job created, job cancelled)
The composition is a single React component but is testable both as a unit (fixture page with mock data) and via consumer branches (which exercise it with real data flows).
Interaction surface
-
Page renders the job list.
Header: page title + "Create new [jobKind]" button (visible when canCreate). Filter row underneath. Job list paginated below.
-
Filter changes re-fetch.
Inherits ui-search-with-filters's debounce + URL-sync mandate. Page resets to 1 on filter change.
-
Per-row tracker updates live.
Rows in queued/running state poll every 2s (per ui-async-job-tracker contract). Terminal-state rows don't poll.
-
Click row opens drill-in modal.
Larger ui-async-job-tracker view with full lifecycle history, errorReason, requestId, download URL. Modal closes on Esc / outside click.
-
Create new opens form modal.
Form fields per JobTypeDef (e.g., for reports: type selector, date range, output format). Submit creates job + appears in list at the top.
-
Cancel + retry per row.
Cancel uses ui-destructive-confirmation. Retry creates a new job with the same params (audit log row references original).
Failure modes
Permission-gated affordances
Trigger: user lacks canCreate / canCancel / canRetry.
Each affordance hidden when the corresponding permission flag is false. Server-side: action endpoints return 403 even if client renders the button (defense in depth). Harness: stub each permission false in turn, assert affordance hidden + API returns 403.
Empty state distinct from loading + filtered-empty
Trigger: three states must be visually distinguishable — initial loading, no jobs ever created, filtered-to-zero.
Initial loading: skeleton rows + aria-busy. No jobs: empty-state card with "Create your first [jobKind]" CTA. Filtered-to-zero: empty-state card with "No [jobKind]s match these filters. Clear filters." Three distinct selectors. Harness: each state, distinct test-attribute visible.
Filter clear resets pagination
Trigger: user is on page 5 of filtered results; clicks Clear All filters.
Clearing filters resets to page 1 (avoids landing on a non-existent page after filter widens the result set). Harness: page=5, clear filters, assert page=1 + URL state matches.
Job-list staleness during long polls
Trigger: page open for 10+ minutes; new jobs created by other users don't appear.
Job list refetches every 30s (slower than per-row tracker's 2s). New jobs surface within the refresh window. Manual refresh CTA in the page header for impatient users. Harness: stub new server-side job, wait 30s, assert it appears in list.
Drill-in modal Esc returns to list scroll position
Trigger: user scrolled to row 47, clicked it for drill-in, Esc to close.
Inherits ui-modal's focus-returns-to-trigger contract. List scroll position preserved (modal didn't unmount the list). Harness: scroll + drill + Esc, scroll position unchanged, focus on row.
Cancel races with server completion
Trigger: user clicks cancel; server completes the job in same tick.
Inherits ui-async-job-tracker's "race-cancel-then-ready" contract. Toast surfaces "Cancel arrived too late — your file is ready." Harness: simulate race, toast visible.
Retry uses idempotency-key
Trigger: user clicks Retry; network blip; clicks Retry again.
Idempotency-Key generated per retry attempt prefixed by idempotencyKeyPrefix + originalJobId + retryCount. Server dedupes. Harness: 2 retry POSTs in 100ms, assert 1 new job created.
Bulk select + cancel
Trigger: user selects 5 in-flight jobs, clicks "Cancel selected."
Cancel cascades through selection. ui-bulk-action-bar pattern. Each cancel uses its own idempotency-key. Audit log row per cancel. Harness: select 5, click Cancel selected, 5 POSTs with distinct keys.
JobKind-mismatch from URL navigation
Trigger: user navigates directly to a job-detail URL for a kind not configured on this page.
Page renders 404-equivalent state with "This job belongs to a different surface." Avoids cross-leak between job kinds (e.g., loading a mailing-job into a reports-page). Harness: navigate with mismatched jobKind in URL, 404-equivalent rendered.
Cross-tenant job access
Trigger: user signed into tenant A navigates to a job URL belonging to tenant B.
Returns 404 (anti-probing). Inherits permission-gated catalog pattern. Harness: forge cross-tenant URL, 404.
Accessibility
- Inherits all tier-1 component a11y contracts.
- Page heading hierarchy: H1 = page title, H2 = filter region, H2 = job list, H3 = per-row job name (when expanded inline). headings-no-skip enforced.
- Live region announces job state transitions via aria-live=polite (ui-async-job-tracker contract).
- Drill-in modal inherits ui-modal focus-trap.
- axe-clean at severity ≥ serious across all states.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-async-job-admin-page | Outer wrapper | data-job-kind attr; component identity |
ui-async-job-admin-create-cta | Page header | Visible only when canCreate |
ui-async-job-admin-filters | Top filter row | Embedded ui-search-with-filters |
ui-async-job-admin-list | Body | Embedded ui-data-table |
ui-async-job-admin-row | Per-row | Embedded per-row ui-async-job-tracker; data-row-status |
ui-async-job-admin-empty-no-jobs | Empty state — no jobs ever | "Create your first [jobKind]" CTA |
ui-async-job-admin-empty-filtered | Empty state — filtered to zero | "Clear filters" link |
ui-async-job-admin-loading | Initial loading | Skeleton + aria-busy |
ui-async-job-admin-drill-in-modal | Per-row drill-in | Embedded ui-modal + larger ui-async-job-tracker |
ui-async-job-admin-create-modal | Create-new flow | Embedded ui-modal + ui-form per JobTypeDef |
ui-async-job-admin-cancel-button | Per-row | Visible when canCancel and row state ∈ {queued, running} |
ui-async-job-admin-retry-button | Per-row | Visible when canRetry and row state = failed |
ui-async-job-admin-bulk-action-bar | Floating bar on selection | Inherits ui-bulk-action-bar contract |
ui-async-job-admin-refresh-cta | Page header | Manual refresh; for impatient users |
Agent test plan
Standalone probes against /admin-test/ui-async-job-admin-page-fixture. Variants per jobKind. Permission flags varied independently.
Probe list
- jobkind-renders-distinct-title: jobKind=report → title contains "Reports"; jobKind=mailing → "Mailings"
- create-cta-permission-gated: canCreate=false → ui-async-job-admin-create-cta hidden
- empty-state-no-jobs: 0 jobs ever → ui-async-job-admin-empty-no-jobs visible with create-first CTA
- empty-state-filtered: 5 jobs all filtered out → ui-async-job-admin-empty-filtered with clear-filters link
- loading-state-aria-busy: initial load → ui-async-job-admin-loading + aria-busy=true
- list-paginated-correctly: 50 jobs / pageSize=25 → page 1 shows 25, footer "Showing 1–25 of 50"
- per-row-tracker-polls: row in running state → fetchJobStatus called every 2s
- terminal-row-stops-polling: row in ready state → no further polls
- drill-in-on-row-click: click row → drill-in-modal visible
- drill-in-esc-closes: open + Esc → modal closes, row focus restored
- create-form-validates: submit incomplete form → validation errors per ui-form
- create-creates-job: submit valid → fetchJobs returns +1 row at top of list
- cancel-uses-destructive-confirmation: click cancel → ui-destructive-confirmation visible
- cancel-success-toast: confirm cancel → ui-toast "Cancelled"
- retry-uses-idempotency-key: click retry 2x within 100ms → 2 POSTs with same Idempotency-Key
- bulk-cancel-cascades: select 5 + bulk cancel → 5 cancel POSTs each with distinct keys
- list-refresh-30s: stub server-side new job, wait 30s → appears in list
- manual-refresh-cta: click refresh → fetchJobs called immediately
- jobkind-mismatch-404: navigate to mismatched URL → 404-equivalent rendered
- cross-tenant-404: forged URL → 404
- color-contrast-status-pills: every status × every tone ≥ 4.5:1 (inherits ui-status-pill)
- axe-clean-serious: across loading/empty/filtered/normal states
Current consumers (will tighten via this compound)
Once branches adopt this tier-3 component, their usesComponents declarations simplify. Each branch goes from listing 7 tier-1 components to listing 1 tier-3 component (plus any branch-specific additions).
| Branch | Before (tier-1 list) | After (tier-3) |
|---|---|---|
| EF-053 guest-messaging | ui-data-table, ui-rich-text-editor, ui-async-job-tracker, ui-status-pill, ui-form, ui-autocomplete | ui-async-job-admin-page (jobKind=mailing) + ui-rich-text-editor |
| EF-054 scheduled-messages | ui-data-table, ui-date-range-picker, ui-async-job-tracker, ui-rich-text-editor, ui-autocomplete | ui-async-job-admin-page + ui-rich-text-editor |
| EF-086..EF-091 reports (6 stories) | ui-async-job-tracker, ui-data-table, ui-status-pill, ui-form, ui-tabs, ui-checkbox | ui-async-job-admin-page (jobKind=report) |
| EF-093 SF campaign-import-export | ui-async-job-tracker, ui-data-table, ui-form | ui-async-job-admin-page (jobKind=import) |
| EF-094 SF sync-limits | ui-async-job-tracker, ui-data-table | ui-async-job-admin-page (jobKind=sync) |
| EF-005 address-book-groups | ui-csv-import-preview, ui-async-job-tracker, ui-bulk-action-bar, ui-data-table | ui-async-job-admin-page (jobKind=import) + ui-csv-import-preview |
| EF-044 group-invite-and-upload | ui-file-uploader, ui-csv-import-preview, ui-async-job-tracker, ui-progress-bar | ui-async-job-admin-page (jobKind=bulk-resend) + ui-csv-import-preview + ui-file-uploader |