@kingsleyweb/bun-ui 0.0.0-stage → 3.0.0-rc.2
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/LICENSE +21 -0
- package/README.md +80 -2
- package/app/docs/App.tsx +72 -0
- package/app/docs/api/client.ts +303 -0
- package/app/docs/api/contract.ts +24 -0
- package/app/docs/api/docs.ts +38 -0
- package/app/docs/api/errors.ts +144 -0
- package/app/docs/boot.tsx +58 -0
- package/app/docs/components/Badge.tsx +65 -0
- package/app/docs/components/Button.tsx +37 -0
- package/app/docs/components/Card.tsx +41 -0
- package/app/docs/components/ConfirmDialog.tsx +173 -0
- package/app/docs/components/CopyButton.tsx +68 -0
- package/app/docs/components/Dialog.tsx +230 -0
- package/app/docs/components/EmptyState.tsx +34 -0
- package/app/docs/components/ErrorView.tsx +70 -0
- package/app/docs/components/Field.tsx +93 -0
- package/app/docs/components/JsonEditor.tsx +151 -0
- package/app/docs/components/JsonView.tsx +338 -0
- package/app/docs/components/KeyValue.tsx +63 -0
- package/app/docs/components/ProblemBanner.tsx +116 -0
- package/app/docs/components/ScreenErrorBoundary.tsx +141 -0
- package/app/docs/components/Spinner.tsx +33 -0
- package/app/docs/components/Table.tsx +33 -0
- package/app/docs/components/Tabs.tsx +226 -0
- package/app/docs/components/classNames.ts +4 -0
- package/app/docs/components/components.css +691 -0
- package/app/docs/components/copy.ts +89 -0
- package/app/docs/components/fieldContext.ts +29 -0
- package/app/docs/components/inputValues.ts +142 -0
- package/app/docs/components/inputs.tsx +387 -0
- package/app/docs/components/jsonParse.ts +338 -0
- package/app/docs/components/listKeys.ts +30 -0
- package/app/docs/config.ts +86 -0
- package/app/docs/context.ts +33 -0
- package/app/docs/embed.ts +74 -0
- package/app/docs/env.d.ts +2 -0
- package/app/docs/format.ts +9 -0
- package/app/docs/hooks/useUrlTab.ts +16 -0
- package/app/docs/main.tsx +11 -0
- package/app/docs/queryClient.ts +54 -0
- package/app/docs/router.tsx +194 -0
- package/app/docs/routing.ts +194 -0
- package/app/docs/screens/docs/document.ts +27 -0
- package/app/docs/screens/docs/http/OperationView.tsx +407 -0
- package/app/docs/screens/docs/http/Sidebar.tsx +190 -0
- package/app/docs/screens/docs/http/TryItPanel.tsx +603 -0
- package/app/docs/screens/docs/http/http.css +405 -0
- package/app/docs/screens/docs/http/index.tsx +395 -0
- package/app/docs/screens/docs/http/model.ts +604 -0
- package/app/docs/screens/docs/http/tryIt.ts +455 -0
- package/app/docs/screens/docs/permissions.ts +6 -0
- package/app/docs/screens/docs/schema/Prose.tsx +43 -0
- package/app/docs/screens/docs/schema/SchemaTree.tsx +319 -0
- package/app/docs/screens/docs/schema/index.ts +30 -0
- package/app/docs/screens/docs/schema/resolve.ts +466 -0
- package/app/docs/screens/docs/schema/schema.css +137 -0
- package/app/docs/screens/docs/schema/skeleton.ts +92 -0
- package/app/docs/screens/docs/viewer.tsx +154 -0
- package/app/docs/screens/docs/ws/InlineText.tsx +25 -0
- package/app/docs/screens/docs/ws/PayloadSchema.tsx +39 -0
- package/app/docs/screens/docs/ws/format.ts +82 -0
- package/app/docs/screens/docs/ws/index.tsx +286 -0
- package/app/docs/screens/docs/ws/items.tsx +786 -0
- package/app/docs/screens/docs/ws/model.ts +1178 -0
- package/app/docs/screens/docs/ws/panels.tsx +440 -0
- package/app/docs/screens/docs/ws/ws.css +231 -0
- package/app/docs/shortcuts.ts +94 -0
- package/app/docs/styles/base.css +847 -0
- package/app/docs/styles/tokens.css +143 -0
- package/app/docs/theme.ts +56 -0
- package/app/kit/unauthorized.ts +222 -0
- package/dist/docs/assets/main-0rchxtv5.js +18 -0
- package/dist/docs/assets/main-0rchxtv5.js.map +99 -0
- package/dist/docs/assets/main-cqgyvs86.css +1 -0
- package/dist/docs/manifest.json +26 -0
- package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js +6 -0
- package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js.map +11 -0
- package/dist/jobs/assets/AddFlowDialog-n7gtc84v.css +1 -0
- package/dist/jobs/assets/added-cbwsxv3e.js +4 -0
- package/dist/jobs/assets/added-cbwsxv3e.js.map +11 -0
- package/dist/jobs/assets/bodies-yyw5hmrp.js +6 -0
- package/dist/jobs/assets/bodies-yyw5hmrp.js.map +10 -0
- package/dist/jobs/assets/channel-tgkvqm8y.js +4 -0
- package/dist/jobs/assets/channel-tgkvqm8y.js.map +10 -0
- package/dist/jobs/assets/channelChecks-s4yz4yhw.js +4 -0
- package/dist/jobs/assets/channelChecks-s4yz4yhw.js.map +10 -0
- package/dist/jobs/assets/index-02427smp.css +1 -0
- package/dist/jobs/assets/index-09z2cjft.js +4 -0
- package/dist/jobs/assets/index-09z2cjft.js.map +16 -0
- package/dist/jobs/assets/index-18bn0gvy.css +1 -0
- package/dist/jobs/assets/index-47pxmyxn.js +4 -0
- package/dist/jobs/assets/index-47pxmyxn.js.map +10 -0
- package/dist/jobs/assets/index-4e55z986.js +4 -0
- package/dist/jobs/assets/index-4e55z986.js.map +11 -0
- package/dist/jobs/assets/index-5c933424.js +4 -0
- package/dist/jobs/assets/index-5c933424.js.map +18 -0
- package/dist/jobs/assets/index-5kbyqspm.css +1 -0
- package/dist/jobs/assets/index-5ng37hk6.css +1 -0
- package/dist/jobs/assets/index-5w8w7fvc.css +1 -0
- package/dist/jobs/assets/index-71qs5qyx.css +1 -0
- package/dist/jobs/assets/index-btpg4r8b.css +1 -0
- package/dist/jobs/assets/index-d5r5n0mm.js +4 -0
- package/dist/jobs/assets/index-d5r5n0mm.js.map +23 -0
- package/dist/jobs/assets/index-e0yy2skq.css +1 -0
- package/dist/jobs/assets/index-hs60b25v.js +10 -0
- package/dist/jobs/assets/index-hs60b25v.js.map +62 -0
- package/dist/jobs/assets/index-k2smrr70.js +4 -0
- package/dist/jobs/assets/index-k2smrr70.js.map +9 -0
- package/dist/jobs/assets/index-q24jw3w9.js +4 -0
- package/dist/jobs/assets/index-q24jw3w9.js.map +32 -0
- package/dist/jobs/assets/index-qfb80xen.css +1 -0
- package/dist/jobs/assets/index-rtynx6wj.js +4 -0
- package/dist/jobs/assets/index-rtynx6wj.js.map +10 -0
- package/dist/jobs/assets/live-6k7s6358.js +4 -0
- package/dist/jobs/assets/live-6k7s6358.js.map +10 -0
- package/dist/jobs/assets/main-0jmeh6mq.js +6 -0
- package/dist/jobs/assets/main-0jmeh6mq.js.map +12 -0
- package/dist/jobs/assets/main-3yj6bv90.js +5 -0
- package/dist/jobs/assets/main-3yj6bv90.js.map +10 -0
- package/dist/jobs/assets/main-4g69fn3x.js +5 -0
- package/dist/jobs/assets/main-4g69fn3x.js.map +10 -0
- package/dist/jobs/assets/main-4gte3eqq.js +5 -0
- package/dist/jobs/assets/main-4gte3eqq.js.map +10 -0
- package/dist/jobs/assets/main-4j1fcevh.js +5 -0
- package/dist/jobs/assets/main-4j1fcevh.js.map +10 -0
- package/dist/jobs/assets/main-54qwkz28.css +1 -0
- package/dist/jobs/assets/main-5grjvsy7.js +5 -0
- package/dist/jobs/assets/main-5grjvsy7.js.map +11 -0
- package/dist/jobs/assets/main-5zbx60gq.js +5 -0
- package/dist/jobs/assets/main-5zbx60gq.js.map +11 -0
- package/dist/jobs/assets/main-9prgjhg4.js +5 -0
- package/dist/jobs/assets/main-9prgjhg4.js.map +10 -0
- package/dist/jobs/assets/main-a250ffk7.js +17 -0
- package/dist/jobs/assets/main-a250ffk7.js.map +115 -0
- package/dist/jobs/assets/main-d6ge7w08.js +5 -0
- package/dist/jobs/assets/main-d6ge7w08.js.map +10 -0
- package/dist/jobs/assets/main-gngzht43.js +5 -0
- package/dist/jobs/assets/main-gngzht43.js.map +10 -0
- package/dist/jobs/assets/main-hg0hrg8j.js +5 -0
- package/dist/jobs/assets/main-hg0hrg8j.js.map +10 -0
- package/dist/jobs/assets/main-hm1ng3yg.js +5 -0
- package/dist/jobs/assets/main-hm1ng3yg.js.map +12 -0
- package/dist/jobs/assets/main-jkvd92z9.js +5 -0
- package/dist/jobs/assets/main-jkvd92z9.js.map +10 -0
- package/dist/jobs/assets/main-jv5xqd13.js +5 -0
- package/dist/jobs/assets/main-jv5xqd13.js.map +21 -0
- package/dist/jobs/assets/main-jxre7w93.js +5 -0
- package/dist/jobs/assets/main-jxre7w93.js.map +23 -0
- package/dist/jobs/assets/main-kwmqkjfx.js +5 -0
- package/dist/jobs/assets/main-kwmqkjfx.js.map +11 -0
- package/dist/jobs/assets/main-n9cf6bxm.js +5 -0
- package/dist/jobs/assets/main-n9cf6bxm.js.map +11 -0
- package/dist/jobs/assets/main-pegp0ygb.js +5 -0
- package/dist/jobs/assets/main-pegp0ygb.js.map +10 -0
- package/dist/jobs/assets/main-qr573853.js +5 -0
- package/dist/jobs/assets/main-qr573853.js.map +12 -0
- package/dist/jobs/assets/main-rq97rdr4.js +4 -0
- package/dist/jobs/assets/main-rq97rdr4.js.map +22 -0
- package/dist/jobs/assets/main-szjdmydc.js +5 -0
- package/dist/jobs/assets/main-szjdmydc.js.map +13 -0
- package/dist/jobs/assets/main-tnbhrm9n.js +7 -0
- package/dist/jobs/assets/main-tnbhrm9n.js.map +11 -0
- package/dist/jobs/assets/main-ts19j4n0.js +5 -0
- package/dist/jobs/assets/main-ts19j4n0.js.map +25 -0
- package/dist/jobs/assets/main-w93x1dzs.js +5 -0
- package/dist/jobs/assets/main-w93x1dzs.js.map +10 -0
- package/dist/jobs/assets/sections-0ksdxs8m.css +1 -0
- package/dist/jobs/assets/sections-0y7w4fb4.js +4 -0
- package/dist/jobs/assets/sections-0y7w4fb4.js.map +10 -0
- package/dist/jobs/assets/sse-mn0t0fh7.js +5 -0
- package/dist/jobs/assets/sse-mn0t0fh7.js.map +11 -0
- package/dist/jobs/assets/sseChecks-t7tcm7ra.js +4 -0
- package/dist/jobs/assets/sseChecks-t7tcm7ra.js.map +10 -0
- package/dist/jobs/assets/validation-ysjfy072.js +4 -0
- package/dist/jobs/assets/validation-ysjfy072.js.map +9 -0
- package/dist/jobs/manifest.json +511 -0
- package/dts/docs/assets.d.ts +144 -0
- package/dts/docs/assets.d.ts.map +1 -0
- package/dts/docs/browser/config.d.ts +61 -0
- package/dts/docs/browser/config.d.ts.map +1 -0
- package/dts/docs/browser/index.d.ts +12 -0
- package/dts/docs/browser/index.d.ts.map +1 -0
- package/dts/docs/docsUi.d.ts +42 -0
- package/dts/docs/docsUi.d.ts.map +1 -0
- package/dts/docs/hosted.d.ts +22 -0
- package/dts/docs/hosted.d.ts.map +1 -0
- package/dts/docs/index.d.ts +18 -0
- package/dts/docs/index.d.ts.map +1 -0
- package/dts/docs/shell.d.ts +57 -0
- package/dts/docs/shell.d.ts.map +1 -0
- package/dts/docs/types.d.ts +137 -0
- package/dts/docs/types.d.ts.map +1 -0
- package/dts/index.d.ts +21 -0
- package/dts/index.d.ts.map +1 -0
- package/dts/jobs/assets.d.ts +157 -0
- package/dts/jobs/assets.d.ts.map +1 -0
- package/dts/jobs/browser/config.d.ts +67 -0
- package/dts/jobs/browser/config.d.ts.map +1 -0
- package/dts/jobs/browser/index.d.ts +10 -0
- package/dts/jobs/browser/index.d.ts.map +1 -0
- package/dts/jobs/browser/unauthorized.d.ts +9 -0
- package/dts/jobs/browser/unauthorized.d.ts.map +1 -0
- package/dts/jobs/index.d.ts +24 -0
- package/dts/jobs/index.d.ts.map +1 -0
- package/dts/jobs/jobsUi.d.ts +63 -0
- package/dts/jobs/jobsUi.d.ts.map +1 -0
- package/dts/jobs/shell.d.ts +77 -0
- package/dts/jobs/shell.d.ts.map +1 -0
- package/dts/jobs/types.d.ts +146 -0
- package/dts/jobs/types.d.ts.map +1 -0
- package/dts/kit/browser/shared.d.ts +10 -0
- package/dts/kit/browser/shared.d.ts.map +1 -0
- package/dts/kit/browser/unauthorized.d.ts +74 -0
- package/dts/kit/browser/unauthorized.d.ts.map +1 -0
- package/dts/kit/unauthorized.d.ts +47 -0
- package/dts/kit/unauthorized.d.ts.map +1 -0
- package/guides/docs.md +170 -0
- package/guides/jobs.md +1120 -0
- package/lib/docs/assets.ts +427 -0
- package/lib/docs/browser/config.ts +66 -0
- package/lib/docs/browser/index.ts +24 -0
- package/lib/docs/docsUi.ts +623 -0
- package/lib/docs/hosted.ts +31 -0
- package/lib/docs/index.ts +37 -0
- package/lib/docs/shell.ts +156 -0
- package/lib/docs/types.ts +149 -0
- package/lib/index.ts +27 -0
- package/lib/jobs/assets.ts +448 -0
- package/lib/jobs/browser/config.ts +65 -0
- package/lib/jobs/browser/index.ts +9 -0
- package/lib/jobs/browser/unauthorized.ts +11 -0
- package/lib/jobs/index.ts +41 -0
- package/lib/jobs/jobsUi.ts +709 -0
- package/lib/jobs/shell.ts +205 -0
- package/lib/jobs/types.ts +162 -0
- package/lib/kit/browser/shared.ts +11 -0
- package/lib/kit/browser/unauthorized.ts +114 -0
- package/lib/kit/unauthorized.ts +222 -0
- package/package.json +198 -4
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cx } from "./classNames";
|
|
3
|
+
|
|
4
|
+
/** One row of a {@link KeyValue}. */
|
|
5
|
+
export interface KeyValueItem {
|
|
6
|
+
/** The term, e.g. "Attempts". */
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
/** The value: text or any node. `null`, `undefined` and `""` render the `empty` placeholder. */
|
|
9
|
+
value: ReactNode;
|
|
10
|
+
/** A stable React key; defaults to the label when it is a string, else the index. */
|
|
11
|
+
key?: string;
|
|
12
|
+
/** A short note under the value, e.g. "of 5 allowed". */
|
|
13
|
+
hint?: ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Props of {@link KeyValue}. */
|
|
17
|
+
export interface KeyValueProps {
|
|
18
|
+
/** The rows, in order. Falsy entries are skipped, so rows can be conditional inline. */
|
|
19
|
+
items: readonly (KeyValueItem | false | null | undefined)[];
|
|
20
|
+
/** `"grid"` puts label and value side by side; `"stacked"` puts the label above. Defaults to `"grid"`. */
|
|
21
|
+
layout?: "grid" | "stacked";
|
|
22
|
+
/** What a missing value shows. Defaults to `"—"`. */
|
|
23
|
+
empty?: ReactNode;
|
|
24
|
+
/** Extra class names on the `<dl>`. */
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A summary as a definition list: label/value rows (a job's timings, a queue's settings). */
|
|
29
|
+
export function KeyValue({
|
|
30
|
+
items,
|
|
31
|
+
layout = "grid",
|
|
32
|
+
empty = "—",
|
|
33
|
+
className,
|
|
34
|
+
}: KeyValueProps) {
|
|
35
|
+
return (
|
|
36
|
+
<dl className={cx("kv", `kv-${layout}`, className)}>
|
|
37
|
+
{items.map((item, index) => {
|
|
38
|
+
if (!item) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const missing =
|
|
42
|
+
item.value === null || item.value === undefined || item.value === "";
|
|
43
|
+
// The index is only the fallback for a row with neither a key nor a
|
|
44
|
+
// string label.
|
|
45
|
+
const rowKey =
|
|
46
|
+
item.key ??
|
|
47
|
+
(typeof item.label === "string" ? item.label : `row-${index}`);
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
key={rowKey}
|
|
51
|
+
className="kv-row"
|
|
52
|
+
>
|
|
53
|
+
<dt className="kv-label">{item.label}</dt>
|
|
54
|
+
<dd className={cx("kv-value", missing && "muted")}>
|
|
55
|
+
{missing ? empty : item.value}
|
|
56
|
+
{item.hint && <span className="kv-hint">{item.hint}</span>}
|
|
57
|
+
</dd>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</dl>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { isApiError } from "../api/errors";
|
|
2
|
+
import { Button } from "./Button";
|
|
3
|
+
import { cx } from "./classNames";
|
|
4
|
+
|
|
5
|
+
/** Props of {@link ProblemBanner}. */
|
|
6
|
+
export interface ProblemBannerProps {
|
|
7
|
+
/** What failed: an `ApiError` shows its title, code, detail, context and issues; anything else its message. */
|
|
8
|
+
error: unknown;
|
|
9
|
+
/** A heading override, e.g. "Could not save limits". Defaults to the problem's title. */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** Offers a Retry button when given. */
|
|
12
|
+
onRetry?: () => void;
|
|
13
|
+
/** Offers a Dismiss button when given. */
|
|
14
|
+
onDismiss?: () => void;
|
|
15
|
+
/** Extra class names. */
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Formats one context value for the banner: strings as-is, the rest as JSON. */
|
|
20
|
+
function contextText(value: unknown): string {
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.stringify(value) ?? String(value);
|
|
26
|
+
} catch {
|
|
27
|
+
return String(value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A compact, inline error for a panel or a dialog: one line of title and
|
|
33
|
+
* code, the detail, the problem's `context` as `key=value` chips, and any
|
|
34
|
+
* validation issues. {@link ErrorView} is the full-size version for a screen.
|
|
35
|
+
*/
|
|
36
|
+
export function ProblemBanner({
|
|
37
|
+
error,
|
|
38
|
+
title,
|
|
39
|
+
onRetry,
|
|
40
|
+
onDismiss,
|
|
41
|
+
className,
|
|
42
|
+
}: ProblemBannerProps) {
|
|
43
|
+
const api = isApiError(error) ? error : null;
|
|
44
|
+
const heading = title ?? api?.title ?? "Something went wrong";
|
|
45
|
+
const detail =
|
|
46
|
+
api?.detail ?? (error instanceof Error ? error.message : String(error));
|
|
47
|
+
const context = api ? Object.entries(api.context) : [];
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={cx("problem-banner", className)}
|
|
51
|
+
role="alert"
|
|
52
|
+
>
|
|
53
|
+
<div className="problem-banner-main">
|
|
54
|
+
<p className="problem-banner-title">
|
|
55
|
+
<strong>{heading}</strong>
|
|
56
|
+
{api && (
|
|
57
|
+
<code className="problem-banner-code">
|
|
58
|
+
{api.code}
|
|
59
|
+
{api.status > 0 ? ` · ${api.status}` : ""}
|
|
60
|
+
</code>
|
|
61
|
+
)}
|
|
62
|
+
</p>
|
|
63
|
+
{detail && detail !== heading && (
|
|
64
|
+
<p className="problem-banner-detail">{detail}</p>
|
|
65
|
+
)}
|
|
66
|
+
{context.length > 0 && (
|
|
67
|
+
<ul
|
|
68
|
+
className="problem-banner-context"
|
|
69
|
+
aria-label="Context"
|
|
70
|
+
>
|
|
71
|
+
{context.map(([key, value]) => (
|
|
72
|
+
<li key={key}>
|
|
73
|
+
<span className="problem-banner-key">{key}</span>=
|
|
74
|
+
<code>{contextText(value)}</code>
|
|
75
|
+
</li>
|
|
76
|
+
))}
|
|
77
|
+
</ul>
|
|
78
|
+
)}
|
|
79
|
+
{api && api.issues.length > 0 && (
|
|
80
|
+
<ul className="problem-banner-issues">
|
|
81
|
+
{api.issues.map((issue) => (
|
|
82
|
+
<li key={`${issue.target}:${issue.path}:${issue.message}`}>
|
|
83
|
+
<code>
|
|
84
|
+
{issue.target}
|
|
85
|
+
{issue.path ? `.${issue.path}` : ""}
|
|
86
|
+
</code>{" "}
|
|
87
|
+
{issue.message}
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
{(onRetry || onDismiss) && (
|
|
94
|
+
<div className="problem-banner-actions">
|
|
95
|
+
{onRetry && (
|
|
96
|
+
<Button
|
|
97
|
+
size="sm"
|
|
98
|
+
onClick={onRetry}
|
|
99
|
+
>
|
|
100
|
+
Retry
|
|
101
|
+
</Button>
|
|
102
|
+
)}
|
|
103
|
+
{onDismiss && (
|
|
104
|
+
<Button
|
|
105
|
+
size="sm"
|
|
106
|
+
variant="ghost"
|
|
107
|
+
onClick={onDismiss}
|
|
108
|
+
>
|
|
109
|
+
Dismiss
|
|
110
|
+
</Button>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { ErrorInfo, ReactNode } from "react";
|
|
3
|
+
import { QueryClientContext } from "@tanstack/react-query";
|
|
4
|
+
import { Component } from "react";
|
|
5
|
+
import { resetScreenQueries } from "../queryClient";
|
|
6
|
+
import { Button } from "./Button";
|
|
7
|
+
|
|
8
|
+
/** Props of {@link ScreenErrorBoundary}. */
|
|
9
|
+
export interface ScreenErrorBoundaryProps {
|
|
10
|
+
/** The screen. */
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
/** When this changes (the location), a failed screen is tried again: navigating away from a crash recovers without a click. */
|
|
13
|
+
resetKey?: string;
|
|
14
|
+
/** Called with each crash, e.g. to log it. Defaults to nothing (React already reports it in development). */
|
|
15
|
+
onError?: (error: unknown, info: ErrorInfo) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** State of {@link ScreenErrorBoundary}. */
|
|
19
|
+
interface ScreenErrorBoundaryState {
|
|
20
|
+
/** What the screen threw while rendering, or `null` while it renders fine. */
|
|
21
|
+
error: unknown;
|
|
22
|
+
/** Whether {@link ScreenErrorBoundaryState.error} is set (an error may be any value, `null` included). */
|
|
23
|
+
failed: boolean;
|
|
24
|
+
/** Bumped by "Reload this screen", remounting the screen with fresh state. */
|
|
25
|
+
attempt: number;
|
|
26
|
+
/** The `resetKey` the failure happened under. */
|
|
27
|
+
failedKey: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Catches a routed screen's render crash so the app around it survives: the
|
|
32
|
+
* header, nav and live status stay, and the screen's place shows what
|
|
33
|
+
* failed with a "Reload this screen" button that mounts it afresh and
|
|
34
|
+
* re-reads its data: the cached answers it read are reset first (see
|
|
35
|
+
* {@link resetScreenQueries}; `/meta` and the permissions are kept), so a
|
|
36
|
+
* crash caused by a bad answer recovers once the host answers well. A lazy
|
|
37
|
+
* screen whose chunk failed to load is retried the same way (see
|
|
38
|
+
* `screens/lazy.tsx`).
|
|
39
|
+
*
|
|
40
|
+
* The app's queries do not throw into it (no `throwOnError`, no suspense
|
|
41
|
+
* queries: a failed read renders the screen's own error view), so there is
|
|
42
|
+
* no `QueryErrorResetBoundary` to reset.
|
|
43
|
+
*/
|
|
44
|
+
export class ScreenErrorBoundary extends Component<
|
|
45
|
+
ScreenErrorBoundaryProps,
|
|
46
|
+
ScreenErrorBoundaryState
|
|
47
|
+
> {
|
|
48
|
+
static override contextType = QueryClientContext;
|
|
49
|
+
|
|
50
|
+
/** The app's query client, when a `QueryClientProvider` is above (none in some tests). */
|
|
51
|
+
declare context: QueryClient | undefined;
|
|
52
|
+
|
|
53
|
+
override state: ScreenErrorBoundaryState = {
|
|
54
|
+
error: null,
|
|
55
|
+
failed: false,
|
|
56
|
+
attempt: 0,
|
|
57
|
+
failedKey: undefined,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
static getDerivedStateFromError(
|
|
61
|
+
error: unknown,
|
|
62
|
+
): Partial<ScreenErrorBoundaryState> {
|
|
63
|
+
return { error, failed: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static getDerivedStateFromProps(
|
|
67
|
+
props: ScreenErrorBoundaryProps,
|
|
68
|
+
state: ScreenErrorBoundaryState,
|
|
69
|
+
): Partial<ScreenErrorBoundaryState> | null {
|
|
70
|
+
// Remember where it failed; a new location clears the failure.
|
|
71
|
+
if (state.failed && state.failedKey === undefined) {
|
|
72
|
+
return { failedKey: props.resetKey ?? "" };
|
|
73
|
+
}
|
|
74
|
+
if (state.failed && state.failedKey !== (props.resetKey ?? "")) {
|
|
75
|
+
return {
|
|
76
|
+
error: null,
|
|
77
|
+
failed: false,
|
|
78
|
+
failedKey: undefined,
|
|
79
|
+
attempt: state.attempt + 1,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
86
|
+
this.props.onError?.(error, info);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resets the screen's cached answers, then mounts it again. */
|
|
90
|
+
private readonly reload = () => {
|
|
91
|
+
if (this.context) {
|
|
92
|
+
void resetScreenQueries(this.context);
|
|
93
|
+
}
|
|
94
|
+
this.setState((state) => ({
|
|
95
|
+
error: null,
|
|
96
|
+
failed: false,
|
|
97
|
+
failedKey: undefined,
|
|
98
|
+
attempt: state.attempt + 1,
|
|
99
|
+
}));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
override render(): ReactNode {
|
|
103
|
+
const { error, failed, attempt } = this.state;
|
|
104
|
+
if (!failed) {
|
|
105
|
+
return <ScreenAttempt key={attempt}>{this.props.children}</ScreenAttempt>;
|
|
106
|
+
}
|
|
107
|
+
const message =
|
|
108
|
+
error instanceof Error ? error.message : String(error ?? "Unknown error");
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
className="screen screen-error"
|
|
112
|
+
data-testid="screen-error"
|
|
113
|
+
>
|
|
114
|
+
<div
|
|
115
|
+
className="error-view"
|
|
116
|
+
role="alert"
|
|
117
|
+
>
|
|
118
|
+
<h1 className="error-view-title">This screen failed to show</h1>
|
|
119
|
+
<p className="error-view-detail">
|
|
120
|
+
Something went wrong while drawing it. The rest of the app still
|
|
121
|
+
works: reload the screen, or pick another one.
|
|
122
|
+
</p>
|
|
123
|
+
<p className="error-view-meta">
|
|
124
|
+
<code>{message}</code>
|
|
125
|
+
</p>
|
|
126
|
+
<Button
|
|
127
|
+
variant="primary"
|
|
128
|
+
onClick={this.reload}
|
|
129
|
+
>
|
|
130
|
+
Reload this screen
|
|
131
|
+
</Button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A keyed wrapper: a new key remounts the screen with fresh state. */
|
|
139
|
+
function ScreenAttempt({ children }: { children: ReactNode }) {
|
|
140
|
+
return <>{children}</>;
|
|
141
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { cx } from "./classNames";
|
|
2
|
+
|
|
3
|
+
/** Props of {@link Spinner}. */
|
|
4
|
+
export interface SpinnerProps {
|
|
5
|
+
/** What is loading; read by screen readers. Defaults to `"Loading"`. */
|
|
6
|
+
label?: string;
|
|
7
|
+
/** Show the label next to the spinner. Defaults to `false` (screen readers only). */
|
|
8
|
+
showLabel?: boolean;
|
|
9
|
+
/** Extra class names. */
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A loading indicator with a live `status` role. */
|
|
14
|
+
export function Spinner({
|
|
15
|
+
label = "Loading",
|
|
16
|
+
showLabel = false,
|
|
17
|
+
className,
|
|
18
|
+
}: SpinnerProps) {
|
|
19
|
+
return (
|
|
20
|
+
<span
|
|
21
|
+
className={cx("spinner-wrap", className)}
|
|
22
|
+
role="status"
|
|
23
|
+
>
|
|
24
|
+
<span
|
|
25
|
+
className="spinner"
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
/>
|
|
28
|
+
<span className={showLabel ? "spinner-label" : "visually-hidden"}>
|
|
29
|
+
{label}
|
|
30
|
+
</span>
|
|
31
|
+
</span>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cx } from "./classNames";
|
|
3
|
+
|
|
4
|
+
/** Props of {@link Table}. */
|
|
5
|
+
export interface TableProps {
|
|
6
|
+
/** Accessible name of the table (rendered as a visually hidden caption). */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Extra class names on the `<table>`. */
|
|
9
|
+
className?: string;
|
|
10
|
+
/** `<thead>` / `<tbody>`. */
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A table in a scrollable, focusable region with a sticky header. Keyboard
|
|
16
|
+
* users can scroll a wide table because the region is tabbable.
|
|
17
|
+
*/
|
|
18
|
+
export function Table({ label, className, children }: TableProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className="table-wrap"
|
|
22
|
+
role="region"
|
|
23
|
+
aria-label={label}
|
|
24
|
+
// A scrollable region must be reachable by keyboard.
|
|
25
|
+
tabIndex={0}
|
|
26
|
+
>
|
|
27
|
+
<table className={cx("table", className)}>
|
|
28
|
+
<caption className="visually-hidden">{label}</caption>
|
|
29
|
+
{children}
|
|
30
|
+
</table>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { KeyboardEvent, ReactNode } from "react";
|
|
2
|
+
import { useCallback, useId, useRef } from "react";
|
|
3
|
+
import { useUrlTab } from "../hooks/useUrlTab";
|
|
4
|
+
import { useRouter } from "../routing";
|
|
5
|
+
import { cx } from "./classNames";
|
|
6
|
+
|
|
7
|
+
/** One tab of {@link Tabs}. */
|
|
8
|
+
export interface TabItem<V extends string> {
|
|
9
|
+
/** The tab's value, e.g. a job state. */
|
|
10
|
+
value: V;
|
|
11
|
+
/** The text on the tab. */
|
|
12
|
+
label: ReactNode;
|
|
13
|
+
/** A count shown as a badge beside the label; omitted when `undefined`. */
|
|
14
|
+
count?: number;
|
|
15
|
+
/** Not selectable. */
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
/** A tooltip on the tab (its `title`), e.g. what a state's label means. Omitted when `undefined`. */
|
|
18
|
+
title?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Props of {@link Tabs}. */
|
|
22
|
+
export interface TabsProps<V extends string> {
|
|
23
|
+
/** The tabs, in order. */
|
|
24
|
+
tabs: readonly TabItem<V>[];
|
|
25
|
+
/** The selected tab's value. */
|
|
26
|
+
value: V;
|
|
27
|
+
/** Called with the tab the user picked. */
|
|
28
|
+
onChange: (value: V) => void;
|
|
29
|
+
/** Accessible name of the tab list, e.g. "Job states". */
|
|
30
|
+
label: string;
|
|
31
|
+
/** The selected tab's content, rendered in the single tab panel. Omit to render only the tab list. */
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
/** `"auto"` selects on arrow-key focus; `"manual"` waits for Enter/Space. Defaults to `"auto"`. */
|
|
34
|
+
activation?: "auto" | "manual";
|
|
35
|
+
/** Extra class names on the wrapper. */
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Formats a tab count compactly: `1234` → `1.2K`. */
|
|
40
|
+
const countFormat = new Intl.NumberFormat(undefined, { notation: "compact" });
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Controlled tabs following the WAI-ARIA tabs pattern: `tablist`/`tab`/
|
|
44
|
+
* `tabpanel`, one tab in the tab order (roving `tabIndex`), Left/Right to
|
|
45
|
+
* move (wrapping), Home/End to jump. Disabled tabs are skipped.
|
|
46
|
+
*/
|
|
47
|
+
export function Tabs<V extends string>({
|
|
48
|
+
tabs,
|
|
49
|
+
value,
|
|
50
|
+
onChange,
|
|
51
|
+
label,
|
|
52
|
+
children,
|
|
53
|
+
activation = "auto",
|
|
54
|
+
className,
|
|
55
|
+
}: TabsProps<V>) {
|
|
56
|
+
const baseId = useId();
|
|
57
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
58
|
+
const tabId = (index: number) => `${baseId}-tab-${index}`;
|
|
59
|
+
const panelId = `${baseId}-panel`;
|
|
60
|
+
const selectedIndex = tabs.findIndex((tab) => tab.value === value);
|
|
61
|
+
|
|
62
|
+
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
63
|
+
const enabled = tabs
|
|
64
|
+
.map((tab, index) => ({ tab, index }))
|
|
65
|
+
.filter(({ tab }) => !tab.disabled);
|
|
66
|
+
if (enabled.length === 0) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const current = enabled.findIndex(
|
|
70
|
+
({ index }) => tabId(index) === event.currentTarget.id,
|
|
71
|
+
);
|
|
72
|
+
let next: number | null = null;
|
|
73
|
+
switch (event.key) {
|
|
74
|
+
case "ArrowRight":
|
|
75
|
+
next = (current + 1) % enabled.length;
|
|
76
|
+
break;
|
|
77
|
+
case "ArrowLeft":
|
|
78
|
+
next = (current - 1 + enabled.length) % enabled.length;
|
|
79
|
+
break;
|
|
80
|
+
case "Home":
|
|
81
|
+
next = 0;
|
|
82
|
+
break;
|
|
83
|
+
case "End":
|
|
84
|
+
next = enabled.length - 1;
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
const target = enabled[next];
|
|
91
|
+
listRef.current?.ownerDocument.getElementById(tabId(target.index))?.focus();
|
|
92
|
+
if (activation === "auto") {
|
|
93
|
+
onChange(target.tab.value);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className={cx("tabs", className)}>
|
|
99
|
+
<div
|
|
100
|
+
ref={listRef}
|
|
101
|
+
className="tabs-list"
|
|
102
|
+
role="tablist"
|
|
103
|
+
aria-label={label}
|
|
104
|
+
>
|
|
105
|
+
{tabs.map((tab, index) => {
|
|
106
|
+
const selected = index === selectedIndex;
|
|
107
|
+
// With nothing selected, the first enabled tab takes the tab stop.
|
|
108
|
+
const tabStop =
|
|
109
|
+
selected ||
|
|
110
|
+
(selectedIndex === -1 &&
|
|
111
|
+
index === tabs.findIndex((item) => !item.disabled));
|
|
112
|
+
return (
|
|
113
|
+
<button
|
|
114
|
+
key={tab.value}
|
|
115
|
+
id={tabId(index)}
|
|
116
|
+
type="button"
|
|
117
|
+
role="tab"
|
|
118
|
+
className="tab"
|
|
119
|
+
aria-selected={selected}
|
|
120
|
+
aria-controls={children !== undefined ? panelId : undefined}
|
|
121
|
+
tabIndex={tabStop ? 0 : -1}
|
|
122
|
+
disabled={tab.disabled}
|
|
123
|
+
title={tab.title}
|
|
124
|
+
onClick={() => onChange(tab.value)}
|
|
125
|
+
onKeyDown={onKeyDown}
|
|
126
|
+
>
|
|
127
|
+
<span className="tab-label">{tab.label}</span>
|
|
128
|
+
{tab.count !== undefined && (
|
|
129
|
+
<span
|
|
130
|
+
className="tab-count"
|
|
131
|
+
title={String(tab.count)}
|
|
132
|
+
>
|
|
133
|
+
{countFormat.format(tab.count)}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
</button>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
|
+
{children !== undefined && (
|
|
141
|
+
<div
|
|
142
|
+
id={panelId}
|
|
143
|
+
className="tabs-panel"
|
|
144
|
+
role="tabpanel"
|
|
145
|
+
aria-labelledby={
|
|
146
|
+
selectedIndex >= 0 ? tabId(selectedIndex) : undefined
|
|
147
|
+
}
|
|
148
|
+
tabIndex={0}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Props of {@link UrlTabs}. */
|
|
158
|
+
export interface UrlTabsProps<V extends string> extends Omit<
|
|
159
|
+
TabsProps<V>,
|
|
160
|
+
"value" | "onChange"
|
|
161
|
+
> {
|
|
162
|
+
/** The query parameter holding the tab, e.g. `"state"`. */
|
|
163
|
+
param: string;
|
|
164
|
+
/** The tab when the parameter is absent or not one of `tabs`; selecting it removes the parameter. */
|
|
165
|
+
defaultValue: V;
|
|
166
|
+
/** Query parameters to remove when the tab changes, e.g. `["offset"]` so paging restarts. */
|
|
167
|
+
resetParams?: readonly string[];
|
|
168
|
+
/** Called after the URL changed. */
|
|
169
|
+
onChange?: (value: V) => void;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* {@link Tabs} whose selection lives in the query string (`?state=failed`),
|
|
174
|
+
* so it survives reload and can be linked. Changes replace the history entry
|
|
175
|
+
* (the router's `navigate(…, { replace: true })`), keep every other
|
|
176
|
+
* parameter, and drop the `resetParams`.
|
|
177
|
+
*/
|
|
178
|
+
export function UrlTabs<V extends string>({
|
|
179
|
+
param,
|
|
180
|
+
defaultValue,
|
|
181
|
+
resetParams = [],
|
|
182
|
+
onChange,
|
|
183
|
+
tabs,
|
|
184
|
+
...rest
|
|
185
|
+
}: UrlTabsProps<V>) {
|
|
186
|
+
const { location, navigate } = useRouter();
|
|
187
|
+
const value = useUrlTab(param, tabs, defaultValue);
|
|
188
|
+
const select = useCallback(
|
|
189
|
+
(next: V) => {
|
|
190
|
+
const params = new URLSearchParams(location.search);
|
|
191
|
+
if (next === defaultValue) {
|
|
192
|
+
params.delete(param);
|
|
193
|
+
} else {
|
|
194
|
+
params.set(param, next);
|
|
195
|
+
}
|
|
196
|
+
if (next !== value) {
|
|
197
|
+
for (const key of resetParams) {
|
|
198
|
+
params.delete(key);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const query = params.toString();
|
|
202
|
+
navigate(`${location.path ?? "/"}${query ? `?${query}` : ""}`, {
|
|
203
|
+
replace: true,
|
|
204
|
+
});
|
|
205
|
+
onChange?.(next);
|
|
206
|
+
},
|
|
207
|
+
[
|
|
208
|
+
location.search,
|
|
209
|
+
location.path,
|
|
210
|
+
defaultValue,
|
|
211
|
+
param,
|
|
212
|
+
value,
|
|
213
|
+
resetParams,
|
|
214
|
+
navigate,
|
|
215
|
+
onChange,
|
|
216
|
+
],
|
|
217
|
+
);
|
|
218
|
+
return (
|
|
219
|
+
<Tabs
|
|
220
|
+
{...rest}
|
|
221
|
+
tabs={tabs}
|
|
222
|
+
value={value}
|
|
223
|
+
onChange={select}
|
|
224
|
+
/>
|
|
225
|
+
);
|
|
226
|
+
}
|