@terpjs/react-core 0.8.0 → 0.9.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 +6 -2
- package/package.json +2 -2
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/LoginView.tsx +22 -75
- package/src/ModuleNav.test.tsx +19 -0
- package/src/ModuleNav.tsx +10 -35
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +15 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +8 -33
- package/src/ResourceList.tsx +13 -24
- package/src/admin/AuditLogAdmin.tsx +1 -10
- package/src/admin/GroupCreate.tsx +1 -1
- package/src/admin/GroupDetail.tsx +2 -2
- package/src/admin/UserCreate.tsx +1 -1
- package/src/admin/admin.test.tsx +31 -0
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/index.ts +7 -1
- package/src/markers.test.ts +118 -12
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +191 -1
- package/src/router.tsx +75 -9
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +37 -5
- package/src/styles.ts +356 -6
- package/src/ui/Button.test.tsx +5 -3
package/src/ResourceList.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
-
import type {
|
|
2
|
+
import type { FormEvent, ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { Authorized } from "./Authorized";
|
|
5
5
|
import { useErrorMessage } from "./errorMessages";
|
|
@@ -9,19 +9,6 @@ import { Button } from "./ui/Button";
|
|
|
9
9
|
import { Input } from "./ui/Input";
|
|
10
10
|
import type { Resource } from "./useResource";
|
|
11
11
|
|
|
12
|
-
const rowStyle: CSSProperties = {
|
|
13
|
-
display: "flex",
|
|
14
|
-
alignItems: "center",
|
|
15
|
-
justifyContent: "space-between",
|
|
16
|
-
gap: "var(--space-3)",
|
|
17
|
-
padding: "var(--space-3)",
|
|
18
|
-
border: "1px solid var(--color-neutral-200)",
|
|
19
|
-
borderRadius: "var(--radius-md)",
|
|
20
|
-
background: "var(--color-neutral-0)",
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const mutedStyle: CSSProperties = { color: "var(--color-neutral-600)" };
|
|
24
|
-
|
|
25
12
|
export interface ResourceListProps<T extends { id: string }> {
|
|
26
13
|
/** Section heading; omit when composed under a `Page` (whose title is the `h1`). */
|
|
27
14
|
title?: UiText;
|
|
@@ -52,6 +39,10 @@ export interface ResourceListProps<T extends { id: string }> {
|
|
|
52
39
|
*
|
|
53
40
|
* It is a composable component, not a hidden CRUD DSL: a screen that needs more just renders its own
|
|
54
41
|
* React and ignores this.
|
|
42
|
+
*
|
|
43
|
+
* It renders no inline styles: the section, the create row, both messages, the list and the
|
|
44
|
+
* rows take their geometry and ink from the injected react-core sheet, matched on the
|
|
45
|
+
* `data-terp` markers stamped below (ADR 0094).
|
|
55
46
|
*/
|
|
56
47
|
export function ResourceList<T extends { id: string }>({
|
|
57
48
|
title,
|
|
@@ -81,37 +72,35 @@ export function ResourceList<T extends { id: string }>({
|
|
|
81
72
|
}
|
|
82
73
|
|
|
83
74
|
return (
|
|
84
|
-
<section
|
|
85
|
-
data-terp="resource-list"
|
|
86
|
-
style={{ display: "grid", gap: "var(--space-4)", maxWidth: "40rem" }}
|
|
87
|
-
>
|
|
75
|
+
<section data-terp="resource-list">
|
|
88
76
|
{title !== undefined && <h1>{resolve(title)}</h1>}
|
|
89
77
|
{renderCreate !== undefined ? (
|
|
90
78
|
<Authorized action="write">{renderCreate()}</Authorized>
|
|
91
79
|
) : createPlaceholder !== undefined ? (
|
|
92
80
|
<Authorized action="write">
|
|
93
|
-
<form onSubmit={onCreate}
|
|
81
|
+
<form onSubmit={onCreate} data-terp="resource-list-create">
|
|
94
82
|
<Input
|
|
95
83
|
placeholder={resolve(createPlaceholder ?? "")}
|
|
96
84
|
value={draft}
|
|
97
85
|
onChange={(event) => setDraft(event.target.value)}
|
|
98
|
-
style={{ flex: 1 }}
|
|
99
86
|
/>
|
|
100
87
|
<Button type="submit">{strings.add}</Button>
|
|
101
88
|
</form>
|
|
102
89
|
</Authorized>
|
|
103
90
|
) : null}
|
|
104
91
|
{resource.error !== null && (
|
|
105
|
-
<p role="alert"
|
|
92
|
+
<p role="alert" data-terp="resource-list-error">
|
|
106
93
|
{messageForCode(resource.cause) ?? resource.error}
|
|
107
94
|
</p>
|
|
108
95
|
)}
|
|
109
96
|
{resource.items.length === 0 ? (
|
|
110
|
-
<p
|
|
97
|
+
<p data-terp="resource-list-empty">
|
|
98
|
+
{resource.loading ? strings.loading : resolve(emptyMessage ?? strings.emptyList)}
|
|
99
|
+
</p>
|
|
111
100
|
) : (
|
|
112
|
-
<ul
|
|
101
|
+
<ul data-terp="resource-list-items">
|
|
113
102
|
{resource.items.map((item) => (
|
|
114
|
-
<li key={item.id}
|
|
103
|
+
<li key={item.id} data-terp="resource-list-row">
|
|
115
104
|
<div>{renderItem(item)}</div>
|
|
116
105
|
{renderActions && <Authorized action="write">{renderActions(item)}</Authorized>}
|
|
117
106
|
</li>
|
|
@@ -46,15 +46,6 @@ function buildColumns(strings: TerpStrings): DataViewColumn<AuditEventRead>[] {
|
|
|
46
46
|
];
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
const payloadStyle = {
|
|
50
|
-
margin: 0,
|
|
51
|
-
padding: "var(--space-3)",
|
|
52
|
-
background: "var(--color-neutral-100)",
|
|
53
|
-
borderRadius: "var(--radius-md)",
|
|
54
|
-
fontSize: "var(--font-size-sm, 0.875rem)",
|
|
55
|
-
overflowX: "auto" as const,
|
|
56
|
-
};
|
|
57
|
-
|
|
58
49
|
/**
|
|
59
50
|
* The packaged audit-log screen (`/admin/audit`): the append-only trail every
|
|
60
51
|
* mutation lands in (ADR 0007), newest first as served, read-only by design.
|
|
@@ -106,7 +97,7 @@ export function AuditLogAdmin() {
|
|
|
106
97
|
]}
|
|
107
98
|
/>
|
|
108
99
|
{event.payload !== null && (
|
|
109
|
-
<pre
|
|
100
|
+
<pre data-terp="admin-payload">{JSON.stringify(event.payload, null, 2)}</pre>
|
|
110
101
|
)}
|
|
111
102
|
</div>
|
|
112
103
|
)}
|
|
@@ -75,7 +75,7 @@ export function GroupCreate() {
|
|
|
75
75
|
/>
|
|
76
76
|
}
|
|
77
77
|
>
|
|
78
|
-
<div
|
|
78
|
+
<div data-terp="admin-form">
|
|
79
79
|
<Stack id={FORM_ID} as="form" gap={4} onSubmit={onSubmit}>
|
|
80
80
|
<Field label={strings.groupName}>
|
|
81
81
|
<Input value={name} onChange={(event) => setName(event.target.value)} required />
|
|
@@ -339,7 +339,7 @@ export function GroupDetail() {
|
|
|
339
339
|
/>
|
|
340
340
|
)}
|
|
341
341
|
<Stack gap={3}>
|
|
342
|
-
<h2
|
|
342
|
+
<h2 data-terp="admin-section-title">
|
|
343
343
|
{strings.members}
|
|
344
344
|
</h2>
|
|
345
345
|
<Stack as="form" direction="row" gap={2} align="end" wrap onSubmit={onAddMember}>
|
|
@@ -376,7 +376,7 @@ export function GroupDetail() {
|
|
|
376
376
|
/>
|
|
377
377
|
</Stack>
|
|
378
378
|
<Stack gap={3}>
|
|
379
|
-
<h2
|
|
379
|
+
<h2 data-terp="admin-section-title">
|
|
380
380
|
{strings.permissions}
|
|
381
381
|
</h2>
|
|
382
382
|
<Stack as="form" direction="row" gap={2} align="end" wrap onSubmit={onGrant}>
|
package/src/admin/UserCreate.tsx
CHANGED
package/src/admin/admin.test.tsx
CHANGED
|
@@ -418,6 +418,15 @@ describe("the packaged admin area", () => {
|
|
|
418
418
|
fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
|
|
419
419
|
await screen.findByRole("heading", { level: 1, name: "Provision user" });
|
|
420
420
|
|
|
421
|
+
// The form's measure is a sheet rule now (ADR 0094), and this is the only gate on it:
|
|
422
|
+
// no admin screen has a specimen, so nothing pictures these three surfaces. What a test
|
|
423
|
+
// can hold is that the marked element is there and styles nothing itself; that the rule
|
|
424
|
+
// exists is held by styles.test.ts, and its values are a verbatim copy of the object
|
|
425
|
+
// this replaced.
|
|
426
|
+
const form = document.querySelector('[data-terp="admin-form"]');
|
|
427
|
+
expect(form).not.toBeNull();
|
|
428
|
+
expect(form?.getAttribute("style")).toBeNull();
|
|
429
|
+
|
|
421
430
|
fireEvent.change(screen.getByLabelText("Email"), {
|
|
422
431
|
target: { value: "new.account@example.com" },
|
|
423
432
|
});
|
|
@@ -510,6 +519,28 @@ describe("the packaged admin area", () => {
|
|
|
510
519
|
);
|
|
511
520
|
});
|
|
512
521
|
|
|
522
|
+
it("marks the detail sections it no longer styles inline", async () => {
|
|
523
|
+
// The second of the three surfaces the admin views used to style at the call site. Same
|
|
524
|
+
// reasoning as the create form above: markers plus the absence of a style attribute,
|
|
525
|
+
// because no admin screen has a specimen.
|
|
526
|
+
//
|
|
527
|
+
// The third, the audit payload, has no assertion here and it is worth saying why rather
|
|
528
|
+
// than quietly having none: the audit fixture serves an empty page, so no row exists to
|
|
529
|
+
// expand and the <pre> never renders in any test. Giving it one means adding rows to a
|
|
530
|
+
// fixture several tests share, which is not a change a styling migration should make.
|
|
531
|
+
// It is held by styles.test.ts (the rule exists) and by the values being a verbatim copy.
|
|
532
|
+
renderAdminApp("/admin/groups/g1");
|
|
533
|
+
const headings = await waitFor(() => {
|
|
534
|
+
const found = document.querySelectorAll('[data-terp="admin-section-title"]');
|
|
535
|
+
expect(found.length).toBe(2);
|
|
536
|
+
return found;
|
|
537
|
+
});
|
|
538
|
+
for (const heading of headings) {
|
|
539
|
+
expect(heading.tagName).toBe("H2");
|
|
540
|
+
expect(heading.getAttribute("style")).toBeNull();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
513
544
|
it("clears group destructive state when navigating between detail records in place", async () => {
|
|
514
545
|
const { router } = renderAdminApp("/admin/groups/g1");
|
|
515
546
|
await screen.findByRole("heading", { level: 1, name: "Finance" });
|
|
@@ -0,0 +1,153 @@
|
|
|
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 { downloadUrl, saveBlob, useEndpointDownload } from "./download";
|
|
6
|
+
import { TerpProvider } from "./TerpProvider";
|
|
7
|
+
|
|
8
|
+
// Downloading a *generated* artifact (ADR 0096). The two things a hand-rolled version
|
|
9
|
+
// gets wrong are what these tests pin: a raw `<a href>` carries no bearer token (so it
|
|
10
|
+
// saves an error page under the intended filename), and a raw fetch leaks the object URL.
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
cleanup();
|
|
14
|
+
vi.unstubAllGlobals();
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
restoreObjectUrl();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// jsdom ships no object-URL implementation, so it is installed per test rather than
|
|
20
|
+
// stubbed over the global `URL` — replacing that breaks the client's own URL building,
|
|
21
|
+
// which is exactly the machinery these tests are here to exercise.
|
|
22
|
+
type ObjectUrlHost = { createObjectURL?: (blob: Blob) => string; revokeObjectURL?: (url: string) => void };
|
|
23
|
+
const objectUrlHost = URL as unknown as ObjectUrlHost;
|
|
24
|
+
const originalCreate = objectUrlHost.createObjectURL;
|
|
25
|
+
const originalRevoke = objectUrlHost.revokeObjectURL;
|
|
26
|
+
|
|
27
|
+
function installObjectUrl(): { revoked: string[] } {
|
|
28
|
+
const revoked: string[] = [];
|
|
29
|
+
objectUrlHost.createObjectURL = () => "blob:x";
|
|
30
|
+
objectUrlHost.revokeObjectURL = (url: string) => void revoked.push(url);
|
|
31
|
+
return { revoked };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function restoreObjectUrl(): void {
|
|
35
|
+
objectUrlHost.createObjectURL = originalCreate;
|
|
36
|
+
objectUrlHost.revokeObjectURL = originalRevoke;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("downloadUrl", () => {
|
|
40
|
+
it("fills path placeholders and appends only the set query keys", () => {
|
|
41
|
+
expect(
|
|
42
|
+
downloadUrl({
|
|
43
|
+
path: "/api/v1/revisions/{revisionId}/evidence",
|
|
44
|
+
filename: "x.json",
|
|
45
|
+
params: { revisionId: "r-1" },
|
|
46
|
+
query: { format: "json", locale: undefined },
|
|
47
|
+
}),
|
|
48
|
+
).toBe("/api/v1/revisions/r-1/evidence?format=json");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("encodes a param rather than splicing it into the path raw", () => {
|
|
52
|
+
expect(downloadUrl({ path: "/api/v1/x/{id}", filename: "f", params: { id: "a/b" } })).toBe(
|
|
53
|
+
"/api/v1/x/a%2Fb",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("refuses an unfilled placeholder instead of requesting a literal {id}", () => {
|
|
58
|
+
// Requesting `/revisions/{revisionId}/evidence` would 404 — or, on a permissive
|
|
59
|
+
// route, hand back somebody else's bytes.
|
|
60
|
+
expect(() =>
|
|
61
|
+
downloadUrl({ path: "/api/v1/revisions/{revisionId}/evidence", filename: "x" }),
|
|
62
|
+
).toThrow(/still contains the placeholder "\{revisionId\}"/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("saveBlob", () => {
|
|
67
|
+
it("offers the bytes under the given filename and revokes the object URL", () => {
|
|
68
|
+
const { revoked } = installObjectUrl();
|
|
69
|
+
const clicked: string[] = [];
|
|
70
|
+
const click = vi
|
|
71
|
+
.spyOn(HTMLAnchorElement.prototype, "click")
|
|
72
|
+
.mockImplementation(function (this: HTMLAnchorElement) {
|
|
73
|
+
clicked.push(this.download);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
saveBlob(new Blob(["body"]), "evidence.json");
|
|
77
|
+
|
|
78
|
+
expect(clicked).toEqual(["evidence.json"]);
|
|
79
|
+
// Revoked in a `finally`, which is the leak every hand-rolled copy forgets.
|
|
80
|
+
expect(revoked).toEqual(["blob:x"]);
|
|
81
|
+
// ...and nothing is left in the document.
|
|
82
|
+
expect(document.querySelector("a")).toBeNull();
|
|
83
|
+
click.mockRestore();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("useEndpointDownload", () => {
|
|
88
|
+
function DownloadButton() {
|
|
89
|
+
const download = useEndpointDownload();
|
|
90
|
+
return (
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() =>
|
|
94
|
+
void download({
|
|
95
|
+
path: "/api/v1/revisions/{revisionId}/evidence",
|
|
96
|
+
params: { revisionId: "r-1" },
|
|
97
|
+
filename: "evidence.json",
|
|
98
|
+
}).catch((error: unknown) => {
|
|
99
|
+
document.title = (error as Error).message;
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
>
|
|
103
|
+
download
|
|
104
|
+
</button>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
it("fetches through the session client, so the request carries the session", async () => {
|
|
109
|
+
const fetchMock = vi.fn<typeof fetch>(
|
|
110
|
+
async () => new Response("bytes", { status: 200, headers: { "content-type": "application/octet-stream" } }),
|
|
111
|
+
);
|
|
112
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
113
|
+
installObjectUrl();
|
|
114
|
+
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
|
115
|
+
|
|
116
|
+
render(
|
|
117
|
+
<TerpProvider baseUrl="https://api.test">
|
|
118
|
+
<DownloadButton />
|
|
119
|
+
</TerpProvider>,
|
|
120
|
+
);
|
|
121
|
+
fireEvent.click(screen.getByRole("button", { name: "download" }));
|
|
122
|
+
|
|
123
|
+
// The provider's own boot session-probe is also on this mock, so match the download
|
|
124
|
+
// rather than assuming it is the first request.
|
|
125
|
+
await waitFor(() =>
|
|
126
|
+
expect(
|
|
127
|
+
fetchMock.mock.calls.map((call) => (call[0] as Request).url),
|
|
128
|
+
// Resolved against the client's base URL — the thing a raw <a href> could not do.
|
|
129
|
+
).toContain("https://api.test/api/v1/revisions/r-1/evidence"),
|
|
130
|
+
);
|
|
131
|
+
click.mockRestore();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects on a non-2xx instead of saving the error body under the filename", async () => {
|
|
135
|
+
vi.stubGlobal(
|
|
136
|
+
"fetch",
|
|
137
|
+
vi.fn<typeof fetch>(async () => new Response("nope", { status: 403 })),
|
|
138
|
+
);
|
|
139
|
+
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
|
140
|
+
|
|
141
|
+
render(
|
|
142
|
+
<TerpProvider baseUrl="https://api.test">
|
|
143
|
+
<DownloadButton />
|
|
144
|
+
</TerpProvider>,
|
|
145
|
+
);
|
|
146
|
+
fireEvent.click(screen.getByRole("button", { name: "download" }));
|
|
147
|
+
|
|
148
|
+
await waitFor(() => expect(document.title).toMatch(/failed with HTTP 403/));
|
|
149
|
+
// Nothing was handed to the browser: the failure surfaces instead of downloading.
|
|
150
|
+
expect(click).not.toHaveBeenCalled();
|
|
151
|
+
click.mockRestore();
|
|
152
|
+
});
|
|
153
|
+
});
|
package/src/download.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handing bytes to the browser as a named download (ADR 0096).
|
|
3
|
+
*
|
|
4
|
+
* `useFileDownload` covers the stored-file case: a `FileMeta` id, fetched from the files
|
|
5
|
+
* capability's content endpoint. What it does not cover is the other half of the same
|
|
6
|
+
* need — **an artifact the backend generates on demand** ("download this revision as
|
|
7
|
+
* proof", a CSV export, a signed evidence bundle). Those have no stored file id, and the
|
|
8
|
+
* only ways to reach them were a raw `fetch` (refused: one typed egress path) or a raw
|
|
9
|
+
* `<a href>` (which carries no bearer token, so it 401s or, worse, silently downloads an
|
|
10
|
+
* error page). The observed outcome was the feature being dropped rather than built.
|
|
11
|
+
*
|
|
12
|
+
* So the blob-to-anchor dance lives here once, and `useEndpointDownload` reaches any
|
|
13
|
+
* authorized GET through the session client. `path` is a plain string, deliberately and
|
|
14
|
+
* unusually: the generated client is keyed by the app's own schema, which this package
|
|
15
|
+
* cannot see, and a byte-stream route is app-specific by nature. Everything else that
|
|
16
|
+
* matters — the base URL, the bearer token, cookie credentials, the refusal on a non-2xx —
|
|
17
|
+
* still comes from the client, which is what the raw alternatives threw away.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useCallback } from "react";
|
|
21
|
+
|
|
22
|
+
import { useTerpClient } from "./TerpProvider";
|
|
23
|
+
import type { TerpClient } from "@terpjs/contract";
|
|
24
|
+
|
|
25
|
+
/** What to download: where the bytes come from, and what the file should be called. */
|
|
26
|
+
export interface DownloadTarget {
|
|
27
|
+
/** API path, e.g. `/api/v1/revisions/{id}/evidence` with `params` filling `{id}`. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Filename offered to the browser (extension included). */
|
|
30
|
+
filename: string;
|
|
31
|
+
/** Path placeholders to substitute, e.g. `{ id: revision.id }`. */
|
|
32
|
+
params?: Record<string, string>;
|
|
33
|
+
/** Query string to append; `undefined` values are omitted. */
|
|
34
|
+
query?: Record<string, string | undefined>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Save *blob* as a named download.
|
|
39
|
+
*
|
|
40
|
+
* Exported because it is the one piece a screen cannot avoid re-implementing when it
|
|
41
|
+
* already holds the bytes (a client-side CSV, a canvas export) — and every hand-rolled
|
|
42
|
+
* copy leaks the object URL, which is why the revoke is in a `finally` here.
|
|
43
|
+
*/
|
|
44
|
+
export function saveBlob(blob: Blob, filename: string): void {
|
|
45
|
+
const url = URL.createObjectURL(blob);
|
|
46
|
+
try {
|
|
47
|
+
const anchor = document.createElement("a");
|
|
48
|
+
anchor.href = url;
|
|
49
|
+
anchor.download = filename;
|
|
50
|
+
document.body.appendChild(anchor);
|
|
51
|
+
anchor.click();
|
|
52
|
+
anchor.remove();
|
|
53
|
+
} finally {
|
|
54
|
+
URL.revokeObjectURL(url);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fill `{placeholder}` segments and append the query string, dropping unset values. */
|
|
59
|
+
export function downloadUrl(target: DownloadTarget): string {
|
|
60
|
+
let path = target.path;
|
|
61
|
+
for (const [name, value] of Object.entries(target.params ?? {})) {
|
|
62
|
+
path = path.replaceAll(`{${name}}`, encodeURIComponent(value));
|
|
63
|
+
}
|
|
64
|
+
const unfilled = /\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(path);
|
|
65
|
+
if (unfilled !== null) {
|
|
66
|
+
// Fail closed rather than requesting a literal `{id}`: the server would 404 or, on a
|
|
67
|
+
// permissive route, hand back somebody else's bytes.
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Download path "${target.path}" still contains the placeholder "${unfilled[0]}" — ` +
|
|
70
|
+
`pass it in \`params\` (e.g. params: { ${unfilled[1]}: row.id }).`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const query = new URLSearchParams();
|
|
74
|
+
for (const [name, value] of Object.entries(target.query ?? {})) {
|
|
75
|
+
if (value !== undefined) {
|
|
76
|
+
query.append(name, value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const suffix = query.toString();
|
|
80
|
+
return suffix.length > 0 ? `${path}?${suffix}` : path;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Download a generated artifact from an authorized endpoint (ADR 0096).
|
|
85
|
+
*
|
|
86
|
+
* ```tsx
|
|
87
|
+
* const download = useEndpointDownload();
|
|
88
|
+
* void download({
|
|
89
|
+
* path: "/api/v1/revisions/{revisionId}/evidence",
|
|
90
|
+
* params: { revisionId: revision.id },
|
|
91
|
+
* filename: `revision-${revision.number}.json`,
|
|
92
|
+
* });
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* Rejects on a non-2xx response, so a caller can surface the failure — a raw anchor would
|
|
96
|
+
* have saved the error body under the intended filename instead.
|
|
97
|
+
*/
|
|
98
|
+
export function useEndpointDownload(): (target: DownloadTarget) => Promise<void> {
|
|
99
|
+
const client = useTerpClient();
|
|
100
|
+
return useCallback(
|
|
101
|
+
async (target: DownloadTarget) => {
|
|
102
|
+
const blob = await fetchDownload(client as unknown as TerpClient, target);
|
|
103
|
+
saveBlob(blob, target.filename);
|
|
104
|
+
},
|
|
105
|
+
[client],
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Fetch a download target's bytes through the session client.
|
|
111
|
+
*
|
|
112
|
+
* Separate from the hook so a caller that wants the blob for something *other* than
|
|
113
|
+
* saving it (a preview, a checksum) does not have to save it first.
|
|
114
|
+
*/
|
|
115
|
+
export async function fetchDownload(client: TerpClient, target: DownloadTarget): Promise<Blob> {
|
|
116
|
+
const url = downloadUrl(target);
|
|
117
|
+
const { data, error, response } = await (
|
|
118
|
+
client as unknown as {
|
|
119
|
+
GET: (
|
|
120
|
+
path: string,
|
|
121
|
+
init: { parseAs: "blob" },
|
|
122
|
+
) => Promise<{ data?: unknown; error?: unknown; response: Response }>;
|
|
123
|
+
}
|
|
124
|
+
).GET(url, { parseAs: "blob" });
|
|
125
|
+
if (error !== undefined || !response.ok) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Download of ${url} failed with HTTP ${response.status}. The endpoint must be a GET ` +
|
|
128
|
+
"the current session is authorized for.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return data as Blob;
|
|
132
|
+
}
|
package/src/files.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useRef, useState } from "react";
|
|
2
2
|
import type { ChangeEvent } from "react";
|
|
3
3
|
|
|
4
|
+
import { saveBlob } from "./download";
|
|
4
5
|
import { useTerpClient } from "./TerpProvider";
|
|
5
6
|
import { Button } from "./ui/Button";
|
|
6
7
|
import { useStrings, useUiText, type UiText } from "./uiText";
|
|
@@ -91,17 +92,7 @@ export function useFileDownload(): (file: Pick<FileMeta, "id" | "filename">) =>
|
|
|
91
92
|
return useCallback(
|
|
92
93
|
async (file: Pick<FileMeta, "id" | "filename">) => {
|
|
93
94
|
const blob = await fetchFileContent(client as unknown as TerpClient, file.id);
|
|
94
|
-
|
|
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
|
-
}
|
|
95
|
+
saveBlob(blob, file.filename);
|
|
105
96
|
},
|
|
106
97
|
[client],
|
|
107
98
|
);
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { canPerform, DEFAULT_RANK_THRESHOLDS } from "./capabilities";
|
|
|
8
8
|
export type { RankThresholds } from "./capabilities";
|
|
9
9
|
export { createAuthClient } from "./createAuthClient";
|
|
10
10
|
export type { AuthClientOptions, TokenGetter } from "./createAuthClient";
|
|
11
|
-
export { Authorized, useCan } from "./Authorized";
|
|
11
|
+
export { Authorized, useCan, useHasPermission, usePermissions } from "./Authorized";
|
|
12
12
|
export type { AuthorizedProps } from "./Authorized";
|
|
13
13
|
export { useResource } from "./useResource";
|
|
14
14
|
export type { Resource, ResourceSource } from "./useResource";
|
|
@@ -109,6 +109,8 @@ export { Alert } from "./ui/Alert";
|
|
|
109
109
|
export type { AlertProps, AlertTone } from "./ui/Alert";
|
|
110
110
|
export { Markdown } from "./ui/Markdown";
|
|
111
111
|
export type { MarkdownProps } from "./ui/Markdown";
|
|
112
|
+
export { saveBlob, useEndpointDownload, fetchDownload, downloadUrl } from "./download";
|
|
113
|
+
export type { DownloadTarget } from "./download";
|
|
112
114
|
export { Field } from "./Field";
|
|
113
115
|
export type { FieldProps } from "./Field";
|
|
114
116
|
export { Stack, DetailList } from "./layout";
|
|
@@ -119,6 +121,7 @@ export {
|
|
|
119
121
|
PROFILE_PATH,
|
|
120
122
|
useRouteParam,
|
|
121
123
|
useRouteParams,
|
|
124
|
+
useRouteSearch,
|
|
122
125
|
useTerpNavigate,
|
|
123
126
|
} from "./router";
|
|
124
127
|
export type { BuildAppRouterOptions } from "./router";
|
|
@@ -129,6 +132,9 @@ export type {
|
|
|
129
132
|
TerpRouteParamName,
|
|
130
133
|
TerpRouteParams,
|
|
131
134
|
TerpRoutePath,
|
|
135
|
+
TerpRouteSearch,
|
|
136
|
+
TerpRouteSearchKey,
|
|
137
|
+
TerpRouteSearchTable,
|
|
132
138
|
TerpRouteTable,
|
|
133
139
|
} from "./routeTypes";
|
|
134
140
|
export { LoginView } from "./LoginView";
|