@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,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
|
+
});
|