@tristan2828/ui-foundation 3.11.0 → 3.13.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.
package/README.md CHANGED
@@ -18,7 +18,7 @@ than installing this by hand:
18
18
 
19
19
  | Import | What |
20
20
  |---|---|
21
- | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView`, `EntityForm`, `Markdown`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
21
+ | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
22
22
  | `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
23
23
  | `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
24
24
  | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
@@ -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,19 @@ 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).
192
+ - `src/routes/widgets/widget-fields.tsx` →
193
+ `src/routes/<entity>/<entity>-fields.tsx`: **one control per field
194
+ the plan edits in place** (`Edit in place`), shared by the form
195
+ (through a `Controller`) and the view (through `editInPlace`), and
196
+ the Markdown long text's `RichTextEditor` (on the form whether or
197
+ not it edits in place). Every other field's control stays inline in
198
+ the form, as `widget-form.tsx`'s Category, Tags and Available From
199
+ do. No `Edit in place` line and no Markdown long text: no fields
200
+ file.
186
201
  - `src/routes/widgets/delete-widget-action.tsx` →
187
202
  `src/routes/<entity>/delete-<entity>-action.tsx` (Delete in the
188
203
  view's header: the confirm dialog, then `onDeleted` leaves for the
@@ -193,7 +208,9 @@ is a filled-in example while the Widgets demo is still here).
193
208
  delete). Swap the zod schema and fields. Every way out lands on a
194
209
  view (below).
195
210
 
196
- **The view** is the read-only page for one record, at `/<entity>/:id`.
211
+ **The view** is the read-only page for one record, at `/<entity>/:id`
212
+ (apart from the plan's quick actions and fields edited in place, if it
213
+ lists any).
197
214
  `<EntityView>` owns its loading skeleton, its not-found state (another
198
215
  user's record is the same plain 404, with a way back to the list and
199
216
  no retry) and its error state with retry. The screen supplies what the
@@ -219,9 +236,31 @@ is a filled-in example while the Widgets demo is still here).
219
236
  entity has one: the name, from the same lookup by id the table uses,
220
237
  inside `<Link to={`/<other>/${id}`}>`. Without a view, the plain name
221
238
  (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
239
+ - **Sub-records are read-only** unless the plan's `Quick actions`
240
+ names the list: the done-count, then each item in order with its
241
+ state as a glyph and words, never a checkbox. Widget's checklist is
242
+ a quick action, so this is the markup to use (the shape Widget's
243
+ view had before it was):
244
+
245
+ ```tsx
246
+ <div className="flex flex-col gap-2">
247
+ <p className="type-caption tabular-nums text-muted-foreground">{checklistDoneCount(items)}</p>
248
+ <ul aria-label="Checklist items" className="flex flex-col gap-1.5">
249
+ {items.map((item, index) => (
250
+ <li key={index} className="flex items-start gap-2">
251
+ {item.done ? (
252
+ <CircleCheckIcon role="img" aria-label="Done" className="mt-0.5 size-4 shrink-0 text-success-text" />
253
+ ) : (
254
+ <CircleIcon role="img" aria-label="Not done" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
255
+ )}
256
+ <span className={item.done ? 'text-muted-foreground' : undefined}>{item.text}</span>
257
+ </li>
258
+ ))}
259
+ </ul>
260
+ </div>
261
+ ```
262
+
263
+ A list of links: each an
225
264
  `<a target="_blank" rel="noreferrer">` with an `ExternalLinkIcon`
226
265
  and a visually hidden "(opens in a new tab)".
227
266
  - **Long text the plan marks Markdown** renders through the
@@ -230,11 +269,42 @@ is a filled-in example while the Widgets demo is still here).
230
269
  as markup. The app needs no Markdown dependency of its own. Plain
231
270
  long text renders as text (`whitespace-pre-line` keeps its line
232
271
  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.
272
+ - **Quick actions: only the fields the plan's `Quick actions` line
273
+ names**, each a control that saves on its own (cell pattern 17,
274
+ `widget-quick-actions.tsx`). A `yes/no` is a
275
+ `Switch` with its words (`WidgetInStockSwitch`); a sub-record list's
276
+ yes/no item field is a checkbox per item in a named group
277
+ (`ChecklistItems`), whose change is a function of the record
278
+ (`(current) => ({ checklist: ... })`) so quick ticks build on each
279
+ other. Each control calls `useSave<Entity>Field(id)` itself and shows
280
+ its own "Saving" spinner; nothing is disabled. A control goes where
281
+ the field already shows (`badges`, a field's `value`, a section's
282
+ `content`): `EntityView` needs nothing new. **With no `Quick actions`
283
+ line, every value is read-only** and there is no quick-actions file:
284
+ the view is exactly the read-only one above.
285
+ - **Edit in place: only the fields the plan's `Edit in place` line
286
+ names** (cell pattern 18). Each becomes `editInPlace({ kind, value,
287
+ control, schema, save })` (`widgetEdits` in `widget-view.tsx`):
288
+ `value` is what the form's control starts from, `control` is the
289
+ shared control from `<entity>-fields.tsx` given the props it's
290
+ handed (`readOnly={props.disabled}`, `aria-label={props.label}`,
291
+ `aria-describedby={props.describedBy}`), `schema` is the form's own
292
+ rule (`<entity>FormSchema.shape.<field>`), and `save` is
293
+ `useEdit<Entity>Field(id).mutateAsync({ <field>: value })` (copy
294
+ `useEditWidgetField`: the single-field save with `optimistic: false,
295
+ toastOnError: false`). `kind` follows the control: `text` (one line;
296
+ a number or integer too), `long-text` (Markdown, `RichTextEditor`
297
+ with `autoFocus` and `onProblem={props.onProblem}`), `choice` (a
298
+ select or a single reference: its list opens with the field,
299
+ `defaultOpen`, non-modal, `onChange={(v) => props.commit(v)}`,
300
+ `onOpenChange={(open) => !open && props.cancel()}`; a yes/no switch
301
+ commits on change), `multi` (a multi reference; `isEqual` comparing
302
+ ids as a set). A field gets `edit`, a `content` section gets `edit`,
303
+ the title gets `titleEdit={{ label, ...edit }}`, and a header badge
304
+ wraps itself in `<EditableValue label edit layout="inline">`. The
305
+ shown value stays exactly as before. **With no `Edit in place` line,
306
+ nothing is editable in place** and none of this is copied. Never a
307
+ computed field or a sub-record list.
238
308
 
239
309
  **The table opens the view, and the form returns to it.** The title
240
310
  column's link is the way in (a `yes/no` marked `toggle` stays in its
@@ -325,7 +395,22 @@ is a filled-in example while the Widgets demo is still here).
325
395
  each value as its cell shows it), plus the sparse record showing its
326
396
  "not set" labels and no blank value. Its flows: the table's title link
327
397
  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
398
+ the new record's view, and Delete confirms then lands on the list.
399
+ Each quick action gets its own tests (copy `quick action: …` in
400
+ `e2e/widget-view.spec.ts`): the change shows and the PATCH carries it
401
+ (a list item sends the whole list), a server-side effect shows once the
402
+ save answers if the plan has one, a refused save (an MSW override
403
+ answering 422 with the reason on the field) goes back with the toast,
404
+ the "Saving" spinner shows while nothing is disabled, and, for a
405
+ list, two quick changes both land. Fields edited in place get their
406
+ own spec (copy `e2e/widget-edit-in-place.spec.ts`): only the plan's
407
+ fields offer it; each kind saves (Enter, a pick, Ctrl/Cmd+Enter,
408
+ leaving) with the PATCH it should send and "Saving…" while it waits;
409
+ unchanged sends nothing; Esc restores; the schema's refusal is never
410
+ sent; a 422 (an MSW override, the reason on the field) and a 500 stay
411
+ open with the draft and the reason; opening a second field saves the
412
+ first, or doesn't open if that fails; and leaving the page with
413
+ unsaved text asks first. Add it to `mobile-chrome`'s `testMatch`. If
329
414
  the plan has Markdown long text, a test forces a record whose text has
330
415
  a heading, a list, a table, a link and raw HTML, and asserts the HTML
331
416
  shows as text. Add the view's spec to `playwright.config.ts`'s
@@ -346,13 +431,25 @@ is a filled-in example while the Widgets demo is still here).
346
431
  `/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
347
432
  of a full record and of the sparse one to its `viewRoutes` (the table
348
433
  page is picked up from the sidebar automatically; the form and the view
349
- aren't in it).
434
+ aren't in it). With fields edited in place, copy `e2e/a11y.spec.ts`'s
435
+ "editing in place" block for them: a field open, saving and refused,
436
+ the rich-text editor, an open list, and the leave prompt, in both
437
+ themes.
350
438
  10. **`npm run verify`.** Fix until it passes. Then stop — do not add
351
439
  anything beyond what this list covers; note ideas in the app's
352
440
  backlog instead.
353
441
 
354
442
  ## What not to copy
355
443
 
444
+ - `widget-quick-actions.tsx`, its `quick action: …` specs and
445
+ `useSaveWidgetField` unless the plan has a `Quick actions` line (or,
446
+ for the hook, a `yes/no` marked `toggle`). Copy only the controls for
447
+ the fields the line names: a plan naming only its yes/no gets the
448
+ switch, and the checklist's checkboxes are left out.
449
+ - `widgetEdits`, `useEditWidgetField`, `widget-edit-in-place.spec.ts` and
450
+ the in-place a11y block unless the plan has an `Edit in place` line,
451
+ and then only for the fields it names.
452
+
356
453
  - The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
357
454
  button group and `visibleColumns`) unless the plan's List screen lists
358
455
  saved views. When it does, copy it: `useTableUrlState`'s third argument
@@ -7,9 +7,10 @@ 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
12
- and their specs pin them. The other patterns from 11 on are
10
+ Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
11
+ to its view), 17 (quick action on the view) and 18 (editable value) are
12
+ the template's own references: Widget's In Stock and Name columns and its
13
+ view render them, 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
15
16
  pattern when one fits. When you use an unproven one, say so in the
@@ -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,167 @@ 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, with one
401
+ click, saved on its own the moment it changes, without the form: a yes/no
402
+ flipped, a sub-record item ticked. Widget's view is the reference
403
+ (`src/routes/widgets/widget-quick-actions.tsx`): In Stock in the header,
404
+ 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` (or `Edit in
408
+ place`, pattern 18); everything else renders as its table cell does, and
409
+ changes through Edit. A quick action is for a value people change often
410
+ and can change back as easily: never anything destructive. Anything you
411
+ type or pick from a list (text, a status) edits in place instead
412
+ (pattern 18); a status was a quick action in 3.12, and moved there in
413
+ 3.13.
414
+
415
+ | The plan's field | The control | Accessible name |
416
+ |---|---|---|
417
+ | `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` |
418
+ | 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) |
419
+
420
+ ```tsx
421
+ const save = useSaveWidgetField(widget.id) // one per control
422
+
423
+ // A value: send the field.
424
+ <Switch size="sm" checked={widget.inStock} aria-label="In stock"
425
+ onCheckedChange={(inStock) => save.mutate({ inStock })} />
426
+
427
+ // A list item: send the whole list, built from the latest record.
428
+ <Checkbox checked={item.done} onCheckedChange={(done) =>
429
+ save.mutate((current) => ({
430
+ checklist: current.checklist.map((other, i) => (i === index ? { ...other, done } : other)),
431
+ }))} />
432
+
433
+ // Beside every control: the "Saving" slot, always there.
434
+ <span className="inline-flex size-3.5 shrink-0">
435
+ {save.isPending && <Spinner aria-label="Saving" className="size-3.5 text-muted-foreground" />}
436
+ </span>
437
+ ```
438
+
439
+ - **Every save goes through `useRecordUpdate`** (the entity's
440
+ `useSave<Entity>Field(id)` in `use-<entity>.ts`), called once per
441
+ control. The change shows at once on the view and in every cached list
442
+ page. When the save answers, the view takes the record the server
443
+ returned, so a server-side effect shows (a computed field, a flag the
444
+ change cleared). Never write a quick action's mutation by hand.
445
+ - **A list is sent whole, built from the latest record**: pass a function
446
+ of the record (`(current) => ({ checklist: ... })`), not a list read from
447
+ props. Saves of one record run one at a time and each request is built
448
+ when it's sent, so two quick ticks both land.
449
+ - **A refusal puts the value back** and toasts "Couldn't update <name>:
450
+ <reason>", the reason being the server's: the 422's message on the
451
+ field, or the error's message. That's built into the hook; don't add a
452
+ second toast.
453
+ - **Saving shows, and blocks nothing.** Each control keeps its own
454
+ "Saving" spinner in a slot that's always there (nothing moves), and
455
+ nothing is disabled: not the other controls, not this one, not Edit.
456
+ - **Keep the value's look.** The yes/no keeps its words; a ticked item
457
+ keeps the muted text a done item had. The control only adds the
458
+ affordance.
459
+ - **One component per control, in its own file** (`<entity>-quick-actions.tsx`),
460
+ so each hook lives in a component and the view's sections stay plain
461
+ data.
462
+ - **Cost:** one click changes data, with no confirmation, and the server
463
+ may still say no. Keep the list short: a view where every value is a
464
+ control reads as a form.
465
+
466
+ ## 18. Editable value
467
+
468
+ A value on the record's view that turns into **the form's own control**
469
+ where it's shown, and saves when you leave it: a title, a status, a
470
+ price, notes, a list of linked records. Widget's view is the reference
471
+ (`src/routes/widgets/widget-view.tsx`): Name (the title), Status (its
472
+ header badge), Price, Description (rich text) and Extra Categories, with
473
+ the controls in `widget-fields.tsx`, the same ones the form uses.
474
+
475
+ The rule behind every part of it: **nothing typed is ever lost.**
476
+
477
+ **Which values.** Only those the plan's "View screen" names under `Edit
478
+ in place`; read-only is the default, and a value not named renders
479
+ exactly as it did. Single values only: text, long text, a single choice
480
+ (optional ones with their "not set" option), an integer or a rating, a
481
+ yes/no, a single or multi reference. Never a computed field, and never a
482
+ sub-record list (adding, removing and reordering items stays on the
483
+ form). The view's Edit button and the form stay.
484
+
485
+ **At rest: says it's editable without shouting.** The value reads as it
486
+ always did. Hover tints it (`bg-muted`) and shows a pencil; the pencil is
487
+ a real button ("Edit <label>"), so Tab reaches it and Enter opens the
488
+ field, and its focus ring tints the value too. On a touch screen the
489
+ pencil always shows. A click anywhere on the value opens it, except on a
490
+ link inside it (a reference to a record with a view, a link in the
491
+ notes), which still navigates.
492
+
493
+ **Open: the form's control, nothing else.** The same labels, choices and
494
+ pickers as `EntityForm`; the field's label is its accessible name. No
495
+ Save button, no Undo:
496
+
497
+ | Kind (`editInPlace({ kind })`) | Saves on | Esc |
498
+ |---|---|---|
499
+ | `text`: one line | Enter, or leaving it | puts the saved value back |
500
+ | `long-text`: notes, rich text for Markdown | Ctrl/Cmd+Enter, or leaving it (Enter is a new line) | the same |
501
+ | `choice`: a select, a switch, a single reference | the pick (its list opens with the field, non-modal) | closes the list with nothing picked: gives up |
502
+ | `multi`: a multi reference | leaving it, or Ctrl/Cmd+Enter (Enter picks) | closes the list first; Esc again gives up |
503
+
504
+ Leaving means clicking or tabbing anywhere else. Unchanged closes with no
505
+ request; a value the form's schema refuses never leaves the browser and
506
+ shows the schema's message. Opening another value saves the open one
507
+ first; if that save fails, the open one stays and the other doesn't
508
+ open.
509
+
510
+ **Saving: still open, read-only, and says so.** Under the control,
511
+ "Saving…" with a spinner (`role="status"`) until the server agrees, then
512
+ it closes on the saved value. **Not optimistic**: the value only shows as
513
+ saved once it is, and the view shows the record the server returned (a
514
+ side effect included). The rest of the page stays usable.
515
+
516
+ **Refused: still open, with exactly what was typed and why.** The reason
517
+ under the control in the destructive text tone (`FieldError`), word for
518
+ word: a 422's field error as `EntityForm` shows it, or the error's
519
+ message (a 500, no answer). The control is `aria-invalid` and described
520
+ by the reason. Nothing reverts on its own, and there's no toast. Leaving
521
+ again retries; Esc gives up.
522
+
523
+ **Leaving the page** (a link, Back) while a value holds unsaved text asks
524
+ "Discard your changes?" first (Keep editing / Discard); closing the tab
525
+ asks through the browser. A link press doesn't save the field on its
526
+ way out: the prompt decides.
527
+
528
+ ```tsx
529
+ const editField = useEditWidgetField(widget.id) // optimistic: false, toastOnError: false
530
+ const price = editInPlace({
531
+ kind: 'text',
532
+ value: widget.price, // as the form's control holds it
533
+ schema: widgetFormSchema.shape.price, // the form's own rule
534
+ save: (price) => editField.mutateAsync({ price }),
535
+ control: (props) => (
536
+ <WidgetPriceInput id={props.id} value={props.value} onChange={props.onChange}
537
+ invalid={props.invalid} readOnly={props.disabled}
538
+ aria-label={props.label} aria-describedby={props.describedBy} />
539
+ ),
540
+ })
541
+ // A field: { label: 'Price', value: <…as the table shows it…>, edit: price }
542
+ // The title: <EntityView titleEdit={{ label: 'Name', ...name }} />
543
+ // A badge: <EditableValue label="Status" edit={status} layout="inline"><Badge>…</Badge></EditableValue>
544
+ ```
545
+
546
+ - **One control per field, shared with the form** (`<entity>-fields.tsx`):
547
+ each takes `value`/`onChange` and `readOnly`, and the form wraps it in a
548
+ `Controller`. Never a second, in-place-only control.
549
+ - **Long text written as Markdown edits as rich text** (`RichTextEditor`):
550
+ formatted where it's read, saved as Markdown with every block the person
551
+ didn't touch kept exactly as written. On the form too, so it's one
552
+ control.
553
+ - **The save is the single-field save** (`useRecordUpdate` with
554
+ `optimistic: false, toastOnError: false`, the entity's
555
+ `useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
556
+ - **Keep the value's look at rest**: the badge stays a badge, the price
557
+ stays formatted. Only the open state looks like a form.
558
+ - **Cost:** a value that looks like text but edits on click can surprise.
559
+ Keep it to the fields the plan names, and leave the form for everything
560
+ else.
@@ -25,7 +25,8 @@ One or two sentences: what one record is, and who uses the screens.
25
25
  - *Demonstrated by the Widget reference* (copied directly): `text` (one
26
26
  line), `long text` (say `Markdown` when it's written in Markdown, like
27
27
  notes or a write-up: the view renders its headings, lists, tables and
28
- links, and it's never a table column), `decimal` (fixed places — say how many),
28
+ links, the form and editing in place edit it as formatted text, and
29
+ it's never a table column), `decimal` (fixed places — say how many),
29
30
  `date-time`, `email`, `yes/no` (a boolean that is always yes or no:
30
31
  say its default and the filter's three labels, e.g. Any stock /
31
32
  In stock / Out of stock), `single choice` (list the options, in display
@@ -75,8 +76,10 @@ per list; delete the section if there are none.
75
76
  - Most items: 50.
76
77
  - In the table: a done-count (`2/6 done`), or blank if the list isn't
77
78
  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).
79
+ - On the view: always shown: the done-count, then each item in order (a
80
+ `url` item field opens in a new tab). Read-only, unless the View
81
+ screen's `Quick actions` names the list: then its yes/no item field is a
82
+ checkbox on each item, ticked in place.
80
83
 
81
84
  | Field | Label | Type | Required | Options / rules |
82
85
  |---|---|---|---|---|
@@ -111,7 +114,8 @@ per list; delete the section if there are none.
111
114
 
112
115
  The read-only page for one record (`/<entity>/:id`), opened by clicking
113
116
  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.
117
+ has no row actions. Every field it shows renders as its table cell does,
118
+ except the quick actions, if any.
115
119
 
116
120
  - Title: the field that names a record, shown as the page heading and
117
121
  linked from the table: `title`.
@@ -130,6 +134,29 @@ has no row actions. Every field it shows renders as its table cell does.
130
134
  - "Not set" labels: what an empty optional field reads as on the view
131
135
  (`dueDate`: No due date; `checklist`: No items). A field not listed here
132
136
  reads "Not set". A `yes/no` is never empty: it reads Yes or No.
137
+ - Quick actions (optional; delete if none): the values the view changes
138
+ with one click, each saved on its own the moment it changes, without
139
+ the form: a `yes/no` (a switch), or a sub-records list (its yes/no item
140
+ field ticked on each item). Only values people change often and can
141
+ 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: checklist
147
+
148
+ - Edit in place (optional; delete if none): the fields that turn into
149
+ their form control where the view shows them and save when you leave
150
+ them (cell pattern 18): the title, a badge field, a field row, a long
151
+ text. Single values only: text, long text, a single choice, an integer
152
+ or a rating, a yes/no, a single or multi reference. Never a computed
153
+ field or a sub-records list (those stay on the form). Every value not
154
+ named here, or under Quick actions, stays read-only. Say any rule the
155
+ server enforces on one (its refusal shows under the field) and any
156
+ side effect:
157
+
158
+ Edit in place: title, status (the server refuses Finished while the
159
+ checklist has open items), dueDate, notes
133
160
 
134
161
  ## Screens and access
135
162
 
@@ -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);
@@ -0,0 +1,76 @@
1
+ type SafeParse = (value: unknown) => {
2
+ success: true;
3
+ data: unknown;
4
+ } | {
5
+ success: false;
6
+ error: {
7
+ issues: readonly {
8
+ message: string;
9
+ }[];
10
+ };
11
+ };
12
+ /** What a field needs to edit in place, read fresh on every use. */
13
+ export type EditFieldConfig = {
14
+ /** The saved value, as the control holds it (the form's value for the field). */
15
+ value: unknown;
16
+ /** The form's zod schema for the field: a value it rejects never leaves the browser. */
17
+ schema?: {
18
+ safeParse: SafeParse;
19
+ };
20
+ /** Sends the value (the schema's output). Resolves once saved; rejects with an AppError. */
21
+ save: (value: unknown) => Promise<unknown>;
22
+ /** Whether the draft is still the saved value. Defaults to comparing as JSON. */
23
+ isEqual?: (a: unknown, b: unknown) => boolean;
24
+ };
25
+ export type EditFieldState = {
26
+ status: 'closed';
27
+ } | OpenFieldState;
28
+ export type OpenFieldState = {
29
+ status: 'open' | 'saving';
30
+ /** What the control shows: exactly what was typed. */
31
+ draft: unknown;
32
+ /** The saved value when the field opened. */
33
+ initial: unknown;
34
+ /** Why it can't save (the schema's or the server's words), shown under it. */
35
+ error: string | null;
36
+ /** A problem the control reports itself (rich text that can't save as it stands). */
37
+ problem: string | null;
38
+ };
39
+ export declare class EditInPlaceStore {
40
+ private configs;
41
+ private states;
42
+ private saving;
43
+ private openKey;
44
+ private listeners;
45
+ subscribe: (listener: () => void) => () => void;
46
+ private emit;
47
+ private set;
48
+ /** A field joins the view. `config` is read fresh each time it's needed. */
49
+ register(key: string, config: () => EditFieldConfig): () => void;
50
+ state(key: string): EditFieldState;
51
+ /** The field that's open, if any. */
52
+ get open(): string | null;
53
+ /** Whether leaving the page now would lose something: a draft that isn't saved. */
54
+ get dirty(): boolean;
55
+ /**
56
+ * Opens a field. The open one, if another, is saved first: if that
57
+ * fails it stays open and this one doesn't, and this returns false.
58
+ */
59
+ start(key: string): Promise<boolean>;
60
+ /** The control's value changed. Ignored while saving: the field is read-only then. */
61
+ change(key: string, draft: unknown): void;
62
+ /** The control can't produce a value to save as it stands (`null` once it can). */
63
+ report(key: string, problem: string | null): void;
64
+ /**
65
+ * Saves the field (leaving it, Enter, a choice picked). Resolves true
66
+ * once it's closed (saved, or unchanged), false if it stays open (the
67
+ * schema refused it, or the save failed). `draft`, when given, is the
68
+ * value to save (a choice saves the moment it's picked).
69
+ */
70
+ commit(key: string, draft?: unknown): Promise<boolean>;
71
+ /** Esc: closes and shows the saved value again. Not while a save is in flight. */
72
+ cancel(key: string): void;
73
+ /** Leaving the page anyway: whatever is open is dropped. */
74
+ discard(): void;
75
+ }
76
+ export {};