@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 +1 -1
- package/conventions/docs/add-an-entity.md +113 -16
- package/conventions/docs/cell-patterns.md +176 -6
- package/conventions/docs/entity-plan-template.md +31 -4
- package/dist/api/gateway/errors.js +7 -1
- 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 +15 -2
- package/dist/components/app/entity-view.js +24 -8
- 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 +71 -0
- package/dist/hooks/use-record-update.js +148 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -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` |
|
|
@@ -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
|
|
158
|
-
|
|
159
|
-
once and
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
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)
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
343
|
-
the
|
|
344
|
-
|
|
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,
|
|
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
|
|
79
|
-
|
|
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
|
-
|
|
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 {};
|