06 - Flows
End-to-end user journeys. A pattern describes one moment; a flow describes a sequence of them.
Every flow here is canonical. Deviations need justification in code review. Consistency across apps is the point.
Prereqs: 04-components.md, 05-patterns.md.
CRUD — the backbone flow
Create, Read, Update, Delete. Every entity in every app goes through this skeleton.
Create
Two patterns — pick one per entity class.
Pattern A — quick create in a Dialog (preferred when the entity has ≤ 4 fields to get started).
List page ├─ Primary action: "+ New <Entity>" in page bar ├─ Opens <Dialog> titled "Create <Entity>" ├─ Minimal required fields (name, type, maybe 1-2 more) ├─ DialogFooter: [Cancel] [Create <Entity>] │ - Create button = ActionButton with pending/success/error │ - errorPlacement="below" in dialog context ├─ On success: │ ├─ Close dialog │ ├─ Navigate to the new entity's detail page │ └─ Focus the entity name or first editable field └─ On error: - Stay in dialog - FormError above action bar with server messagePattern B — dedicated create page (when the entity needs many fields or a preview).
List page ├─ Primary action: "+ New <Entity>" → navigates to /<entities>/newNew page (Form archetype) ├─ ListPageBar title "New <Entity>" ├─ PageContent narrow ├─ FormSection groups for logical field groups ├─ FormError above ActionBar └─ ActionBar: [Cancel] [Create] - Cancel = navigate back to list (warn if dirty) - Create = ActionButton, onSuccess navigates to detail pageRead
The detail page (Detail archetype).
List page → click row → detail page ├─ DetailPageBar back → parent list URL ├─ Tabs = logical sub-sections (max 8) ├─ Default tab is the most-used (not alphabetical) └─ Deep-linkable: /entities/:id/<tab> works directlyRules:
- Row click opens detail (whole row is clickable, not just the name).
- Don’t open detail in a new tab by default — Cmd-click does that natively.
- If the detail is view-only, use the Detail archetype without ActionBar.
- If the detail is editable, it’s a hybrid (Detail + Form) — use the same tabs, ActionBar on the form tab only.
Update
Edit patterns follow three-tier inline edit (see 05-patterns.md).
Settings-style update (persistent form)
Detail page, tab = "Settings" ├─ Form fields pre-populated ├─ isDirty tracked (react-hook-form) ├─ ActionBar: [Reset] [Save] │ - Save disabled when !isDirty │ - Save = ActionButton, successText="Saved" ├─ Unsaved changes warning: │ ├─ Block navigation (useBlocker) │ └─ Unsaved-changes dialog: "Discard changes?" [Keep editing] [Discard] └─ On success: ├─ ActionButton shows ✓ Saved (1500ms) └─ Form resets dirty stateInline update (click-to-edit or icon-edit)
Display: "Name: Acme Corporation" ├─ Click or pencil-icon → input appears in place ├─ Commit on Enter or blur (not on every keystroke) ├─ Escape cancels (revert to original) └─ On commit: ├─ Optimistic UI update ├─ Mutation fires └─ On error: revert + inline error + shakeDelete
Two-tier by blast radius — see 04-components.md → ConfirmPopover / ConfirmDialog.
Routine delete (per-row)
Table row ├─ Ghost icon-xs trash button with ConfirmPopover ├─ Popover: "Remove <item>?" [Cancel] [Remove] ├─ Confirm → mutation ├─ On success: │ ├─ Optimistic remove from list │ └─ Optionally: bottom toast with "Removed. Undo" for 5s └─ On error: revert + inline errorCatastrophic delete (whole entity)
Detail page → Danger Zone section (always last) ├─ [Delete <Entity>] destructive button triggers ConfirmDialog ├─ Dialog: "Delete this <entity>?" │ description: "This permanently deletes the entity, all its data..." │ confirmLabel: "Delete" (the verb, not "OK") ├─ Extra friction for truly irreversible: type the entity name to confirm └─ On confirm: ├─ ActionButton on the dialog's Delete button ├─ On success: close dialog, navigate to parent list, toast "Deleted" └─ On error: stay in dialog, FormErrorAuth flows
Sign in
Keycloak-backed. The app’s job is a dumb form.
Unauthenticated route access ├─ AuthProvider checks isAuthenticated() ├─ If not: render <LoginScreen> │ ├─ Email + password (password-input with reveal toggle) │ ├─ "Sign in" = ActionButton │ └─ On success: reload to target page └─ On error: FormError inline with server messageRules:
- No “Remember me” checkbox — that’s a setting we shouldn’t need.
- No “Forgot password?” link in the form if SSO-only. Add it only if email+password is actually supported.
- After sign-in, redirect target = URL’s
?redirect=param, sanitized viasanitizeRedirect().
Session expiry
Silent refresh via updateToken() from @na/auth. If refresh fails:
Token refresh fails ├─ toast.error('Session expired. Please sign in again.') ├─ Redirect to /login?redirect=<current-path> └─ AuthProvider shows LoginScreenNo confirm modals. No “your session is about to expire” pop-up. It just works or it prompts for sign-in.
Sign out
Sidebar footer avatar → dropdown → "Sign out" ├─ Confirm modal? NO. Sign out is not destructive, it's expected. ├─ Call logout() from @na/auth └─ Reload to /loginOnboarding — first-run experience
The user created an account, landed on the product. They see an empty list.
Zero-state → first entity
First page load with zero entities ├─ EmptyState (blank-slate variant) │ ├─ Icon │ ├─ Title: "No agents yet" │ ├─ Description: concrete, explains the value │ └─ Action: "Create your first agent" ├─ Action → opens the same Quick Create Dialog └─ After creation → navigate to detail page with pre-filled sensible defaultsRules:
- Do not show a tour modal on first load (users dismiss tours instantly).
- Do show inline hints via
<Tooltip>on first-use elements (only visible first time, dismissed on interaction). - Never re-show first-run onboarding to returning users (A8 — a static invitation fires once).
Second-entity prompt
After the first entity is created, the next visit shows the populated list. No “great job, create another!” celebration. The product is the celebration.
Bulk actions
When users need to act on multiple entities at once.
List page with checkbox column ├─ Click row checkbox → row selected (bg-muted/50 + check) ├─ When ≥ 1 selected: │ ├─ ContentToolbar appears (or existing toolbar changes mode) │ ├─ Left side: "<N> selected" + [Clear selection] │ └─ Right side: bulk actions — Delete, Export, Change status, etc. ├─ Bulk action clicked → ConfirmDialog (catastrophic) or ConfirmPopover (routine) │ ├─ "Delete <N> contacts?" │ └─ [Cancel] [Delete <N>] ├─ On confirm: │ ├─ Disable action button (ActionButton pending) │ ├─ Progress indicator if > 5s (e.g., "Deleting 23 of 47...") │ └─ On success: clear selection, toast with count, optionally "Undo" └─ On partial error: ├─ Show which items failed and why └─ Allow retry on just the failed subsetRules:
- Select-all checkbox in the table header: only visible when the column exists.
- Max 1000 items acted on at once. Above that, use a background job with progress page.
- Ctrl/Cmd-click range selection is optional but nice.
Multi-step wizard
For flows with > 5 logical inputs that naturally split into steps (new-agent setup, import mapping, billing upgrade). Uses the Focus archetype — see 03-layout.md § Named page archetypes and references/attio-layout-decision-guide.md § Decision tree 2.25.
Step indicator (breadcrumb-like) [● Step 1] [○ Step 2] [○ Step 3] active next pending
Body: step contentFooter: [Back] [Next] or [Skip] if optionalFinal step: [Back] [Finish]Sizing rules
- Show progress (step N of M) at top. Linear, not a free-form tab switcher.
- Never have a wizard with 1 step. Use a Dialog.
- Never have a wizard with 8+ steps. Split into sub-flows or cut.
- On the last step, the button label = the outcome (“Create agent”, “Start import”).
State machine
Use useReducer (not per-field useState) for any wizard with ≥ 3 fields total. The reducer captures the form draft, the current step, and a derived isDirty flag. One source of truth makes step preservation, validation, and submit straightforward.
type WizardState = { step: number; draft: { template?: string; name?: string; tools?: string[]; ... }; isDirty: boolean;};
type WizardAction = | { type: 'pickTemplate'; template: string } // not dirty (see below) | { type: 'setField'; field: keyof Draft; value: string } // sets isDirty | { type: 'toggleTool'; tool: string } // sets isDirty | { type: 'next' } | { type: 'prev' } | { type: 'jumpTo'; step: number }; // only allowed for completed stepsFor wizards with ≤ 3 simple fields, plain useState is fine. The threshold is “would I lose track of which setX call updates which slice of state?”
URL state vs component state
| Goes in URL | Stays in component state | Never persisted to localStorage |
|---|---|---|
?step=N (so browser-back works) | All form input (name, description, …) | Wizard draft (any field) |
?<starter>=<id> for low-info starter choices | isDirty flag | |
| Validation error messages |
Field data does NOT go in the URL — it’s high-cardinality, possibly sensitive, and too long. Refreshing or sharing a wizard URL starts the user fresh from Step 1; partial-fill recovery is a separate (Plan-2-style) feature, not the default.
Validation
- Validate-on-Next, not validate-on-blur. Blur-validation interrupts the user’s typing flow during a step; the user is happy to be told once at step transition that something’s wrong.
- Block advance if the current step is invalid; refuse the Next click and focus the first invalid field.
- Preserve all state on back-nav. Hitting Back from Step 3 to Step 2 returns to Step 2 with every field exactly as the user left it. Same in reverse — going forward to Step 3 again, the data is still there.
Dirty-state rule
Dirty = “user has typed text in an input OR toggled a default selection.” Dirty triggers the close-confirm flow.
NOT dirty:
- Picking a starter / template card on Step 1. Starters are scaffolding choices, not user content the user would mourn losing. Triggering “discard your draft?” after a single click is friction without value.
- Visiting a step (advancing to Step 3) without changing anything on it.
This is the difference between “I picked a starter” (no draft) and “I typed my agent’s name” (draft).
Multi-API submit
If the final commit needs N backend calls, prefer one atomic backend endpoint that accepts everything (e.g. extend POST /v1/agents to take tools[] instead of POST /v1/agents then PUT /v1/agents/:id/tools). Bundle the backend work into the same ticket as the frontend wizard.
If atomic isn’t possible, the rules:
- Sequential, not parallel. Order matters (you can’t attach tools to an agent that doesn’t exist yet).
- Last-step retry. A failure leaves you in a partial-success state. Surface a non-blocking banner on the entity’s landing page: “Couldn’t attach tools — add them from the Tools tab.” Don’t try to roll back.
- Never auto-DELETE on partial failure. A
DELETE /v1/agents/:idon a possibly-already-created agent is risky if the create succeeded server-side but the client never saw the response. The user can manually delete a half-configured entity if they want; the wizard shouldn’t decide for them. - Idempotency keys if the backend supports them — preferred over rollback.
Browser-back during in-flight submit
User clicks the final “Create” button → mutation pending → user hits browser back. Allow it. The mutation continues in the background; the new entity appears in the destination list when TanStack Query (or equivalent) invalidates the list onSuccess. Don’t useBlocker the navigation — that’s modal-stacking by another name.
Exit-path close behavior — never silent
A wizard has four ways the user can leave: ✕ button, Cancel button (in the action bar), Esc key, sidebar/back-navigation. Every one of them is observable, and every one MUST do something visible when the draft is dirty:
- Not dirty: all four navigate immediately to
closeTo(no prompt — the user hasn’t typed anything to lose). - Dirty (✕ / Cancel): wrap each trigger in its own
<ConfirmPopover>anchored to itself. Same copy, same destructive variant. - Dirty (Esc): programmatically click the ✕ ref to open its popover. No new keyboard-only confirm path — reuse the visual one.
- Dirty (sidebar / browser back): intercept with React Router’s
useBlocker(or equivalent) and show a<ConfirmDialog>. This is the one case that escalates to a modal, because there’s no in-flow trigger to anchor a popover to.
Hard rule: a silent no-op on any exit path is forbidden. Returning early from a close handler with no UI feedback (the easy bug) makes the button look broken. If isDirty && !isPending, the user must see a prompt — every time, on every path. The compliance script doesn’t catch this — reviewers must.
One confirm copy — don’t render different messages for ✕ vs Cancel vs Esc. The user shouldn’t have to learn multiple flows for the same outcome.
See 04-components.md § FocusLayout § Confirm-on-discard for the full table and the implementation pattern.
Save state on Next
Save the form draft to component state on every Next. Do NOT persist mid-wizard drafts to localStorage — that creates “stale draft” UX problems on next visit (the user comes back days later, doesn’t remember what they were doing, ends up confused). Refresh-loses-work is the right tradeoff for an occasional task; if the wizard is daily-use, that’s a sign it’s the wrong archetype (use Form, not Focus).
Lazy-load the route
Wizards live at non-default routes (/agents/new, /import/contacts). Use React.lazy(() => import('./AgentCreatePage')) so the more-frequent route (/agents, /contacts) doesn’t pay the wizard’s bundle cost. Adds ~50ms to wizard first-paint, saves ~5–10KB gzipped on every list-view load.
Test pattern
See 09-i18n-testing.md § Wizard test pattern for the mandatory test file layout. Wizards earn 5 test files (page-level reducer + per-step + 1 happy-path E2E if Playwright is configured).
Save / commit semantics
Three models. Pick one per surface and stick to it.
Model A — explicit Save (the default)
User edits → ActionBar shows isDirty → click Save → mutation.
- Use for: settings pages, long forms, any complex edit.
- Dirty-tracking via
form.formState.isDirty. - Unsaved-changes guard blocks navigation.
- Save button disabled when clean.
Model B — instant apply (Switch and similar)
User toggles → saves immediately. No “Save” step.
- Use for: feature toggles, notification preferences, boolean settings.
- Show brief inline confirmation (ActionButton’s success tick, or field-level “Saved” ephemeral).
- Roll back visibly on error.
Model C — auto-save with debounce
User edits → debounce 1-2s → save in the background → show “Saved” / “Saving…” in a quiet corner.
- Use for: rich-text content (prompt editor, notes), canvas layout (workflow positions).
- Always show the last-saved state; never let the user think “did that save?”
- Offer explicit Save as a fallback (Ctrl+S).
Never mix Model A and Model C on the same page. Users can’t tell what saves when.
Undo
For any reversible mutation, offer Undo. Not every mutation is reversible; don’t fake it.
Delete a row ├─ Optimistic remove + bottom toast: "Contact removed. [Undo]" ├─ Toast persists for 5s ├─ Undo click: │ ├─ Call the undo mutation │ └─ Restore the row └─ After 5s, commit becomes permanentRules:
- Undo window: 5s for single-entity mutations, 10s for bulk.
- Undo is optimistic: the original row stays “deleted” visually, but a restore mutation fires on undo.
- Show a spinner if the undo restore is slow.
- Don’t offer undo for truly irreversible actions (payment, send email).
Long-running jobs
Imports, exports, bulk operations that take > 10 seconds.
Inline progress
For jobs < 30 seconds where the user waits:
[Run import] → button becomes ActionButton pending ├─ Progress bar inline: "Processing 234 of 1,000 rows" ├─ Cancel button (if cancellable) └─ On success: summary of results with [Close] or [View items]Background jobs
For jobs > 30 seconds — let the user leave and come back:
[Start import] → job queued ├─ Toast: "Import started. We'll notify you when it's done." ├─ Notification badge on the sidebar item (polling or SSE) ├─ User can navigate freely └─ On completion: ├─ toast.success("Import complete: 1,247 contacts added") ├─ Badge clears └─ Optional: Result page or direct link to the imported itemsJobs should have a “Jobs” page showing history + status. Essential for debugging.
Export / download
[Export] button → opens popover or dialog with options: ├─ Format: CSV / JSON / Excel ├─ Scope: All / Filtered / Selected ├─ [Cancel] [Export]Generating... ├─ Progress if > 3s └─ On success: ├─ Browser download triggers automatically └─ Toast: "Export ready" with a link if download blockedFor large exports, treat as a background job (see above).
Import
[Import] button → Import wizard ├─ Step 1: Upload file (CSV) ├─ Step 2: Map columns (our field ← CSV column) ├─ Step 3: Preview first 10 rows with validation highlights ├─ Step 4: Confirm and start ├─ Progress (inline for < 30s, background otherwise) └─ Result page: ├─ Success count ├─ Warning count with drill-down ├─ Error count with drill-down (export failures for fixing) └─ [Close] or [View imported items]Duplicate (entity copy)
Row action (or detail page action) → "Duplicate" ├─ Optionally: rename dialog with proposed name "<Original> (copy)" ├─ On confirm: mutation ├─ On success: │ ├─ Navigate to new entity's detail page, OR │ └─ Show row with animation + toast: "Duplicated. [Open]" └─ Never call it "Clone" — stick to "Duplicate"Navigation-heavy flows
Deep link with shared state
Filters, selected rows, tab state — all in URL params so the page is shareable and refresh-safe.
/agents?status=active&sort=name&page=2 ├─ URL is the source of truth ├─ Filters, sort, pagination sync with URL └─ On load: parse URL params, applyBack + forward
- Back from Detail → parent list.
- Back from Create page → list.
- Back from a wizard step → previous step.
- Back from the root of a nested context (app switch) → previous app.
Use React Router’s useNavigate(-1) only when you’re sure of the history. Default to explicit target URLs.
Error recovery flows
Network error on load
PageContent with Skeleton → fetch fails ├─ Replace skeleton with error state: │ ├─ Icon │ ├─ Title: "Couldn't load contacts" │ ├─ Description: "Check your connection and try again." │ └─ [Retry] button └─ Retry click → re-fetch with Skeleton again404 on detail page
Fetch returns 404 ├─ Show Exception page (Exception archetype) │ ├─ Icon: AlertCircle │ ├─ Title: "Contact not found" │ ├─ Description: "This contact may have been deleted or the link is wrong." │ └─ [Back to contacts] [Go home]Permission denied
Fetch returns 403 ├─ Show Exception page │ ├─ Icon: Lock │ ├─ Title: "You don't have access to this" │ ├─ Description: "Ask your admin to grant <permission> to your account." │ └─ [Contact admin] or similarNever redirect silently to a different page — the user needs to know why they can’t see what they expected.
Flow anti-patterns
| Don’t | Do |
|---|---|
| Show a tour modal on first load | Inline tooltips on first-use elements |
| Toast after a click-triggered mutation | Inline ActionButton feedback |
| Require “OK” confirmation for non-destructive actions | Just do the thing |
| ”Are you sure?” on every save | Only on destructive / irreversible |
| Two primary buttons in a neutral decision | Both outline |
| Success message that auto-dismisses after 500ms | 1500ms minimum; let users see it |
| Clear form fields on submit failure | Preserve the data; show the error |
| Empty undo toast (“Removed.”) | Include item name (“Removed contact Jane Doe”) |
| Wizard with one step | Use a Dialog |
Back button that uses history.back() | Explicit target URL |
| Infinite scroll on admin tables | Pagination |
| Auto-save + explicit Save together | Pick one model |
Next: 07-voice.md.