← All stories

COMPONENT (Tier 3) · ui-async-job-admin-page

ui-async-job-admin-page

Component · Tier 3 (compound) Recurrence: 12+ stories Composes: ui-data-table, ui-pagination, ui-async-job-tracker, ui-status-pill, ui-search-with-filters, ui-date-range-picker, ui-destructive-confirmation, ui-toast

The first tier-3 compound component, harvested after the 94-story corpus showed this composition recurring in 12+ branch stories. A full admin surface for any background-job lifecycle: list (paginated table) + per-job tracker + filters + bulk actions. Branches reference it once instead of threading 7 tier-1 components through each story. Tightening the compound ratchets all 12 consumers.

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 rows
  • fetchJobStatus: (jobId) => Promise<JobStatus> — per-row tracker uses this
  • jobTypes: 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 hidden
  • onCancelJob?: (jobId) => Promise — per-row cancel button visible when provided
  • onRetryJob?: (jobId) => Promise — per-row retry button visible when provided
  • filterFields?: FilterField[] — passed to the embedded ui-search-with-filters
  • permissions: { canCreate, canCancel, canRetry, canDelete } — gates UI affordances
  • idempotencyKeyPrefix: string — for create + retry idempotency

Composition

Internally constructs:

  • ui-search-with-filters — search + filter chips at the top; debounced 300ms
  • ui-date-range-picker — embedded in the filter row when filterFields includes a date-range
  • ui-data-table — the job list itself; columns: name, type, status (ui-status-pill), created-by, created-at, duration, actions
  • ui-pagination — bottom of the list; page-size + total + status text
  • ui-async-job-tracker — embedded per-row when a row is in {queued, running} state, AND optionally as the primary surface in a drill-in modal
  • ui-status-pill — per row's status column
  • ui-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

  1. Page renders the job list.

    Header: page title + "Create new [jobKind]" button (visible when canCreate). Filter row underneath. Job list paginated below.

  2. Filter changes re-fetch.

    Inherits ui-search-with-filters's debounce + URL-sync mandate. Page resets to 1 on filter change.

  3. 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.

  4. 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.

  5. 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.

  6. 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-testWherePurpose
ui-async-job-admin-pageOuter wrapperdata-job-kind attr; component identity
ui-async-job-admin-create-ctaPage headerVisible only when canCreate
ui-async-job-admin-filtersTop filter rowEmbedded ui-search-with-filters
ui-async-job-admin-listBodyEmbedded ui-data-table
ui-async-job-admin-rowPer-rowEmbedded per-row ui-async-job-tracker; data-row-status
ui-async-job-admin-empty-no-jobsEmpty state — no jobs ever"Create your first [jobKind]" CTA
ui-async-job-admin-empty-filteredEmpty state — filtered to zero"Clear filters" link
ui-async-job-admin-loadingInitial loadingSkeleton + aria-busy
ui-async-job-admin-drill-in-modalPer-row drill-inEmbedded ui-modal + larger ui-async-job-tracker
ui-async-job-admin-create-modalCreate-new flowEmbedded ui-modal + ui-form per JobTypeDef
ui-async-job-admin-cancel-buttonPer-rowVisible when canCancel and row state ∈ {queued, running}
ui-async-job-admin-retry-buttonPer-rowVisible when canRetry and row state = failed
ui-async-job-admin-bulk-action-barFloating bar on selectionInherits ui-bulk-action-bar contract
ui-async-job-admin-refresh-ctaPage headerManual 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).

BranchBefore (tier-1 list)After (tier-3)
EF-053 guest-messagingui-data-table, ui-rich-text-editor, ui-async-job-tracker, ui-status-pill, ui-form, ui-autocompleteui-async-job-admin-page (jobKind=mailing) + ui-rich-text-editor
EF-054 scheduled-messagesui-data-table, ui-date-range-picker, ui-async-job-tracker, ui-rich-text-editor, ui-autocompleteui-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-checkboxui-async-job-admin-page (jobKind=report)
EF-093 SF campaign-import-exportui-async-job-tracker, ui-data-table, ui-formui-async-job-admin-page (jobKind=import)
EF-094 SF sync-limitsui-async-job-tracker, ui-data-tableui-async-job-admin-page (jobKind=sync)
EF-005 address-book-groupsui-csv-import-preview, ui-async-job-tracker, ui-bulk-action-bar, ui-data-tableui-async-job-admin-page (jobKind=import) + ui-csv-import-preview
EF-044 group-invite-and-uploadui-file-uploader, ui-csv-import-preview, ui-async-job-tracker, ui-progress-barui-async-job-admin-page (jobKind=bulk-resend) + ui-csv-import-preview + ui-file-uploader