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