milestone 23 s2: react aria primitives and their cluster
This commit is contained in:
@@ -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"]));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user