@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,142 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { FileUpload, useFileDownload, type FileMeta } from "./files";
6
+ import { TerpProvider } from "./TerpProvider";
7
+
8
+ function json(body: unknown, status = 200): Response {
9
+ return new Response(JSON.stringify(body), {
10
+ status,
11
+ headers: { "content-type": "application/json" },
12
+ });
13
+ }
14
+
15
+ const STORED: FileMeta = {
16
+ id: "f-1",
17
+ filename: "report.txt",
18
+ content_type: "text/plain",
19
+ size: 5,
20
+ sha256: "abc",
21
+ owner_id: null,
22
+ version: 1,
23
+ created_at: "2026-01-01T00:00:00Z",
24
+ updated_at: "2026-01-01T00:00:00Z",
25
+ };
26
+
27
+ afterEach(() => {
28
+ cleanup();
29
+ vi.restoreAllMocks();
30
+ });
31
+
32
+ function stubFetch(handler: (request: Request) => Promise<Response | null>) {
33
+ vi.stubGlobal(
34
+ "fetch",
35
+ vi.fn<typeof fetch>(async (input) => {
36
+ const request = input as Request;
37
+ if (request.url.endsWith("/api/v1/auth/refresh")) return json({}, 401);
38
+ return (await handler(request)) ?? json({}, 404);
39
+ }),
40
+ );
41
+ }
42
+
43
+ describe("FileUpload", () => {
44
+ it("uploads the picked file as multipart/form-data and reports the metadata", async () => {
45
+ stubFetch(async (request) => {
46
+ if (request.url.endsWith("/api/v1/files/") && request.method === "POST") {
47
+ // The client must send multipart/form-data with the browser-set boundary,
48
+ // carrying the picked file under the backend's `file` field.
49
+ expect(request.headers.get("content-type")).toMatch(/^multipart\/form-data/);
50
+ const form = await request.clone().formData();
51
+ expect(form.get("file")).not.toBeNull();
52
+ return json(STORED, 201);
53
+ }
54
+ return null;
55
+ });
56
+ const onUploaded = vi.fn();
57
+
58
+ const { container } = render(
59
+ <TerpProvider baseUrl="https://api.test">
60
+ <FileUpload onUploaded={onUploaded} />
61
+ </TerpProvider>,
62
+ );
63
+
64
+ expect(screen.getByRole("button", { name: "Upload file" })).toBeInTheDocument();
65
+ const input = container.querySelector('input[type="file"]');
66
+ expect(input).not.toBeNull();
67
+ fireEvent.change(input as HTMLInputElement, {
68
+ target: { files: [new File(["hello"], "report.txt", { type: "text/plain" })] },
69
+ });
70
+
71
+ await waitFor(() => expect(onUploaded).toHaveBeenCalledWith(STORED));
72
+ });
73
+
74
+ it("reports a failed upload through onError", async () => {
75
+ stubFetch(async (request) => {
76
+ if (request.url.endsWith("/api/v1/files/") && request.method === "POST") {
77
+ return json({ detail: "File too large", code: "file_too_large" }, 413);
78
+ }
79
+ return null;
80
+ });
81
+ const onUploaded = vi.fn();
82
+ const onError = vi.fn();
83
+
84
+ const { container } = render(
85
+ <TerpProvider baseUrl="https://api.test">
86
+ <FileUpload onUploaded={onUploaded} onError={onError} />
87
+ </TerpProvider>,
88
+ );
89
+
90
+ fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
91
+ target: { files: [new File(["x"], "big.bin")] },
92
+ });
93
+
94
+ await waitFor(() => expect(onError).toHaveBeenCalled());
95
+ expect(onUploaded).not.toHaveBeenCalled();
96
+ });
97
+ });
98
+
99
+ describe("useFileDownload", () => {
100
+ it("fetches the content through the client and saves it as a named download", async () => {
101
+ stubFetch(async (request) => {
102
+ if (request.url.endsWith("/api/v1/files/f-1/content")) {
103
+ return new Response("hello", {
104
+ status: 200,
105
+ headers: { "content-type": "application/octet-stream" },
106
+ });
107
+ }
108
+ return null;
109
+ });
110
+ const objectUrls: Blob[] = [];
111
+ const urlWithObjectUrls = URL as typeof URL & {
112
+ createObjectURL?: (blob: Blob) => string;
113
+ revokeObjectURL?: (url: string) => void;
114
+ };
115
+ urlWithObjectUrls.createObjectURL = (blob: Blob) => {
116
+ objectUrls.push(blob);
117
+ return "blob:mock";
118
+ };
119
+ urlWithObjectUrls.revokeObjectURL = () => {};
120
+ const clicked = vi.fn();
121
+ vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(clicked);
122
+
123
+ function Download() {
124
+ const download = useFileDownload();
125
+ return (
126
+ <button type="button" onClick={() => void download({ id: "f-1", filename: "report.txt" })}>
127
+ Download
128
+ </button>
129
+ );
130
+ }
131
+
132
+ render(
133
+ <TerpProvider baseUrl="https://api.test">
134
+ <Download />
135
+ </TerpProvider>,
136
+ );
137
+
138
+ fireEvent.click(screen.getByRole("button", { name: "Download" }));
139
+ await waitFor(() => expect(clicked).toHaveBeenCalled());
140
+ expect(await objectUrls[0]?.text()).toBe("hello");
141
+ });
142
+ });
package/src/files.tsx ADDED
@@ -0,0 +1,174 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import type { ChangeEvent, CSSProperties } from "react";
3
+
4
+ import { useTerpClient } from "./TerpProvider";
5
+ import { Button } from "./ui/Button";
6
+ import { useStrings, useUiText, type UiText } from "./uiText";
7
+ import { unwrap } from "./unwrap";
8
+ import type { TerpClient, TerpClientFor } from "@terpjs/contract";
9
+
10
+ /**
11
+ * The frontend surface of the files capability (ADR 0056/0057): a token-styled
12
+ * upload control ({@link FileUpload}) and an authenticated download helper
13
+ * ({@link useFileDownload}). The endpoints are app-mounted (the capability is
14
+ * opt-in), so they are not part of the baked base-profile contract; the wire
15
+ * shapes below mirror the capability's DTOs, which the shipped capability keeps
16
+ * stable.
17
+ */
18
+
19
+ /** A stored file's metadata — the files capability's `FileRead` DTO. */
20
+ export interface FileMeta {
21
+ id: string;
22
+ filename: string;
23
+ content_type: string;
24
+ size: number;
25
+ sha256: string;
26
+ owner_id: string | null;
27
+ version: number;
28
+ created_at: string;
29
+ updated_at: string;
30
+ }
31
+
32
+ /** The files capability endpoints the helpers call, typed for the shared client. */
33
+ interface FilesPaths {
34
+ "/api/v1/files/": {
35
+ post: {
36
+ parameters: { query?: never; header?: never; path?: never; cookie?: never };
37
+ requestBody: { content: { "multipart/form-data": { file: string } } };
38
+ responses: {
39
+ 201: { headers: { [name: string]: unknown }; content: { "application/json": FileMeta } };
40
+ };
41
+ };
42
+ };
43
+ "/api/v1/files/{file_id}/content": {
44
+ get: {
45
+ parameters: { path: { file_id: string }; query?: never; header?: never; cookie?: never };
46
+ requestBody?: never;
47
+ responses: {
48
+ 200: {
49
+ headers: { [name: string]: unknown };
50
+ content: { "application/octet-stream": string };
51
+ };
52
+ };
53
+ };
54
+ };
55
+ }
56
+
57
+ function filesClient(client: TerpClient): TerpClientFor<FilesPaths> {
58
+ return client as unknown as TerpClientFor<FilesPaths>;
59
+ }
60
+
61
+ /** Upload one file through the typed client; resolves to the stored metadata. */
62
+ export async function uploadFile(client: TerpClient, file: File): Promise<FileMeta> {
63
+ return unwrap(
64
+ await filesClient(client).POST("/api/v1/files/", {
65
+ body: { file: "" },
66
+ bodySerializer: () => {
67
+ const form = new FormData();
68
+ form.append("file", file, file.name);
69
+ return form;
70
+ },
71
+ }),
72
+ );
73
+ }
74
+
75
+ /** Fetch a stored file's bytes through the typed client (bearer + cookies attached). */
76
+ export async function fetchFileContent(client: TerpClient, fileId: string): Promise<Blob> {
77
+ const { data, error, response } = await filesClient(client).GET(
78
+ "/api/v1/files/{file_id}/content",
79
+ { params: { path: { file_id: fileId } }, parseAs: "blob" },
80
+ );
81
+ return unwrap({ data: data as Blob | undefined, error, response });
82
+ }
83
+
84
+ /**
85
+ * An authenticated file download: fetches `/files/{id}/content` through the session
86
+ * client (a raw `<a href>` would carry no bearer token) and hands the bytes to the
87
+ * browser as a named download.
88
+ */
89
+ export function useFileDownload(): (file: Pick<FileMeta, "id" | "filename">) => Promise<void> {
90
+ const client = useTerpClient();
91
+ return useCallback(
92
+ async (file: Pick<FileMeta, "id" | "filename">) => {
93
+ const blob = await fetchFileContent(client as unknown as TerpClient, file.id);
94
+ const url = URL.createObjectURL(blob);
95
+ try {
96
+ const anchor = document.createElement("a");
97
+ anchor.href = url;
98
+ anchor.download = file.filename;
99
+ document.body.appendChild(anchor);
100
+ anchor.click();
101
+ anchor.remove();
102
+ } finally {
103
+ URL.revokeObjectURL(url);
104
+ }
105
+ },
106
+ [client],
107
+ );
108
+ }
109
+
110
+ const hiddenInputStyle: CSSProperties = { display: "none" };
111
+
112
+ export interface FileUploadProps {
113
+ /** Called with the stored metadata after a successful upload. */
114
+ onUploaded?: (file: FileMeta) => void;
115
+ /** Called when an upload fails (surface it via a toast or an inline error). */
116
+ onError?: (error: unknown) => void;
117
+ /** Button label; defaults to the `uploadFile` string. */
118
+ label?: UiText;
119
+ /** Native file-picker filter, e.g. `"image/*"` or `".pdf"`. */
120
+ accept?: string;
121
+ /** Disable the control (e.g. while the surrounding view is busy). */
122
+ disabled?: boolean;
123
+ }
124
+
125
+ /**
126
+ * The attachment picker for the files capability: a token-styled button that opens the
127
+ * native file dialog and uploads the choice as `multipart/form-data` through the typed
128
+ * session client, reporting the stored {@link FileMeta} to `onUploaded`.
129
+ */
130
+ export function FileUpload({ onUploaded, onError, label, accept, disabled }: FileUploadProps) {
131
+ const client = useTerpClient();
132
+ const strings = useStrings();
133
+ const resolve = useUiText();
134
+ const inputRef = useRef<HTMLInputElement>(null);
135
+ const [busy, setBusy] = useState(false);
136
+
137
+ async function onChange(event: ChangeEvent<HTMLInputElement>) {
138
+ const file = event.target.files?.[0];
139
+ // Allow re-selecting the same file to upload it again.
140
+ event.target.value = "";
141
+ if (!file) return;
142
+ setBusy(true);
143
+ try {
144
+ const meta = await uploadFile(client as unknown as TerpClient, file);
145
+ onUploaded?.(meta);
146
+ } catch (error) {
147
+ onError?.(error);
148
+ } finally {
149
+ setBusy(false);
150
+ }
151
+ }
152
+
153
+ return (
154
+ <>
155
+ <input
156
+ ref={inputRef}
157
+ type="file"
158
+ accept={accept}
159
+ style={hiddenInputStyle}
160
+ onChange={(event) => void onChange(event)}
161
+ aria-hidden="true"
162
+ tabIndex={-1}
163
+ />
164
+ <Button
165
+ type="button"
166
+ variant="secondary"
167
+ disabled={disabled || busy}
168
+ onClick={() => inputRef.current?.click()}
169
+ >
170
+ {busy ? strings.uploading : resolve(label ?? strings.uploadFile)}
171
+ </Button>
172
+ </>
173
+ );
174
+ }
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { ICON_GLYPHS, NavIcon, TerpMark } from "./icons";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("NavIcon", () => {
10
+ it("renders the named glyph as decorative svg", () => {
11
+ const { container } = render(<NavIcon name="users" label="Users" />);
12
+ const slot = container.querySelector('[data-terp="nav-icon"]');
13
+ const svg = container.querySelector("svg");
14
+ expect(slot).toHaveStyle({ width: "1.25rem", height: "1.25rem", flex: "0 0 1.25rem" });
15
+ expect(svg).not.toBeNull();
16
+ expect(svg).toHaveAttribute("aria-hidden", "true");
17
+ });
18
+
19
+ it("falls back to the label's initial for an unknown or missing name", () => {
20
+ const { container } = render(<NavIcon name="no-such-glyph" label="widgets" />);
21
+ expect(screen.getByText("W")).toBeInTheDocument();
22
+ expect(container.querySelector('[data-terp="nav-icon"]')).toHaveStyle({
23
+ width: "1.25rem",
24
+ height: "1.25rem",
25
+ flex: "0 0 1.25rem",
26
+ });
27
+ render(<NavIcon label="records" />);
28
+ expect(screen.getByText("R")).toBeInTheDocument();
29
+ });
30
+
31
+ it("ships a stable set of named glyphs", () => {
32
+ for (const name of ["home", "list", "users", "shield", "settings", "audit", "hub"]) {
33
+ expect(ICON_GLYPHS[name], name).toBeDefined();
34
+ }
35
+ });
36
+ });
37
+
38
+ describe("TerpMark", () => {
39
+ it("is a decorative, token-coloured placeholder mark", () => {
40
+ const { container } = render(<TerpMark />);
41
+ const svg = container.querySelector("svg");
42
+ expect(svg).not.toBeNull();
43
+ expect(svg).toHaveAttribute("aria-hidden", "true");
44
+ expect(container.innerHTML).toContain("var(--color-brand-primary)");
45
+ });
46
+ });