@terpjs/react-core 0.1.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.
Files changed (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
@@ -0,0 +1,249 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { UiText } from "../uiText";
4
+
5
+ /**
6
+ * Query descriptor the DataView emits — the only contract with the data layer.
7
+ * A {@link DataViewRepository} receives this and returns one {@link DataViewResult} page;
8
+ * the component never fetches or filters on its own.
9
+ */
10
+ export interface DataViewQuery {
11
+ pagination: { pageIndex: number; pageSize: number };
12
+ sorting: { id: string; desc: boolean }[];
13
+ filters: { id: string; value: unknown }[];
14
+ search: string;
15
+ /** Broadened search scope active (e.g. include archived/closed records). */
16
+ searchBroadened: boolean;
17
+ }
18
+
19
+ /** Uniform page result every repository returns. */
20
+ export interface DataViewResult<T> {
21
+ rows: T[];
22
+ totalCount: number;
23
+ }
24
+
25
+ /**
26
+ * The repository abstraction. DataView only ever talks to this — swapping in-memory ↔ HTTP
27
+ * data requires zero changes to any component file (dependency inversion / open-closed).
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const repo = new InMemoryDataViewRepository(tickets, {
32
+ * getRowId: (t) => t.id,
33
+ * searchFields: ["title", "assignee"],
34
+ * });
35
+ * <DataView repository={repo} columns={columns} />
36
+ * ```
37
+ */
38
+ export interface DataViewRepository<T> {
39
+ query(q: DataViewQuery, signal?: AbortSignal): Promise<DataViewResult<T>>;
40
+ /** Stable row identity — selection/expansion must survive re-sorts and refetches. */
41
+ getRowId(row: T): string;
42
+ /** Capability flags let the component adapt (e.g. hide the search box). */
43
+ capabilities: {
44
+ /** true → manual sorting/filtering/pagination; the repo does the work per query. */
45
+ serverSide: boolean;
46
+ search: boolean;
47
+ /** Supports the "search everything" broadened toggle. */
48
+ searchScope: boolean;
49
+ };
50
+ /**
51
+ * Optional facet support: the distinct values of one column across the full data set
52
+ * (client-side repositories can provide it cheaply; server-side ones may omit it).
53
+ */
54
+ getFacetedValues?(columnId: string): unknown[];
55
+ }
56
+
57
+ /** Everything the user customises about a view, persisted per stable `viewId`. */
58
+ export interface DataViewState {
59
+ columnVisibility: Record<string, boolean>;
60
+ columnOrder: string[];
61
+ /** User-resized widths in px. */
62
+ columnSizing: Record<string, number>;
63
+ /** Client-side views only. */
64
+ sorting: { id: string; desc: boolean }[];
65
+ /** Client-side views only. */
66
+ filters: { id: string; value: unknown }[];
67
+ search: string;
68
+ }
69
+
70
+ /**
71
+ * Where a user's view customisations live. DataView never touches `localStorage`
72
+ * directly — it loads/saves through this seam, so preferences can move to any store
73
+ * (server-backed, in-memory for tests) without touching a component file.
74
+ */
75
+ export interface ViewStateRepository {
76
+ load(viewId: string): DataViewState | undefined;
77
+ save(viewId: string, state: DataViewState): void;
78
+ }
79
+
80
+ /** An empty {@link DataViewState} — the fallback when nothing was persisted yet. */
81
+ export function emptyDataViewState(): DataViewState {
82
+ return {
83
+ columnVisibility: {},
84
+ columnOrder: [],
85
+ columnSizing: {},
86
+ sorting: [],
87
+ filters: [],
88
+ search: "",
89
+ };
90
+ }
91
+
92
+ /** Slot a column occupies in the responsive card layout. */
93
+ export type DataViewMobileSlot = "title" | "subtitle" | "status" | "date";
94
+
95
+ /** Typed column meta the DataView-specific features read. */
96
+ export interface DataViewColumnMeta {
97
+ /** Human-readable name used in the column-settings menu (falls back to the header). */
98
+ label?: UiText;
99
+ /** Slot in the auto-composed card layout. */
100
+ mobileSlot?: DataViewMobileSlot;
101
+ /** Fixed width hint (number = px, or any CSS length). */
102
+ width?: number | string;
103
+ }
104
+
105
+ /** Generic, typed column definition for {@link DataView}. */
106
+ export interface DataViewColumn<T> {
107
+ /** Stable id — used for sorting/filter ids, visibility, ordering and sizing. */
108
+ id: string;
109
+ /** Header content. */
110
+ header: UiText;
111
+ /** The raw value of this column for a row (used by default cell rendering). */
112
+ accessor?: (row: T) => unknown;
113
+ /** Custom cell renderer; defaults to `String(accessor(row))`. */
114
+ cell?: (row: T) => ReactNode;
115
+ /** Whether the header offers the 3-state sort toggle (default true). */
116
+ enableSorting?: boolean;
117
+ meta?: DataViewColumnMeta;
118
+ }
119
+
120
+ /** A per-row action rendered by {@link DataView}'s actions column (or in card view). */
121
+ export interface DataViewRowAction<T> {
122
+ label: UiText;
123
+ icon?: ReactNode;
124
+ onClick?: (row: T) => void;
125
+ variant?: "default" | "destructive";
126
+ /** Boolean or predicate of the row. */
127
+ disabled?: boolean | ((row: T) => boolean);
128
+ /** Boolean or predicate of the row. */
129
+ hidden?: boolean | ((row: T) => boolean);
130
+ /** Render beside the ellipsis menu in "menu" layout. */
131
+ inline?: boolean;
132
+ /**
133
+ * Fully custom control; custom controls always render inline and own their
134
+ * interaction surface (DataView only stops row-click propagation around them).
135
+ */
136
+ render?: (row: T) => ReactNode;
137
+ }
138
+
139
+ /** A batch action shown in the selection toolbar. */
140
+ export interface DataViewBatchAction<T> {
141
+ label: UiText;
142
+ icon?: ReactNode;
143
+ onClick: (rows: T[]) => void;
144
+ /**
145
+ * Invoked instead of `onClick` when select-all-across-pages mode is active
146
+ * (the current page's rows are still passed for context).
147
+ */
148
+ onSelectAll?: (rows: T[]) => void;
149
+ variant?: "default" | "destructive";
150
+ /** Render as a button in the toolbar; otherwise it goes into the overflow menu. */
151
+ inline?: boolean;
152
+ }
153
+
154
+ /** Caller-owned wiring of the broadened "search everything" toggle. */
155
+ export interface DataViewSearchScope {
156
+ broadened: boolean;
157
+ onBroadenedChange: (broadened: boolean) => void;
158
+ /** Button label while the scope is narrow (e.g. "Search everything"). */
159
+ label: UiText;
160
+ /** Button label while the scope is broadened (e.g. "Searching everything"). */
161
+ broadenedLabel: UiText;
162
+ }
163
+
164
+ /** Every user-facing string the DataView renders, overridable per instance. */
165
+ export interface DataViewStrings {
166
+ searchPlaceholder: UiText;
167
+ clearSearch: UiText;
168
+ clearFilters: UiText;
169
+ viewOptions: UiText;
170
+ columns: UiText;
171
+ moveUp: UiText;
172
+ moveDown: UiText;
173
+ tableView: UiText;
174
+ cardView: UiText;
175
+ pageSize: UiText;
176
+ resultsRange: UiText; // "{from}–{to} of {total} results"
177
+ pageOf: UiText; // "Page {page} of {pages}"
178
+ firstPage: UiText;
179
+ previousPage: UiText;
180
+ nextPage: UiText;
181
+ lastPage: UiText;
182
+ selectAllPage: UiText;
183
+ selectRow: UiText;
184
+ selected: UiText; // "{count} selected"
185
+ selectAllResults: UiText; // "Select all {total} results"
186
+ clearSelection: UiText;
187
+ moreActions: UiText;
188
+ actions: UiText;
189
+ openRow: UiText; // "Open details: {label}"
190
+ expandRow: UiText;
191
+ collapseRow: UiText;
192
+ empty: UiText;
193
+ loading: UiText;
194
+ refreshing: UiText;
195
+ errorTitle: UiText;
196
+ resizeColumn: UiText;
197
+ }
198
+
199
+ export const DEFAULT_DATA_VIEW_STRINGS: DataViewStrings = {
200
+ searchPlaceholder: "Search…",
201
+ clearSearch: "Clear search",
202
+ clearFilters: "Clear filters",
203
+ viewOptions: "View options",
204
+ columns: "Columns",
205
+ moveUp: "Move up",
206
+ moveDown: "Move down",
207
+ tableView: "Table view",
208
+ cardView: "Card view",
209
+ pageSize: "Rows per page",
210
+ resultsRange: "{from}–{to} of {total} results",
211
+ pageOf: "Page {page} of {pages}",
212
+ firstPage: "First page",
213
+ previousPage: "Previous page",
214
+ nextPage: "Next page",
215
+ lastPage: "Last page",
216
+ selectAllPage: "Select all rows on this page",
217
+ selectRow: "Select row",
218
+ selected: "{count} selected",
219
+ selectAllResults: "Select all {total} results",
220
+ clearSelection: "Clear selection",
221
+ moreActions: "More actions",
222
+ actions: "Actions",
223
+ openRow: "Open details: {label}",
224
+ expandRow: "Expand row",
225
+ collapseRow: "Collapse row",
226
+ empty: "Nothing to show.",
227
+ loading: "Loading…",
228
+ refreshing: "Refreshing…",
229
+ errorTitle: "Could not load data.",
230
+ resizeColumn: "Resize column",
231
+ };
232
+
233
+ /** Tiny `{placeholder}` formatter for the countable strings above. */
234
+ export function formatDataViewString(
235
+ template: string,
236
+ values: Record<string, string | number>,
237
+ ): string {
238
+ return template.replace(/\{(\w+)\}/g, (match, key: string) =>
239
+ key in values ? String(values[key]) : match,
240
+ );
241
+ }
242
+
243
+ /** Resolve a row-action boolean-or-predicate flag against a row. */
244
+ export function resolveRowFlag<T>(
245
+ flag: boolean | ((row: T) => boolean) | undefined,
246
+ row: T,
247
+ ): boolean {
248
+ return typeof flag === "function" ? flag(row) : flag === true;
249
+ }
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { ErrorState } from "./ErrorState";
6
+ import { ErrorMessagesProvider, useErrorMessage } from "./errorMessages";
7
+ import { UiTextProvider } from "./uiText";
8
+ import { ApiError } from "./unwrap";
9
+
10
+ afterEach(cleanup);
11
+
12
+ function Probe({ error }: { error: unknown }) {
13
+ const messageForCode = useErrorMessage();
14
+ return <output>{messageForCode(error) ?? "(none)"}</output>;
15
+ }
16
+
17
+ describe("useErrorMessage", () => {
18
+ it("maps a known code to the default client-owned copy", () => {
19
+ render(<Probe error={new ApiError("backend detail", { code: "stale_data", status: 409 })} />);
20
+
21
+ expect(screen.getByRole("status")).toHaveTextContent(
22
+ "This item was changed by someone else. Refresh and try again.",
23
+ );
24
+ });
25
+
26
+ it("returns null for unknown codes and non-envelope errors", () => {
27
+ render(
28
+ <>
29
+ <Probe error={new ApiError("backend detail", { code: "freight_locked", status: 409 })} />
30
+ <Probe error={new Error("plain")} />
31
+ <Probe error="boom" />
32
+ </>,
33
+ );
34
+
35
+ for (const status of screen.getAllByRole("status")) {
36
+ expect(status).toHaveTextContent("(none)");
37
+ }
38
+ });
39
+
40
+ it("lets nested providers override and extend the map, resolving UiText descriptors", () => {
41
+ render(
42
+ <ErrorMessagesProvider
43
+ messages={{
44
+ stale_data: { id: "errors.stale", message: "Someone edited this. Reload." },
45
+ }}
46
+ >
47
+ <ErrorMessagesProvider messages={{ freight_locked: "This freight is locked." }}>
48
+ <Probe error={new ApiError("x", { code: "stale_data", status: 409 })} />
49
+ <Probe error={new ApiError("x", { code: "freight_locked", status: 409 })} />
50
+ </ErrorMessagesProvider>
51
+ </ErrorMessagesProvider>,
52
+ );
53
+
54
+ const statuses = screen.getAllByRole("status");
55
+ expect(statuses[0]).toHaveTextContent("Someone edited this. Reload.");
56
+ expect(statuses[1]).toHaveTextContent("This freight is locked.");
57
+ });
58
+ });
59
+
60
+ describe("ErrorState with coded errors", () => {
61
+ it("prefers the mapped message over the backend detail for known codes", () => {
62
+ render(
63
+ <ErrorState
64
+ error={new ApiError("Backend-produced detail.", { code: "permission_denied", status: 403 })}
65
+ />,
66
+ );
67
+
68
+ expect(screen.getByRole("alert")).toHaveTextContent(
69
+ "You do not have permission to do this.",
70
+ );
71
+ expect(screen.queryByText("Backend-produced detail.")).not.toBeInTheDocument();
72
+ });
73
+
74
+ it("falls back to the backend detail for unknown codes", () => {
75
+ render(
76
+ <UiTextProvider>
77
+ <ErrorState error={new ApiError("Module-specific detail.", { code: "freight_locked", status: 409 })} />
78
+ </UiTextProvider>,
79
+ );
80
+
81
+ expect(screen.getByRole("alert")).toHaveTextContent("Module-specific detail.");
82
+ });
83
+ });
@@ -0,0 +1,79 @@
1
+ import { createContext, useCallback, useContext, useMemo } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { useUiText } from "./uiText";
5
+ import type { UiText } from "./uiText";
6
+
7
+ /**
8
+ * Client-owned messages for the platform's stable error codes.
9
+ *
10
+ * The backend serialises every failure into the uniform envelope
11
+ * `{ code, detail, request_id }`: `code` is a stable machine identifier from
12
+ * the typed `AppError` taxonomy (`terp.core.errors`), `detail` the message the
13
+ * backend produced. Mapping codes to copy here lets the UI evolve (and
14
+ * localise) failure wording without a backend deploy; codes without an entry
15
+ * simply fall back to the backend `detail`, so the map never has to be
16
+ * complete. Values are {@link UiText}, resolved through the UiText seam like
17
+ * every other framework string.
18
+ */
19
+ export type ErrorMessages = Record<string, UiText>;
20
+
21
+ /**
22
+ * English defaults for the core `AppError` taxonomy. Apps override or extend
23
+ * per code via {@link ErrorMessagesProvider} — including codes their own
24
+ * backend modules add.
25
+ */
26
+ export const DEFAULT_ERROR_MESSAGES: ErrorMessages = {
27
+ bad_request: "The request could not be processed.",
28
+ validation_failed: "Some fields are invalid. Check the form and try again.",
29
+ invalid_token: "Your session is invalid. Sign in again.",
30
+ authentication_required: "Sign in to continue.",
31
+ permission_denied: "You do not have permission to do this.",
32
+ not_found: "This item could not be found.",
33
+ conflict: "This conflicts with the current state. Refresh and try again.",
34
+ stale_data: "This item was changed by someone else. Refresh and try again.",
35
+ };
36
+
37
+ const ErrorMessagesContext = createContext<ErrorMessages>(DEFAULT_ERROR_MESSAGES);
38
+
39
+ export interface ErrorMessagesProviderProps {
40
+ /** Per-code overrides and additions, merged over any outer provider's map. */
41
+ messages: ErrorMessages;
42
+ children: ReactNode;
43
+ }
44
+
45
+ /**
46
+ * Override or extend the code→message map for a subtree. Wrap the app once to
47
+ * localise the built-in codes and register module-specific ones; nests, so a
48
+ * module can add its own codes without touching the app shell.
49
+ */
50
+ export function ErrorMessagesProvider({ messages, children }: ErrorMessagesProviderProps) {
51
+ const parent = useContext(ErrorMessagesContext);
52
+ const value = useMemo(() => ({ ...parent, ...messages }), [parent, messages]);
53
+ return <ErrorMessagesContext.Provider value={value}>{children}</ErrorMessagesContext.Provider>;
54
+ }
55
+
56
+ /**
57
+ * Resolve a caught failure to display copy: the mapped message for the error's
58
+ * stable `code` when one is registered, else `null` (callers fall back to the
59
+ * error's own message — the backend `detail`). Reads the error's `code`
60
+ * property, as carried by `ApiError` thrown from `unwrap`.
61
+ */
62
+ export function useErrorMessage(): (error: unknown) => string | null {
63
+ const messages = useContext(ErrorMessagesContext);
64
+ const resolve = useUiText();
65
+ return useCallback(
66
+ (error: unknown) => {
67
+ if (error === null || typeof error !== "object") {
68
+ return null;
69
+ }
70
+ const { code } = error as { code?: unknown };
71
+ if (typeof code !== "string") {
72
+ return null;
73
+ }
74
+ const message = messages[code];
75
+ return message === undefined ? null : resolve(message);
76
+ },
77
+ [messages, resolve],
78
+ );
79
+ }
@@ -0,0 +1,167 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { ConfirmDialog } from "./ConfirmDialog";
6
+ import { EmptyState } from "./EmptyState";
7
+ import { describeError, ErrorState } from "./ErrorState";
8
+ import { UiTextProvider } from "./uiText";
9
+ import { Button } from "./ui/Button";
10
+
11
+ afterEach(cleanup);
12
+
13
+ describe("EmptyState", () => {
14
+ it("renders title, description, and the action slot", () => {
15
+ render(
16
+ <EmptyState
17
+ title="No tasks yet"
18
+ description="Create your first task to get going."
19
+ action={<Button>New task</Button>}
20
+ />,
21
+ );
22
+
23
+ expect(screen.getByText("No tasks yet")).toBeInTheDocument();
24
+ expect(screen.getByText("Create your first task to get going.")).toBeInTheDocument();
25
+ expect(screen.getByRole("button", { name: "New task" })).toBeInTheDocument();
26
+ });
27
+ });
28
+
29
+ describe("describeError", () => {
30
+ it("derives a message from Errors, envelope objects, and strings", () => {
31
+ expect(describeError(new Error("task not found"))).toBe("task not found");
32
+ expect(describeError({ detail: "task not found", code: "not_found" })).toBe(
33
+ "task not found",
34
+ );
35
+ expect(describeError({ code: "not_found" })).toBe("not_found");
36
+ expect(describeError("boom")).toBe("boom");
37
+ expect(describeError(42)).toBeNull();
38
+ expect(describeError(null)).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe("ErrorState", () => {
43
+ it("is an alert with a default title and the derived error message", () => {
44
+ render(<ErrorState error={new Error("task not found")} />);
45
+
46
+ const alert = screen.getByRole("alert");
47
+ expect(alert).toHaveTextContent("Something went wrong.");
48
+ expect(alert).toHaveTextContent("task not found");
49
+ });
50
+
51
+ it("prefers an explicit description and localises the title", () => {
52
+ render(
53
+ <UiTextProvider strings={{ errorTitle: "Er ging iets mis." }}>
54
+ <ErrorState description="Try again later." error={new Error("ignored")} />
55
+ </UiTextProvider>,
56
+ );
57
+
58
+ expect(screen.getByRole("alert")).toHaveTextContent("Er ging iets mis.");
59
+ expect(screen.getByText("Try again later.")).toBeInTheDocument();
60
+ expect(screen.queryByText("ignored")).not.toBeInTheDocument();
61
+ });
62
+ });
63
+
64
+ describe("ConfirmDialog", () => {
65
+ it("is closed (not modal) while open is false", () => {
66
+ render(
67
+ <ConfirmDialog open={false} onOpenChange={() => {}} onConfirm={() => {}} title="Delete?" />,
68
+ );
69
+ // The element stays mounted (so closing runs close(), which restores opener focus)
70
+ // but is not shown: no `open` attribute means the UA hides it.
71
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
72
+ });
73
+
74
+ it("closes via close() when open flips back to false", () => {
75
+ const { rerender } = render(
76
+ <ConfirmDialog open onOpenChange={() => {}} onConfirm={() => {}} title="Delete?" />,
77
+ );
78
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
79
+ rerender(
80
+ <ConfirmDialog open={false} onOpenChange={() => {}} onConfirm={() => {}} title="Delete?" />,
81
+ );
82
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
83
+ });
84
+
85
+ it("confirms and cancels through the labelled buttons", () => {
86
+ const onConfirm = vi.fn();
87
+ const onOpenChange = vi.fn();
88
+ render(
89
+ <ConfirmDialog
90
+ open
91
+ onOpenChange={onOpenChange}
92
+ onConfirm={onConfirm}
93
+ title="Delete this task?"
94
+ description="This cannot be undone."
95
+ destructive
96
+ />,
97
+ );
98
+
99
+ expect(screen.getByRole("dialog", { name: "Delete this task?" })).toBeInTheDocument();
100
+ fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
101
+ expect(onConfirm).toHaveBeenCalledTimes(1);
102
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
103
+ expect(onOpenChange).toHaveBeenCalledWith(false);
104
+ });
105
+
106
+ it("dismisses on Escape (native cancel) while idle", () => {
107
+ const onOpenChange = vi.fn();
108
+ render(
109
+ <ConfirmDialog open onOpenChange={onOpenChange} onConfirm={() => {}} title="Delete?" />,
110
+ );
111
+
112
+ const dialog = screen.getByRole("dialog");
113
+ const cancelEvent = new Event("cancel", { cancelable: true });
114
+ fireEvent(dialog, cancelEvent);
115
+ expect(cancelEvent.defaultPrevented).toBe(true);
116
+ expect(onOpenChange).toHaveBeenCalledWith(false);
117
+ });
118
+
119
+ it("blocks Escape and backdrop dismissal and disables buttons while pending", () => {
120
+ const onOpenChange = vi.fn();
121
+ render(
122
+ <ConfirmDialog
123
+ open
124
+ onOpenChange={onOpenChange}
125
+ onConfirm={() => {}}
126
+ title="Delete?"
127
+ isPending
128
+ />,
129
+ );
130
+
131
+ expect(screen.getByRole("button", { name: "Confirm" })).toBeDisabled();
132
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
133
+ const dialog = screen.getByRole("dialog");
134
+ fireEvent(dialog, new Event("cancel", { cancelable: true }));
135
+ fireEvent.click(dialog);
136
+ expect(onOpenChange).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it("dismisses on backdrop click while idle, but not on clicks inside the panel", () => {
140
+ const onOpenChange = vi.fn();
141
+ render(
142
+ <ConfirmDialog
143
+ open
144
+ onOpenChange={onOpenChange}
145
+ onConfirm={() => {}}
146
+ title="Delete?"
147
+ description="Body copy"
148
+ />,
149
+ );
150
+
151
+ fireEvent.click(screen.getByText("Body copy"));
152
+ expect(onOpenChange).not.toHaveBeenCalled();
153
+ fireEvent.click(screen.getByRole("dialog"));
154
+ expect(onOpenChange).toHaveBeenCalledWith(false);
155
+ });
156
+
157
+ it("locks body scroll while open and restores it on close", () => {
158
+ const { rerender } = render(
159
+ <ConfirmDialog open onOpenChange={() => {}} onConfirm={() => {}} title="Delete?" />,
160
+ );
161
+ expect(document.body.style.overflow).toBe("hidden");
162
+ rerender(
163
+ <ConfirmDialog open={false} onOpenChange={() => {}} onConfirm={() => {}} title="Delete?" />,
164
+ );
165
+ expect(document.body.style.overflow).toBe("");
166
+ });
167
+ });