@tristan2828/ui-foundation 3.11.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -154,11 +154,13 @@ is a filled-in example while the Widgets demo is still here).
154
154
 
155
155
  **A `yes/no` marked `toggle`** copies In Stock's cell
156
156
  (`in-stock-toggle.tsx`, cell pattern 15): a `Switch` in its own
157
- component that PATCHes the one field through an optimistic mutation
158
- (`useToggleWidgetInStockMutation`: every cached list page updates at
159
- once and is restored if the save fails, with a toast naming the row),
160
- then refetches the lists. Its specs cover a flip that saves, the
161
- keyboard, and a failed save putting the switch back.
157
+ component that PATCHes the one field through `useSaveWidgetField`, the
158
+ entity's wrapper of the foundation's `useRecordUpdate` (the row and the
159
+ record's view update at once and go back if the save is refused, with
160
+ a toast naming the row and the server's reason; the lists refetch
161
+ after). Its specs cover a flip that saves, the keyboard, and a failed
162
+ save putting the switch back. Copy `useSaveWidgetField` whenever the
163
+ plan has a `toggle` or any quick action, and only then.
162
164
  - `src/routes/widgets/use-widgets.ts`, `use-widget-categories.ts` →
163
165
  `src/routes/<entity>/use-<entity>.ts` (TanStack Query hooks over the
164
166
  new gateway module)
@@ -183,6 +185,10 @@ is a filled-in example while the Widgets demo is still here).
183
185
  `src/routes/<entity>/<entity>-view.tsx` — thin consumer of the
184
186
  foundation's `<EntityView>`. Swap the title, badges and sections for
185
187
  the plan's "View screen" (below).
188
+ - `src/routes/widgets/widget-quick-actions.tsx` →
189
+ `src/routes/<entity>/<entity>-quick-actions.tsx`, **only when the
190
+ plan's "View screen" lists `Quick actions`**: one component per
191
+ control the plan names (below).
186
192
  - `src/routes/widgets/delete-widget-action.tsx` →
187
193
  `src/routes/<entity>/delete-<entity>-action.tsx` (Delete in the
188
194
  view's header: the confirm dialog, then `onDeleted` leaves for the
@@ -193,7 +199,8 @@ is a filled-in example while the Widgets demo is still here).
193
199
  delete). Swap the zod schema and fields. Every way out lands on a
194
200
  view (below).
195
201
 
196
- **The view** is the read-only page for one record, at `/<entity>/:id`.
202
+ **The view** is the read-only page for one record, at `/<entity>/:id`
203
+ (apart from the plan's quick actions, if it lists any).
197
204
  `<EntityView>` owns its loading skeleton, its not-found state (another
198
205
  user's record is the same plain 404, with a way back to the list and
199
206
  no retry) and its error state with retry. The screen supplies what the
@@ -219,9 +226,31 @@ is a filled-in example while the Widgets demo is still here).
219
226
  entity has one: the name, from the same lookup by id the table uses,
220
227
  inside `<Link to={`/<other>/${id}`}>`. Without a view, the plain name
221
228
  (Widget's categories have none).
222
- - **Sub-records are read-only**: the done-count, then each item in
223
- order with its state as a glyph and words, never a checkbox
224
- (`ChecklistItems` in `widget-view.tsx`). A list of links: each an
229
+ - **Sub-records are read-only** unless the plan's `Quick actions`
230
+ names the list: the done-count, then each item in order with its
231
+ state as a glyph and words, never a checkbox. Widget's checklist is
232
+ a quick action, so this is the markup to use (the shape Widget's
233
+ view had before it was):
234
+
235
+ ```tsx
236
+ <div className="flex flex-col gap-2">
237
+ <p className="type-caption tabular-nums text-muted-foreground">{checklistDoneCount(items)}</p>
238
+ <ul aria-label="Checklist items" className="flex flex-col gap-1.5">
239
+ {items.map((item, index) => (
240
+ <li key={index} className="flex items-start gap-2">
241
+ {item.done ? (
242
+ <CircleCheckIcon role="img" aria-label="Done" className="mt-0.5 size-4 shrink-0 text-success-text" />
243
+ ) : (
244
+ <CircleIcon role="img" aria-label="Not done" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
245
+ )}
246
+ <span className={item.done ? 'text-muted-foreground' : undefined}>{item.text}</span>
247
+ </li>
248
+ ))}
249
+ </ul>
250
+ </div>
251
+ ```
252
+
253
+ A list of links: each an
225
254
  `<a target="_blank" rel="noreferrer">` with an `ExternalLinkIcon`
226
255
  and a visually hidden "(opens in a new tab)".
227
256
  - **Long text the plan marks Markdown** renders through the
@@ -230,11 +259,23 @@ is a filled-in example while the Widgets demo is still here).
230
259
  as markup. The app needs no Markdown dependency of its own. Plain
231
260
  long text renders as text (`whitespace-pre-line` keeps its line
232
261
  breaks).
233
- - **Quick actions and editing in place aren't in the playbook yet**
234
- (flipping a yes/no, changing a status or ticking an item from the
235
- view). Don't build them on your own: a row's value can hold a control
236
- and `actions` takes any buttons, so they'll slot in once a release
237
- adds them.
262
+ - **Quick actions: only the fields the plan's `Quick actions` line
263
+ names**, each a control that saves on its own (cell pattern 17,
264
+ `widget-quick-actions.tsx`). A `single choice` in the header is a
265
+ `Select` showing its badge (`WidgetStatusSelect`); a `yes/no` is a
266
+ `Switch` with its words (`WidgetInStockSwitch`); a sub-record list's
267
+ yes/no item field is a checkbox per item in a named group
268
+ (`ChecklistItems`), whose change is a function of the record
269
+ (`(current) => ({ checklist: ... })`) so quick ticks build on each
270
+ other. Each control calls `useSave<Entity>Field(id)` itself and shows
271
+ its own "Saving" spinner; nothing is disabled. A control goes where
272
+ the field already shows (`badges`, a field's `value`, a section's
273
+ `content`): `EntityView` needs nothing new. **With no `Quick actions`
274
+ line, every value is read-only** and there is no quick-actions file:
275
+ the view is exactly the read-only one above.
276
+ - **Editing in place isn't in the playbook yet** (a text or date field
277
+ turning into its form control on the view). Don't build it: every
278
+ other change goes through Edit.
238
279
 
239
280
  **The table opens the view, and the form returns to it.** The title
240
281
  column's link is the way in (a `yes/no` marked `toggle` stays in its
@@ -325,7 +366,14 @@ is a filled-in example while the Widgets demo is still here).
325
366
  each value as its cell shows it), plus the sparse record showing its
326
367
  "not set" labels and no blank value. Its flows: the table's title link
327
368
  opens it, saving and cancelling the form return to it, creating opens
328
- the new record's view, and Delete confirms then lands on the list. If
369
+ the new record's view, and Delete confirms then lands on the list.
370
+ Each quick action gets its own tests (copy `quick action: …` in
371
+ `e2e/widget-view.spec.ts`): the change shows and the PATCH carries it
372
+ (a list item sends the whole list), a server-side effect shows once the
373
+ save answers if the plan has one, a refused save (an MSW override
374
+ answering 422 with the reason on the field) goes back with the toast,
375
+ the "Saving" spinner shows while nothing is disabled, and, for a
376
+ list, two quick changes both land. If
329
377
  the plan has Markdown long text, a test forces a record whose text has
330
378
  a heading, a list, a table, a link and raw HTML, and asserts the HTML
331
379
  shows as text. Add the view's spec to `playwright.config.ts`'s
@@ -353,6 +401,13 @@ is a filled-in example while the Widgets demo is still here).
353
401
 
354
402
  ## What not to copy
355
403
 
404
+ - `widget-quick-actions.tsx`, its `quick action: …` specs and
405
+ `useSaveWidgetField` unless the plan has a `Quick actions` line (or,
406
+ for the hook, a `yes/no` marked `toggle`). Copy only the controls for
407
+ the fields the line names: a plan naming only its status gets the
408
+ status picker, and the In Stock switch and the checklist's checkboxes
409
+ are left out.
410
+
356
411
  - The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
357
412
  button group and `visibleColumns`) unless the plan's List screen lists
358
413
  saved views. When it does, copy it: `useTableUrlState`'s third argument
@@ -7,8 +7,9 @@ per-entity, and only the shape is reusable. Copy the markup into your
7
7
 
8
8
  Patterns 1–10 were each chosen for a real column, from a page of
9
9
  alternatives ([`column-options.md`](column-options.md)), not designed in the abstract.
10
- Patterns 12 (boolean) and 16 (title linking to its view) are the
11
- template's own references: Widget's In Stock and Name columns render them
10
+ Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
11
+ to its view) and 17 (quick action on the view) are the template's own
12
+ references: Widget's In Stock and Name columns and its view render them,
12
13
  and their specs pin them. The other patterns from 11 on are
13
14
  marked **Unproven**: written ahead of a real
14
15
  column, so no app has tested them on real data yet. Prefer a proven
@@ -339,9 +340,14 @@ now", "happened"). Widget's In Stock column is the reference
339
340
 
340
341
  - **A `Switch`, never a checkbox.** It acts at once; a checkbox in a row
341
342
  reads as "select this row".
342
- - **It saves on its own:** a PATCH of that one field, not the form. Show
343
- the new value at once (optimistic) and put it back, with a toast naming
344
- the row, if the save fails (`useToggleWidgetInStockMutation`).
343
+ - **It saves on its own:** a PATCH of that one field, not the form,
344
+ through the foundation's `useRecordUpdate` (Widget's
345
+ `useSaveWidgetField`). The new value shows at once, in the table and on
346
+ the record's view, and goes back, with a toast naming the row and the
347
+ server's reason, if the save is refused.
348
+ - **Saving blocks nothing.** The switch stays usable (a second flip waits
349
+ for the first and builds on it), and a "Saving" spinner shows beside it
350
+ in a slot that's always there, so the cell never changes width.
345
351
  - **Keep the word beside it** (pattern 12), `aria-hidden` since the switch
346
352
  announces its own state. Give the switch a row-specific name
347
353
  (`In stock: ${row.original.name}`).
@@ -388,3 +394,70 @@ cell: ({ row }) => (
388
394
  - **Cost:** none to speak of. Specs that match the title as a cell still
389
395
  work (`getByRole('cell', { name })` reads the link's text); ones that
390
396
  opened the form from a row button go through the view's Edit instead.
397
+
398
+ ## 17. Quick action on the view
399
+
400
+ A value on the record's view that changes right where it's shown, saved on
401
+ its own the moment it changes, without the form: a status picked from the
402
+ header, a yes/no flipped, a sub-record item ticked. Widget's view is the
403
+ reference (`src/routes/widgets/widget-quick-actions.tsx`): Status and In
404
+ Stock in the header, Checklist items ticked in place.
405
+
406
+ **Read-only is the default.** A value on the view is a control only when
407
+ the plan's "View screen" names it under `Quick actions`; everything else
408
+ renders as its table cell does, and changes through Edit. A quick action
409
+ is for a value people change often and can change back as easily: never
410
+ anything destructive, never text (that's editing in place, not built
411
+ yet).
412
+
413
+ | The plan's field | The control | Accessible name |
414
+ |---|---|---|
415
+ | `single choice`, in the header | A `Select` (`size="sm"`) whose value and options render as the field's tone-mapped `Badge` (pattern 5), in place of the badge | The field's label: `Status` |
416
+ | `yes/no`, in the header or a field row | A `Switch` (`size="sm"`) with the value in words beside it, `aria-hidden` (pattern 15) | The field's label: `In stock` |
417
+ | A sub-record list's `yes/no` item field | Each item a `Checkbox` inside a `<label>` with the item's text, so the text ticks it too; the list in one `role="group"` named for the list, described by the done-count | `Done: <item text>` (a visually hidden `Done: ` inside the label) |
418
+
419
+ ```tsx
420
+ const save = useSaveWidgetField(widget.id) // one per control
421
+
422
+ // A value: send the field.
423
+ <Switch size="sm" checked={widget.inStock} aria-label="In stock"
424
+ onCheckedChange={(inStock) => save.mutate({ inStock })} />
425
+
426
+ // A list item: send the whole list, built from the latest record.
427
+ <Checkbox checked={item.done} onCheckedChange={(done) =>
428
+ save.mutate((current) => ({
429
+ checklist: current.checklist.map((other, i) => (i === index ? { ...other, done } : other)),
430
+ }))} />
431
+
432
+ // Beside every control: the "Saving" slot, always there.
433
+ <span className="inline-flex size-3.5 shrink-0">
434
+ {save.isPending && <Spinner aria-label="Saving" className="size-3.5 text-muted-foreground" />}
435
+ </span>
436
+ ```
437
+
438
+ - **Every save goes through `useRecordUpdate`** (the entity's
439
+ `useSave<Entity>Field(id)` in `use-<entity>.ts`), called once per
440
+ control. The change shows at once on the view and in every cached list
441
+ page. When the save answers, the view takes the record the server
442
+ returned, so a server-side effect shows (a computed field, a flag the
443
+ change cleared). Never write a quick action's mutation by hand.
444
+ - **A list is sent whole, built from the latest record**: pass a function
445
+ of the record (`(current) => ({ checklist: ... })`), not a list read from
446
+ props. Saves of one record run one at a time and each request is built
447
+ when it's sent, so two quick ticks both land.
448
+ - **A refusal puts the value back** and toasts "Couldn't update <name>:
449
+ <reason>", the reason being the server's: the 422's message on the
450
+ field, or the error's message. That's built into the hook; don't add a
451
+ second toast.
452
+ - **Saving shows, and blocks nothing.** Each control keeps its own
453
+ "Saving" spinner in a slot that's always there (nothing moves), and
454
+ nothing is disabled: not the other controls, not this one, not Edit.
455
+ - **Keep the value's look.** The status keeps its tone-mapped badge
456
+ inside the picker; the yes/no keeps its words; a ticked item keeps the
457
+ muted text a done item had. The control only adds the affordance.
458
+ - **One component per control, in its own file** (`<entity>-quick-actions.tsx`),
459
+ so each hook lives in a component and the view's sections stay plain
460
+ data.
461
+ - **Cost:** one click changes data, with no confirmation, and the server
462
+ may still say no. Keep the list short: a view where every value is a
463
+ control reads as a form.
@@ -75,8 +75,10 @@ per list; delete the section if there are none.
75
75
  - Most items: 50.
76
76
  - In the table: a done-count (`2/6 done`), or blank if the list isn't
77
77
  shown.
78
- - On the view: always shown, read-only: the done-count, then each item in
79
- order (a `url` item field opens in a new tab).
78
+ - On the view: always shown: the done-count, then each item in order (a
79
+ `url` item field opens in a new tab). Read-only, unless the View
80
+ screen's `Quick actions` names the list: then its yes/no item field is a
81
+ checkbox on each item, ticked in place.
80
82
 
81
83
  | Field | Label | Type | Required | Options / rules |
82
84
  |---|---|---|---|---|
@@ -111,7 +113,8 @@ per list; delete the section if there are none.
111
113
 
112
114
  The read-only page for one record (`/<entity>/:id`), opened by clicking
113
115
  its title in the table. Edit and Delete live in its header, so the table
114
- has no row actions. Every field it shows renders as its table cell does.
116
+ has no row actions. Every field it shows renders as its table cell does,
117
+ except the quick actions, if any.
115
118
 
116
119
  - Title: the field that names a record, shown as the page heading and
117
120
  linked from the table: `title`.
@@ -130,6 +133,18 @@ has no row actions. Every field it shows renders as its table cell does.
130
133
  - "Not set" labels: what an empty optional field reads as on the view
131
134
  (`dueDate`: No due date; `checklist`: No items). A field not listed here
132
135
  reads "Not set". A `yes/no` is never empty: it reads Yes or No.
136
+ - Quick actions (optional; delete if none): the fields the view changes
137
+ right where they show, each saved on its own the moment it changes,
138
+ without the form. Every other value stays read-only. Each one is a
139
+ `single choice` (picked from a list, usually a badge field), a `yes/no`
140
+ (a switch), or a sub-records list (its yes/no item field ticked on each
141
+ item). Only values people change often and can change back as easily.
142
+ Say any rule the server enforces on one (it can refuse the change) and
143
+ any side effect (another field the server changes with it), so each
144
+ gets a test; the view always shows the record the server returns.
145
+
146
+ Quick actions: status (the server refuses Finished while the checklist
147
+ has open items), checklist
133
148
 
134
149
  ## Screens and access
135
150
 
@@ -13,7 +13,13 @@ export async function safeFetch(path, init) {
13
13
  }
14
14
  export function toAppError(status, body) {
15
15
  if (status === 422) {
16
- const detail = body?.detail ?? [];
16
+ // A rule the server enforces (a cap, a status the change needs) is
17
+ // often refused with FastAPI's HTTPException shape: a sentence, not a
18
+ // list of field issues. That sentence is the message to show.
19
+ const raw = body?.detail;
20
+ if (typeof raw === "string")
21
+ return { kind: "validation", message: raw, fieldErrors: {} };
22
+ const detail = raw ?? [];
17
23
  const fieldErrors = {};
18
24
  for (const issue of detail) {
19
25
  const field = fieldKey(issue.loc);
@@ -30,7 +30,11 @@ export type EntityViewSection = {
30
30
  export type EntityViewProps = {
31
31
  /** The record's name or title, the page's <h1>. */
32
32
  title?: string;
33
- /** A few status-like values beside the title, as `<Badge>`s. */
33
+ /**
34
+ * A few status-like values beside the title, as `<Badge>`s, or as the
35
+ * control that changes one when the plan makes it a quick action (a
36
+ * status `Select`, a yes/no `Switch`).
37
+ */
34
38
  badges?: ReactNode;
35
39
  /** The header's actions: an Edit button, a Delete with its confirm dialog. */
36
40
  actions?: ReactNode;
@@ -12,9 +12,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  // is that an empty value reads as the plan's "not set" label, never a
13
13
  // blank.
14
14
  //
15
- // A value can be any markup, a control included, and `actions` takes any
16
- // buttons: that's the room left for quick actions on this page (a toggle,
17
- // a status change, ticking a checklist item) without the form.
15
+ // A value can be any markup, a control included, and so can `badges`:
16
+ // that's where quick actions go (cell pattern 17), a value the plan marks
17
+ // as changed straight from this page and saved on its own through
18
+ // `useRecordUpdate`: a status picked in the header, a yes/no switch,
19
+ // checklist items ticked in place. Everything else stays read-only, and
20
+ // every change of more than that goes through Edit, in `actions`.
18
21
  import { useId } from 'react';
19
22
  import { Link } from 'react-router';
20
23
  import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
@@ -0,0 +1,58 @@
1
+ import { type MutationOptions, type QueryClient, type QueryKey } from '@tanstack/react-query';
2
+ import type { AppError } from '../api/contracts.js';
3
+ type RecordId = string | number;
4
+ /**
5
+ * What one save changes: the fields to send (`{ status: 'done' }`), or a
6
+ * function building them from the record (`(task) => ({ checklist: ... })`).
7
+ * Use the function when the new value depends on the current one, like a
8
+ * whole list with one item changed: it is applied to the record on screen
9
+ * at once, and again to the server's latest record when the request is
10
+ * sent, so a change made while another is saving builds on it.
11
+ */
12
+ export type RecordChange<TRecord, TUpdate> = TUpdate | ((record: TRecord) => TUpdate);
13
+ export type RecordUpdateOptions<TRecord extends {
14
+ id: RecordId;
15
+ }, TUpdate extends Partial<TRecord>> = {
16
+ /** The record's id: list items with this id are the ones updated. */
17
+ id: TRecord['id'];
18
+ /** The record's detail query key, the one its view reads. */
19
+ detailKey: QueryKey;
20
+ /** The prefix every list query of the entity starts with; each holds a `Page<TRecord>`. */
21
+ listsKey: QueryKey;
22
+ /** The gateway's PATCH: sends `update`, resolves with the saved record, rejects with an `AppError`. */
23
+ update: (id: TRecord['id'], update: TUpdate) => Promise<TRecord>;
24
+ /** What a refusal's toast calls the record: "Couldn't update <name>: <reason>". */
25
+ name: (record: TRecord) => string;
26
+ };
27
+ type Entry<TRecord, TUpdate> = {
28
+ change: RecordChange<TRecord, TUpdate>;
29
+ };
30
+ /**
31
+ * Why a save was refused, in the server's words: for a 422, the message
32
+ * on a field the save sent (the first field error otherwise), else the
33
+ * `AppError`'s message.
34
+ */
35
+ export declare function refusalReason(error: AppError, sent?: object): string;
36
+ /**
37
+ * The mutation options behind `useRecordUpdate`, for a given QueryClient.
38
+ * Exported for tests and for code outside React; screens use the hook.
39
+ */
40
+ export declare function recordUpdateMutationOptions<TRecord extends {
41
+ id: RecordId;
42
+ }, TUpdate extends Partial<TRecord>>(queryClient: QueryClient, { id, detailKey, listsKey, update, name }: RecordUpdateOptions<TRecord, TUpdate>): MutationOptions<TRecord, AppError, RecordChange<TRecord, TUpdate>, {
43
+ entry: Entry<TRecord, TUpdate>;
44
+ }>;
45
+ /**
46
+ * Saves a record's fields on their own, optimistically, wherever the
47
+ * record is cached (see the top of this file). Call it once per control,
48
+ * with the entity's query keys and its gateway PATCH, then
49
+ * `mutate({ field: value })` or `mutate((record) => ({ field: ... }))`.
50
+ * `isPending` is that control's own save in flight. A refusal rolls back
51
+ * and shows a toast by itself.
52
+ */
53
+ export declare function useRecordUpdate<TRecord extends {
54
+ id: RecordId;
55
+ }, TUpdate extends Partial<TRecord>>(options: RecordUpdateOptions<TRecord, TUpdate>): import("@tanstack/react-query").UseMutationResult<TRecord, AppError, RecordChange<TRecord, TUpdate>, {
56
+ entry: Entry<TRecord, TUpdate>;
57
+ }>;
58
+ export {};
@@ -0,0 +1,142 @@
1
+ // One record's fields saved on their own, straight from the screen showing
2
+ // them (a quick action on the view, a switch in a table row), instead of
3
+ // through the form. A PATCH of what changed, optimistic everywhere the
4
+ // record is cached: its detail query (what the view shows) and every list
5
+ // page holding it. The piece any save-without-the-form reuses.
6
+ //
7
+ // - **Shown at once.** The change is applied to the cached record before
8
+ // the request goes out, so the screen moves when the user does.
9
+ // - **The server's record wins.** When the save answers, the cache takes
10
+ // the record the server returned, not the one sent, so a server-side
11
+ // effect (a computed field, a flag one change clears) shows at once.
12
+ // - **Rapid changes don't lose each other.** Saves of one record run one
13
+ // at a time (a TanStack mutation scope per record), and each request is
14
+ // built when it is sent, from the server's latest record. Ticking two
15
+ // checklist items quickly sends the whole list twice, and the second
16
+ // request already holds the first tick. A change can be a function of
17
+ // the record for exactly this: `(record) => ({ checklist: ... })`.
18
+ // - **A refusal rolls back only itself.** The cache goes back to the
19
+ // server's latest record with every change still waiting re-applied on
20
+ // top, and a toast gives the server's reason (the field's 422 message,
21
+ // or the AppError's).
22
+ // - **The lists refetch once the last save settles**, so a filter on the
23
+ // field drops the row once the server agrees.
24
+ import { hashKey, useMutation, useQueryClient, } from '@tanstack/react-query';
25
+ import { toast } from 'sonner';
26
+ const states = new WeakMap();
27
+ function recordStates(queryClient) {
28
+ let map = states.get(queryClient);
29
+ if (!map)
30
+ states.set(queryClient, (map = new Map()));
31
+ return map;
32
+ }
33
+ function resolve(change, record) {
34
+ return typeof change === 'function' ? change(record) : change;
35
+ }
36
+ /**
37
+ * Why a save was refused, in the server's words: for a 422, the message
38
+ * on a field the save sent (the first field error otherwise), else the
39
+ * `AppError`'s message.
40
+ */
41
+ export function refusalReason(error, sent) {
42
+ const fieldErrors = Object.entries(error.fieldErrors ?? {});
43
+ const fields = Object.keys(sent ?? {});
44
+ const onSentField = fieldErrors.find(([key]) => fields.some((field) => key === field || key.startsWith(`${field}.`)));
45
+ return (onSentField ?? fieldErrors[0])?.[1][0] ?? error.message;
46
+ }
47
+ /**
48
+ * The mutation options behind `useRecordUpdate`, for a given QueryClient.
49
+ * Exported for tests and for code outside React; screens use the hook.
50
+ */
51
+ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsKey, update, name }) {
52
+ const key = hashKey(detailKey);
53
+ const lists = { queryKey: listsKey };
54
+ const map = recordStates(queryClient);
55
+ // The record as the caches hold it now: the detail, else a list's copy.
56
+ function cached() {
57
+ const detail = queryClient.getQueryData(detailKey);
58
+ if (detail)
59
+ return detail;
60
+ for (const [, page] of queryClient.getQueriesData(lists)) {
61
+ const item = page?.items.find((record) => record.id === id);
62
+ if (item)
63
+ return item;
64
+ }
65
+ return undefined;
66
+ }
67
+ // Writes confirmed + pending changes to the detail (when it's cached)
68
+ // and to every list page holding the record.
69
+ function render(state) {
70
+ if (!state.confirmed)
71
+ return;
72
+ const shown = state.pending.reduce((record, entry) => ({ ...record, ...resolve(entry.change, record) }), state.confirmed);
73
+ queryClient.setQueryData(detailKey, (detail) => detail && shown);
74
+ queryClient.setQueriesData(lists, (page) => page && { ...page, items: page.items.map((record) => (record.id === id ? shown : record)) });
75
+ }
76
+ // A save has settled: drop its change and show what's left on top of
77
+ // the server's latest record. After the last one, refetch the lists.
78
+ function settle(state, entry) {
79
+ state.pending = state.pending.filter((pending) => pending !== entry);
80
+ render(state);
81
+ if (state.pending.length === 0) {
82
+ map.delete(key);
83
+ void queryClient.invalidateQueries(lists);
84
+ }
85
+ }
86
+ return {
87
+ // One save of this record at a time: the next waits for the last
88
+ // one's answer, so its request is built on the server's latest record.
89
+ scope: { id: `record-update:${key}` },
90
+ mutationFn: (change) => {
91
+ const confirmed = map.get(key)?.confirmed ?? cached();
92
+ if (!confirmed)
93
+ return Promise.reject(new Error('The record to update is not loaded'));
94
+ return update(id, resolve(change, confirmed));
95
+ },
96
+ onMutate: async (change) => {
97
+ let state = map.get(key);
98
+ if (!state)
99
+ map.set(key, (state = { confirmed: undefined, pending: [] }));
100
+ const entry = { change };
101
+ state.pending.push(entry);
102
+ // A refetch landing now would overwrite the change on screen.
103
+ await Promise.all([
104
+ queryClient.cancelQueries({ queryKey: detailKey }),
105
+ queryClient.cancelQueries(lists),
106
+ ]);
107
+ // The first change of a burst: the caches still hold the server's record.
108
+ state.confirmed ??= cached();
109
+ render(state);
110
+ return { entry };
111
+ },
112
+ onError: (error, _change, context) => {
113
+ const state = map.get(key);
114
+ if (!state || !context)
115
+ return;
116
+ // What was sent: the change built on the record it was sent against.
117
+ const record = state.confirmed;
118
+ const sent = record && resolve(context.entry.change, record);
119
+ settle(state, context.entry);
120
+ toast.error(`Couldn't update ${record ? name(record) : 'this record'}: ${refusalReason(error, sent)}`);
121
+ },
122
+ onSuccess: (saved, _change, context) => {
123
+ const state = map.get(key);
124
+ if (!state || !context)
125
+ return;
126
+ state.confirmed = saved;
127
+ settle(state, context.entry);
128
+ },
129
+ };
130
+ }
131
+ /**
132
+ * Saves a record's fields on their own, optimistically, wherever the
133
+ * record is cached (see the top of this file). Call it once per control,
134
+ * with the entity's query keys and its gateway PATCH, then
135
+ * `mutate({ field: value })` or `mutate((record) => ({ field: ... }))`.
136
+ * `isPending` is that control's own save in flight. A refusal rolls back
137
+ * and shows a toast by itself.
138
+ */
139
+ export function useRecordUpdate(options) {
140
+ const queryClient = useQueryClient();
141
+ return useMutation(recordUpdateMutationOptions(queryClient, options));
142
+ }
package/dist/index.d.ts CHANGED
@@ -22,5 +22,6 @@ export { createQueryClient } from './api/query-client.js';
22
22
  export { useTableUrlState } from './hooks/use-table-url-state.js';
23
23
  export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
24
24
  export { useDebouncedValue } from './hooks/use-debounced-value.js';
25
+ export { useRecordUpdate, type RecordChange, type RecordUpdateOptions, } from './hooks/use-record-update.js';
25
26
  export { useIsMobile } from './hooks/use-mobile.js';
26
27
  export { cn } from './lib/utils.js';
package/dist/index.js CHANGED
@@ -27,6 +27,7 @@ export { createQueryClient } from './api/query-client.js';
27
27
  // Hooks
28
28
  export { useTableUrlState } from './hooks/use-table-url-state.js';
29
29
  export { useDebouncedValue } from './hooks/use-debounced-value.js';
30
+ export { useRecordUpdate, } from './hooks/use-record-update.js';
30
31
  export { useIsMobile } from './hooks/use-mobile.js';
31
32
  // Utilities
32
33
  export { cn } from './lib/utils.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {