@tristan2828/ui-foundation 3.9.0 → 3.11.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 +3 -3
- package/conventions/AGENTS.md +4 -3
- package/conventions/agents/skills/new-entity/SKILL.md +1 -1
- package/conventions/docs/add-an-entity.md +107 -15
- package/conventions/docs/cell-patterns.md +40 -2
- package/conventions/docs/entity-plan-template.md +46 -4
- 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 +48 -0
- package/dist/components/app/entity-view.js +63 -0
- package/dist/components/app/markdown.d.ts +7 -0
- package/dist/components/app/markdown.js +60 -0
- package/dist/hooks/table-url-changes.d.ts +7 -0
- package/dist/hooks/table-url-changes.js +14 -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 +2 -0
- package/dist/index.js +2 -0
- package/dist/testing/index.d.ts +7 -1
- package/dist/testing/index.js +9 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
[](https://github.com/Tristan2828/ui-foundation/actions/workflows/release.yml)
|
|
5
5
|
|
|
6
6
|
The shared layer of a family of small, contract-first, database-backed React
|
|
7
|
-
apps: shadcn primitives, the `DataTable`/`EntityForm`
|
|
8
|
-
shell with session-cookie auth, the gateway's error seam, design tokens,
|
|
7
|
+
apps: shadcn primitives, the `DataTable`/`EntityView`/`EntityForm`
|
|
8
|
+
composites, an app shell with session-cookie auth, the gateway's error seam, design tokens,
|
|
9
9
|
lint rules, Playwright suites, and the conventions an AI agent follows to
|
|
10
10
|
build on it. Apps upgrade by bumping the version.
|
|
11
11
|
|
|
@@ -18,7 +18,7 @@ than installing this by hand:
|
|
|
18
18
|
|
|
19
19
|
| Import | What |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityForm`, `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`, `EntityForm`, `Markdown`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `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` |
|
package/conventions/AGENTS.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
## Stack
|
|
4
4
|
Vite + React 19 + TypeScript, Tailwind v4, shadcn/ui (Base UI primitives),
|
|
5
5
|
React Router v7, TanStack Query. The shared layer — primitives, the
|
|
6
|
-
`DataTable`/`EntityForm` composites, the app shell, auth,
|
|
7
|
-
seam, design tokens, lint rules — is the npm package
|
|
6
|
+
`DataTable`/`EntityView`/`EntityForm` composites, the app shell, auth,
|
|
7
|
+
the gateway error seam, design tokens, lint rules — is the npm package
|
|
8
8
|
`@tristan2828/ui-foundation`, updated by bumping its version. Everything in
|
|
9
9
|
`src/` is this app's own.
|
|
10
10
|
|
|
@@ -75,7 +75,8 @@ is right. Do not disable, skip, or work around it.
|
|
|
75
75
|
|
|
76
76
|
## Required States
|
|
77
77
|
Every data view handles: loading, empty, error, and success. Use
|
|
78
|
-
`<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does
|
|
78
|
+
`<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does,
|
|
79
|
+
and `EntityView` (one record) adds not found: a plain 404, never a retry.
|
|
79
80
|
Every screen has one Playwright test per state, forced via MSW overrides
|
|
80
81
|
(`@tristan2828/ui-foundation/testing`).
|
|
81
82
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: new-entity
|
|
3
|
-
description: This skill should be used when the user asks to "add an entity", "add a new entity", "scaffold a CRUD screen", "add a resource screen", or names a new domain object (e.g. "add Invoice") that needs a spec, gateway, mocks, table, and form built following this repo's widgets reference pattern.
|
|
3
|
+
description: This skill should be used when the user asks to "add an entity", "add a new entity", "scaffold a CRUD screen", "add a resource screen", or names a new domain object (e.g. "add Invoice") that needs a spec, gateway, mocks, table, view and form built following this repo's widgets reference pattern.
|
|
4
4
|
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Agent
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Add an Entity
|
|
2
2
|
|
|
3
3
|
The most-repeated task in this system: add a full CRUD entity — spec, mocks,
|
|
4
|
-
gateway, tests, table, form, routes — following the patterns in
|
|
4
|
+
gateway, tests, table, view, form, routes — following the patterns in
|
|
5
5
|
`src/routes/widgets/`. This file is the only copy of the steps, written for
|
|
6
6
|
**any AI coding tool or a person**: follow it directly ("add a Game entity
|
|
7
7
|
following docs/foundation/add-an-entity.md"). In Claude Code,
|
|
@@ -56,6 +56,11 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
56
56
|
unresolved item under "Open questions", or needs a field type or screen
|
|
57
57
|
shape the foundation doesn't support yet (the supported list is in the
|
|
58
58
|
template). Write the case in `docs/BLOCKERS.md`.
|
|
59
|
+
- **The plan's "Screens" line names a view** (`list, view, create, edit,
|
|
60
|
+
delete`), and its "View screen" section says what the view shows. A plan
|
|
61
|
+
written before the view existed has neither: ask the developer whether
|
|
62
|
+
to add them before step 1. The reference files build the table *with* a
|
|
63
|
+
view, where Edit and Delete live, so the table has no row actions.
|
|
59
64
|
|
|
60
65
|
1. **Add `<Entity>` to `openapi.yaml`** — schema, list, get, create, update,
|
|
61
66
|
delete — exactly as the plan specifies: its fields, types, required
|
|
@@ -162,18 +167,85 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
162
167
|
`<Entity>Create`/`<Entity>Update`, plus the form ↔ wire conversion
|
|
163
168
|
functions — see `widgetToFormValues`/`formValuesToWidgetCreate` for
|
|
164
169
|
the shape)
|
|
170
|
+
- `src/routes/widgets/widget-format.ts` →
|
|
171
|
+
`src/routes/<entity>/<entity>-format.ts` (the lookups and formatters
|
|
172
|
+
both the columns and the view render values with: badge variant maps,
|
|
173
|
+
date and number formatters, the done-count)
|
|
165
174
|
- `src/routes/widgets/widgets-columns.tsx` →
|
|
166
|
-
`src/routes/<entity>/<entity>-columns.tsx` (column defs
|
|
167
|
-
|
|
168
|
-
|
|
175
|
+
`src/routes/<entity>/<entity>-columns.tsx` (column defs: the plan's
|
|
176
|
+
title field is a link to the record's view, cell pattern 16, and
|
|
177
|
+
there is no row-actions column)
|
|
169
178
|
- `src/routes/widgets/widgets-table.tsx` →
|
|
170
179
|
`src/routes/<entity>/<entity>-table.tsx` — thin consumer of the
|
|
171
180
|
foundation's `<DataTable>`. Swap the type, columns, and toolbar
|
|
172
181
|
filters.
|
|
182
|
+
- `src/routes/widgets/widget-view.tsx` →
|
|
183
|
+
`src/routes/<entity>/<entity>-view.tsx` — thin consumer of the
|
|
184
|
+
foundation's `<EntityView>`. Swap the title, badges and sections for
|
|
185
|
+
the plan's "View screen" (below).
|
|
186
|
+
- `src/routes/widgets/delete-widget-action.tsx` →
|
|
187
|
+
`src/routes/<entity>/delete-<entity>-action.tsx` (Delete in the
|
|
188
|
+
view's header: the confirm dialog, then `onDeleted` leaves for the
|
|
189
|
+
list; its own file, a fast-refresh hazard otherwise)
|
|
173
190
|
- `src/routes/widgets/widget-form.tsx` →
|
|
174
191
|
`src/routes/<entity>/<entity>-form.tsx` — thin consumer of the
|
|
175
192
|
foundation's `<EntityForm>` (its `danger` slot takes an edit-only
|
|
176
|
-
delete). Swap the zod schema and fields.
|
|
193
|
+
delete). Swap the zod schema and fields. Every way out lands on a
|
|
194
|
+
view (below).
|
|
195
|
+
|
|
196
|
+
**The view** is the read-only page for one record, at `/<entity>/:id`.
|
|
197
|
+
`<EntityView>` owns its loading skeleton, its not-found state (another
|
|
198
|
+
user's record is the same plain 404, with a way back to the list and
|
|
199
|
+
no retry) and its error state with retry. The screen supplies what the
|
|
200
|
+
plan's "View screen" section lists:
|
|
201
|
+
|
|
202
|
+
- **Title and badges.** The plan's title field is the page's `<h1>`.
|
|
203
|
+
Its badge fields sit beside it, each the same `Badge` and variant map
|
|
204
|
+
as its table cell. Edit (a `Button` rendering a `Link` to
|
|
205
|
+
`/<entity>/:id/edit`) and the delete action go in `actions`.
|
|
206
|
+
- **Sections**, one per row of the plan's section table, in order.
|
|
207
|
+
Fields are label/value rows (`fields`), labelled as on the form. A
|
|
208
|
+
section that holds one long text or one sub-record list is a block
|
|
209
|
+
(`content`) with no label of its own, since a label would only repeat
|
|
210
|
+
the heading.
|
|
211
|
+
- **Every value renders the way its table cell does**: badges, names
|
|
212
|
+
of linked records, a done-count, from the shared lookups in
|
|
213
|
+
`<entity>-format.ts`. Never re-derive one for the view.
|
|
214
|
+
- **An empty value is passed through as it is** (`null`, `''`, or
|
|
215
|
+
`list.length > 0 && …`), and `EntityView` shows the field's
|
|
216
|
+
`emptyLabel`, the plan's "not set" label, never a blank. A yes/no is
|
|
217
|
+
never empty: it reads Yes or No (cell pattern 12).
|
|
218
|
+
- **A reference links to the referenced record's view** when that
|
|
219
|
+
entity has one: the name, from the same lookup by id the table uses,
|
|
220
|
+
inside `<Link to={`/<other>/${id}`}>`. Without a view, the plain name
|
|
221
|
+
(Widget's categories have none).
|
|
222
|
+
- **Sub-records are read-only**: the done-count, then each item in
|
|
223
|
+
order with its state as a glyph and words, never a checkbox
|
|
224
|
+
(`ChecklistItems` in `widget-view.tsx`). A list of links: each an
|
|
225
|
+
`<a target="_blank" rel="noreferrer">` with an `ExternalLinkIcon`
|
|
226
|
+
and a visually hidden "(opens in a new tab)".
|
|
227
|
+
- **Long text the plan marks Markdown** renders through the
|
|
228
|
+
foundation's `<Markdown>`: headings, lists, tables and links, links
|
|
229
|
+
opening in a new tab; raw HTML shows as the characters typed, never
|
|
230
|
+
as markup. The app needs no Markdown dependency of its own. Plain
|
|
231
|
+
long text renders as text (`whitespace-pre-line` keeps its line
|
|
232
|
+
breaks).
|
|
233
|
+
- **Quick actions and editing in place aren't in the playbook yet**
|
|
234
|
+
(flipping a yes/no, changing a status or ticking an item from the
|
|
235
|
+
view). Don't build them on your own: a row's value can hold a control
|
|
236
|
+
and `actions` takes any buttons, so they'll slot in once a release
|
|
237
|
+
adds them.
|
|
238
|
+
|
|
239
|
+
**The table opens the view, and the form returns to it.** The title
|
|
240
|
+
column's link is the way in (a `yes/no` marked `toggle` stays in its
|
|
241
|
+
row). Saving an edit, or cancelling one, goes back to the record's view;
|
|
242
|
+
creating opens the new record's view (the id the create returns);
|
|
243
|
+
cancelling a create goes back to the list. Each navigates with
|
|
244
|
+
`{ replace: true }`, so Back from the view doesn't reopen the form. The
|
|
245
|
+
create and update mutations put the saved record into its detail cache
|
|
246
|
+
(`setQueryData`), so the view shows the save at once, and the delete
|
|
247
|
+
mutation drops it (`removeQueries`), so the view it leaves never
|
|
248
|
+
refetches a record that's gone (`use-widgets.ts`).
|
|
177
249
|
|
|
178
250
|
**`<SelectValue>` shows the value, not the label.** Base UI renders the
|
|
179
251
|
raw *value*, and `placeholder` only applies when the value is `null`.
|
|
@@ -239,14 +311,26 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
239
311
|
them choose. That is how every pattern in the catalogue was picked.
|
|
240
312
|
- Any colour involved: [`design-language.md`](design-language.md). Most
|
|
241
313
|
values should stay grey, and colour is never the only signal.
|
|
242
|
-
7. **Register routes** for `/<entity>`, `/<entity>/new`,
|
|
243
|
-
`/<entity>/:id/edit` in `src/App.tsx`, and add a nav
|
|
244
|
-
`src/nav.ts`.
|
|
245
|
-
8. **Add Playwright specs for
|
|
314
|
+
7. **Register routes** for `/<entity>`, `/<entity>/new`, `/<entity>/:id`
|
|
315
|
+
(the view) and `/<entity>/:id/edit` in `src/App.tsx`, and add a nav
|
|
316
|
+
entry to `src/nav.ts`.
|
|
317
|
+
8. **Add Playwright specs for all three screens**, one test per state
|
|
246
318
|
(`loading`, `empty`, `error`, `validation`, `success`), forced through
|
|
247
319
|
MSW overrides — `forceMswOverride`, `waitForMswReady` and the
|
|
248
320
|
`window.__msw` handle from `@tristan2828/ui-foundation/testing`, and
|
|
249
|
-
`e2e/widgets-table.spec.ts`/`e2e/widget-form.spec.ts
|
|
321
|
+
`e2e/widgets-table.spec.ts`/`e2e/widget-form.spec.ts`/
|
|
322
|
+
`e2e/widget-view.spec.ts` for the pattern. The view's states are
|
|
323
|
+
loading, not found (a 404: "Not found", the way back, no retry), error
|
|
324
|
+
(a failed load, then Try again recovering) and success (every section,
|
|
325
|
+
each value as its cell shows it), plus the sparse record showing its
|
|
326
|
+
"not set" labels and no blank value. Its flows: the table's title link
|
|
327
|
+
opens it, saving and cancelling the form return to it, creating opens
|
|
328
|
+
the new record's view, and Delete confirms then lands on the list. If
|
|
329
|
+
the plan has Markdown long text, a test forces a record whose text has
|
|
330
|
+
a heading, a list, a table, a link and raw HTML, and asserts the HTML
|
|
331
|
+
shows as text. Add the view's spec to `playwright.config.ts`'s
|
|
332
|
+
`mobile-chrome` `testMatch`, with a test that at phone width each
|
|
333
|
+
label sits above its value and nothing scrolls sideways.
|
|
250
334
|
States forced via a first-load init script (loading, load-time error)
|
|
251
335
|
need `page.addInitScript` before navigation; states forced after the
|
|
252
336
|
page is already up (a mutation's error response) can use a
|
|
@@ -258,20 +342,28 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
258
342
|
which cannot see requests the MSW service worker makes
|
|
259
343
|
(`e2e/msw-contract.spec.ts` has an example).
|
|
260
344
|
9. **Register the nav entry and route names** in `e2e/shell.spec.ts`'s
|
|
261
|
-
`NAV_ENTRIES` so the shell smoke test covers the new screen,
|
|
262
|
-
`/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes
|
|
263
|
-
|
|
345
|
+
`NAV_ENTRIES` so the shell smoke test covers the new screen, add
|
|
346
|
+
`/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
|
|
347
|
+
of a full record and of the sparse one to its `viewRoutes` (the table
|
|
348
|
+
page is picked up from the sidebar automatically; the form and the view
|
|
349
|
+
aren't in it).
|
|
264
350
|
10. **`npm run verify`.** Fix until it passes. Then stop — do not add
|
|
265
351
|
anything beyond what this list covers; note ideas in the app's
|
|
266
352
|
backlog instead.
|
|
267
353
|
|
|
268
354
|
## What not to copy
|
|
269
355
|
|
|
356
|
+
- The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
|
|
357
|
+
button group and `visibleColumns`) unless the plan's List screen lists
|
|
358
|
+
saved views. When it does, copy it: `useTableUrlState`'s third argument
|
|
359
|
+
takes the views, `activeView` says which one the filters are, and that
|
|
360
|
+
view's `columns` go to `DataTable`'s `visibleColumns` (the table's own
|
|
361
|
+
default list when no view is active, or `undefined` for every column).
|
|
270
362
|
- `src/api/gateway/widgets.ts`, `src/mocks/data.ts`, `src/mocks/handlers.ts`
|
|
271
363
|
are demo-domain content — write the entity's own versions rather than
|
|
272
364
|
adapting these by find-and-replace.
|
|
273
|
-
- The composites (`DataTable`, `EntityForm`, `
|
|
274
|
-
are the foundation's and shared by every app. If a new entity needs a
|
|
365
|
+
- The composites (`DataTable`, `EntityForm`, `EntityView`, `Markdown`,
|
|
366
|
+
`ErrorState`, `AppShell`) are the foundation's and shared by every app. If a new entity needs a
|
|
275
367
|
capability they don't have (a new field-type widget, say), don't copy
|
|
276
368
|
one into `src/` to change it: raise it for the foundation (AGENTS.md
|
|
277
369
|
"Changing the Foundation") and build the screen once the release ships.
|
|
@@ -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
|
-
|
|
11
|
-
|
|
10
|
+
Patterns 12 (boolean) and 16 (title linking to its view) are the
|
|
11
|
+
template's own references: Widget's In Stock and Name columns render them
|
|
12
|
+
and their specs pin them. The other patterns from 11 on are
|
|
12
13
|
marked **Unproven**: written ahead of a real
|
|
13
14
|
column, so no app has tested them on real data yet. Prefer a proven
|
|
14
15
|
pattern when one fits. When you use an unproven one, say so in the
|
|
@@ -350,3 +351,40 @@ now", "happened"). Widget's In Stock column is the reference
|
|
|
350
351
|
stable").
|
|
351
352
|
- **Cost:** one click changes data, with no confirmation. Only for values
|
|
352
353
|
that are cheap to flip back; never for anything destructive.
|
|
354
|
+
|
|
355
|
+
## 16. Title linking to the record's view
|
|
356
|
+
|
|
357
|
+
The column that names a record (its name or title), when the entity has a
|
|
358
|
+
view (`/<entity>/:id`). Widget's Name column is the reference
|
|
359
|
+
(`src/routes/widgets/widgets-columns.tsx`).
|
|
360
|
+
|
|
361
|
+
```tsx
|
|
362
|
+
const TITLE_LINK_CLASS =
|
|
363
|
+
'rounded-sm font-medium text-foreground underline-offset-4 outline-none hover:underline ' +
|
|
364
|
+
'focus-visible:underline focus-visible:ring-3 focus-visible:ring-ring/50'
|
|
365
|
+
|
|
366
|
+
cell: ({ row }) => (
|
|
367
|
+
<Link to={`/widgets/${row.original.id}`} className={TITLE_LINK_CLASS}>
|
|
368
|
+
{row.original.name}
|
|
369
|
+
</Link>
|
|
370
|
+
)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
- **A plain `Link`, not a `Button`.** It goes somewhere and does nothing
|
|
374
|
+
else; a button-styled title down every row is a wall of buttons
|
|
375
|
+
(pattern 7's cost).
|
|
376
|
+
- **The record's own name is the link text**, so each row's link is
|
|
377
|
+
already distinct for a screen reader, with no `aria-label`.
|
|
378
|
+
- `font-medium` marks it as the row's name; the underline appears on hover
|
|
379
|
+
and keyboard focus. Foreground, not `text-primary`: it's the row's
|
|
380
|
+
heading, and a column of coloured text pulls the eye from every other
|
|
381
|
+
column.
|
|
382
|
+
- **The view replaces the row actions.** Edit and Delete live in the
|
|
383
|
+
view's header, so a table whose title links to a view has no actions
|
|
384
|
+
column. A `yes/no` marked `toggle` (pattern 15) stays in its row.
|
|
385
|
+
- **A reference to another record** (a cell showing a linked record's
|
|
386
|
+
name) uses the same link when *that* entity has a view, pointing at
|
|
387
|
+
`/<other>/${id}`, on the table and on the view alike.
|
|
388
|
+
- **Cost:** none to speak of. Specs that match the title as a cell still
|
|
389
|
+
work (`getByRole('cell', { name })` reads the link's text); ones that
|
|
390
|
+
opened the form from a row button go through the view's Edit instead.
|
|
@@ -23,7 +23,9 @@ One or two sentences: what one record is, and who uses the screens.
|
|
|
23
23
|
- **Field**: camelCase, as it appears in the API (`releaseDate`).
|
|
24
24
|
- **Type** — what the foundation supports today:
|
|
25
25
|
- *Demonstrated by the Widget reference* (copied directly): `text` (one
|
|
26
|
-
line), `long text
|
|
26
|
+
line), `long text` (say `Markdown` when it's written in Markdown, like
|
|
27
|
+
notes or a write-up: the view renders its headings, lists, tables and
|
|
28
|
+
links, and it's never a table column), `decimal` (fixed places — say how many),
|
|
27
29
|
`date-time`, `email`, `yes/no` (a boolean that is always yes or no:
|
|
28
30
|
say its default and the filter's three labels, e.g. Any stock /
|
|
29
31
|
In stock / Out of stock), `single choice` (list the options, in display
|
|
@@ -56,7 +58,8 @@ One or two sentences: what one record is, and who uses the screens.
|
|
|
56
58
|
a screen — a Base UI `Select` can't take `""` as an item value, so the
|
|
57
59
|
pattern needs a sentinel and gets the display wrong without one.
|
|
58
60
|
- **List**: `column` if it shows in the table, and `sortable` if you can
|
|
59
|
-
sort by it; blank if it's
|
|
61
|
+
sort by it; blank if it's only on the form and the view. The title
|
|
62
|
+
field's column links to the record's view. A `yes/no` can also say `toggle`:
|
|
60
63
|
flipped straight from its row, saved on its own (cell pattern 15).
|
|
61
64
|
- **Filter**: `search` (the text search box), `yes` (a filter control), or
|
|
62
65
|
blank.
|
|
@@ -72,6 +75,8 @@ per list; delete the section if there are none.
|
|
|
72
75
|
- Most items: 50.
|
|
73
76
|
- In the table: a done-count (`2/6 done`), or blank if the list isn't
|
|
74
77
|
shown.
|
|
78
|
+
- On the view: always shown, read-only: the done-count, then each item in
|
|
79
|
+
order (a `url` item field opens in a new tab).
|
|
75
80
|
|
|
76
81
|
| Field | Label | Type | Required | Options / rules |
|
|
77
82
|
|---|---|---|---|---|
|
|
@@ -87,12 +92,49 @@ per list; delete the section if there are none.
|
|
|
87
92
|
|
|
88
93
|
- Default sort:
|
|
89
94
|
- Page size: 10 unless there's a reason.
|
|
95
|
+
- Saved views (optional; delete if none): one row per view, a button
|
|
96
|
+
above the filters that sets every filter and the sort in one press.
|
|
97
|
+
Columns: blank for the table's usual columns, or the exact columns the
|
|
98
|
+
view shows, for a view of one kind of record that shows a field only
|
|
99
|
+
that kind uses. The columns last while the view's filters do; any other
|
|
100
|
+
filter puts the usual columns back. If the usual columns should leave
|
|
101
|
+
out a field only a view shows, say so here.
|
|
102
|
+
|
|
103
|
+
| View | Filters | Sort | Columns |
|
|
104
|
+
|---|---|---|---|
|
|
105
|
+
| All tasks | none | dueDate asc | |
|
|
106
|
+
| Bugs | kind = bug | dueDate asc | title, severity, assignee, dueDate |
|
|
107
|
+
|
|
90
108
|
- Anything else the table must show or do:
|
|
91
109
|
|
|
110
|
+
## View screen
|
|
111
|
+
|
|
112
|
+
The read-only page for one record (`/<entity>/:id`), opened by clicking
|
|
113
|
+
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.
|
|
115
|
+
|
|
116
|
+
- Title: the field that names a record, shown as the page heading and
|
|
117
|
+
linked from the table: `title`.
|
|
118
|
+
- Badges beside the title: a few status-like fields, as badges: `status`,
|
|
119
|
+
`priority`.
|
|
120
|
+
- Sections, in order. Each lists its fields in the order they show. A
|
|
121
|
+
section holding a single long text or a single sub-records list shows
|
|
122
|
+
it under the section's heading, with no label of its own.
|
|
123
|
+
|
|
124
|
+
| Section | Fields |
|
|
125
|
+
|---|---|
|
|
126
|
+
| Details | dueDate, project, tags |
|
|
127
|
+
| Checklist | checklist |
|
|
128
|
+
| Notes | notes |
|
|
129
|
+
|
|
130
|
+
- "Not set" labels: what an empty optional field reads as on the view
|
|
131
|
+
(`dueDate`: No due date; `checklist`: No items). A field not listed here
|
|
132
|
+
reads "Not set". A `yes/no` is never empty: it reads Yes or No.
|
|
133
|
+
|
|
92
134
|
## Screens and access
|
|
93
135
|
|
|
94
|
-
- Screens: list, create, edit, delete (the
|
|
95
|
-
|
|
136
|
+
- Screens: list, view, create, edit, delete (the shape the playbook
|
|
137
|
+
builds).
|
|
96
138
|
- Ownership: **shared** (every signed-in user sees every record) or
|
|
97
139
|
**per-user** (each user sees only their own).
|
|
98
140
|
|
|
@@ -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
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { AppError } from '../../api/contracts.js';
|
|
3
|
+
export type EntityViewField = {
|
|
4
|
+
label: string;
|
|
5
|
+
/**
|
|
6
|
+
* The value as the screen renders it. `null`, `undefined`, `false`, `''`
|
|
7
|
+
* and an empty array all count as empty and show `emptyLabel` instead,
|
|
8
|
+
* so `{tags.map(...)}` or `{email}` can be passed as they are.
|
|
9
|
+
*/
|
|
10
|
+
value: ReactNode;
|
|
11
|
+
/** What an empty value reads as: the plan's "not set" label. Defaults to "Not set". */
|
|
12
|
+
emptyLabel?: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A titled card on the view: either label/value rows (`fields`), or one
|
|
16
|
+
* block of content under the heading with no label of its own (`content`),
|
|
17
|
+
* for long text (`<Markdown>`) or a sub-record list, where a label would
|
|
18
|
+
* only repeat the heading. A block that's empty shows `emptyLabel`, the
|
|
19
|
+
* same rule as a field.
|
|
20
|
+
*/
|
|
21
|
+
export type EntityViewSection = {
|
|
22
|
+
/** The section's heading (an <h2>). Unique on the page. */
|
|
23
|
+
title: string;
|
|
24
|
+
fields: EntityViewField[];
|
|
25
|
+
} | {
|
|
26
|
+
title: string;
|
|
27
|
+
content: ReactNode;
|
|
28
|
+
emptyLabel?: string;
|
|
29
|
+
};
|
|
30
|
+
export type EntityViewProps = {
|
|
31
|
+
/** The record's name or title, the page's <h1>. */
|
|
32
|
+
title?: string;
|
|
33
|
+
/** A few status-like values beside the title, as `<Badge>`s. */
|
|
34
|
+
badges?: ReactNode;
|
|
35
|
+
/** The header's actions: an Edit button, a Delete with its confirm dialog. */
|
|
36
|
+
actions?: ReactNode;
|
|
37
|
+
sections: EntityViewSection[];
|
|
38
|
+
isLoading: boolean;
|
|
39
|
+
/** A `notfound` error (including another user's record) shows "Not found" and a way back, never a retry. */
|
|
40
|
+
error?: AppError | null;
|
|
41
|
+
onRetry?: () => void;
|
|
42
|
+
/** The entity's list: a link above the title, and the not-found state's way out. */
|
|
43
|
+
back: {
|
|
44
|
+
to: string;
|
|
45
|
+
label: string;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
export declare function EntityView({ title, badges, actions, sections, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Generic read-only view of one record, the read-side partner of
|
|
3
|
+
// EntityForm: the record's title with a few status badges beside it, an
|
|
4
|
+
// actions area (Edit, Delete), and sections of label/value rows laid out to
|
|
5
|
+
// read rather than as a form. It owns the loading, not-found and error
|
|
6
|
+
// states, the way DataTable does for a list.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately thin, like EntityForm: each value is the screen's own
|
|
9
|
+
// markup, built with the same cell patterns as the table's columns
|
|
10
|
+
// (badges, names of linked records, a done-count), and <Markdown> for
|
|
11
|
+
// long text. This component never formats a field. What it does guarantee
|
|
12
|
+
// is that an empty value reads as the plan's "not set" label, never a
|
|
13
|
+
// blank.
|
|
14
|
+
//
|
|
15
|
+
// A value can be any markup, a control included, and `actions` takes any
|
|
16
|
+
// buttons: that's the room left for quick actions on this page (a toggle,
|
|
17
|
+
// a status change, ticking a checklist item) without the form.
|
|
18
|
+
import { useId } from 'react';
|
|
19
|
+
import { Link } from 'react-router';
|
|
20
|
+
import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
|
|
21
|
+
import { ErrorState } from './error-state.js';
|
|
22
|
+
import { Button } from '../ui/button.js';
|
|
23
|
+
import { Card, CardContent, CardHeader } from '../ui/card.js';
|
|
24
|
+
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
|
|
25
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
26
|
+
const DEFAULT_EMPTY_LABEL = 'Not set';
|
|
27
|
+
function isEmptyValue(value) {
|
|
28
|
+
return (value === null ||
|
|
29
|
+
value === undefined ||
|
|
30
|
+
value === false ||
|
|
31
|
+
value === '' ||
|
|
32
|
+
(Array.isArray(value) && value.length === 0));
|
|
33
|
+
}
|
|
34
|
+
function BackLink({ back }) {
|
|
35
|
+
return (_jsxs(Link, { to: back.to, className: "inline-flex w-fit items-center gap-1 rounded-sm type-label text-muted-foreground outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50", children: [_jsx(ArrowLeftIcon, { "aria-hidden": "true", className: "size-4" }), back.label] }));
|
|
36
|
+
}
|
|
37
|
+
function orEmptyLabel(value, emptyLabel) {
|
|
38
|
+
return isEmptyValue(value) ? (_jsx("span", { className: "text-muted-foreground", children: emptyLabel ?? DEFAULT_EMPTY_LABEL })) : (value);
|
|
39
|
+
}
|
|
40
|
+
// Label beside value from `sm` up; below it (a phone) the label sits
|
|
41
|
+
// above its value, so nothing is squeezed into a narrow second column.
|
|
42
|
+
function FieldRow({ field }) {
|
|
43
|
+
return (_jsxs("div", { className: "flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: orEmptyLabel(field.value, field.emptyLabel) })] }));
|
|
44
|
+
}
|
|
45
|
+
function ViewSection({ section }) {
|
|
46
|
+
const headingId = useId();
|
|
47
|
+
return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: "flex flex-col divide-y divide-border", children: section.fields.map((field) => (_jsx(FieldRow, { field: field }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: orEmptyLabel(section.content, section.emptyLabel) })) })] }) }));
|
|
48
|
+
}
|
|
49
|
+
function EntityViewSkeleton() {
|
|
50
|
+
return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] }), Array.from({ length: 2 }, (_, section) => (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(Skeleton, { className: "h-5 w-32" }) }), _jsx(CardContent, { className: "flex flex-col gap-4", children: Array.from({ length: 4 }, (_, row) => (_jsxs("div", { className: "flex flex-col gap-1 sm:flex-row sm:gap-6", children: [_jsx(Skeleton, { className: "h-5 w-32" }), _jsx(Skeleton, { className: "h-5 flex-1" })] }, row))) })] }, section)))] }));
|
|
51
|
+
}
|
|
52
|
+
export function EntityView({ title, badges, actions, sections, isLoading, error, onRetry, back, }) {
|
|
53
|
+
if (error?.kind === 'notfound') {
|
|
54
|
+
// A record that doesn't exist and one that belongs to another user are
|
|
55
|
+
// the same plain 404, so this never says which. Retrying can't help.
|
|
56
|
+
return (_jsxs(Empty, { "data-state": "notfound", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(SearchXIcon, {}) }), _jsx(EmptyTitle, { children: "Not found" }), _jsx(EmptyDescription, { children: error.message })] }), _jsx(EmptyContent, { children: _jsxs(Button, { size: "sm", variant: "outline", nativeButton: false, render: _jsx(Link, { to: back.to }), children: ["Back to ", back.label] }) })] }));
|
|
57
|
+
}
|
|
58
|
+
if (error)
|
|
59
|
+
return _jsx(ErrorState, { error: error, onRetry: onRetry });
|
|
60
|
+
if (isLoading)
|
|
61
|
+
return _jsx(EntityViewSkeleton, {});
|
|
62
|
+
return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "success", children: [_jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: title }), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), sections.map((section) => (_jsx(ViewSection, { section: section }, section.title)))] }));
|
|
63
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react';
|
|
2
|
+
export type MarkdownProps = {
|
|
3
|
+
/** The Markdown source. Raw HTML in it is shown as text, never rendered. */
|
|
4
|
+
children: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
} & Omit<ComponentProps<'div'>, 'children'>;
|
|
7
|
+
export declare function Markdown({ children, className, ...rest }: MarkdownProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import ReactMarkdown, {} from 'react-markdown';
|
|
3
|
+
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import { cn } from 'cn';
|
|
5
|
+
import { ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
|
|
6
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
|
|
7
|
+
const REMARK_PLUGINS = [remarkGfm];
|
|
8
|
+
// Every element react-markdown hands over carries `node` (the syntax tree
|
|
9
|
+
// node). It isn't a DOM attribute, so it's dropped before spreading.
|
|
10
|
+
function omitNode(props) {
|
|
11
|
+
const rest = { ...props };
|
|
12
|
+
delete rest.node;
|
|
13
|
+
return rest;
|
|
14
|
+
}
|
|
15
|
+
const COMPONENTS = {
|
|
16
|
+
h1: (props) => _jsx("h3", { ...omitNode(props), className: "type-section-title mt-2 text-foreground" }),
|
|
17
|
+
h2: (props) => _jsx("h4", { ...omitNode(props), className: "type-section-title mt-2 text-foreground" }),
|
|
18
|
+
h3: (props) => _jsx("h5", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
|
|
19
|
+
h4: (props) => _jsx("h6", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
|
|
20
|
+
h5: (props) => _jsx("h6", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
|
|
21
|
+
h6: (props) => _jsx("h6", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
|
|
22
|
+
a: (props) => {
|
|
23
|
+
const { href, children, ...rest } = omitNode(props);
|
|
24
|
+
if (!href)
|
|
25
|
+
return _jsx("span", { children: children });
|
|
26
|
+
return (_jsxs("a", { ...rest, href: href, target: "_blank", rel: "noreferrer", className: "text-primary underline underline-offset-4 hover:text-primary/80", children: [children, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "ml-0.5 inline size-3 align-baseline" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] }));
|
|
27
|
+
},
|
|
28
|
+
ul: (props) => {
|
|
29
|
+
const { className, ...rest } = omitNode(props);
|
|
30
|
+
const isTaskList = className?.includes('contains-task-list');
|
|
31
|
+
return _jsx("ul", { ...rest, className: cn('flex flex-col gap-1', isTaskList ? 'list-none' : 'list-disc pl-6') });
|
|
32
|
+
},
|
|
33
|
+
ol: (props) => _jsx("ol", { ...omitNode(props), className: "flex list-decimal flex-col gap-1 pl-6" }),
|
|
34
|
+
li: (props) => {
|
|
35
|
+
const { className, ...rest } = omitNode(props);
|
|
36
|
+
return _jsx("li", { ...rest, className: cn(className?.includes('task-list-item') && 'flex items-start gap-2') });
|
|
37
|
+
},
|
|
38
|
+
// A GitHub task-list box (`- [x] done`). Read-only, so a glyph with its
|
|
39
|
+
// state in words, never a checkbox someone will try to tick.
|
|
40
|
+
input: ({ checked }) => checked ? (_jsx(SquareCheckIcon, { role: "img", "aria-label": "Done", className: "mt-0.5 size-4 shrink-0 text-foreground" })) : (_jsx(SquareIcon, { role: "img", "aria-label": "Not done", className: "mt-0.5 size-4 shrink-0 text-muted-foreground" })),
|
|
41
|
+
blockquote: (props) => (_jsx("blockquote", { ...omitNode(props), className: "flex flex-col gap-3 border-l-2 border-border pl-4 text-muted-foreground" })),
|
|
42
|
+
hr: () => _jsx("hr", { className: "border-border" }),
|
|
43
|
+
pre: (props) => (_jsx("pre", { ...omitNode(props), className: "overflow-x-auto rounded-lg bg-muted p-3 font-mono text-foreground [&_code]:bg-transparent [&_code]:p-0" })),
|
|
44
|
+
code: (props) => _jsx("code", { ...omitNode(props), className: "rounded bg-muted px-1 py-0.5 font-mono text-foreground" }),
|
|
45
|
+
img: (props) => {
|
|
46
|
+
const { alt, ...rest } = omitNode(props);
|
|
47
|
+
return _jsx("img", { ...rest, alt: alt ?? '', loading: "lazy", className: "max-w-full rounded-md" });
|
|
48
|
+
},
|
|
49
|
+
// GFM tables use the package's own table, zebra stripes and density
|
|
50
|
+
// tokens included, so a table in the notes reads like every other table.
|
|
51
|
+
table: (props) => _jsx(Table, { ...omitNode(props) }),
|
|
52
|
+
thead: (props) => _jsx(TableHeader, { ...omitNode(props) }),
|
|
53
|
+
tbody: (props) => _jsx(TableBody, { ...omitNode(props) }),
|
|
54
|
+
tr: (props) => _jsx(TableRow, { ...omitNode(props) }),
|
|
55
|
+
th: (props) => _jsx(TableHead, { ...omitNode(props) }),
|
|
56
|
+
td: (props) => _jsx(TableCell, { ...omitNode(props) }),
|
|
57
|
+
};
|
|
58
|
+
export function Markdown({ children, className, ...rest }) {
|
|
59
|
+
return (_jsx("div", { "data-slot": "markdown", ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', className), children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, components: COMPONENTS, children: children }) }));
|
|
60
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { AppShell, type AppShellProps, type NavItem } from './components/app/app-shell.js';
|
|
2
2
|
export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
|
|
3
3
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
4
|
+
export { EntityView, type EntityViewField, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
4
5
|
export { ErrorState } from './components/app/error-state.js';
|
|
5
6
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
7
|
+
export { Markdown, type MarkdownProps } from './components/app/markdown.js';
|
|
6
8
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
7
9
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
8
10
|
export { MultiReference } from './components/app/multi-reference.js';
|
package/dist/index.js
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
export { AppShell } from './components/app/app-shell.js';
|
|
7
7
|
export { DataTable } from './components/app/data-table.js';
|
|
8
8
|
export { EntityForm } from './components/app/entity-form.js';
|
|
9
|
+
export { EntityView, } from './components/app/entity-view.js';
|
|
9
10
|
export { ErrorState } from './components/app/error-state.js';
|
|
10
11
|
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
12
|
+
export { Markdown } from './components/app/markdown.js';
|
|
11
13
|
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
12
14
|
export { MultiChoice } from './components/app/multi-choice.js';
|
|
13
15
|
export { MultiReference } from './components/app/multi-reference.js';
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -18,6 +18,12 @@ export type A11ySuiteOptions = {
|
|
|
18
18
|
* plus an edit route when the form shows something only existing data has.
|
|
19
19
|
*/
|
|
20
20
|
formRoutes: readonly string[];
|
|
21
|
+
/**
|
|
22
|
+
* Record views (`/<entity>/:id`, built on EntityView), which aren't in
|
|
23
|
+
* the sidebar either: each entity's view of a full record, plus its
|
|
24
|
+
* sparse one, so both filled values and "not set" labels are checked.
|
|
25
|
+
*/
|
|
26
|
+
viewRoutes?: readonly string[];
|
|
21
27
|
/** Screens outside the app shell, e.g. '/login' (and '/register' if the app has it). */
|
|
22
28
|
loggedOutRoutes: readonly string[];
|
|
23
29
|
};
|
|
@@ -29,7 +35,7 @@ export type A11ySuiteOptions = {
|
|
|
29
35
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
30
36
|
* emulateMedia switches it.
|
|
31
37
|
*/
|
|
32
|
-
export declare function defineA11ySuite({ formRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
|
|
38
|
+
export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
|
|
33
39
|
export type MockModeBannerSuiteOptions = {
|
|
34
40
|
/** Screens to check, logged-out ones included (e.g. '/login'). */
|
|
35
41
|
routes: readonly string[];
|
package/dist/testing/index.js
CHANGED
|
@@ -68,7 +68,7 @@ async function navRoutes(page, isMobile) {
|
|
|
68
68
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
69
69
|
* emulateMedia switches it.
|
|
70
70
|
*/
|
|
71
|
-
export function defineA11ySuite({ formRoutes, loggedOutRoutes }) {
|
|
71
|
+
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes }) {
|
|
72
72
|
for (const colorScheme of ['light', 'dark']) {
|
|
73
73
|
test.describe(`accessibility (${colorScheme})`, () => {
|
|
74
74
|
test.beforeEach(async ({ page }) => {
|
|
@@ -90,6 +90,14 @@ export function defineA11ySuite({ formRoutes, loggedOutRoutes }) {
|
|
|
90
90
|
await expectNoAxeViolations(page);
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
|
+
for (const route of viewRoutes) {
|
|
94
|
+
test(`${route} has zero axe violations`, async ({ page }) => {
|
|
95
|
+
await page.goto(route);
|
|
96
|
+
// EntityView's loaded state, not its skeleton or an error.
|
|
97
|
+
await expect(page.locator('[data-state="success"]')).toBeVisible();
|
|
98
|
+
await expectNoAxeViolations(page);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
93
101
|
});
|
|
94
102
|
}
|
|
95
103
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.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": {
|
|
@@ -134,6 +134,8 @@
|
|
|
134
134
|
"js-yaml": "^5.4.2",
|
|
135
135
|
"lucide-react": "^1.47.0",
|
|
136
136
|
"react-day-picker": "^10.0.1",
|
|
137
|
+
"react-markdown": "^10.1.0",
|
|
138
|
+
"remark-gfm": "^4.0.1",
|
|
137
139
|
"shadcn": "^4.21.0",
|
|
138
140
|
"tw-animate-css": "^1.4.0"
|
|
139
141
|
},
|