@tristan2828/ui-foundation 4.36.0 → 4.38.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.
@@ -25,15 +25,15 @@
25
25
  - [15. Yes/no you flip in the row](#15-yesno-you-flip-in-the-row) · lines 538–647
26
26
  - [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 564–647
27
27
  - [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 649–684
28
- - [17. Quick action on the view](#17-quick-action-on-the-view) · lines 686–753
29
- - [18. Editable value](#18-editable-value) · lines 755–1170
30
- - [A list of sub-items](#a-list-of-sub-items) · lines 1014–1071
31
- - [In a table's rows](#in-a-tables-rows) · lines 1073–1158
32
- - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1160–1170
33
- - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1172–1238
34
- - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1240–1304
35
- - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1306–1371
36
- - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1373–1401
28
+ - [17. Quick action on the view](#17-quick-action-on-the-view) · lines 686–759
29
+ - [18. Editable value](#18-editable-value) · lines 761–1176
30
+ - [A list of sub-items](#a-list-of-sub-items) · lines 1020–1077
31
+ - [In a table's rows](#in-a-tables-rows) · lines 1079–1164
32
+ - [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1166–1176
33
+ - [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1178–1244
34
+ - [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1246–1310
35
+ - [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1312–1377
36
+ - [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1379–1407
37
37
 
38
38
  <!-- /index -->
39
39
 
@@ -731,6 +731,12 @@ const save = useSaveWidgetField(widget.id) // one per control
731
731
  page. When the save answers, the view takes the record the server
732
732
  returned, so a server-side effect shows (a computed field, a flag the
733
733
  change cleared). Never write a quick action's mutation by hand.
734
+ - **A refresh the save always needs goes on the hook**: another query
735
+ the change affects (a summary, a related list) is invalidated in
736
+ `useRecordUpdate`'s own `onSuccess`, never `mutate(change, { onSuccess })`,
737
+ which TanStack skips once the control unmounts (a popover closed
738
+ mid-save). Per-call effects (navigate, a toast in the call's words)
739
+ stay on `mutate()`.
734
740
  - **A list is sent whole, built from the latest record**: pass a function
735
741
  of the record (`(current) => ({ checklist: ... })`), not a list read from
736
742
  props. Saves of one record run one at a time and each request is built
@@ -12,11 +12,11 @@
12
12
  - [Typography](#typography) · lines 242–324
13
13
  - [Written text (notes)](#written-text-notes) · lines 273–324
14
14
  - [Density](#density) · lines 326–383
15
- - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 385–460
16
- - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 462–520
17
- - [Notices](#notices) · lines 522–550
18
- - [A value over time](#a-value-over-time) · lines 552–585
19
- - [A list as a board](#a-list-as-a-board) · lines 587–638
15
+ - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 385–463
16
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 465–523
17
+ - [Notices](#notices) · lines 525–553
18
+ - [A value over time](#a-value-over-time) · lines 555–588
19
+ - [A list as a board](#a-list-as-a-board) · lines 590–641
20
20
 
21
21
  <!-- /index -->
22
22
 
@@ -424,7 +424,10 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
424
424
  - **Three to six rows.** A widget is a sample, not the list: ask the API
425
425
  for that many (the query's page size) and put the rest behind the
426
426
  header `link` ("All", "All doing"), which goes to the full list
427
- filtered the same way. Set `skeletonRows` to the number it usually holds, so the card
427
+ filtered the same way. A widget about a service outside the app links
428
+ its header there instead: `link={{ href: 'https://…', label: 'Open
429
+ <service>' }}` opens it in a new tab, with the external-link mark and
430
+ "(opens in a new tab)" for a screen reader. Set `skeletonRows` to the number it usually holds, so the card
428
431
  keeps its height when the data arrives.
429
432
  - **Each widget owns its four states.** Loading is a skeleton of rows,
430
433
  empty is one quiet line the app writes ("Nothing waiting", not "No
@@ -68,6 +68,18 @@ Scope it to the signed-in user, as Widgets are.
68
68
  sends a reminder when today is one of its days in its time zone and the
69
69
  time has come, once a day at most. More than 15 minutes late (the
70
70
  backend was down) and it's skipped for the day rather than sent late.
71
+ - **Reminders in a table of the app's own** (another schema, another
72
+ name, as an app with a schema per module keeps them): define its table
73
+ model with the same columns (`id`, `user_id`, `title`, `body`, `url`,
74
+ `time_of_day`, `days`, `time_zone`, `enabled`, `last_sent_on`) and hand
75
+ it to the job, `reminders.register(model=MyReminder)` in place of
76
+ `reminders.register()`. Left out, the job reads the package's
77
+ `reminders` table. Neither table is on `SQLModel.metadata` until the
78
+ app uses it: `push_subscriptions` arrives with `foundation_backend.push`
79
+ (and `foundation_backend.reminders`, which sends through it), the
80
+ package's `reminders` only with its `Reminder` model (imported, or the
81
+ job run without a model). So an app without push, or with its own
82
+ table, gets neither from `create_all` or autogenerate.
71
83
  - **Something else to send?** Call `send_push` from the app's own code or
72
84
  job: a message when a long task finishes, a daily summary. The `url` is
73
85
  always the app's own path.
@@ -14,14 +14,12 @@ export interface WidgetCardProps {
14
14
  headingLevel?: 2 | 3 | 4 | 5 | 6;
15
15
  /**
16
16
  * A link on the right of the header to everything the widget samples:
17
- * `{ to: '/tasks?status=doing', label: 'All doing' }`. Its accessible
18
- * name is `label`, described by the title, so several "All" links on one
19
- * page stay distinct.
17
+ * `{ to: '/tasks?status=doing', label: 'All doing' }`, or an outside
18
+ * address, opened in a new tab: `{ href: 'https://example.com', label:
19
+ * 'Open Example' }`. Its accessible name is `label`, described by the
20
+ * title, so several "All" links on one page stay distinct.
20
21
  */
21
- link?: {
22
- to: To;
23
- label: string;
24
- };
22
+ link?: WidgetCardLink;
25
23
  isLoading: boolean;
26
24
  error?: AppError | null;
27
25
  onRetry?: () => void;
@@ -44,6 +42,15 @@ export interface WidgetCardProps {
44
42
  children?: ReactNode;
45
43
  className?: string;
46
44
  }
45
+ export type WidgetCardLink = {
46
+ to: To;
47
+ label: string;
48
+ href?: never;
49
+ } | {
50
+ href: string;
51
+ label: string;
52
+ to?: never;
53
+ };
47
54
  export declare function WidgetCard({ title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, collapsible, defaultCollapsed, storageKey, children, className, }: WidgetCardProps): import("react").JSX.Element;
48
55
  /** The rows of a WidgetCard: a list with a divider between rows. */
49
56
  export declare function WidgetList({ children, className }: {
@@ -24,6 +24,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
24
24
  // where that's blocked). The body is hidden, not unmounted, so whatever
25
25
  // the app keeps in it stays put.
26
26
  //
27
+ // The header's link is an app route (`to`) or, for a widget about a
28
+ // service outside the app, its address (`href`, #248): that one opens in a
29
+ // new tab, with the outside-link mark and a screen reader's "(opens in a
30
+ // new tab)", as WidgetListItem's `href` does.
31
+ //
27
32
  // WidgetList and WidgetListItem are the common body: rows with dividers,
28
33
  // each a primary link (or plain text) truncated to one line, an optional
29
34
  // muted note ("2 h ago") and an optional trailing element (a badge, a
@@ -52,7 +57,7 @@ export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoadin
52
57
  const state = error ? 'error' : isLoading ? 'loading' : isEmpty ? 'empty' : 'success';
53
58
  const [folded, setFolded] = useRememberedFlag(collapsible && storageKey !== undefined ? `${storageKey}:collapsed` : undefined, defaultCollapsed);
54
59
  const collapsed = collapsible && folded;
55
- return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, "data-collapsed": collapsed || undefined, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), (link || collapsible) && (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label })), collapsible && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-labelledby": headingId, "aria-expanded": !collapsed, "aria-controls": contentId, onClick: () => setFolded(!collapsed),
60
+ return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, "data-collapsed": collapsed || undefined, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), (link || collapsible) && (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [link?.href !== undefined ? (_jsxs("a", { href: link.href, target: "_blank", rel: "noreferrer", "aria-describedby": headingId, className: cn(HEADER_LINK_CLASS, 'inline-flex items-center gap-1'), children: [link.label, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))), collapsible && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-labelledby": headingId, "aria-expanded": !collapsed, "aria-controls": contentId, onClick: () => setFolded(!collapsed),
56
61
  // The button's own open look (a filled background) would
57
62
  // read as pressed: the chevron says it.
58
63
  className: "-my-1 -mr-1.5 aria-expanded:not-hover:bg-transparent", children: _jsx(ChevronDownIcon, { "aria-hidden": "true", className: cn('transition-transform', collapsed && '-rotate-90') }) }))] }))] }), _jsx(CardContent, { id: contentId, hidden: collapsed, "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
@@ -35,6 +35,17 @@ export type RecordUpdateOptions<TRecord extends {
35
35
  * `AppError`, and `refusalReason` words it.
36
36
  */
37
37
  toastOnError?: boolean;
38
+ /**
39
+ * After a save the server accepted, once the caches hold its record:
40
+ * a refresh the save always needs (another query it changes). Runs
41
+ * even when the calling component has unmounted, unlike a `mutate()`
42
+ * callback; keep per-call effects (navigate, a toast) on `mutate()`.
43
+ */
44
+ onSuccess?: (saved: TRecord, change: RecordChange<TRecord, TUpdate>) => unknown;
45
+ /** After a refused save, once it has rolled back (and toasted, unless `toastOnError: false`). */
46
+ onError?: (error: AppError, change: RecordChange<TRecord, TUpdate>) => unknown;
47
+ /** After every save, accepted or refused, following `onSuccess` or `onError`. */
48
+ onSettled?: (saved: TRecord | undefined, error: AppError | null, change: RecordChange<TRecord, TUpdate>) => unknown;
38
49
  };
39
50
  type Entry<TRecord, TUpdate> = {
40
51
  change: RecordChange<TRecord, TUpdate>;
@@ -52,7 +63,7 @@ export declare function refusalReason(error: AppError, sent?: object): string;
52
63
  */
53
64
  export declare function recordUpdateMutationOptions<TRecord extends {
54
65
  id: RecordId;
55
- }, TUpdate extends Partial<TRecord>>(queryClient: QueryClient, { id, detailKey, listsKey, update, name, optimistic, toastOnError }: RecordUpdateOptions<TRecord, TUpdate>): MutationOptions<TRecord, AppError, RecordChange<TRecord, TUpdate>, {
66
+ }, TUpdate extends Partial<TRecord>>(queryClient: QueryClient, { id, detailKey, listsKey, update, name, optimistic, toastOnError, onSuccess, onError, onSettled, }: RecordUpdateOptions<TRecord, TUpdate>): MutationOptions<TRecord, AppError, RecordChange<TRecord, TUpdate>, {
56
67
  entry: Entry<TRecord, TUpdate>;
57
68
  }>;
58
69
  /**
@@ -61,7 +72,8 @@ export declare function recordUpdateMutationOptions<TRecord extends {
61
72
  * with the entity's query keys and its gateway PATCH, then
62
73
  * `mutate({ field: value })` or `mutate((record) => ({ field: ... }))`.
63
74
  * `isPending` is that control's own save in flight. A refusal rolls back
64
- * and shows a toast by itself.
75
+ * and shows a toast by itself. A refresh every save needs goes in the
76
+ * options' `onSuccess`, which runs even if the control has unmounted.
65
77
  */
66
78
  export declare function useRecordUpdate<TRecord extends {
67
79
  id: RecordId;
@@ -30,6 +30,13 @@
30
30
  // `toastOnError: false`: nothing changes on screen until the server
31
31
  // agrees, and a refusal comes back to the field (`mutateAsync` rejects)
32
32
  // instead of a toast.
33
+ //
34
+ // A follow-up the save needs once the server answers (refetching a
35
+ // summary or a related list) goes in the hook's own `onSuccess`,
36
+ // `onError` or `onSettled`, not `mutate(change, { onSuccess })`: TanStack
37
+ // runs a `mutate()` callback only while the calling component is still
38
+ // mounted, so a popover closed mid-save would skip it. These run on the
39
+ // mutation, after the package's own cache work, either way (#250).
33
40
  import { hashKey, useMutation, useQueryClient, } from '@tanstack/react-query';
34
41
  import { toast } from 'sonner';
35
42
  const states = new WeakMap();
@@ -62,7 +69,7 @@ export function refusalReason(error, sent) {
62
69
  * The mutation options behind `useRecordUpdate`, for a given QueryClient.
63
70
  * Exported for tests and for code outside React; screens use the hook.
64
71
  */
65
- export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsKey, update, name, optimistic = true, toastOnError = true }) {
72
+ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsKey, update, name, optimistic = true, toastOnError = true, onSuccess, onError, onSettled, }) {
66
73
  const key = hashKey(detailKey);
67
74
  const lists = { queryKey: listsKey };
68
75
  const map = recordStates(queryClient);
@@ -133,25 +140,27 @@ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsK
133
140
  render(state);
134
141
  return { entry };
135
142
  },
136
- onError: (error, _change, context) => {
143
+ onError: (error, change, context) => {
137
144
  const state = map.get(key);
138
- if (!state || !context)
139
- return;
140
- // What was sent: the change built on the record it was sent against.
141
- const record = state.confirmed;
142
- const { change } = context.entry;
143
- const sent = record ? resolve(change, record) : typeof change === 'function' ? undefined : change;
144
- settle(state, context.entry);
145
- if (toastOnError)
146
- toast.error(`Couldn't update ${record ? name(record) : 'this record'}: ${refusalReason(error, sent)}`);
145
+ if (state && context) {
146
+ // What was sent: the change built on the record it was sent against.
147
+ const record = state.confirmed;
148
+ const sent = record ? resolve(change, record) : typeof change === 'function' ? undefined : change;
149
+ settle(state, context.entry);
150
+ if (toastOnError)
151
+ toast.error(`Couldn't update ${record ? name(record) : 'this record'}: ${refusalReason(error, sent)}`);
152
+ }
153
+ return onError?.(error, change);
147
154
  },
148
- onSuccess: (saved, _change, context) => {
155
+ onSuccess: (saved, change, context) => {
149
156
  const state = map.get(key);
150
- if (!state || !context)
151
- return;
152
- state.confirmed = saved;
153
- settle(state, context.entry);
157
+ if (state && context) {
158
+ state.confirmed = saved;
159
+ settle(state, context.entry);
160
+ }
161
+ return onSuccess?.(saved, change);
154
162
  },
163
+ onSettled: (saved, error, change) => onSettled?.(saved, error, change),
155
164
  };
156
165
  }
157
166
  /**
@@ -160,7 +169,8 @@ export function recordUpdateMutationOptions(queryClient, { id, detailKey, listsK
160
169
  * with the entity's query keys and its gateway PATCH, then
161
170
  * `mutate({ field: value })` or `mutate((record) => ({ field: ... }))`.
162
171
  * `isPending` is that control's own save in flight. A refusal rolls back
163
- * and shows a toast by itself.
172
+ * and shows a toast by itself. A refresh every save needs goes in the
173
+ * options' `onSuccess`, which runs even if the control has unmounted.
164
174
  */
165
175
  export function useRecordUpdate(options) {
166
176
  const queryClient = useQueryClient();
package/dist/index.d.ts CHANGED
@@ -40,7 +40,7 @@ export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from '.
40
40
  export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
41
41
  export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
42
42
  export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripProps, } from './components/app/module-card.js';
43
- export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
43
+ export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
44
44
  export { ThemeProvider } from './components/theme-provider.js';
45
45
  export { LoginRoute, type LoginRouteProps } from './routes/login.js';
46
46
  export { RegisterRoute } from './routes/register.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.36.0",
3
+ "version": "4.38.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": {