@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
package/src/uiText.tsx
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A piece of user-facing text: either a plain string (used as-is) or a message
|
|
6
|
+
* descriptor — a stable `id` for a translation catalog plus the source-language
|
|
7
|
+
* `message` used as the fallback. Components accept `UiText` so an app can go
|
|
8
|
+
* from hardcoded strings to a full i18n runtime without changing call sites.
|
|
9
|
+
*/
|
|
10
|
+
export type UiText = string | { id: string; message: string };
|
|
11
|
+
|
|
12
|
+
/** Resolves a {@link UiText} to the display string for the active locale. */
|
|
13
|
+
export type ResolveUiText = (text: UiText) => string;
|
|
14
|
+
|
|
15
|
+
/** The default resolver: plain strings as-is, descriptors via their fallback `message`. */
|
|
16
|
+
export function resolveUiText(text: UiText): string {
|
|
17
|
+
return typeof text === "string" ? text : text.message;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every user-facing string the framework renders itself. Each key can be
|
|
22
|
+
* overridden per app (or wholesale rerouted through {@link UiTextProvider}'s
|
|
23
|
+
* `resolveText`), so react-core stays locale-agnostic: it ships English
|
|
24
|
+
* defaults but never forces them.
|
|
25
|
+
*/
|
|
26
|
+
export interface TerpStrings {
|
|
27
|
+
/** Body placeholder while a page's data loads. */
|
|
28
|
+
loading: string;
|
|
29
|
+
/** Default empty-list message. */
|
|
30
|
+
emptyList: string;
|
|
31
|
+
/** Label of the default single-field create button. */
|
|
32
|
+
add: string;
|
|
33
|
+
/** Label of the header sign-out button. */
|
|
34
|
+
signOut: string;
|
|
35
|
+
/** Login view heading and submit label. */
|
|
36
|
+
signIn: string;
|
|
37
|
+
/** Login submit label while the request is in flight. */
|
|
38
|
+
signingIn: string;
|
|
39
|
+
/** Login email placeholder. */
|
|
40
|
+
email: string;
|
|
41
|
+
/** Login password placeholder. */
|
|
42
|
+
password: string;
|
|
43
|
+
/** Login failure message. */
|
|
44
|
+
signInFailed: string;
|
|
45
|
+
/** Label of the dev-only button that fills the seeded development credentials. */
|
|
46
|
+
fillDevCredentials: string;
|
|
47
|
+
/** Prefix of an SSO provider button label ("Continue with {provider}"). */
|
|
48
|
+
continueWith: string;
|
|
49
|
+
/** Separator between the credentials form and the SSO provider buttons. */
|
|
50
|
+
orSeparator: string;
|
|
51
|
+
/** Message when an SSO login attempt fails. */
|
|
52
|
+
ssoFailed: string;
|
|
53
|
+
/** Label of the {@link FileUpload} button. */
|
|
54
|
+
uploadFile: string;
|
|
55
|
+
/** {@link FileUpload} button label while an upload is in flight. */
|
|
56
|
+
uploading: string;
|
|
57
|
+
/** Default message when the user may not access a route. */
|
|
58
|
+
unauthorized: string;
|
|
59
|
+
/** Accessible name of the breadcrumb `nav` landmark. */
|
|
60
|
+
breadcrumbsLabel: string;
|
|
61
|
+
/** Accessible name of intra-module secondary navigation. */
|
|
62
|
+
moduleNavigationLabel: string;
|
|
63
|
+
/** Accessible label of the page-actions overflow trigger. */
|
|
64
|
+
moreActions: string;
|
|
65
|
+
/** Default {@link ErrorState} title. */
|
|
66
|
+
errorTitle: string;
|
|
67
|
+
/** Default confirm-button label of {@link ConfirmDialog}. */
|
|
68
|
+
confirm: string;
|
|
69
|
+
/** Default cancel-button label of {@link ConfirmDialog}. */
|
|
70
|
+
cancel: string;
|
|
71
|
+
/** Default success-toast title. */
|
|
72
|
+
successTitle: string;
|
|
73
|
+
/** Default warning-toast title. */
|
|
74
|
+
warningTitle: string;
|
|
75
|
+
/** Accessible label of a toast's dismiss button. */
|
|
76
|
+
dismiss: string;
|
|
77
|
+
/** Accessible label of the {@link UserMenu} avatar trigger. */
|
|
78
|
+
accountMenu: string;
|
|
79
|
+
/** Label of the {@link UserMenu} item that opens the profile / settings page. */
|
|
80
|
+
settings: string;
|
|
81
|
+
/** Title of the built-in profile page (and its breadcrumb). */
|
|
82
|
+
profile: string;
|
|
83
|
+
/** Label of the profile page's role detail. */
|
|
84
|
+
role: string;
|
|
85
|
+
/** Label of the application home/root page. */
|
|
86
|
+
home: string;
|
|
87
|
+
/** Accessible name of the sidebar `nav` landmark. */
|
|
88
|
+
primaryNavigationLabel: string;
|
|
89
|
+
/** Accessible label of the header toggle when it collapses the expanded sidebar. */
|
|
90
|
+
collapseSidebar: string;
|
|
91
|
+
/** Accessible label of the header toggle when it expands the collapsed sidebar. */
|
|
92
|
+
expandSidebar: string;
|
|
93
|
+
/** Accessible label of the header toggle when it opens the mobile navigation drawer. */
|
|
94
|
+
openNavigation: string;
|
|
95
|
+
/** Accessible label of the control that closes the mobile navigation drawer. */
|
|
96
|
+
closeNavigation: string;
|
|
97
|
+
/** Label of the {@link ThemeToggle} select. */
|
|
98
|
+
theme: string;
|
|
99
|
+
/** {@link ThemeToggle} option: the light theme. */
|
|
100
|
+
themeLight: string;
|
|
101
|
+
/** {@link ThemeToggle} option: the dark theme. */
|
|
102
|
+
themeDark: string;
|
|
103
|
+
/** {@link ThemeToggle} option: follow the OS preference. */
|
|
104
|
+
themeSystem: string;
|
|
105
|
+
/** Label of the {@link LanguageSwitcher} select. */
|
|
106
|
+
language: string;
|
|
107
|
+
/** The packaged admin area: nav label + hub title. */
|
|
108
|
+
admin: string;
|
|
109
|
+
/** Admin hub card / users overview title. */
|
|
110
|
+
adminUsers: string;
|
|
111
|
+
/** Admin hub: users card description. */
|
|
112
|
+
adminUsersDescription: string;
|
|
113
|
+
/** Admin hub card / groups overview title. */
|
|
114
|
+
adminGroups: string;
|
|
115
|
+
/** Admin hub: groups card description. */
|
|
116
|
+
adminGroupsDescription: string;
|
|
117
|
+
/** Admin hub card / audit overview title. */
|
|
118
|
+
adminAudit: string;
|
|
119
|
+
/** Admin hub: audit card description. */
|
|
120
|
+
adminAuditDescription: string;
|
|
121
|
+
/** Generic "Status" column header. */
|
|
122
|
+
statusColumn: string;
|
|
123
|
+
/** Generic "Created" column header. */
|
|
124
|
+
createdColumn: string;
|
|
125
|
+
/** Active-account status label. */
|
|
126
|
+
statusActive: string;
|
|
127
|
+
/** Deactivated-account status label. */
|
|
128
|
+
statusDeactivated: string;
|
|
129
|
+
/** Users admin: provision-form submit label. */
|
|
130
|
+
provisionUser: string;
|
|
131
|
+
/** Users admin: viewer role label. */
|
|
132
|
+
roleViewer: string;
|
|
133
|
+
/** Users admin: editor role label. */
|
|
134
|
+
roleEditor: string;
|
|
135
|
+
/** Users admin: administrator role label. */
|
|
136
|
+
roleAdmin: string;
|
|
137
|
+
/** Generic in-flight label for a pending mutation. */
|
|
138
|
+
working: string;
|
|
139
|
+
/** Users admin: change-role action; `{role}` is replaced by the role's name. */
|
|
140
|
+
makeRole: string;
|
|
141
|
+
/** Users admin: reset-password action + dialog confirm label. */
|
|
142
|
+
resetPassword: string;
|
|
143
|
+
/** Users admin: reset dialog's password field label. */
|
|
144
|
+
newPassword: string;
|
|
145
|
+
/** Users admin: deactivate action. */
|
|
146
|
+
deactivate: string;
|
|
147
|
+
/** Users admin: reactivate action. */
|
|
148
|
+
reactivate: string;
|
|
149
|
+
/** Users admin: confirmation before changing a role; `{role}` is replaced. */
|
|
150
|
+
changeRoleConfirm: string;
|
|
151
|
+
/** Users admin: confirmation before deactivation. */
|
|
152
|
+
deactivateUserConfirm: string;
|
|
153
|
+
/** Users admin: confirmation before reactivation. */
|
|
154
|
+
reactivateUserConfirm: string;
|
|
155
|
+
/** Groups admin: name field / column. */
|
|
156
|
+
groupName: string;
|
|
157
|
+
/** Groups admin: description field / column. */
|
|
158
|
+
description: string;
|
|
159
|
+
/** Groups admin: members column / detail section title. */
|
|
160
|
+
members: string;
|
|
161
|
+
/** Groups admin: create-form submit label. */
|
|
162
|
+
createGroup: string;
|
|
163
|
+
/** Groups admin: delete action. */
|
|
164
|
+
deleteGroup: string;
|
|
165
|
+
/** Groups admin: delete confirmation body. */
|
|
166
|
+
deleteGroupConfirm: string;
|
|
167
|
+
/** Group detail: add-member submit label. */
|
|
168
|
+
addMember: string;
|
|
169
|
+
/** Group detail: remove-member action. */
|
|
170
|
+
removeMember: string;
|
|
171
|
+
/** Group detail: confirmation before removing a member. */
|
|
172
|
+
removeMemberConfirm: string;
|
|
173
|
+
/** Group detail: the user field of the add-member form. */
|
|
174
|
+
userField: string;
|
|
175
|
+
/** Group detail: no account matched the typed email. */
|
|
176
|
+
userNotFound: string;
|
|
177
|
+
/** Group detail: permissions section title. */
|
|
178
|
+
permissions: string;
|
|
179
|
+
/** Group detail: grant-form submit label. */
|
|
180
|
+
grantPermission: string;
|
|
181
|
+
/** Group detail: permission field / column. */
|
|
182
|
+
permission: string;
|
|
183
|
+
/** Group detail: revoke-grant action. */
|
|
184
|
+
revoke: string;
|
|
185
|
+
/** Group detail: confirmation before revoking a permission. */
|
|
186
|
+
revokeConfirm: string;
|
|
187
|
+
/** Audit admin: action column. */
|
|
188
|
+
actionColumn: string;
|
|
189
|
+
/** Audit admin: actor column. */
|
|
190
|
+
actorColumn: string;
|
|
191
|
+
/** Audit admin: target column. */
|
|
192
|
+
targetColumn: string;
|
|
193
|
+
/** Audit admin: timestamp column. */
|
|
194
|
+
whenColumn: string;
|
|
195
|
+
/** Audit admin: expanded row's payload heading. */
|
|
196
|
+
details: string;
|
|
197
|
+
/** Generic success toast after a saved mutation. */
|
|
198
|
+
saved: string;
|
|
199
|
+
/** Generic failure toast when a request did not go through. */
|
|
200
|
+
requestFailed: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const DEFAULT_STRINGS: TerpStrings = {
|
|
204
|
+
loading: "Loading...",
|
|
205
|
+
emptyList: "Nothing here yet.",
|
|
206
|
+
add: "Add",
|
|
207
|
+
signOut: "Sign out",
|
|
208
|
+
signIn: "Sign in",
|
|
209
|
+
signingIn: "Signing in…",
|
|
210
|
+
email: "Email",
|
|
211
|
+
password: "Password",
|
|
212
|
+
signInFailed: "Sign-in failed. Check your credentials.",
|
|
213
|
+
fillDevCredentials: "Fill dev credentials",
|
|
214
|
+
continueWith: "Continue with",
|
|
215
|
+
orSeparator: "or",
|
|
216
|
+
ssoFailed: "Single sign-on failed. Try again.",
|
|
217
|
+
uploadFile: "Upload file",
|
|
218
|
+
uploading: "Uploading…",
|
|
219
|
+
unauthorized: "You do not have access to this page.",
|
|
220
|
+
breadcrumbsLabel: "Breadcrumb",
|
|
221
|
+
moduleNavigationLabel: "Module navigation",
|
|
222
|
+
moreActions: "More actions",
|
|
223
|
+
errorTitle: "Something went wrong.",
|
|
224
|
+
confirm: "Confirm",
|
|
225
|
+
cancel: "Cancel",
|
|
226
|
+
successTitle: "Success",
|
|
227
|
+
warningTitle: "Heads up",
|
|
228
|
+
dismiss: "Dismiss",
|
|
229
|
+
accountMenu: "Account menu",
|
|
230
|
+
settings: "Settings",
|
|
231
|
+
profile: "Profile",
|
|
232
|
+
role: "Role",
|
|
233
|
+
home: "Home",
|
|
234
|
+
primaryNavigationLabel: "Primary",
|
|
235
|
+
collapseSidebar: "Collapse sidebar",
|
|
236
|
+
expandSidebar: "Expand sidebar",
|
|
237
|
+
openNavigation: "Open navigation",
|
|
238
|
+
closeNavigation: "Close navigation",
|
|
239
|
+
theme: "Theme",
|
|
240
|
+
themeLight: "Light",
|
|
241
|
+
themeDark: "Dark",
|
|
242
|
+
themeSystem: "System",
|
|
243
|
+
language: "Language",
|
|
244
|
+
admin: "Admin",
|
|
245
|
+
adminUsers: "Users",
|
|
246
|
+
adminUsersDescription: "Provision accounts, change roles, reset passwords",
|
|
247
|
+
adminGroups: "Groups",
|
|
248
|
+
adminGroupsDescription: "Bundle permissions; membership applies them",
|
|
249
|
+
adminAudit: "Audit log",
|
|
250
|
+
adminAuditDescription: "Every change: what, who, when",
|
|
251
|
+
statusColumn: "Status",
|
|
252
|
+
createdColumn: "Created",
|
|
253
|
+
statusActive: "Active",
|
|
254
|
+
statusDeactivated: "Deactivated",
|
|
255
|
+
provisionUser: "Provision user",
|
|
256
|
+
roleViewer: "Viewer",
|
|
257
|
+
roleEditor: "Editor",
|
|
258
|
+
roleAdmin: "Administrator",
|
|
259
|
+
working: "Working…",
|
|
260
|
+
makeRole: "Make {role}",
|
|
261
|
+
resetPassword: "Reset password",
|
|
262
|
+
newPassword: "New password",
|
|
263
|
+
deactivate: "Deactivate",
|
|
264
|
+
reactivate: "Reactivate",
|
|
265
|
+
changeRoleConfirm: "Change this user's role to {role}?",
|
|
266
|
+
deactivateUserConfirm: "Deactivate this account? Its active sessions will be revoked.",
|
|
267
|
+
reactivateUserConfirm: "Reactivate this account?",
|
|
268
|
+
groupName: "Name",
|
|
269
|
+
description: "Description",
|
|
270
|
+
members: "Members",
|
|
271
|
+
createGroup: "Create group",
|
|
272
|
+
deleteGroup: "Delete group",
|
|
273
|
+
deleteGroupConfirm: "Delete this group? Its memberships and permission grants go with it.",
|
|
274
|
+
addMember: "Add member",
|
|
275
|
+
removeMember: "Remove",
|
|
276
|
+
removeMemberConfirm: "Remove this member from the group?",
|
|
277
|
+
userField: "User",
|
|
278
|
+
userNotFound: "No account matches that email.",
|
|
279
|
+
permissions: "Permissions",
|
|
280
|
+
grantPermission: "Grant permission",
|
|
281
|
+
permission: "Permission",
|
|
282
|
+
revoke: "Revoke",
|
|
283
|
+
revokeConfirm: "Revoke this permission from the group?",
|
|
284
|
+
actionColumn: "Action",
|
|
285
|
+
actorColumn: "Actor",
|
|
286
|
+
targetColumn: "Target",
|
|
287
|
+
whenColumn: "When",
|
|
288
|
+
details: "Details",
|
|
289
|
+
saved: "Saved",
|
|
290
|
+
requestFailed: "The request failed. Try again.",
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
interface UiTextContextValue {
|
|
294
|
+
strings: TerpStrings;
|
|
295
|
+
resolveText: ResolveUiText;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const UiTextContext = createContext<UiTextContextValue>({
|
|
299
|
+
strings: DEFAULT_STRINGS,
|
|
300
|
+
resolveText: resolveUiText,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
export interface UiTextProviderProps {
|
|
304
|
+
/** Per-key overrides of the framework's own strings (e.g. translations). */
|
|
305
|
+
strings?: Partial<TerpStrings>;
|
|
306
|
+
/**
|
|
307
|
+
* Custom {@link UiText} resolver — the hook for a real i18n runtime: pass a
|
|
308
|
+
* function that looks descriptors up in the active locale's catalog
|
|
309
|
+
* (falling back to `message`). Defaults to {@link resolveUiText}.
|
|
310
|
+
*/
|
|
311
|
+
resolveText?: ResolveUiText;
|
|
312
|
+
children: ReactNode;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The locale seam. react-core components read all their own strings and
|
|
317
|
+
* resolve all `UiText` props through this context; without a provider they
|
|
318
|
+
* use the bundled English defaults. An app localises by wrapping its tree
|
|
319
|
+
* once — no per-component wiring, no i18n dependency inside react-core.
|
|
320
|
+
*/
|
|
321
|
+
export function UiTextProvider({ strings, resolveText, children }: UiTextProviderProps) {
|
|
322
|
+
const parent = useContext(UiTextContext);
|
|
323
|
+
const value = useMemo<UiTextContextValue>(
|
|
324
|
+
() => ({
|
|
325
|
+
strings: { ...parent.strings, ...strings },
|
|
326
|
+
resolveText: resolveText ?? parent.resolveText,
|
|
327
|
+
}),
|
|
328
|
+
[parent, strings, resolveText],
|
|
329
|
+
);
|
|
330
|
+
return <UiTextContext.Provider value={value}>{children}</UiTextContext.Provider>;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The framework strings for the active locale (defaults merged with any overrides). */
|
|
334
|
+
export function useStrings(): TerpStrings {
|
|
335
|
+
return useContext(UiTextContext).strings;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The active {@link UiText} resolver — call it on any `UiText` prop before rendering. */
|
|
339
|
+
export function useUiText(): ResolveUiText {
|
|
340
|
+
const { resolveText } = useContext(UiTextContext);
|
|
341
|
+
return useCallback((text: UiText) => resolveText(text), [resolveText]);
|
|
342
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { ApiError, unwrap } from "./unwrap";
|
|
4
|
+
|
|
5
|
+
function response(status: number): Response {
|
|
6
|
+
return new Response(null, { status });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("unwrap", () => {
|
|
10
|
+
it("returns the data on a 2xx result", () => {
|
|
11
|
+
expect(unwrap({ data: { items: [1, 2] }, response: response(200) })).toEqual({
|
|
12
|
+
items: [1, 2],
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns undefined data on a 204 (no body) without throwing", () => {
|
|
17
|
+
expect(unwrap<undefined>({ response: response(204) })).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("throws the backend error `detail` on an HTTP error", () => {
|
|
21
|
+
expect(() =>
|
|
22
|
+
unwrap({
|
|
23
|
+
error: { code: "permission_denied", detail: "You do not have permission." },
|
|
24
|
+
response: response(403),
|
|
25
|
+
}),
|
|
26
|
+
).toThrow("You do not have permission.");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("flattens structured validation detail into field messages", () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
unwrap({
|
|
32
|
+
error: {
|
|
33
|
+
detail: [
|
|
34
|
+
{ loc: ["body", "title"], msg: "String should have at least 1 character" },
|
|
35
|
+
{ loc: ["body", "owner", "email"], msg: "Input should be a valid email" },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
response: response(422),
|
|
39
|
+
}),
|
|
40
|
+
).toThrow("title: String should have at least 1 character; owner.email: Input should be a valid email");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("falls back to the error `code`, then the status, when there is no detail", () => {
|
|
44
|
+
expect(() => unwrap({ error: { code: "conflict" }, response: response(409) })).toThrow(
|
|
45
|
+
"conflict",
|
|
46
|
+
);
|
|
47
|
+
expect(() => unwrap({ response: response(500) })).toThrow("HTTP 500");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("throws an ApiError carrying the stable code, status, and request id", () => {
|
|
51
|
+
let caught: unknown;
|
|
52
|
+
try {
|
|
53
|
+
unwrap({
|
|
54
|
+
error: { code: "stale_data", detail: "Row changed.", request_id: "req-1" },
|
|
55
|
+
response: response(409),
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
caught = error;
|
|
59
|
+
}
|
|
60
|
+
expect(caught).toBeInstanceOf(ApiError);
|
|
61
|
+
const apiError = caught as ApiError;
|
|
62
|
+
expect(apiError.code).toBe("stale_data");
|
|
63
|
+
expect(apiError.status).toBe(409);
|
|
64
|
+
expect(apiError.requestId).toBe("req-1");
|
|
65
|
+
expect(apiError.message).toBe("Row changed.");
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/unwrap.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unwrap an `openapi-fetch` result, throwing on any non-2xx.
|
|
3
|
+
*
|
|
4
|
+
* `openapi-fetch` does **not** throw on an HTTP error status — it returns `{ data, error }`
|
|
5
|
+
* (error carrying the parsed response body). A data hook that reads `.data` alone therefore
|
|
6
|
+
* silently swallows 401 / 403 / 409 / 422 / 500: a failed read shows an empty list, a failed
|
|
7
|
+
* write no-ops. Passing the result through {@link unwrap} makes every failure surface — it
|
|
8
|
+
* throws an {@link ApiError} carrying the backend's error-envelope `detail` (falling back to
|
|
9
|
+
* `code`, then the HTTP status), so a module's `useResource` reports it instead of hiding it.
|
|
10
|
+
*/
|
|
11
|
+
export interface FetchResult<T> {
|
|
12
|
+
/** The parsed 2xx response body (absent on error / 204). */
|
|
13
|
+
data?: T;
|
|
14
|
+
/** The parsed error-response body (absent on success). */
|
|
15
|
+
error?: unknown;
|
|
16
|
+
/** The raw response, used to detect a non-ok status even when the body did not parse. */
|
|
17
|
+
response: Response;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A failed request, carrying the machine-readable parts of the platform error
|
|
22
|
+
* envelope alongside the human-readable `message`. The stable `code` lets UI code
|
|
23
|
+
* dispatch on the failure kind (and map it to client-owned copy) without
|
|
24
|
+
* pattern-matching prose; `message` is the backend `detail` fallback.
|
|
25
|
+
*/
|
|
26
|
+
export class ApiError extends Error {
|
|
27
|
+
/** Stable machine code from the envelope (e.g. `stale_data`), if present. */
|
|
28
|
+
readonly code?: string;
|
|
29
|
+
/** HTTP status of the failed response. */
|
|
30
|
+
readonly status: number;
|
|
31
|
+
/** Correlation id from the envelope, for support and log lookup. */
|
|
32
|
+
readonly requestId?: string;
|
|
33
|
+
|
|
34
|
+
constructor(message: string, options: { code?: string; status: number; requestId?: string }) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "ApiError";
|
|
37
|
+
this.code = options.code;
|
|
38
|
+
this.status = options.status;
|
|
39
|
+
this.requestId = options.requestId;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Return the result's `data` on success, or throw an {@link ApiError} describing the failure. */
|
|
44
|
+
export function unwrap<T>(result: FetchResult<T>): T {
|
|
45
|
+
if (result.error !== undefined || !result.response.ok) {
|
|
46
|
+
const envelope =
|
|
47
|
+
result.error !== null && typeof result.error === "object"
|
|
48
|
+
? (result.error as { code?: unknown; request_id?: unknown })
|
|
49
|
+
: {};
|
|
50
|
+
throw new ApiError(errorMessage(result.error, result.response), {
|
|
51
|
+
code: typeof envelope.code === "string" ? envelope.code : undefined,
|
|
52
|
+
status: result.response.status,
|
|
53
|
+
requestId: typeof envelope.request_id === "string" ? envelope.request_id : undefined,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return result.data as T;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Human-readable message for a failed request: envelope `detail`, else `code`, else the status. */
|
|
60
|
+
function errorMessage(error: unknown, response: Response): string {
|
|
61
|
+
if (error !== null && typeof error === "object") {
|
|
62
|
+
const envelope = error as { detail?: unknown; code?: unknown };
|
|
63
|
+
if (typeof envelope.detail === "string" && envelope.detail.length > 0) {
|
|
64
|
+
return envelope.detail;
|
|
65
|
+
}
|
|
66
|
+
const structured = structuredDetail(envelope.detail);
|
|
67
|
+
if (structured !== null) {
|
|
68
|
+
return structured;
|
|
69
|
+
}
|
|
70
|
+
if (typeof envelope.code === "string" && envelope.code.length > 0) {
|
|
71
|
+
return envelope.code;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return `Request failed (HTTP ${response.status})`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Flatten common FastAPI/Pydantic validation details into an agent/user-actionable message. */
|
|
78
|
+
function structuredDetail(detail: unknown): string | null {
|
|
79
|
+
if (!Array.isArray(detail)) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const messages = detail
|
|
83
|
+
.map((item) => {
|
|
84
|
+
if (item === null || typeof item !== "object") {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const field = item as { loc?: unknown; msg?: unknown };
|
|
88
|
+
if (typeof field.msg !== "string" || field.msg.length === 0) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const loc = Array.isArray(field.loc)
|
|
92
|
+
? field.loc.filter((part) => typeof part === "string" || typeof part === "number")
|
|
93
|
+
: [];
|
|
94
|
+
const path = loc
|
|
95
|
+
.filter((part) => part !== "body" && part !== "query" && part !== "path")
|
|
96
|
+
.join(".");
|
|
97
|
+
return path.length > 0 ? `${path}: ${field.msg}` : field.msg;
|
|
98
|
+
})
|
|
99
|
+
.filter((message): message is string => message !== null);
|
|
100
|
+
return messages.length > 0 ? messages.join("; ") : null;
|
|
101
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { useResource } from "./useResource";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
describe("useResource", () => {
|
|
10
|
+
it("loads on mount and exposes the rows", async () => {
|
|
11
|
+
const { result } = renderHook(() =>
|
|
12
|
+
useResource<string, string>({ list: async () => ["a", "b"] }),
|
|
13
|
+
);
|
|
14
|
+
expect(result.current.loading).toBe(true);
|
|
15
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
16
|
+
expect(result.current.items).toEqual(["a", "b"]);
|
|
17
|
+
expect(result.current.error).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("reloads when a declared dependency changes (in-place route-param navigation)", async () => {
|
|
21
|
+
// A detail view keyed by a route param must refetch when the param changes
|
|
22
|
+
// without a remount — otherwise the previous record's data lingers.
|
|
23
|
+
const list = vi.fn(async () => ["row"]);
|
|
24
|
+
const { result, rerender } = renderHook(
|
|
25
|
+
({ id }: { id: string }) =>
|
|
26
|
+
useResource<string>(
|
|
27
|
+
{
|
|
28
|
+
list: async () => {
|
|
29
|
+
await list();
|
|
30
|
+
return [id];
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
[id],
|
|
34
|
+
),
|
|
35
|
+
{ initialProps: { id: "g1" } },
|
|
36
|
+
);
|
|
37
|
+
await waitFor(() => expect(result.current.items).toEqual(["g1"]));
|
|
38
|
+
|
|
39
|
+
rerender({ id: "g2" });
|
|
40
|
+
await waitFor(() => expect(result.current.items).toEqual(["g2"]));
|
|
41
|
+
expect(list).toHaveBeenCalledTimes(2);
|
|
42
|
+
|
|
43
|
+
// A rerender with the same dependency does not refetch.
|
|
44
|
+
rerender({ id: "g2" });
|
|
45
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
46
|
+
expect(list).toHaveBeenCalledTimes(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("create runs the source create then reloads", async () => {
|
|
50
|
+
let rows = ["a"];
|
|
51
|
+
const create = vi.fn(async (value: string) => {
|
|
52
|
+
rows = [...rows, value];
|
|
53
|
+
});
|
|
54
|
+
const { result } = renderHook(() =>
|
|
55
|
+
useResource<string, string>({ list: async () => rows, create }),
|
|
56
|
+
);
|
|
57
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
58
|
+
|
|
59
|
+
await act(async () => {
|
|
60
|
+
await result.current.create("b");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(create).toHaveBeenCalledWith("b");
|
|
64
|
+
expect(result.current.items).toEqual(["a", "b"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("captures a list error as a message", async () => {
|
|
68
|
+
const { result } = renderHook(() =>
|
|
69
|
+
useResource<string>({
|
|
70
|
+
list: async () => {
|
|
71
|
+
throw new Error("boom");
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
await waitFor(() => expect(result.current.error).toBe("boom"));
|
|
76
|
+
expect(result.current.cause).toBeInstanceOf(Error);
|
|
77
|
+
expect(result.current.items).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects create on a read-only resource", async () => {
|
|
81
|
+
const { result } = renderHook(() => useResource<string, string>({ list: async () => [] }));
|
|
82
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
83
|
+
await expect(result.current.create("x")).rejects.toThrow(/read-only/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("surfaces a failed create as an error and rejects (not a silent no-op)", async () => {
|
|
87
|
+
const create = vi.fn(async () => {
|
|
88
|
+
throw new Error("You do not have permission.");
|
|
89
|
+
});
|
|
90
|
+
const { result } = renderHook(() =>
|
|
91
|
+
useResource<string, string>({ list: async () => ["a"], create }),
|
|
92
|
+
);
|
|
93
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
94
|
+
|
|
95
|
+
await act(async () => {
|
|
96
|
+
await expect(result.current.create("b")).rejects.toThrow("You do not have permission.");
|
|
97
|
+
});
|
|
98
|
+
expect(result.current.error).toBe("You do not have permission.");
|
|
99
|
+
expect(result.current.cause).toBeInstanceOf(Error);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("mutate surfaces module-specific write failures and rejects", async () => {
|
|
103
|
+
const { result } = renderHook(() =>
|
|
104
|
+
useResource<string, string>({ list: async () => ["a"] }),
|
|
105
|
+
);
|
|
106
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
107
|
+
|
|
108
|
+
await act(async () => {
|
|
109
|
+
await expect(
|
|
110
|
+
result.current.mutate(async () => {
|
|
111
|
+
throw new Error("Delete failed.");
|
|
112
|
+
}),
|
|
113
|
+
).rejects.toThrow("Delete failed.");
|
|
114
|
+
});
|
|
115
|
+
expect(result.current.error).toBe("Delete failed.");
|
|
116
|
+
expect(result.current.items).toEqual(["a"]);
|
|
117
|
+
});
|
|
118
|
+
});
|