@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,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
|
+
});
|
package/vitest.setup.ts
ADDED
|
@@ -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
|
+
}
|