@tristan2828/ui-foundation 3.12.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 +1 -1
- package/conventions/docs/add-an-entity.md +53 -11
- package/conventions/docs/cell-patterns.md +114 -17
- package/conventions/docs/entity-plan-template.md +21 -9
- package/dist/components/app/edit-in-place-store.d.ts +76 -0
- package/dist/components/app/edit-in-place-store.js +159 -0
- package/dist/components/app/edit-in-place.d.ts +88 -0
- package/dist/components/app/edit-in-place.js +10 -0
- package/dist/components/app/editable-value.d.ts +23 -0
- package/dist/components/app/editable-value.js +176 -0
- package/dist/components/app/entity-view.d.ts +10 -1
- package/dist/components/app/entity-view.js +20 -7
- package/dist/components/app/milkdown-editor.d.ts +2 -0
- package/dist/components/app/milkdown-editor.js +221 -0
- package/dist/components/app/multi-reference.d.ts +5 -1
- package/dist/components/app/multi-reference.js +2 -2
- package/dist/components/app/rich-text-editor.d.ts +20 -0
- package/dist/components/app/rich-text-editor.js +25 -0
- package/dist/components/ui/field.js +2 -2
- package/dist/hooks/use-record-update.d.ts +14 -1
- package/dist/hooks/use-record-update.js +10 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/lib/markdown-merge.d.ts +19 -0
- package/dist/lib/markdown-merge.js +195 -0
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +21 -2
- package/package.json +5 -2
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` |
|
|
@@ -189,6 +189,15 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
189
189
|
`src/routes/<entity>/<entity>-quick-actions.tsx`, **only when the
|
|
190
190
|
plan's "View screen" lists `Quick actions`**: one component per
|
|
191
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.
|
|
192
201
|
- `src/routes/widgets/delete-widget-action.tsx` →
|
|
193
202
|
`src/routes/<entity>/delete-<entity>-action.tsx` (Delete in the
|
|
194
203
|
view's header: the confirm dialog, then `onDeleted` leaves for the
|
|
@@ -200,7 +209,8 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
200
209
|
view (below).
|
|
201
210
|
|
|
202
211
|
**The view** is the read-only page for one record, at `/<entity>/:id`
|
|
203
|
-
(apart from the plan's quick actions, if it
|
|
212
|
+
(apart from the plan's quick actions and fields edited in place, if it
|
|
213
|
+
lists any).
|
|
204
214
|
`<EntityView>` owns its loading skeleton, its not-found state (another
|
|
205
215
|
user's record is the same plain 404, with a way back to the list and
|
|
206
216
|
no retry) and its error state with retry. The screen supplies what the
|
|
@@ -261,8 +271,7 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
261
271
|
breaks).
|
|
262
272
|
- **Quick actions: only the fields the plan's `Quick actions` line
|
|
263
273
|
names**, each a control that saves on its own (cell pattern 17,
|
|
264
|
-
`widget-quick-actions.tsx`). A `
|
|
265
|
-
`Select` showing its badge (`WidgetStatusSelect`); a `yes/no` is a
|
|
274
|
+
`widget-quick-actions.tsx`). A `yes/no` is a
|
|
266
275
|
`Switch` with its words (`WidgetInStockSwitch`); a sub-record list's
|
|
267
276
|
yes/no item field is a checkbox per item in a named group
|
|
268
277
|
(`ChecklistItems`), whose change is a function of the record
|
|
@@ -273,9 +282,29 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
273
282
|
`content`): `EntityView` needs nothing new. **With no `Quick actions`
|
|
274
283
|
line, every value is read-only** and there is no quick-actions file:
|
|
275
284
|
the view is exactly the read-only one above.
|
|
276
|
-
- **
|
|
277
|
-
|
|
278
|
-
|
|
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.
|
|
279
308
|
|
|
280
309
|
**The table opens the view, and the form returns to it.** The title
|
|
281
310
|
column's link is the way in (a `yes/no` marked `toggle` stays in its
|
|
@@ -373,7 +402,15 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
373
402
|
save answers if the plan has one, a refused save (an MSW override
|
|
374
403
|
answering 422 with the reason on the field) goes back with the toast,
|
|
375
404
|
the "Saving" spinner shows while nothing is disabled, and, for a
|
|
376
|
-
list, two quick changes both land.
|
|
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
|
|
377
414
|
the plan has Markdown long text, a test forces a record whose text has
|
|
378
415
|
a heading, a list, a table, a link and raw HTML, and asserts the HTML
|
|
379
416
|
shows as text. Add the view's spec to `playwright.config.ts`'s
|
|
@@ -394,7 +431,10 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
394
431
|
`/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
|
|
395
432
|
of a full record and of the sparse one to its `viewRoutes` (the table
|
|
396
433
|
page is picked up from the sidebar automatically; the form and the view
|
|
397
|
-
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.
|
|
398
438
|
10. **`npm run verify`.** Fix until it passes. Then stop — do not add
|
|
399
439
|
anything beyond what this list covers; note ideas in the app's
|
|
400
440
|
backlog instead.
|
|
@@ -404,9 +444,11 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
404
444
|
- `widget-quick-actions.tsx`, its `quick action: …` specs and
|
|
405
445
|
`useSaveWidgetField` unless the plan has a `Quick actions` line (or,
|
|
406
446
|
for the hook, a `yes/no` marked `toggle`). Copy only the controls for
|
|
407
|
-
the fields the line names: a plan naming only its
|
|
408
|
-
|
|
409
|
-
|
|
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.
|
|
410
452
|
|
|
411
453
|
- The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
|
|
412
454
|
button group and `visibleColumns`) unless the plan's List screen lists
|
|
@@ -8,9 +8,9 @@ per-entity, and only the shape is reusable. Copy the markup into your
|
|
|
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
10
|
Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
|
|
11
|
-
to its view)
|
|
12
|
-
references: Widget's In Stock and Name columns and its
|
|
13
|
-
and their specs pin them. The other patterns from 11 on are
|
|
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
|
|
14
14
|
marked **Unproven**: written ahead of a real
|
|
15
15
|
column, so no app has tested them on real data yet. Prefer a proven
|
|
16
16
|
pattern when one fits. When you use an unproven one, say so in the
|
|
@@ -397,22 +397,23 @@ cell: ({ row }) => (
|
|
|
397
397
|
|
|
398
398
|
## 17. Quick action on the view
|
|
399
399
|
|
|
400
|
-
A value on the record's view that changes right where it's shown,
|
|
401
|
-
its own the moment it changes, without the form: a
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
405
|
|
|
406
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
|
|
408
|
-
renders as its table cell does, and
|
|
409
|
-
is for a value people change often
|
|
410
|
-
|
|
411
|
-
|
|
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.
|
|
412
414
|
|
|
413
415
|
| The plan's field | The control | Accessible name |
|
|
414
416
|
|---|---|---|
|
|
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
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` |
|
|
417
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) |
|
|
418
419
|
|
|
@@ -452,12 +453,108 @@ const save = useSaveWidgetField(widget.id) // one per control
|
|
|
452
453
|
- **Saving shows, and blocks nothing.** Each control keeps its own
|
|
453
454
|
"Saving" spinner in a slot that's always there (nothing moves), and
|
|
454
455
|
nothing is disabled: not the other controls, not this one, not Edit.
|
|
455
|
-
- **Keep the value's look.** The
|
|
456
|
-
|
|
457
|
-
|
|
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.
|
|
458
459
|
- **One component per control, in its own file** (`<entity>-quick-actions.tsx`),
|
|
459
460
|
so each hook lives in a component and the view's sections stay plain
|
|
460
461
|
data.
|
|
461
462
|
- **Cost:** one click changes data, with no confirmation, and the server
|
|
462
463
|
may still say no. Keep the list short: a view where every value is a
|
|
463
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,
|
|
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
|
|
@@ -133,18 +134,29 @@ except the quick actions, if any.
|
|
|
133
134
|
- "Not set" labels: what an empty optional field reads as on the view
|
|
134
135
|
(`dueDate`: No due date; `checklist`: No items). A field not listed here
|
|
135
136
|
reads "Not set". A `yes/no` is never empty: it reads Yes or No.
|
|
136
|
-
- Quick actions (optional; delete if none): the
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
item). Only values people change often and can change back as easily.
|
|
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
142
|
Say any rule the server enforces on one (it can refuse the change) and
|
|
143
143
|
any side effect (another field the server changes with it), so each
|
|
144
144
|
gets a test; the view always shows the record the server returns.
|
|
145
145
|
|
|
146
|
-
Quick actions:
|
|
147
|
-
|
|
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
|
|
148
160
|
|
|
149
161
|
## Screens and access
|
|
150
162
|
|
|
@@ -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 {};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { refusalReason } from '../../hooks/use-record-update.js';
|
|
2
|
+
const CLOSED = { status: 'closed' };
|
|
3
|
+
const sameAsJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
4
|
+
function messageOf(error) {
|
|
5
|
+
if (error && typeof error === 'object' && 'kind' in error)
|
|
6
|
+
return refusalReason(error);
|
|
7
|
+
if (error instanceof Error)
|
|
8
|
+
return error.message;
|
|
9
|
+
return 'The save failed. Try again.';
|
|
10
|
+
}
|
|
11
|
+
export class EditInPlaceStore {
|
|
12
|
+
configs = new Map();
|
|
13
|
+
states = new Map();
|
|
14
|
+
saving = new Map();
|
|
15
|
+
openKey = null;
|
|
16
|
+
listeners = new Set();
|
|
17
|
+
subscribe = (listener) => {
|
|
18
|
+
this.listeners.add(listener);
|
|
19
|
+
return () => {
|
|
20
|
+
this.listeners.delete(listener);
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
emit() {
|
|
24
|
+
for (const listener of this.listeners)
|
|
25
|
+
listener();
|
|
26
|
+
}
|
|
27
|
+
set(key, state) {
|
|
28
|
+
this.states.set(key, state);
|
|
29
|
+
if (state.status === 'closed' && this.openKey === key)
|
|
30
|
+
this.openKey = null;
|
|
31
|
+
this.emit();
|
|
32
|
+
}
|
|
33
|
+
/** A field joins the view. `config` is read fresh each time it's needed. */
|
|
34
|
+
register(key, config) {
|
|
35
|
+
this.configs.set(key, config);
|
|
36
|
+
return () => {
|
|
37
|
+
this.configs.delete(key);
|
|
38
|
+
this.states.delete(key);
|
|
39
|
+
if (this.openKey === key)
|
|
40
|
+
this.openKey = null;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
state(key) {
|
|
44
|
+
return this.states.get(key) ?? CLOSED;
|
|
45
|
+
}
|
|
46
|
+
/** The field that's open, if any. */
|
|
47
|
+
get open() {
|
|
48
|
+
return this.openKey;
|
|
49
|
+
}
|
|
50
|
+
/** Whether leaving the page now would lose something: a draft that isn't saved. */
|
|
51
|
+
get dirty() {
|
|
52
|
+
if (this.openKey === null)
|
|
53
|
+
return false;
|
|
54
|
+
const state = this.state(this.openKey);
|
|
55
|
+
if (state.status === 'closed')
|
|
56
|
+
return false;
|
|
57
|
+
if (state.status === 'saving' || state.error !== null)
|
|
58
|
+
return true;
|
|
59
|
+
const isEqual = this.configs.get(this.openKey)?.().isEqual ?? sameAsJson;
|
|
60
|
+
return !isEqual(state.draft, state.initial);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Opens a field. The open one, if another, is saved first: if that
|
|
64
|
+
* fails it stays open and this one doesn't, and this returns false.
|
|
65
|
+
*/
|
|
66
|
+
async start(key) {
|
|
67
|
+
if (this.openKey === key)
|
|
68
|
+
return true;
|
|
69
|
+
if (this.openKey !== null) {
|
|
70
|
+
const closed = await this.commit(this.openKey);
|
|
71
|
+
if (!closed)
|
|
72
|
+
return false;
|
|
73
|
+
// Another field may have opened while that save was in flight.
|
|
74
|
+
if (this.openKey !== null && this.openKey !== key)
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const config = this.configs.get(key)?.();
|
|
78
|
+
if (!config)
|
|
79
|
+
return false;
|
|
80
|
+
this.openKey = key;
|
|
81
|
+
this.set(key, { status: 'open', draft: config.value, initial: config.value, error: null, problem: null });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
/** The control's value changed. Ignored while saving: the field is read-only then. */
|
|
85
|
+
change(key, draft) {
|
|
86
|
+
const state = this.state(key);
|
|
87
|
+
if (state.status !== 'open')
|
|
88
|
+
return;
|
|
89
|
+
this.set(key, { ...state, draft });
|
|
90
|
+
}
|
|
91
|
+
/** The control can't produce a value to save as it stands (`null` once it can). */
|
|
92
|
+
report(key, problem) {
|
|
93
|
+
const state = this.state(key);
|
|
94
|
+
if (state.status === 'closed' || state.problem === problem)
|
|
95
|
+
return;
|
|
96
|
+
this.set(key, { ...state, problem });
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Saves the field (leaving it, Enter, a choice picked). Resolves true
|
|
100
|
+
* once it's closed (saved, or unchanged), false if it stays open (the
|
|
101
|
+
* schema refused it, or the save failed). `draft`, when given, is the
|
|
102
|
+
* value to save (a choice saves the moment it's picked).
|
|
103
|
+
*/
|
|
104
|
+
commit(key, draft) {
|
|
105
|
+
const inFlight = this.saving.get(key);
|
|
106
|
+
if (inFlight)
|
|
107
|
+
return inFlight;
|
|
108
|
+
let state = this.state(key);
|
|
109
|
+
if (state.status === 'closed')
|
|
110
|
+
return Promise.resolve(true);
|
|
111
|
+
if (draft !== undefined && state.status === 'open') {
|
|
112
|
+
state = { ...state, draft };
|
|
113
|
+
this.set(key, state);
|
|
114
|
+
}
|
|
115
|
+
const open = state;
|
|
116
|
+
const config = this.configs.get(key)?.();
|
|
117
|
+
if (!config)
|
|
118
|
+
return Promise.resolve(false);
|
|
119
|
+
if (open.problem) {
|
|
120
|
+
this.set(key, { ...open, error: open.problem });
|
|
121
|
+
return Promise.resolve(false);
|
|
122
|
+
}
|
|
123
|
+
const isEqual = config.isEqual ?? sameAsJson;
|
|
124
|
+
if (isEqual(open.draft, open.initial)) {
|
|
125
|
+
this.set(key, CLOSED);
|
|
126
|
+
return Promise.resolve(true);
|
|
127
|
+
}
|
|
128
|
+
const parsed = config.schema ? config.schema.safeParse(open.draft) : { success: true, data: open.draft };
|
|
129
|
+
if (!parsed.success) {
|
|
130
|
+
this.set(key, { ...open, error: parsed.error.issues[0]?.message ?? 'Check this value.' });
|
|
131
|
+
return Promise.resolve(false);
|
|
132
|
+
}
|
|
133
|
+
this.set(key, { ...open, status: 'saving', error: null });
|
|
134
|
+
const save = config
|
|
135
|
+
.save(parsed.data)
|
|
136
|
+
.then(() => {
|
|
137
|
+
this.set(key, CLOSED);
|
|
138
|
+
return true;
|
|
139
|
+
}, (error) => {
|
|
140
|
+
this.set(key, { ...open, status: 'open', error: messageOf(error) });
|
|
141
|
+
return false;
|
|
142
|
+
})
|
|
143
|
+
.finally(() => this.saving.delete(key));
|
|
144
|
+
this.saving.set(key, save);
|
|
145
|
+
return save;
|
|
146
|
+
}
|
|
147
|
+
/** Esc: closes and shows the saved value again. Not while a save is in flight. */
|
|
148
|
+
cancel(key) {
|
|
149
|
+
const state = this.state(key);
|
|
150
|
+
if (state.status !== 'open')
|
|
151
|
+
return;
|
|
152
|
+
this.set(key, CLOSED);
|
|
153
|
+
}
|
|
154
|
+
/** Leaving the page anyway: whatever is open is dropped. */
|
|
155
|
+
discard() {
|
|
156
|
+
if (this.openKey !== null)
|
|
157
|
+
this.cancel(this.openKey);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { EditInPlaceStore } from './edit-in-place-store.js';
|
|
3
|
+
/** What an editable value's control is given. Spread what applies onto the form's own control. */
|
|
4
|
+
export type EditControlProps<TDraft> = {
|
|
5
|
+
/** The control's id. */
|
|
6
|
+
id: string;
|
|
7
|
+
/** The draft: exactly what's been typed or picked. */
|
|
8
|
+
value: TDraft;
|
|
9
|
+
onChange: (value: TDraft) => void;
|
|
10
|
+
/**
|
|
11
|
+
* Save now. A choice calls it with the value picked (`commit(value)`):
|
|
12
|
+
* a choice saves the moment it's picked.
|
|
13
|
+
*/
|
|
14
|
+
commit: (value?: TDraft) => void;
|
|
15
|
+
/** Close without saving: a list closed with nothing picked. */
|
|
16
|
+
cancel: () => void;
|
|
17
|
+
/** The save is in flight: show the draft, don't let it change. */
|
|
18
|
+
disabled: boolean;
|
|
19
|
+
/** The draft was refused; its reason is under it (`aria-describedby`). */
|
|
20
|
+
invalid: boolean;
|
|
21
|
+
/** The field's label: the control's accessible name. */
|
|
22
|
+
label: string;
|
|
23
|
+
/** The id of the line under the control (Saving…, or why it can't save). */
|
|
24
|
+
describedBy: string;
|
|
25
|
+
/** For a control that can't always produce a value to save (RichTextEditor's `onProblem`). */
|
|
26
|
+
onProblem: (message: string | null) => void;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* How the value saves, which follows from its control:
|
|
30
|
+
* - `text`: one line. Enter saves (as does leaving it).
|
|
31
|
+
* - `long-text`: Ctrl/Cmd+Enter saves; Enter is a new line.
|
|
32
|
+
* - `choice`: one pick (a select, a switch, a single reference). Saves the
|
|
33
|
+
* moment it's picked; its list opens with the field.
|
|
34
|
+
* - `multi`: several picks (a multi reference). Saves on leaving it, or
|
|
35
|
+
* Ctrl/Cmd+Enter; Enter picks.
|
|
36
|
+
* Leaving the field saves every kind; Esc gives up.
|
|
37
|
+
*/
|
|
38
|
+
export type EditKind = 'text' | 'long-text' | 'choice' | 'multi';
|
|
39
|
+
type SafeParseResult<TValue> = {
|
|
40
|
+
success: true;
|
|
41
|
+
data: TValue;
|
|
42
|
+
} | {
|
|
43
|
+
success: false;
|
|
44
|
+
error: {
|
|
45
|
+
issues: readonly {
|
|
46
|
+
message: string;
|
|
47
|
+
}[];
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
/** A field marked editable. Make one with `editInPlace({...})`. */
|
|
51
|
+
export type EditInPlace = {
|
|
52
|
+
kind: EditKind;
|
|
53
|
+
value: unknown;
|
|
54
|
+
control: (props: EditControlProps<unknown>) => ReactNode;
|
|
55
|
+
schema?: {
|
|
56
|
+
safeParse: (value: unknown) => SafeParseResult<unknown>;
|
|
57
|
+
};
|
|
58
|
+
save: (value: unknown) => Promise<unknown>;
|
|
59
|
+
isEqual?: (a: unknown, b: unknown) => boolean;
|
|
60
|
+
};
|
|
61
|
+
export type EditInPlaceOptions<TDraft, TValue> = {
|
|
62
|
+
kind: EditKind;
|
|
63
|
+
/** The saved value as the control holds it: what the form's control would start from. */
|
|
64
|
+
value: TDraft;
|
|
65
|
+
/** The form's own control for this field (one control per field, shared with the form). */
|
|
66
|
+
control: (props: EditControlProps<TDraft>) => ReactNode;
|
|
67
|
+
/**
|
|
68
|
+
* The form's schema for this field (`widgetFormSchema.shape.name`): a
|
|
69
|
+
* value it refuses never leaves the browser, and its message shows
|
|
70
|
+
* under the field. Its output is what `save` gets.
|
|
71
|
+
*/
|
|
72
|
+
schema?: {
|
|
73
|
+
safeParse: (value: unknown) => SafeParseResult<TValue>;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Saves the value: the entity's single-field save (`useRecordUpdate`
|
|
77
|
+
* with `optimistic: false, toastOnError: false`), `mutateAsync` of the
|
|
78
|
+
* field's update. Rejects with the AppError; a 422's field error shows
|
|
79
|
+
* under the field word for word.
|
|
80
|
+
*/
|
|
81
|
+
save: (value: TValue) => Promise<unknown>;
|
|
82
|
+
/** Whether a draft is still the saved value. Defaults to comparing as JSON. */
|
|
83
|
+
isEqual?: (a: TDraft, b: TDraft) => boolean;
|
|
84
|
+
};
|
|
85
|
+
/** Marks a field editable in place, typed end to end: draft → schema → save. */
|
|
86
|
+
export declare function editInPlace<TDraft, TValue = TDraft>(options: EditInPlaceOptions<TDraft, TValue>): EditInPlace;
|
|
87
|
+
export declare const EditInPlaceContext: import("react").Context<EditInPlaceStore | null>;
|
|
88
|
+
export {};
|