@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,110 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ /** An async collection: the loaded rows plus loading/error state, a reload, and a create-then-reload. */
4
+ export interface Resource<T, TCreate> {
5
+ /** The loaded rows (empty until the first load resolves). */
6
+ items: T[];
7
+ /** True while the initial load or a reload is in flight. */
8
+ loading: boolean;
9
+ /** The last error message, or `null` when the most recent load succeeded. */
10
+ error: string | null;
11
+ /**
12
+ * The last caught failure itself, or `null` — typically the `ApiError` thrown by
13
+ * `unwrap`, whose stable `code` lets `useErrorMessage` map it to client-owned copy.
14
+ * Optional so hand-built `Resource` objects (which predate this field) keep compiling;
15
+ * consumers treat an absent cause the same as `null` (no code-mapped copy).
16
+ */
17
+ cause?: unknown;
18
+ /** Re-run the list query. */
19
+ reload: () => Promise<void>;
20
+ /** Create a row via `source.create`, then reload. Rejects if the resource is read-only. */
21
+ create: (input: TCreate) => Promise<void>;
22
+ /** Run any module-specific mutation (delete, custom action), surface failures, then reload. */
23
+ mutate: (operation: () => Promise<void>) => Promise<void>;
24
+ }
25
+
26
+ /** How a module fetches (and optionally creates) its rows — typically typed contract-client calls. */
27
+ export interface ResourceSource<T, TCreate> {
28
+ /** Fetch the current rows (e.g. `(await client.GET("/api/v1/notes/", {})).data?.items ?? []`). */
29
+ list: () => Promise<T[]>;
30
+ /** Optional create (e.g. a typed client POST); omit for a read-only resource. */
31
+ create?: (input: TCreate) => Promise<void>;
32
+ }
33
+
34
+ /**
35
+ * The list + create state machine every module's data hook needs, factored out of the view: it loads
36
+ * once on mount, tracks `loading`/`error`, and exposes `reload` plus a `create` that refreshes the
37
+ * list. Modules wrap it in a typed `useX()` hook (e.g. `useNotes`) that supplies `list`/`create` over
38
+ * the contract client, so views stay declarative and every module fetches the same way.
39
+ *
40
+ * `source` may be rebuilt each render (its callbacks are read through a ref), so a module can pass
41
+ * inline closures without triggering a reload loop. Pass `deps` (e.g. a route param the query
42
+ * closes over) to reload automatically when they change — a detail view keyed by `$id` refreshes
43
+ * on in-place navigation instead of showing the previous record.
44
+ */
45
+ export function useResource<T, TCreate = void>(
46
+ source: ResourceSource<T, TCreate>,
47
+ deps: readonly unknown[] = [],
48
+ ): Resource<T, TCreate> {
49
+ const sourceRef = useRef(source);
50
+ sourceRef.current = source;
51
+
52
+ const [items, setItems] = useState<T[]>([]);
53
+ const [loading, setLoading] = useState(true);
54
+ const [error, setError] = useState<string | null>(null);
55
+ const [cause, setCause] = useState<unknown>(null);
56
+
57
+ const fail = useCallback((caught: unknown) => {
58
+ setError(caught instanceof Error ? caught.message : String(caught));
59
+ setCause(caught);
60
+ }, []);
61
+
62
+ const reload = useCallback(async () => {
63
+ setLoading(true);
64
+ setError(null);
65
+ setCause(null);
66
+ try {
67
+ setItems(await sourceRef.current.list());
68
+ } catch (caught) {
69
+ fail(caught);
70
+ } finally {
71
+ setLoading(false);
72
+ }
73
+ // The spread keys the loader to caller-declared dependencies (route params etc.).
74
+ // eslint-disable-next-line react-hooks/exhaustive-deps
75
+ }, [fail, ...deps]);
76
+
77
+ useEffect(() => {
78
+ void reload();
79
+ }, [reload]);
80
+
81
+ const mutate = useCallback(
82
+ async (operation: () => Promise<void>) => {
83
+ setError(null);
84
+ setCause(null);
85
+ try {
86
+ await operation();
87
+ } catch (caught) {
88
+ // Surface a failed write (e.g. 403 / 409 / 422) instead of silently no-op'ing, then
89
+ // rethrow so the caller can keep local UI state for a retry.
90
+ fail(caught);
91
+ throw caught;
92
+ }
93
+ await reload();
94
+ },
95
+ [reload, fail],
96
+ );
97
+
98
+ const create = useCallback(
99
+ async (input: TCreate) => {
100
+ const createFn = sourceRef.current.create;
101
+ if (!createFn) {
102
+ throw new Error("This resource is read-only (no create was provided).");
103
+ }
104
+ await mutate(() => createFn(input));
105
+ },
106
+ [mutate],
107
+ );
108
+
109
+ return { items, loading, error, cause, reload, create, mutate };
110
+ }
@@ -0,0 +1,35 @@
1
+ import type { Client } from "openapi-fetch";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { useTerpClient } from "./TerpProvider";
5
+
6
+ // A synthetic app contract with an endpoint that is NOT in @terpjs/contract's base profile —
7
+ // what an app's own openapi-typescript output looks like.
8
+ interface AppPaths {
9
+ "/api/v1/invoices/": {
10
+ get: {
11
+ responses: {
12
+ 200: { content: { "application/json": { items: { id: string }[] } } };
13
+ };
14
+ };
15
+ };
16
+ }
17
+
18
+ // Compile-time proofs, verified by `tsc --noEmit` (the typecheck CI step); never invoked at
19
+ // runtime, so the hook is not called outside a provider. They fail to compile if the hook
20
+ // stops threading the app's own paths through.
21
+ function _returnsAClientForTheAppsPaths(): Client<AppPaths> {
22
+ return useTerpClient<AppPaths>();
23
+ }
24
+
25
+ function _typesTheAppsOwnEndpoint(client: Client<AppPaths>): unknown {
26
+ return client.GET("/api/v1/invoices/", {});
27
+ }
28
+
29
+ describe("useTerpClient", () => {
30
+ it("is generic over the app's own generated paths (typed at compile time)", () => {
31
+ expect(typeof useTerpClient).toBe("function");
32
+ expect(typeof _returnsAClientForTheAppsPaths).toBe("function");
33
+ expect(typeof _typesTheAppsOwnEndpoint).toBe("function");
34
+ });
35
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7
+ "types": [],
8
+ "jsx": "react-jsx",
9
+ "strict": true,
10
+ "noEmit": true,
11
+ "skipLibCheck": true,
12
+ "esModuleInterop": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "verbatimModuleSyntax": true
15
+ },
16
+ "include": ["src", "vitest.setup.ts"]
17
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,14 @@
1
+ import react from "@vitejs/plugin-react";
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ // Vite + Vitest config for @terpjs/react-core. The React plugin handles TSX. The default
5
+ // environment is node (headless client/logic tests); component tests opt into jsdom with
6
+ // a `// @vitest-environment jsdom` docblock so the node-based tests keep their fetch/Response.
7
+ export default defineConfig({
8
+ plugins: [react()],
9
+ test: {
10
+ environment: "node",
11
+ setupFiles: ["./vitest.setup.ts"],
12
+ include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
13
+ },
14
+ });
@@ -0,0 +1,58 @@
1
+ // Vitest setup: register @testing-library/jest-dom matchers (toBeInTheDocument, …).
2
+ // Importing here is harmless for node-environment tests; the matchers are only used by
3
+ // the jsdom component tests.
4
+ import "@testing-library/jest-dom/vitest";
5
+
6
+ // jsdom's File / Blob / FormData are structurally incompatible with Node's built-in
7
+ // (undici) fetch: a jsdom File inside a FormData body serializes as an empty, nameless
8
+ // part, and on Node >= 24 `Request.formData()` brand-checks reject jsdom instances
9
+ // outright. The component tests exercise uploads through the real fetch pipeline, so in
10
+ // the jsdom environment swap these globals for Node's own classes. The tsconfig compiles
11
+ // with `types: []` (browser-only), so `node:buffer` is imported dynamically through a
12
+ // variable specifier; Node's FormData class is not exported, so it is recovered from a
13
+ // parsed urlencoded Response body.
14
+ if (typeof window !== "undefined") {
15
+ const bufferModuleId = "node:buffer";
16
+ const { File: NativeFile, Blob: NativeBlob } = (await import(
17
+ /* @vite-ignore */ bufferModuleId
18
+ )) as { File: typeof File; Blob: typeof Blob };
19
+ globalThis.File = NativeFile;
20
+ globalThis.Blob = NativeBlob;
21
+ window.File = NativeFile;
22
+ window.Blob = NativeBlob;
23
+ const NativeFormData = (
24
+ await new Response("a=b", {
25
+ headers: { "content-type": "application/x-www-form-urlencoded" },
26
+ }).formData()
27
+ ).constructor as typeof FormData;
28
+ globalThis.FormData = NativeFormData;
29
+ window.FormData = NativeFormData;
30
+ }
31
+
32
+ // jsdom does not implement scrollTo, which TanStack Router calls on navigation; stub it so
33
+ // the router render tests do not emit a noisy "Not implemented" warning. Guarded so the
34
+ // node-environment tests (no window) are unaffected.
35
+ if (typeof window !== "undefined") {
36
+ window.scrollTo = (() => {}) as typeof window.scrollTo;
37
+ }
38
+
39
+
40
+ // jsdom does not implement the native <dialog> modal API (showModal/close); polyfill just
41
+ // enough for the ConfirmDialog tests: toggle the `open` property and fire the `close` event.
42
+ if (typeof window !== "undefined" && typeof HTMLDialogElement !== "undefined") {
43
+ const proto = HTMLDialogElement.prototype as HTMLDialogElement & {
44
+ showModal?: () => void;
45
+ close?: () => void;
46
+ };
47
+ if (typeof proto.showModal !== "function") {
48
+ proto.showModal = function showModal(this: HTMLDialogElement) {
49
+ this.setAttribute("open", "");
50
+ };
51
+ }
52
+ if (typeof proto.close !== "function") {
53
+ proto.close = function close(this: HTMLDialogElement) {
54
+ this.removeAttribute("open");
55
+ this.dispatchEvent(new Event("close"));
56
+ };
57
+ }
58
+ }