@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.
- package/README.md +190 -0
- package/package.json +44 -0
- package/src/AppShell.test.tsx +152 -0
- package/src/AppShell.tsx +554 -0
- package/src/Authorized.test.tsx +60 -0
- package/src/Authorized.tsx +21 -0
- package/src/Breadcrumbs.test.tsx +45 -0
- package/src/Breadcrumbs.tsx +110 -0
- package/src/ConfirmDialog.tsx +170 -0
- package/src/DetailPage.tsx +28 -0
- package/src/EmptyState.tsx +74 -0
- package/src/ErrorState.tsx +108 -0
- package/src/Field.test.tsx +53 -0
- package/src/Field.tsx +51 -0
- package/src/HubPage.test.tsx +108 -0
- package/src/HubPage.tsx +204 -0
- package/src/LoadingState.test.tsx +40 -0
- package/src/LoadingState.tsx +96 -0
- package/src/LoginView.test.tsx +57 -0
- package/src/LoginView.tsx +203 -0
- package/src/ModuleNav.test.tsx +96 -0
- package/src/ModuleNav.tsx +88 -0
- package/src/OverviewPage.tsx +26 -0
- package/src/Page.test.tsx +147 -0
- package/src/Page.tsx +158 -0
- package/src/PageActions.test.tsx +104 -0
- package/src/PageActions.tsx +72 -0
- package/src/ProfileView.test.tsx +112 -0
- package/src/ProfileView.tsx +84 -0
- package/src/RequireAuth.test.tsx +89 -0
- package/src/RequireAuth.tsx +22 -0
- package/src/ResourceList.test.tsx +176 -0
- package/src/ResourceList.tsx +123 -0
- package/src/TerpProvider.tsx +320 -0
- package/src/UserMenu.test.tsx +166 -0
- package/src/UserMenu.tsx +125 -0
- package/src/admin/AdminHub.tsx +108 -0
- package/src/admin/AuditLogAdmin.tsx +116 -0
- package/src/admin/GroupCreate.tsx +90 -0
- package/src/admin/GroupDetail.tsx +446 -0
- package/src/admin/GroupsAdmin.tsx +109 -0
- package/src/admin/UserCreate.tsx +115 -0
- package/src/admin/UserDetail.tsx +228 -0
- package/src/admin/UsersAdmin.tsx +111 -0
- package/src/admin/admin.test.tsx +537 -0
- package/src/admin/crumbs.tsx +14 -0
- package/src/admin/module.tsx +51 -0
- package/src/admin/roles.ts +19 -0
- package/src/bootstrap.test.tsx +67 -0
- package/src/bootstrap.tsx +270 -0
- package/src/capabilities.test.ts +24 -0
- package/src/capabilities.ts +26 -0
- package/src/createAuthClient.test.ts +176 -0
- package/src/createAuthClient.ts +105 -0
- package/src/dataview/DataView.test.tsx +392 -0
- package/src/dataview/DataView.tsx +467 -0
- package/src/dataview/DataViewCardList.tsx +189 -0
- package/src/dataview/DataViewColumnSettings.tsx +118 -0
- package/src/dataview/DataViewExpandableRow.tsx +67 -0
- package/src/dataview/DataViewPagination.tsx +113 -0
- package/src/dataview/DataViewRowActions.tsx +131 -0
- package/src/dataview/DataViewTable.tsx +359 -0
- package/src/dataview/DataViewToolbar.tsx +260 -0
- package/src/dataview/README.md +138 -0
- package/src/dataview/glyphs.tsx +175 -0
- package/src/dataview/hooks/hooks.test.tsx +240 -0
- package/src/dataview/hooks/useDataViewQuery.ts +72 -0
- package/src/dataview/hooks/useDataViewState.ts +310 -0
- package/src/dataview/hooks/useServerDataView.ts +154 -0
- package/src/dataview/hooks/useViewSearch.ts +68 -0
- package/src/dataview/index.ts +62 -0
- package/src/dataview/internal.tsx +96 -0
- package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
- package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
- package/src/dataview/repositories/repositories.test.ts +158 -0
- package/src/dataview/repositories/viewState.test.ts +90 -0
- package/src/dataview/repositories/viewState.ts +128 -0
- package/src/dataview/types.ts +249 -0
- package/src/errorMessages.test.tsx +83 -0
- package/src/errorMessages.tsx +79 -0
- package/src/feedback.test.tsx +167 -0
- package/src/files.test.tsx +142 -0
- package/src/files.tsx +174 -0
- package/src/icons.test.tsx +46 -0
- package/src/icons.tsx +533 -0
- package/src/index.ts +155 -0
- package/src/layout.test.tsx +72 -0
- package/src/layout.tsx +90 -0
- package/src/layoutContract.test.tsx +179 -0
- package/src/layoutContract.ts +137 -0
- package/src/locale.test.tsx +97 -0
- package/src/locale.tsx +246 -0
- package/src/nav.test.ts +21 -0
- package/src/nav.ts +13 -0
- package/src/pageMarker.ts +15 -0
- package/src/raw.d.ts +7 -0
- package/src/realtime-hook.test.tsx +226 -0
- package/src/realtime.test.ts +44 -0
- package/src/realtime.ts +307 -0
- package/src/refresh-session.test.tsx +114 -0
- package/src/revocation.test.tsx +81 -0
- package/src/router.test.tsx +307 -0
- package/src/router.tsx +222 -0
- package/src/sso.test.tsx +128 -0
- package/src/sso.ts +142 -0
- package/src/ssr.test.tsx +45 -0
- package/src/styles.test.ts +21 -0
- package/src/styles.ts +302 -0
- package/src/theme.test.tsx +74 -0
- package/src/theme.tsx +143 -0
- package/src/toast.test.tsx +94 -0
- package/src/toast.tsx +214 -0
- package/src/tokens.guard.test.ts +51 -0
- package/src/ui/Alert.test.tsx +19 -0
- package/src/ui/Alert.tsx +115 -0
- package/src/ui/Badge.test.tsx +14 -0
- package/src/ui/Badge.tsx +48 -0
- package/src/ui/Button.test.tsx +36 -0
- package/src/ui/Button.tsx +95 -0
- package/src/ui/Card.test.tsx +40 -0
- package/src/ui/Card.tsx +92 -0
- package/src/ui/Checkbox.test.tsx +17 -0
- package/src/ui/Checkbox.tsx +51 -0
- package/src/ui/Combobox.test.tsx +58 -0
- package/src/ui/Combobox.tsx +313 -0
- package/src/ui/DatePicker.test.tsx +60 -0
- package/src/ui/DatePicker.tsx +421 -0
- package/src/ui/Input.tsx +30 -0
- package/src/ui/Markdown.test.tsx +32 -0
- package/src/ui/Markdown.tsx +213 -0
- package/src/ui/Menu.test.tsx +85 -0
- package/src/ui/Menu.tsx +216 -0
- package/src/ui/Popover.tsx +218 -0
- package/src/ui/Radio.test.tsx +29 -0
- package/src/ui/Radio.tsx +127 -0
- package/src/ui/Select.tsx +40 -0
- package/src/ui/Switch.test.tsx +17 -0
- package/src/ui/Switch.tsx +53 -0
- package/src/ui/Tabs.test.tsx +29 -0
- package/src/ui/Tabs.tsx +128 -0
- package/src/ui/Textarea.tsx +27 -0
- package/src/ui/Tooltip.test.tsx +28 -0
- package/src/ui/Tooltip.tsx +67 -0
- package/src/ui/controlStyles.ts +9 -0
- package/src/uiText.test.tsx +93 -0
- package/src/uiText.tsx +342 -0
- package/src/unwrap.test.ts +67 -0
- package/src/unwrap.ts +101 -0
- package/src/useResource.test.tsx +118 -0
- package/src/useResource.ts +110 -0
- package/src/useTerpClient.test.ts +35 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +14 -0
- 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
|
+
});
|