milestone 23 s2: react aria primitives and their cluster

This commit is contained in:
2026-08-12 22:12:18 +02:00
parent 994bbf922c
commit 01e455c8af
32 changed files with 2316 additions and 532 deletions
+131 -3
View File
@@ -12,6 +12,7 @@
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
"react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8"
},
"devDependencies": {
@@ -620,6 +621,33 @@
}
}
},
"node_modules/@internationalized/date": {
"version": "3.12.3",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz",
"integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/number": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.7.tgz",
"integrity": "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@internationalized/string": {
"version": "3.2.10",
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.10.tgz",
"integrity": "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1046,6 +1074,15 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@react-types/shared": {
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
"integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -1389,6 +1426,15 @@
"unplugin": "^2.3.11"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2419,6 +2465,18 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -2527,6 +2585,21 @@
"node": ">=18"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -3422,6 +3495,46 @@
"node": ">=0.10.0"
}
},
"node_modules/react-aria": {
"version": "3.51.0",
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz",
"integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"aria-hidden": "^1.2.3",
"clsx": "^2.0.0",
"react-stately": "3.49.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-aria-components": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.20.0.tgz",
"integrity": "sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"client-only": "^0.0.1",
"react-aria": "3.51.0",
"react-stately": "3.49.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
@@ -3441,6 +3554,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-stately": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.49.0.tgz",
"integrity": "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==",
"license": "Apache-2.0",
"dependencies": {
"@internationalized/date": "^3.12.3",
"@internationalized/number": "^3.6.7",
"@internationalized/string": "^3.2.10",
"@react-types/shared": "^3.36.1",
"@swc/helpers": "^0.5.0",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -3694,9 +3824,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/typescript": {
"version": "7.0.2",
+1
View File
@@ -29,6 +29,7 @@
"@tanstack/react-query": "5.101.4",
"@tanstack/react-router": "1.170.18",
"react": "19.2.8",
"react-aria-components": "1.20.0",
"react-dom": "19.2.8"
},
"devDependencies": {
@@ -41,14 +41,20 @@ const RESPONSES: Record<string, unknown> = {
};
let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];
beforeEach(() => {
clearRefreshStatus();
resolveUpdate = null;
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/blocklists/update" && init?.method === "POST") {
return new Promise<Response>((resolve) => {
resolveUpdate = resolve;
@@ -227,3 +233,27 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
vi.useRealTimers();
}
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleted).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that source", async () => {
renderBlocklistsRoute();
await screen.findByRole("heading", { name: "Blocklists" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
});
+131 -49
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import {
@@ -13,21 +14,89 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
import BlocklistForm from "./BlocklistForm";
import { useRefreshStatus } from "./refreshStore";
import SourceStatusSection from "./SourceStatusSection";
import {
dangerLinkButtonClass,
focusRing,
linkButtonClass,
primaryButtonClass,
tableWrapClass,
tdClass,
thClass,
} from "@/ui/classes";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
header: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
},
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
done: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.primaryOnSurface,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
name: {
fontWeight: 500,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.border,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.text,
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
numeric: {
fontVariantNumeric: "tabular-nums",
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function BlocklistsPage() {
const queryClient = useQueryClient();
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const [editing, setEditing] = useState<Blocklist | null>(null);
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
const create = useMutation(blocklistCreateMutation(queryClient));
const save = useMutation(blocklistUpdateMutation(queryClient));
@@ -57,10 +126,10 @@ export default function BlocklistsPage() {
});
}
function deleteBlocklist(b: Blocklist) {
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) {
remove.mutate(b.id);
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
const formError = editing === null ? create.error : save.error;
@@ -68,60 +137,56 @@ export default function BlocklistsPage() {
return (
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Blocklists</h1>
<div {...stylex.props(styles.header)}>
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
<button
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
</div>
{updateNow.isSuccess && !updateNow.isPending && (
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status">
<p {...stylex.props(styles.done)} role="status">
Update completed; source status refreshed below.
</p>
)}
<InlineError error={updateNow.error} />
{blocklists.length === 0 ? (
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th className={thClass}>Name</th>
<th className={thClass}>URL</th>
<th className={thClass}>Enabled</th>
<th className={thClass}>Domains</th>
<th className={thClass}>Wildcards</th>
<th className={thClass}>Skipped regex</th>
<th className={thClass}>Last updated</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
<th {...stylex.props(shared.th)}>Name</th>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>Domains</th>
<th {...stylex.props(shared.th)}>Wildcards</th>
<th {...stylex.props(shared.th)}>Skipped regex</th>
<th {...stylex.props(shared.th)}>Last updated</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td className={tdClass}>
<span className="font-medium">{b.name}</span>
{b.is_suggested && (
<span className="ml-2 rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
Suggested
</span>
)}
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.name)}>{b.name}</span>
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
</td>
<td className={tdClass}>
<span className="block max-w-72 truncate" title={b.url}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.url)} title={b.url}>
{b.url}
</span>
</td>
<td className={tdClass}>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
aria-label={`${b.name} enabled`}
@@ -129,32 +194,36 @@ export default function BlocklistsPage() {
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
</td>
<td className={`${tdClass} tabular-nums`}>{b.domain_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.wildcard_count}</td>
<td className={`${tdClass} tabular-nums`}>{b.skipped_regex_count}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td, styles.numeric)}>{b.domain_count}</td>
<td {...stylex.props(shared.td, styles.numeric)}>{b.wildcard_count}</td>
<td {...stylex.props(shared.td, styles.numeric)}>{b.skipped_regex_count}</td>
<td {...stylex.props(shared.td)}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
<td className={tdClass}>
<div className="flex gap-3">
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => setEditing(b)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => deleteBlocklist(b)}
onClick={() => setPendingDelete(b)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
@@ -179,6 +248,19 @@ export default function BlocklistsPage() {
/>
<SourceStatusSection sources={sources} namesById={namesById} />
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete blocklist"
message={
pendingDelete === null
? ""
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
+76 -60
View File
@@ -1,9 +1,12 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
import Dialog from "@/ui/Dialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
@@ -12,7 +15,34 @@ interface Props {
onClose: () => void;
}
const dialogInputClass = `mt-1 w-full ${smallInputClass}`;
const styles = stylex.create({
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
marginTop: "1rem",
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
dialogInput: {
marginTop: "0.25rem",
width: "100%",
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
},
});
export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient();
@@ -22,64 +52,50 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
const readOnly = useReadOnlyConfig();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div
role="dialog"
aria-modal="true"
aria-label={`Edit client ${client.ip}`}
className="w-full max-w-md rounded-lg border border-zinc-200 bg-white p-6 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
<form
{...stylex.props(styles.form)}
onSubmit={(event) => {
event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
>
<h2 className="text-lg font-semibold">Edit {client.ip}</h2>
<form
className="mt-4 space-y-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate(
{ id: client.id, edit: { name: name.trim(), group_id: groupId } },
{ onSuccess: onClose },
);
}}
>
<label className="block text-sm font-medium">
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className={dialogInputClass}
autoFocus
/>
</label>
<label className="block text-sm font-medium">
Group
<select
value={String(groupId)}
onChange={(event) => setGroupId(Number(event.target.value))}
className={dialogInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
</label>
<InlineError error={mutation.error} />
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className={buttonClass}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
Save
</button>
</div>
</form>
</div>
</div>
<label {...stylex.props(styles.fieldLabel)}>
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
{...stylex.props(shared.smallInput, styles.dialogInput, shared.focusRing)}
/>
</label>
<Select
label="Group"
variant="compactField"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
<InlineError error={mutation.error} />
<div {...stylex.props(styles.actions)}>
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save
</button>
</div>
</form>
</Dialog>
);
}
+20 -5
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -106,10 +106,25 @@ test("edit opens a dialog seeded with the client's name and group", async () =>
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
expect(dialog).toBeTruthy();
expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("1");
// The dialog portals out of the table, so every field query is scoped to it.
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
// The RAC Select names its trigger with the value and then the label.
expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
});
test("the group picker offers every group and reports the choice", async () => {
await renderClientsPage(BASE);
fireEvent.click(screen.getAllByRole("button", { name: "Edit" })[0]!);
const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);
fireEvent.click(screen.getByRole("option", { name: "kids" }));
expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
});
test("prefix editor starts clean and dirties on add", async () => {
+138 -34
View File
@@ -1,16 +1,16 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientDeleteMutation, clientPrefixesQuery, clientsQuery, groupsQuery } from "@/lib/queries";
import { formatTime } from "@/lib/format";
import type { Client } from "@/lib/types";
import ClientEditDialog from "./ClientEditDialog";
import PrefixesEditor from "./PrefixesEditor";
import InlineError from "@/lib/InlineError";
import { smallButtonClass, tableWrapClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const cellClass = "px-3 py-2";
/**
* Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts
@@ -18,6 +18,97 @@ const cellClass = "px-3 py-2";
*/
const DECLARED_CLIENT_NOTE = "This client is declared in the configuration file; remove it there and restart.";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "48rem",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
},
mono: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
right: {
textAlign: "right",
},
dash: {
color: colors.textMuted,
},
badge: {
marginLeft: "0.5rem",
borderRadius: "0.25rem",
backgroundColor: colors.primary,
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
fontWeight: 500,
color: colors.primaryText,
},
confirmGroup: {
display: "inline-flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: "0.5rem",
},
actionGroup: {
display: "inline-flex",
gap: "0.5rem",
},
note: {
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
/**
* The accessible name of the actions column, kept out of the visual table
* without leaving the accessibility tree.
*/
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
@@ -30,46 +121,46 @@ export default function ClientsPage() {
return (
<section>
<h1 className="text-2xl font-semibold">Clients</h1>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
{clients.length === 0 ? (
<p className="mt-4 text-zinc-500">
<p {...stylex.props(styles.empty)}>
No clients yet. Rows appear automatically as devices on the network make DNS queries there is
nothing to create by hand.
</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-[48rem] text-left text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
<th className={cellClass}>IP</th>
<th className={cellClass}>Name</th>
<th className={cellClass}>Group</th>
<th className={cellClass}>First seen</th>
<th className={cellClass}>Last seen</th>
<th className={cellClass}>
<span className="sr-only">Actions</span>
<tr {...stylex.props(styles.headRow)}>
<th {...stylex.props(styles.cell)}>IP</th>
<th {...stylex.props(styles.cell)}>Name</th>
<th {...stylex.props(styles.cell)}>Group</th>
<th {...stylex.props(styles.cell)}>First seen</th>
<th {...stylex.props(styles.cell)}>Last seen</th>
<th {...stylex.props(styles.cell)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{clients.map((client) => (
<tr key={client.id} className="border-b border-zinc-100 dark:border-zinc-800">
<td className={`${cellClass} font-mono`}>{client.ip}</td>
<td className={cellClass}>
{client.name === "" ? <span className="text-zinc-400"></span> : client.name}
{client.hand_edited && (
<span className="ml-2 rounded bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
edited
</span>
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, styles.mono)}>{client.ip}</td>
<td {...stylex.props(styles.cell)}>
{client.name === "" ? (
<span {...stylex.props(styles.dash)}></span>
) : (
client.name
)}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td>
<td className={cellClass}>{client.group}</td>
<td className={cellClass}>{formatTime(client.first_seen)}</td>
<td className={cellClass}>{formatTime(client.last_seen)}</td>
<td className={`${cellClass} text-right`}>
<td {...stylex.props(styles.cell)}>{client.group}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
<td {...stylex.props(styles.cell, styles.right)}>
{confirmingId === client.id ? (
<span className="inline-flex flex-wrap items-center justify-end gap-2">
<span className="text-xs text-zinc-500">
<span {...stylex.props(styles.confirmGroup)}>
<span {...stylex.props(styles.note)}>
Deleted clients re-materialize on their next DNS query.
</span>
<button
@@ -78,26 +169,34 @@ export default function ClientsPage() {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
className={smallButtonClass}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
</button>
</span>
) : (
<span className="inline-flex gap-2">
<span {...stylex.props(styles.actionGroup)}>
<button
type="button"
onClick={() => setEditing(client)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${smallButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
@@ -110,7 +209,12 @@ export default function ClientsPage() {
? DECLARED_CLIENT_NOTE
: undefined
}
className={`${smallButtonClass} text-red-700 disabled:opacity-50 dark:text-red-400`}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
+91 -26
View File
@@ -1,11 +1,14 @@
import { useReducer, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
interface Props {
@@ -13,6 +16,72 @@ interface Props {
groups: Group[];
}
const styles = stylex.create({
section: {
marginTop: "2.5rem",
},
heading: {
fontSize: "1.25rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
intro: {
marginTop: "0.25rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
rows: {
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "1rem",
listStyleType: "none",
padding: 0,
},
row: {
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "0.5rem",
},
prefixInput: {
width: "13rem",
},
priorityInput: {
width: "5rem",
},
removeButton: {
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: "transparent",
paddingInline: "0.5rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
validation: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.danger,
},
actions: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
},
});
export default function PrefixesEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
@@ -21,6 +90,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
const dirty = isDirty(state);
const fallbackGroupId = defaultGroupId(groups);
const readOnly = useReadOnlyConfig();
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
const save = () => {
const problem = firstProblem(state.rows);
@@ -32,18 +102,18 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
};
return (
<section className="mt-10">
<h2 className="text-xl font-semibold">Client prefixes</h2>
<p className="mt-1 text-sm text-zinc-500">
<section {...stylex.props(styles.section)}>
<h2 {...stylex.props(styles.heading)}>Client prefixes</h2>
<p {...stylex.props(styles.intro)}>
Prefixes assign a group to whole address ranges. The list is saved as a whole; the highest priority
match wins.
</p>
{state.rows.length === 0 ? (
<p className="mt-4 text-sm text-zinc-500">No prefixes configured.</p>
<p {...stylex.props(styles.empty)}>No prefixes configured.</p>
) : (
<ul className="mt-4 space-y-2">
<ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => (
<li key={index} className="flex flex-wrap items-center gap-2">
<li key={index} {...stylex.props(styles.row)}>
<input
type="text"
aria-label={`Prefix ${index + 1}`}
@@ -52,22 +122,17 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
}
className={`${smallInputClass} w-52`}
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/>
<select
<Select
aria-label={`Group for prefix ${index + 1}`}
variant="inline"
value={String(row.group_id)}
onChange={(event) =>
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
onChange={(value) =>
dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
}
className={smallInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
{group.name}
</option>
))}
</select>
options={groupOptions}
/>
<input
type="text"
inputMode="numeric"
@@ -77,12 +142,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
}
className={`${smallInputClass} w-20`}
{...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
/>
<button
type="button"
onClick={() => dispatch({ type: "remove", index })}
className={`rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 ${focusRing} dark:border-zinc-700 dark:text-red-400`}
{...stylex.props(styles.removeButton, shared.focusRing)}
>
Remove
</button>
@@ -91,16 +156,16 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
</ul>
)}
{validation !== null && (
<p role="alert" className="mt-2 text-sm text-red-700 dark:text-red-400">
<p role="alert" {...stylex.props(styles.validation)}>
{validation}
</p>
)}
<InlineError error={mutation.error} />
<div className="mt-4 flex gap-2">
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
className={buttonClass}
{...stylex.props(shared.button, shared.focusRing)}
>
Add prefix
</button>
@@ -109,7 +174,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onClick={save}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save prefixes
</button>
@@ -120,7 +185,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
className={buttonClass}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard changes
</button>
+87 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -24,6 +24,12 @@ function createFetchMock() {
records = [...records, created];
return json(created, 201);
}
if (url.startsWith("/api/local-records/") && method === "DELETE") {
const id = Number(url.slice("/api/local-records/".length));
records = records.filter((record) => record.id !== id);
return new Response(null, { status: 204 });
}
if (url.startsWith("/api/forward-zones/") && method === "DELETE") return new Response(null, { status: 204 });
if (url === "/api/forward-zones" && method === "GET") {
return json({ forward_zones: [{ id: 7, zone: "lan.home", resolver: "udp://192.168.1.1:53" }] });
}
@@ -65,13 +71,34 @@ test("renders the records table and switches to the forward zones tab", async ()
expect(screen.getByText("udp://192.168.1.1:53")).toBeTruthy();
});
test("the arrow keys move between tabs", async () => {
renderPage();
await screen.findByText("nas.lan.home");
const tablist = screen.getByRole("tablist", { name: "Local DNS" });
const records = screen.getByRole("tab", { name: "Records" });
expect(records.getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(tablist, { key: "ArrowRight" });
const zones = screen.getByRole("tab", { name: "Forward zones" });
expect(zones.getAttribute("aria-selected")).toBe("true");
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("false");
await screen.findByText("lan.home");
fireEvent.keyDown(tablist, { key: "ArrowLeft" });
expect(screen.getByRole("tab", { name: "Records" }).getAttribute("aria-selected")).toBe("true");
await screen.findByText("nas.lan.home");
});
test("creates a record: POST body per LocalRecordInput, list refreshes", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Add record" }));
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "printer.lan.home" } });
fireEvent.change(screen.getByLabelText("Type"), { target: { value: "AAAA" } });
// The record type is a RAC Select now: open the listbox, then pick.
fireEvent.click(screen.getByRole("button", { name: /Type$/ }));
fireEvent.click(await screen.findByRole("option", { name: "AAAA" }));
fireEvent.change(screen.getByLabelText("Value"), { target: { value: "fd00::11" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
@@ -83,3 +110,61 @@ test("creates a record: POST body per LocalRecordInput, list refreshes", async (
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ name: "printer.lan.home", rtype: "AAAA", value: "fd00::11" });
});
test("cancelling the record delete dialog sends no request", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete record "nas.lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
expect(screen.getByText("nas.lan.home")).toBeTruthy();
});
test("confirming the record delete dialog issues the DELETE", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/local-records/1",
),
).toBe(true),
);
await waitFor(() => expect(screen.queryByText("nas.lan.home")).toBeNull());
});
test("the forward zone delete dialog names the zone and confirms", async () => {
renderPage();
await screen.findByText("nas.lan.home");
fireEvent.click(screen.getByRole("tab", { name: "Forward zones" }));
await screen.findByText("lan.home");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete forward zone "lan.home"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false);
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(
fetchMock.mock.calls.some(
([input, init]) => init?.method === "DELETE" && String(input) === "/api/forward-zones/7",
),
).toBe(true),
);
});
+17 -69
View File
@@ -1,79 +1,27 @@
import { useState } from "react";
import * as stylex from "@stylexjs/stylex";
import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab";
import { focusRing } from "@/ui/classes";
import Tabs from "@/ui/Tabs";
type Tab = "records" | "zones";
function TabButton({
id,
controls,
selected,
onClick,
children,
}: {
id: string;
controls: string;
selected: boolean;
onClick: () => void;
children: string;
}) {
return (
<button
type="button"
role="tab"
id={id}
aria-controls={controls}
aria-selected={selected}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 font-medium ${focusRing} ${
selected
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{children}
</button>
);
}
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
});
export default function LocalDnsPage() {
const [tab, setTab] = useState<Tab>("records");
return (
<section>
<h1 className="text-2xl font-semibold">Local DNS</h1>
<div
role="tablist"
aria-label="Local DNS"
className="mt-4 flex gap-2 border-b border-zinc-200 dark:border-zinc-800"
>
<TabButton
id="tab-records"
controls="panel-records"
selected={tab === "records"}
onClick={() => setTab("records")}
>
Records
</TabButton>
<TabButton
id="tab-zones"
controls="panel-zones"
selected={tab === "zones"}
onClick={() => setTab("zones")}
>
Forward zones
</TabButton>
</div>
{tab === "records" ? (
<div role="tabpanel" id="panel-records" aria-labelledby="tab-records">
<RecordsTab />
</div>
) : (
<div role="tabpanel" id="panel-zones" aria-labelledby="tab-zones">
<ZonesTab />
</div>
)}
<h1 {...stylex.props(styles.heading)}>Local DNS</h1>
<Tabs
label="Local DNS"
tabs={[
{ id: "records", label: "Records", content: <RecordsTab /> },
{ id: "zones", label: "Forward zones", content: <ZonesTab /> },
]}
/>
</section>
);
}
+160 -58
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
localRecordCreateMutation,
localRecordDeleteMutation,
@@ -8,18 +9,100 @@ import {
} from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { useCrudForm } from "@/ui/useCrudForm";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const RTYPE_OPTIONS = RTYPES.map((rtype) => ({ value: rtype, label: rtype }));
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
mono: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
function RecordForm({
initial,
@@ -50,10 +133,12 @@ function RecordForm({
}
return (
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New record" : `Edit ${initial.name}`}
</h3>
<div>
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
<label htmlFor={`${id}-name`} {...stylex.props(styles.fieldLabel)}>
Name
</label>
<input
@@ -62,28 +147,19 @@ function RecordForm({
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="nas.lan.home"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-rtype`} className="block text-sm font-medium">
Type
</label>
<select
id={`${id}-rtype`}
<Select
label="Type"
value={rtype}
onChange={(event) => setRtype(event.target.value as LocalRecordType)}
className={inputClass}
>
{RTYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
onChange={(next) => setRtype(next as LocalRecordType)}
options={RTYPE_OPTIONS}
/>
</div>
<div>
<label htmlFor={`${id}-value`} className="block text-sm font-medium">
<label htmlFor={`${id}-value`} {...stylex.props(styles.fieldLabel)}>
Value
</label>
<input
@@ -94,11 +170,11 @@ function RecordForm({
placeholder={
rtype === "CNAME" ? "target.example.com" : rtype === "AAAA" ? "fd00::10" : "192.168.1.10"
}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-ttl`} className="block text-sm font-medium">
<label htmlFor={`${id}-ttl`} {...stylex.props(styles.fieldLabel)}>
TTL (seconds)
</label>
<input
@@ -108,19 +184,19 @@ function RecordForm({
value={ttl}
onChange={(event) => setTtl(event.target.value)}
placeholder="300"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div className="flex gap-2">
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel
</button>
</div>
@@ -131,10 +207,19 @@ function RecordForm({
export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
LocalRecord,
LocalRecordInput
>({
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<LocalRecord, LocalRecordInput>({
create: localRecordCreateMutation,
update: localRecordUpdateMutation,
remove: localRecordDeleteMutation,
@@ -144,14 +229,14 @@ export default function RecordsTab() {
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">Answers served directly for LAN names. Changes apply live.</p>
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>Answers served directly for LAN names. Changes apply live.</p>
<button
type="button"
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add record
</button>
@@ -166,37 +251,37 @@ export default function RecordsTab() {
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Name
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
Type
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
Value
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
TTL
</th>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{records.length === 0 && (
<tr>
<td colSpan={5} className="py-4 text-zinc-500">
<td colSpan={5} {...stylex.props(styles.emptyCell)}>
No local records yet.
</td>
</tr>
)}
{records.map((record) => (
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
<tr key={record.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}>
<RecordForm
@@ -210,17 +295,21 @@ export default function RecordsTab() {
</td>
) : (
<>
<td className="py-2 pr-4 font-mono">{record.name}</td>
<td className="py-2 pr-4">{record.rtype}</td>
<td className="py-2 pr-4 font-mono">{record.value}</td>
<td className="py-2 pr-4">{record.ttl}</td>
<td className="py-2 text-right whitespace-nowrap">
<td {...stylex.props(styles.cell, styles.mono)}>{record.name}</td>
<td {...stylex.props(styles.cell)}>{record.rtype}</td>
<td {...stylex.props(styles.cell, styles.mono)}>{record.value}</td>
<td {...stylex.props(styles.cell)}>{record.ttl}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: record })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
@@ -229,7 +318,12 @@ export default function RecordsTab() {
onClick={() => onDelete(record)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -241,6 +335,14 @@ export default function RecordsTab() {
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete record"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
+147 -38
View File
@@ -1,5 +1,6 @@
import { useId, useState, type FormEvent } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
forwardZoneCreateMutation,
forwardZoneDeleteMutation,
@@ -8,17 +9,97 @@ import {
} from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { useCrudForm } from "@/ui/useCrudForm";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
formHeading: {
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
buttonRow: {
display: "flex",
gap: "0.5rem",
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: "1rem",
},
intro: {
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
table: {
width: "100%",
textAlign: "left",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
headRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
color: colors.textMuted,
},
headCell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
fontWeight: 500,
},
headCellLast: {
paddingBlock: "0.5rem",
},
bodyRow: {
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
cell: {
paddingBlock: "0.5rem",
paddingRight: "1rem",
},
mono: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
emptyCell: {
paddingBlock: "1rem",
color: colors.textMuted,
},
actionCell: {
paddingBlock: "0.5rem",
textAlign: "right",
whiteSpace: "nowrap",
},
dangerText: {
color: colors.danger,
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
function ZoneForm({
initial,
busy,
@@ -44,10 +125,12 @@ function ZoneForm({
}
return (
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
<form onSubmit={submit} {...stylex.props(shared.formCard)}>
<h3 {...stylex.props(styles.formHeading)}>
{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}
</h3>
<div>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
<label htmlFor={`${id}-zone`} {...stylex.props(styles.fieldLabel)}>
Zone
</label>
<input
@@ -56,11 +139,11 @@ function ZoneForm({
value={zone}
onChange={(event) => setZone(event.target.value)}
placeholder="lan.home"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor={`${id}-resolver`} className="block text-sm font-medium">
<label htmlFor={`${id}-resolver`} {...stylex.props(styles.fieldLabel)}>
Resolver
</label>
<input
@@ -69,19 +152,19 @@ function ZoneForm({
value={resolver}
onChange={(event) => setResolver(event.target.value)}
placeholder="udp://192.168.1.1:53"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div className="flex gap-2">
<div {...stylex.props(styles.buttonRow)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={largeButtonClass}>
<button type="button" onClick={onCancel} {...stylex.props(shared.largeButton, shared.focusRing)}>
Cancel
</button>
</div>
@@ -92,10 +175,19 @@ function ZoneForm({
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
ForwardZone,
ForwardZoneInput
>({
const {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
} = useCrudForm<ForwardZone, ForwardZoneInput>({
create: forwardZoneCreateMutation,
update: forwardZoneUpdateMutation,
remove: forwardZoneDeleteMutation,
@@ -105,8 +197,8 @@ export default function ZonesTab() {
return (
<div>
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-zinc-500">
<div {...stylex.props(styles.toolbar)}>
<p {...stylex.props(styles.intro)}>
Names under these zones go to their own resolver. Changes apply live.
</p>
<button
@@ -114,7 +206,7 @@ export default function ZonesTab() {
onClick={() => openForm({ mode: "create" })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={largePrimaryButtonClass}
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
>
Add zone
</button>
@@ -129,31 +221,31 @@ export default function ZonesTab() {
onCancel={closeForm}
/>
)}
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
<th scope="col" className="py-2 pr-4 font-medium">
<tr {...stylex.props(styles.headRow)}>
<th scope="col" {...stylex.props(styles.headCell)}>
Zone
</th>
<th scope="col" className="py-2 pr-4 font-medium">
<th scope="col" {...stylex.props(styles.headCell)}>
Resolver
</th>
<th scope="col" className="py-2">
<span className="sr-only">Actions</span>
<th scope="col" {...stylex.props(styles.headCellLast)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{zones.length === 0 && (
<tr>
<td colSpan={3} className="py-4 text-zinc-500">
<td colSpan={3} {...stylex.props(styles.emptyCell)}>
No forward zones yet.
</td>
</tr>
)}
{zones.map((zone) => (
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
<tr key={zone.id} {...stylex.props(styles.bodyRow)}>
{form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
@@ -167,15 +259,19 @@ export default function ZonesTab() {
</td>
) : (
<>
<td className="py-2 pr-4 font-mono">{zone.zone}</td>
<td className="py-2 pr-4 font-mono">{zone.resolver}</td>
<td className="py-2 text-right whitespace-nowrap">
<td {...stylex.props(styles.cell, styles.mono)}>{zone.zone}</td>
<td {...stylex.props(styles.cell, styles.mono)}>{zone.resolver}</td>
<td {...stylex.props(styles.actionCell)}>
<button
type="button"
onClick={() => openForm({ mode: "edit", entity: zone })}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.rowButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
@@ -184,7 +280,12 @@ export default function ZonesTab() {
onClick={() => onDelete(zone)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${rowButtonClass} text-red-600 disabled:opacity-50 dark:text-red-400`}
{...stylex.props(
shared.rowButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
@@ -196,6 +297,14 @@ export default function ZonesTab() {
</tbody>
</table>
</div>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete forward zone"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</div>
);
}
+60 -13
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
@@ -33,16 +33,26 @@ const RESPONSES: Record<string, unknown> = {
// The API orders groups by name, so the id-1 default is not always first.
let groups: { id: number; name: string; safe_search: boolean }[];
let deleted: string[];
function deleteCalls(): string[] {
return deleted;
}
beforeEach(() => {
groups = [
{ id: 1, name: "Default", safe_search: false },
{ id: 2, name: "Kids", safe_search: true },
];
deleted = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "DELETE") {
deleted.push(url);
return new Response(null, { status: 204 });
}
if (url === "/api/rules" && init?.method === "POST") {
return new Response(JSON.stringify({ error: "rate limited" }), {
status: 429,
@@ -75,6 +85,25 @@ function renderRulesRoute() {
);
}
/**
* A RAC Select names its trigger with the current value and then the label, so
* the label alone is a suffix match. Opening it is the only way to read the
* options: there is no `<select>` carrying them any more.
*/
function trigger(label: string): HTMLElement {
return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}
async function optionsOf(label: string): Promise<(string | null)[]> {
fireEvent.click(trigger(label));
const options = await screen.findAllByRole("option");
const labels = options.map((option) => option.textContent);
// Re-picking the current value closes the listbox and changes nothing.
fireEvent.click(options.find((option) => option.getAttribute("aria-selected") === "true") ?? options[0]!);
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
return labels;
}
test("renders the rule table and the create form with contract enums", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
@@ -87,14 +116,9 @@ test("renders the rule table and the create form with contract enums", async ()
expect(table.getByText("Kids")).toBeTruthy();
expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2);
const kindSelect = screen.getByLabelText("Kind") as HTMLSelectElement;
expect(Array.from(kindSelect.options).map((o) => o.value)).toEqual(["exact", "wildcard"]);
const actionSelect = screen.getByLabelText("Action") as HTMLSelectElement;
expect(Array.from(actionSelect.options).map((o) => o.value)).toEqual(["allow", "block"]);
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Default", "Kids"]);
expect(await optionsOf("Kind")).toEqual(["exact", "wildcard"]);
expect(await optionsOf("Action")).toEqual(["allow", "block"]);
expect(await optionsOf("Group")).toEqual(["Default", "Kids"]);
});
test("rule create shows a countdown when rate limited with Retry-After", async () => {
@@ -116,9 +140,8 @@ test("the group select preselects the id-1 default, not the alphabetically first
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
const groupSelect = screen.getByLabelText("Group") as HTMLSelectElement;
expect(Array.from(groupSelect.options).map((o) => o.textContent)).toEqual(["Attic", "Default"]);
expect(groupSelect.value).toBe("1");
expect(await optionsOf("Group")).toEqual(["Attic", "Default"]);
expect(trigger("Group").textContent).toContain("Default");
});
test("the group select falls back to the first group when the default is absent", async () => {
@@ -129,5 +152,29 @@ test("the group select falls back to the first group when the default is absent"
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
expect((screen.getByLabelText("Group") as HTMLSelectElement).value).toBe("5");
expect(trigger("Group").textContent).toContain("Attic");
});
test("delete asks for confirmation, and cancelling sends no request", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete the block rule for "ads.example.com"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(deleteCalls()).toEqual([]);
});
test("confirming the delete dialog issues the DELETE for that rule", async () => {
renderRulesRoute();
await screen.findByRole("heading", { name: "Rules" });
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteCalls()).toEqual(["/api/rules/2"]));
});
+155 -89
View File
@@ -1,13 +1,96 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import InlineError from "@/lib/InlineError";
import { groupsQuery, ruleCreateMutation, ruleDeleteMutation, rulesQuery } from "@/lib/queries";
import type { Rule, RuleAction, RuleKind } from "@/lib/types";
import { defaultGroupId } from "@/lib/defaultGroup";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import ConfirmDialog from "@/ui/ConfirmDialog";
import Select from "@/ui/Select";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const KIND_OPTIONS = [
{ value: "exact", label: "exact" },
{ value: "wildcard", label: "wildcard" },
];
const ACTION_OPTIONS = [
{ value: "allow", label: "allow" },
{ value: "block", label: "block" },
];
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
pattern: {
fontWeight: 500,
},
allow: {
color: colors.primaryOnSurface,
},
block: {
color: colors.danger,
},
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1.5rem",
maxWidth: "36rem",
},
formHeading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
/** One column on a phone, three from the `sm` breakpoint, as before. */
fieldGrid: {
display: "grid",
gap: "0.75rem",
gridTemplateColumns: {
default: "repeat(1, minmax(0, 1fr))",
"@media (min-width: 640px)": "repeat(3, minmax(0, 1fr))",
},
},
submitRow: {
display: "flex",
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function RulesPage() {
const queryClient = useQueryClient();
const { data: rules } = useSuspenseQuery(rulesQuery());
@@ -20,6 +103,7 @@ export default function RulesPage() {
const [kind, setKind] = useState<RuleKind>("exact");
const [action, setAction] = useState<RuleAction>("block");
const [groupId, setGroupId] = useState(() => defaultGroupId(groups));
const [pendingDelete, setPendingDelete] = useState<Rule | null>(null);
const readOnly = useReadOnlyConfig();
function onSubmit(event: FormEvent<HTMLFormElement>) {
@@ -30,58 +114,52 @@ export default function RulesPage() {
);
}
function deleteRule(rule: Rule) {
if (window.confirm(`Delete the ${rule.action} rule for "${rule.pattern}"?`)) {
remove.mutate(rule.id);
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id);
setPendingDelete(null);
}
return (
<section>
<h1 className="text-2xl font-semibold">Rules</h1>
<h1 {...stylex.props(styles.heading)}>Rules</h1>
{rules.length === 0 ? (
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
<p {...stylex.props(styles.empty)}>No allow or block rules yet. Create one below.</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th className={thClass}>Pattern</th>
<th className={thClass}>Kind</th>
<th className={thClass}>Action</th>
<th className={thClass}>Group</th>
<th className={thClass}>Created</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
<th {...stylex.props(shared.th)}>Pattern</th>
<th {...stylex.props(shared.th)}>Kind</th>
<th {...stylex.props(shared.th)}>Action</th>
<th {...stylex.props(shared.th)}>Group</th>
<th {...stylex.props(shared.th)}>Created</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td className={`${tdClass} font-medium`}>{rule.pattern}</td>
<td className={tdClass}>{rule.kind}</td>
<td className={tdClass}>
<span
className={
rule.action === "allow"
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}
>
<td {...stylex.props(shared.td, styles.pattern)}>{rule.pattern}</td>
<td {...stylex.props(shared.td)}>{rule.kind}</td>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(rule.action === "allow" ? styles.allow : styles.block)}>
{rule.action}
</span>
</td>
<td className={tdClass}>{rule.group}</td>
<td className={tdClass}>{formatTime(rule.created_at)}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td)}>{rule.group}</td>
<td {...stylex.props(shared.td)}>{formatTime(rule.created_at)}</td>
<td {...stylex.props(shared.td)}>
<button
type="button"
onClick={() => deleteRule(rule)}
onClick={() => setPendingDelete(rule)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
@@ -94,10 +172,10 @@ export default function RulesPage() {
)}
<InlineError error={remove.error} />
<form onSubmit={onSubmit} className="mt-6 max-w-xl space-y-3">
<h2 className="text-lg font-medium">Create rule</h2>
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.formHeading)}>Create rule</h2>
<div>
<label htmlFor="rule-pattern" className="block text-sm font-medium">
<label htmlFor="rule-pattern" {...stylex.props(styles.fieldLabel)}>
Pattern
</label>
<input
@@ -107,66 +185,54 @@ export default function RulesPage() {
value={pattern}
onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com or *.example.com"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label htmlFor="rule-kind" className="block text-sm font-medium">
Kind
</label>
<select
id="rule-kind"
value={kind}
onChange={(event) => setKind(event.target.value as RuleKind)}
className={inputClass}
>
<option value="exact">exact</option>
<option value="wildcard">wildcard</option>
</select>
</div>
<div>
<label htmlFor="rule-action" className="block text-sm font-medium">
Action
</label>
<select
id="rule-action"
value={action}
onChange={(event) => setAction(event.target.value as RuleAction)}
className={inputClass}
>
<option value="allow">allow</option>
<option value="block">block</option>
</select>
</div>
<div>
<label htmlFor="rule-group" className="block text-sm font-medium">
Group
</label>
<select
id="rule-group"
value={groupId}
onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div {...stylex.props(styles.fieldGrid)}>
<Select
label="Kind"
value={kind}
onChange={(value) => setKind(value as RuleKind)}
options={KIND_OPTIONS}
/>
<Select
label="Action"
value={action}
onChange={(value) => setAction(value as RuleAction)}
options={ACTION_OPTIONS}
/>
<Select
label="Group"
value={String(groupId)}
onChange={(value) => setGroupId(Number(value))}
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
/>
</div>
<div {...stylex.props(styles.submitRow)}>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
</div>
<button
type="submit"
disabled={create.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{create.isPending ? "Creating…" : "Create rule"}
</button>
<InlineError error={create.error} />
</form>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete rule"
message={
pendingDelete === null
? ""
: `Delete the ${pendingDelete.action} rule for "${pendingDelete.pattern}"?`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
+64 -15
View File
@@ -1,7 +1,9 @@
import { useState, type FormEvent } from "react";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT } from "@/features/settings/authority";
const DEFAULT_PRIORITY = "100";
@@ -16,6 +18,49 @@ interface UpstreamFormProps {
onCancel?: () => void;
}
const styles = stylex.create({
form: {
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginTop: "1rem",
maxWidth: "36rem",
},
heading: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 500,
},
fieldLabel: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
hint: {
marginTop: "0.25rem",
fontSize: "0.75rem",
lineHeight: "1rem",
color: colors.textMuted,
},
checkboxLabel: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
actions: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
},
cancelButton: {
fontWeight: 500,
},
});
export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit, onCancel }: UpstreamFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [priority, setPriority] = useState(initial === undefined ? DEFAULT_PRIORITY : String(initial.priority));
@@ -45,10 +90,10 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
}
return (
<form onSubmit={handleSubmit} className="mt-4 max-w-xl space-y-3">
<h2 className="text-lg font-medium">{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
<form onSubmit={handleSubmit} {...stylex.props(styles.form)}>
<h2 {...stylex.props(styles.heading)}>{initial === undefined ? "Add upstream" : `Edit ${initial.url}`}</h2>
<div>
<label htmlFor="upstream-url" className="block text-sm font-medium">
<label htmlFor="upstream-url" {...stylex.props(styles.fieldLabel)}>
URL
</label>
<input
@@ -58,11 +103,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="udp://1.1.1.1:53"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor="upstream-priority" className="block text-sm font-medium">
<label htmlFor="upstream-priority" {...stylex.props(styles.fieldLabel)}>
Priority
</label>
<input
@@ -71,11 +116,11 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
min={0}
value={priority}
onChange={(event) => setPriority(event.target.value)}
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
</div>
<div>
<label htmlFor="upstream-tls-name" className="block text-sm font-medium">
<label htmlFor="upstream-tls-name" {...stylex.props(styles.fieldLabel)}>
TLS name
</label>
<input
@@ -84,32 +129,36 @@ export default function UpstreamForm({ initial, busy, readOnly, error, onSubmit,
value={tlsName}
onChange={(event) => setTlsName(event.target.value)}
placeholder="one.one.one.one"
className={inputClass}
{...stylex.props(shared.input, shared.focusRing)}
/>
<p className="mt-1 text-xs text-zinc-500">
<p {...stylex.props(styles.hint)}>
The SNI and certificate name for a <code>tls://</code> upstream. Leave empty for every other scheme.
</p>
</div>
<label className="flex items-center gap-2 text-sm font-medium">
<label {...stylex.props(styles.checkboxLabel)}>
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
Enabled
</label>
<div className="flex items-center gap-2">
<div {...stylex.props(styles.actions)}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
{initial === undefined ? "Add upstream" : "Save changes"}
</button>
{onCancel !== undefined && (
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
<button
type="button"
onClick={onCancel}
{...stylex.props(shared.button, styles.cancelButton, shared.focusRing)}
>
Cancel
</button>
)}
@@ -134,15 +134,29 @@ test("toggling enabled resends the whole row", async () => {
await screen.findByRole("status");
});
test("delete asks for confirmation and skips the request when refused", async () => {
/** The row Delete opens the dialog; the dialog's own Delete is the confirm. */
async function openDeleteDialog() {
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
return await screen.findByRole("alertdialog");
}
test("delete asks for confirmation and skips the request when cancelled", async () => {
await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(false);
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
const dialog = await openDeleteDialog();
expect(dialog.textContent).toContain('Delete upstream "udp://1.1.1.1:53"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(calls).toHaveLength(0);
});
test("confirming the delete dialog issues the DELETE and raises the restart banner", async () => {
await renderUpstreamsRoute();
await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
await waitFor(() => expect(calls).toHaveLength(1));
expect(calls[0]!.method).toBe("DELETE");
expect(calls[0]!.url).toBe("/api/upstreams/1");
@@ -185,14 +199,14 @@ test("a 409 on toggle renders the last-enabled conflict above the form", async (
test("a 409 on delete renders the last-enabled conflict", async () => {
await renderUpstreamsRoute();
vi.spyOn(window, "confirm").mockReturnValue(true);
writeResponse = () =>
new Response(JSON.stringify({ error: "the last enabled upstream cannot be removed" }), {
status: 409,
headers: { "content-type": "application/json" },
});
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
await openDeleteDialog();
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toBe("the last enabled upstream cannot be removed");
+103 -27
View File
@@ -1,17 +1,76 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import InlineError from "@/lib/InlineError";
import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation, upstreamsQuery } from "@/lib/queries";
import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
import ConfirmDialog from "@/ui/ConfirmDialog";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
const styles = stylex.create({
heading: {
fontSize: "1.5rem",
lineHeight: "2rem",
fontWeight: 600,
},
intro: {
marginTop: "0.5rem",
maxWidth: "42rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
empty: {
marginTop: "1rem",
color: colors.textMuted,
},
table: {
width: "100%",
minWidth: "max-content",
borderCollapse: "collapse",
fontSize: "0.875rem",
lineHeight: "1.25rem",
},
url: {
display: "block",
maxWidth: "18rem",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 500,
},
numeric: {
fontVariantNumeric: "tabular-nums",
},
actions: {
display: "flex",
gap: "0.75rem",
},
dimWhenDisabled: {
opacity: { default: 1, ":disabled": 0.5 },
},
srOnly: {
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
borderWidth: 0,
},
});
export default function UpstreamsPage() {
const queryClient = useQueryClient();
const { data: upstreams } = useSuspenseQuery(upstreamsQuery());
const [editing, setEditing] = useState<Upstream | null>(null);
const [pendingDelete, setPendingDelete] = useState<Upstream | null>(null);
const create = useMutation(upstreamCreateMutation(queryClient));
const save = useMutation(upstreamUpdateMutation(queryClient));
@@ -39,10 +98,10 @@ export default function UpstreamsPage() {
);
}
function deleteUpstream(u: Upstream) {
if (window.confirm(`Delete upstream "${u.url}"? Queries stop being forwarded to it.`)) {
remove.mutate(u.id, { onSuccess: () => raiseRestartBanner() });
}
function confirmDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.id, { onSuccess: () => raiseRestartBanner() });
setPendingDelete(null);
}
const formError = editing === null ? create.error : save.error;
@@ -50,38 +109,38 @@ export default function UpstreamsPage() {
return (
<section>
<h1 className="text-2xl font-semibold">Upstreams</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-500">
<h1 {...stylex.props(styles.heading)}>Upstreams</h1>
<p {...stylex.props(styles.intro)}>
The pool builds its clients at startup, so an edit here takes effect at the next restart. The upstream
health table on the Dashboard reflects the running pool, not this list.
</p>
{upstreams.length === 0 ? (
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p>
<p {...stylex.props(styles.empty)}>No upstreams yet. Add one below.</p>
) : (
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<div {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<tr>
<th className={thClass}>URL</th>
<th className={thClass}>Priority</th>
<th className={thClass}>Enabled</th>
<th className={thClass}>TLS name</th>
<th className={thClass}>
<span className="sr-only">Actions</span>
<th {...stylex.props(shared.th)}>URL</th>
<th {...stylex.props(shared.th)}>Priority</th>
<th {...stylex.props(shared.th)}>Enabled</th>
<th {...stylex.props(shared.th)}>TLS name</th>
<th {...stylex.props(shared.th)}>
<span {...stylex.props(styles.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{upstreams.map((u) => (
<tr key={u.id}>
<td className={tdClass}>
<span className="block max-w-72 truncate font-medium" title={u.url}>
<td {...stylex.props(shared.td)}>
<span {...stylex.props(styles.url)} title={u.url}>
{u.url}
</span>
</td>
<td className={`${tdClass} tabular-nums`}>{u.priority}</td>
<td className={tdClass}>
<td {...stylex.props(shared.td, styles.numeric)}>{u.priority}</td>
<td {...stylex.props(shared.td)}>
<input
type="checkbox"
aria-label={`${u.url} enabled`}
@@ -89,27 +148,31 @@ export default function UpstreamsPage() {
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(u)}
className={focusRing}
{...stylex.props(shared.focusRing)}
/>
</td>
<td className={tdClass}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td className={tdClass}>
<div className="flex gap-3">
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td {...stylex.props(shared.td)}>
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => setEditing(u)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
{...stylex.props(
shared.linkButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => deleteUpstream(u)}
onClick={() => setPendingDelete(u)}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
>
Delete
</button>
@@ -132,6 +195,19 @@ export default function UpstreamsPage() {
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}
/>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete upstream"
message={
pendingDelete === null
? ""
: `Delete upstream "${pendingDelete.url}"? Queries stop being forwarded to it.`
}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
</section>
);
}
+113
View File
@@ -0,0 +1,113 @@
/**
* The destructive-action confirmation (milestone 23, ruling 5), which replaces
* every `window.confirm` call in the app.
*
* `role="alertdialog"` rather than `dialog`: the message is the reason the
* dialog exists, so it is announced with the dialog instead of after it. The
* overlay is deliberately not dismissable — a delete needs an explicit answer,
* and a stray outside click is not one. Escape still cancels.
*/
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
interface Props {
isOpen: boolean;
title: string;
/** The full sentence the operator reads before confirming; names the entity. */
message: string;
confirmLabel: string;
onConfirm: () => void;
onCancel: () => void;
}
const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
panel: {
width: "100%",
maxWidth: "26rem",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.dangerBorder,
backgroundColor: colors.surfaceRaised,
color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
body: {
outlineStyle: "none",
},
title: {
fontSize: "1.125rem",
lineHeight: "1.75rem",
fontWeight: 600,
},
message: {
marginTop: "0.5rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
color: colors.textMuted,
},
actions: {
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
marginTop: "1.5rem",
},
dangerButton: {
borderRadius: "0.25rem",
borderStyle: "none",
backgroundColor: colors.danger,
color: colors.primaryText,
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
});
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onCancel();
}}
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog role="alertdialog" {...stylex.props(styles.body)}>
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
{title}
</Heading>
<p {...stylex.props(styles.message)}>{message}</p>
<div {...stylex.props(styles.actions)}>
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
{...stylex.props(styles.dangerButton, shared.focusRing)}
>
{confirmLabel}
</button>
</div>
</AriaDialog>
</Modal>
</ModalOverlay>
);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* The modal dialog (milestone 23, ruling 4).
*
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
* wiring that the hand-rolled overlay only approximated. State is controlled by
* the caller because the trigger is a table row button, not a `DialogTrigger`.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
import { colors } from "./tokens.stylex";
interface Props {
/** The dialog's accessible name. */
label: string;
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}
const styles = stylex.create({
overlay: {
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1rem",
backgroundColor: "rgba(0, 0, 0, 0.4)",
},
panel: {
width: "100%",
maxWidth: "28rem",
borderRadius: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
backgroundColor: colors.surfaceRaised,
color: colors.text,
padding: "1.5rem",
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
/** The panel already draws the boundary; the dialog's own ring would double it. */
body: {
outlineStyle: "none",
},
});
export default function Dialog({ label, isOpen, onClose, children }: Props) {
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
isDismissable
className={() => stylex.props(styles.overlay).className ?? ""}
>
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
{children}
</AriaDialog>
</Modal>
</ModalOverlay>
);
}
+140
View File
@@ -0,0 +1,140 @@
/**
* The single-choice picker (milestone 23, ruling 4), replacing every native
* `<select>` in the app.
*
* RAC composes a Select out of Label, Button, SelectValue, Popover, ListBox and
* ListBoxItem; those parts are the Select, not extra components adopted beyond
* ruling 4's scope, and nothing outside this file imports them.
*
* Keys are strings because a `<select>`'s value was a string. A call site that
* models an id converts on both edges.
*/
import * as stylex from "@stylexjs/stylex";
import { Button, Label, ListBox, ListBoxItem, Popover, Select as AriaSelect, SelectValue } from "react-aria-components";
import { colors } from "./tokens.stylex";
import { styles as shared } from "./styles";
export interface SelectOption {
value: string;
label: string;
}
interface Props {
options: readonly SelectOption[];
value: string;
onChange: (value: string) => void;
/** The visible label. Omit it only when `aria-label` names the control. */
label?: string;
"aria-label"?: string;
/**
* `field` matches a full-width form input, `compactField` the smaller one a
* dialog uses, `inline` a control sitting in a row of other controls.
*/
variant?: "field" | "compactField" | "inline";
}
const styles = stylex.create({
root: {
display: "block",
},
label: {
display: "block",
fontSize: "0.875rem",
lineHeight: "1.25rem",
fontWeight: 500,
},
trigger: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
textAlign: "left",
cursor: "pointer",
},
compact: {
marginTop: "0.25rem",
width: "100%",
},
/** Explicit, so RAC's default `react-aria-SelectValue` class does not land. */
value: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
chevron: {
color: colors.textMuted,
},
popover: {
width: "var(--trigger-width)",
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.25rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.borderStrong,
backgroundColor: colors.surfaceRaised,
color: colors.text,
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
},
listBox: {
outlineStyle: "none",
paddingBlock: "0.25rem",
},
item: {
paddingInline: "0.75rem",
paddingBlock: "0.375rem",
fontSize: "0.875rem",
lineHeight: "1.25rem",
cursor: "pointer",
outlineStyle: "none",
},
itemFocused: {
backgroundColor: colors.primary,
color: colors.primaryText,
},
itemSelected: {
fontWeight: 600,
},
});
export default function Select({ options, value, onChange, label, "aria-label": ariaLabel, variant = "field" }: Props) {
const base = variant === "field" ? shared.input : shared.smallInput;
const block = variant === "compactField" ? styles.compact : null;
return (
<AriaSelect
aria-label={ariaLabel}
value={value}
onChange={(key) => onChange(String(key ?? ""))}
{...stylex.props(styles.root)}
>
{label !== undefined && <Label {...stylex.props(styles.label)}>{label}</Label>}
<Button className={() => stylex.props(base, block, styles.trigger, shared.focusRing).className ?? ""}>
<SelectValue className={() => stylex.props(styles.value).className ?? ""} />
<span aria-hidden="true" {...stylex.props(styles.chevron)}>
</span>
</Button>
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
<ListBox {...stylex.props(styles.listBox)}>
{options.map((option) => (
<ListBoxItem
key={option.value}
id={option.value}
textValue={option.label}
className={({ isFocused, isSelected }) =>
stylex.props(
styles.item,
isSelected && styles.itemSelected,
isFocused && styles.itemFocused,
).className ?? ""
}
>
{option.label}
</ListBoxItem>
))}
</ListBox>
</Popover>
</AriaSelect>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* The tab switcher (milestone 23, ruling 4).
*
* The hand-rolled version spelled the ARIA attributes by hand but had no
* keyboard navigation; React Aria brings arrow-key movement and roving
* tabindex with the same roles. State stays inside RAC — no caller needs to
* read which tab is open.
*
* RAC exposes state as render-prop booleans, so every rule below is a
* boolean-guarded style object: StyleX cannot express `[data-selected]`.
*/
import type { ReactNode } from "react";
import * as stylex from "@stylexjs/stylex";
import { Tab, TabList, TabPanel, Tabs as AriaTabs } from "react-aria-components";
import { colors } from "./tokens.stylex";
export interface TabSpec {
id: string;
label: string;
content: ReactNode;
}
interface Props {
/** The tab list's accessible name. */
label: string;
tabs: readonly TabSpec[];
}
const styles = stylex.create({
list: {
display: "flex",
gap: "0.5rem",
marginTop: "1rem",
borderBottomWidth: 1,
borderBottomStyle: "solid",
borderBottomColor: colors.border,
},
tab: {
marginBottom: -1,
borderBottomWidth: 2,
borderBottomStyle: "solid",
borderBottomColor: "transparent",
paddingInline: "0.75rem",
paddingBlock: "0.5rem",
fontWeight: 500,
color: colors.textMuted,
cursor: "pointer",
},
tabSelected: {
borderBottomColor: colors.primary,
color: colors.primaryOnSurface,
},
tabHovered: {
color: colors.text,
},
/**
* The milestone-9 focus floor. A Tab is a `div` with a roving tabindex, so
* the ring is driven by RAC's `isFocusVisible` rather than `:focus-visible`.
*/
tabFocusVisible: {
outlineWidth: 2,
outlineStyle: "solid",
outlineColor: colors.focus,
outlineOffset: 2,
},
panel: {
outlineStyle: "none",
},
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
root: {
display: "block",
},
});
export default function Tabs({ label, tabs }: Props) {
return (
<AriaTabs className={() => stylex.props(styles.root).className ?? ""}>
<TabList aria-label={label} className={() => stylex.props(styles.list).className ?? ""}>
{tabs.map((tab) => (
<Tab
key={tab.id}
id={tab.id}
className={({ isSelected, isHovered, isFocusVisible }) =>
stylex.props(
styles.tab,
isHovered && !isSelected && styles.tabHovered,
isSelected && styles.tabSelected,
isFocusVisible && styles.tabFocusVisible,
).className ?? ""
}
>
{tab.label}
</Tab>
))}
</TabList>
{tabs.map((tab) => (
<TabPanel key={tab.id} id={tab.id} className={() => stylex.props(styles.panel).className ?? ""}>
{tab.content}
</TabPanel>
))}
</AriaTabs>
);
}
+69 -8
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { act, fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
import ConfirmDialog from "./ConfirmDialog";
import { useCrudForm } from "./useCrudForm";
interface Row {
@@ -41,6 +42,50 @@ function setup() {
return { hook, created, updated, removed };
}
/**
* The delete path is only finished when a real dialog drives it, so this
* harness wires the hook's pending-delete state to the component operators
* actually see (ruling 5). `window.confirm` no longer exists to stub.
*/
function DeleteHarness({ onRemoved }: { onRemoved: (id: number) => void }) {
const { onDelete, pendingDelete, confirmPendingDelete, cancelPendingDelete } = useCrudForm<Row, string>({
create: (_qc: QC) => ({ mutationFn: async () => undefined }),
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
remove: (_qc: QC) => ({
mutationFn: async (id: number) => {
onRemoved(id);
},
}),
confirmDelete: (row) => `Delete "${row.name}"?`,
});
return (
<>
<button type="button" onClick={() => onDelete({ id: 3, name: "gamma" })}>
Delete gamma
</button>
<ConfirmDialog
isOpen={pendingDelete !== null}
title="Delete row"
message={pendingDelete?.message ?? ""}
confirmLabel="Delete"
onConfirm={confirmPendingDelete}
onCancel={cancelPendingDelete}
/>
</>
);
}
function renderDeleteHarness() {
const removed: number[] = [];
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<DeleteHarness onRemoved={(id) => removed.push(id)} />
</QueryClientProvider>,
);
return removed;
}
afterEach(() => vi.unstubAllGlobals());
test("the create form submits through the create mutation and closes", async () => {
@@ -96,18 +141,34 @@ test("openForm clears a stale create error so the reopened form starts clean", a
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
});
test("delete asks for confirmation and only removes when confirmed", async () => {
test("onDelete opens the confirmation instead of removing the row", () => {
const { hook, removed } = setup();
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
expect(hook.result.current.pendingDelete).toEqual({ entity: { id: 3, name: "gamma" }, message: 'Delete "gamma"?' });
expect(removed).toEqual([]);
});
confirm.mockReturnValue(true);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
test("cancelling the confirmation dialog leaves the row alone", async () => {
const removed = renderDeleteHarness();
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
const dialog = await screen.findByRole("alertdialog");
expect(dialog.textContent).toContain('Delete "gamma"?');
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
expect(removed).toEqual([]);
});
test("confirming the dialog removes the row by id", async () => {
const removed = renderDeleteHarness();
fireEvent.click(screen.getByRole("button", { name: "Delete gamma" }));
await screen.findByRole("alertdialog");
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => expect(removed).toEqual([3]));
expect(screen.queryByRole("alertdialog")).toBeNull();
});
test("closeForm closes whichever form is open", () => {
+36 -6
View File
@@ -4,20 +4,28 @@ import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions
/** Which form is open: none, the create form, or the edit form for one row. */
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
/** The row a confirmation dialog is open for, and the message it shows. */
export interface PendingDelete<Entity> {
entity: Entity;
message: string;
}
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
export interface CrudFormSpec<Entity extends { id: number }, Input> {
create: MutationFactory<Input>;
update: MutationFactory<{ id: number; input: Input }>;
remove: MutationFactory<number>;
/** The `window.confirm` text shown before a delete. */
/** The message the confirmation dialog shows before a delete. */
confirmDelete: (entity: Entity) => string;
}
/**
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
* three mutations, the open-form state, and the three handlers. The field JSX
* and the table stay in the caller.
* three mutations, the open-form state, the pending-delete state and the
* handlers. The field JSX, the table and the confirmation dialog stay in the
* caller: `onDelete` opens the confirmation instead of deciding anything, so
* the hook never reaches for `window.confirm` (ruling 5).
*/
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
const queryClient = useQueryClient();
@@ -25,6 +33,7 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
const update = useMutation(spec.update(queryClient));
const remove = useMutation(spec.remove(queryClient));
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
const [pendingDelete, setPendingDelete] = useState<PendingDelete<Entity> | null>(null);
function openForm(next: CrudFormState<Entity>) {
create.reset();
@@ -46,9 +55,30 @@ export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudForm
}
function onDelete(entity: Entity) {
if (!window.confirm(spec.confirmDelete(entity))) return;
remove.mutate(entity.id);
setPendingDelete({ entity, message: spec.confirmDelete(entity) });
}
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete };
function confirmPendingDelete() {
if (pendingDelete === null) return;
remove.mutate(pendingDelete.entity.id);
setPendingDelete(null);
}
function cancelPendingDelete() {
setPendingDelete(null);
}
return {
create,
update,
remove,
form,
openForm,
closeForm,
onSubmit,
onDelete,
pendingDelete,
confirmPendingDelete,
cancelPendingDelete,
};
}
+9 -1
View File
@@ -7,7 +7,14 @@ import { defineConfig } from "vite";
export default defineConfig(({ mode }) => ({
plugins: [
stylex.vite({ useCSSLayers: true, dev: mode === "development", runtimeInjection: false }),
// StyleX resolves the `.stylex.ts` theme file itself, so it needs the `@`
// alias too — `resolve.alias` below is Vite's and the plugin cannot see it.
stylex.vite({
useCSSLayers: true,
dev: mode === "development",
runtimeInjection: false,
aliases: { "@/*": [fileURLToPath(new URL("./src/*", import.meta.url))] },
}),
react(),
tailwindcss(),
],
@@ -25,5 +32,6 @@ export default defineConfig(({ mode }) => ({
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
},
}));
+49
View File
@@ -0,0 +1,49 @@
/**
* jsdom implements no `CSS` interface at all, and React Aria's collection code
* calls `CSS.escape` unguarded when it looks an item up by key
* (`react-aria/dist/private/selection/utils.mjs:22`). Without this, opening a
* RAC Select or moving between RAC Tabs throws in tests but works in a browser.
*
* The implementation is CSSOM's `serialize an identifier` algorithm
* (https://drafts.csswg.org/cssom/#serialize-an-identifier), not an
* approximation: a wrong escape would silently break the key lookups instead of
* failing loudly.
*/
function escapeIdentifier(value: string): string {
let result = "";
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
const char = value[index]!;
if (code === 0x0000) {
result += "";
} else if (
(code >= 0x0001 && code <= 0x001f) ||
code === 0x007f ||
(index === 0 && code >= 0x0030 && code <= 0x0039) ||
(index === 1 && code >= 0x0030 && code <= 0x0039 && value.charCodeAt(0) === 0x002d)
) {
result += `\\${code.toString(16)} `;
} else if (index === 0 && code === 0x002d && value.length === 1) {
result += `\\${char}`;
} else if (
code >= 0x0080 ||
code === 0x002d ||
code === 0x005f ||
(code >= 0x0030 && code <= 0x0039) ||
(code >= 0x0041 && code <= 0x005a) ||
(code >= 0x0061 && code <= 0x007a)
) {
result += char;
} else {
result += `\\${char}`;
}
}
return result;
}
if (globalThis.CSS === undefined) {
Object.defineProperty(globalThis, "CSS", { value: { escape: escapeIdentifier }, configurable: true });
} else if (typeof globalThis.CSS.escape !== "function") {
globalThis.CSS.escape = escapeIdentifier;
}