rename web/ to admin/, along with the web-named build and cli identifiers

This commit is contained in:
2026-08-16 00:17:58 +02:00
parent 5b3d1cd65c
commit 1e97c80f6b
136 changed files with 196 additions and 196 deletions
@@ -0,0 +1,104 @@
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 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 {
client: Client;
groups: Group[];
onClose: () => void;
}
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();
const mutation = useMutation(clientUpdateMutation(queryClient));
const readOnly = useReadOnlyConfig();
// Adopting the learned name as a typed one is the natural gesture, but only
// where the save can land: under file authority the PUT answers 403, and the
// file's declared name is the one that wins.
const [name, setName] = useState(client.name === "" && !readOnly ? client.learned_name : client.name);
const [groupId, setGroupId] = useState(client.group_id);
return (
<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 },
);
}}
>
<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>
);
}
@@ -0,0 +1,161 @@
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";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
const GROUPS = {
groups: [
{ id: 1, name: "default", safe_search: false },
{ id: 2, name: "kids", safe_search: true },
],
};
const CLIENTS = {
clients: [
{
id: 1,
ip: "192.168.1.10",
name: "laptop",
learned_name: "laptop-1.lan",
group_id: 1,
group: "default",
hand_edited: true,
first_seen: 1700000000,
last_seen: 1700003600,
},
{
id: 2,
ip: "192.168.1.11",
name: "",
learned_name: "kids-tablet.lan",
group_id: 2,
group: "kids",
hand_edited: false,
first_seen: 1700000000,
last_seen: 1700007200,
},
],
};
const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
function stubFetch(map: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const key = `${init?.method ?? "GET"} ${String(input)}`;
const payload = map[key];
if (payload === undefined) {
return new Response(JSON.stringify({ error: `not stubbed: ${key}` }), { status: 404 });
}
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
}
async function renderClientsPage(map: Record<string, unknown>) {
stubFetch(map);
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/clients"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: "Clients" });
}
const BASE = {
"GET /api/clients": CLIENTS,
"GET /api/client-prefixes": PREFIXES,
"GET /api/groups": GROUPS,
"GET /api/version": VERSION,
};
afterEach(() => {
vi.unstubAllGlobals();
});
test("renders the client table with group names and one hand-edited badge", async () => {
await renderClientsPage(BASE);
expect(screen.getByText("192.168.1.10")).toBeTruthy();
expect(screen.getByText("192.168.1.11")).toBeTruthy();
expect(screen.getByText("laptop")).toBeTruthy();
expect(screen.getAllByText("edited")).toHaveLength(1);
expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
});
test("a named row shows the typed name and hides the learned one", async () => {
await renderClientsPage(BASE);
expect(screen.getByText("laptop")).toBeTruthy();
expect(screen.queryByText("laptop-1.lan")).toBeNull();
});
test("an unnamed row shows the learned name with the learned affordance", async () => {
await renderClientsPage(BASE);
// The cell holds the learned name followed by the tag, so the match is on
// the containing span rather than on a bare text node.
const learned = screen.getByText(
(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
);
// The affordance is text, not colour, so a screen reader announces it too.
expect(within(learned).getByText("learned")).toBeTruthy();
});
test("shows the DNS-activity empty state when there are no clients", async () => {
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
expect(screen.getByText(/rows appear automatically as devices on the network make dns queries/i)).toBeTruthy();
expect(screen.queryByRole("table")).toBeNull();
});
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]!);
// 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 () => {
await renderClientsPage(BASE);
expect((screen.getByLabelText("Prefix 1") as HTMLInputElement).value).toBe("192.168.1.0/24");
const save = screen.getByRole("button", { name: "Save prefixes" }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "Add prefix" }));
expect(save.disabled).toBe(false);
expect((screen.getByLabelText("Prefix 2") as HTMLInputElement).value).toBe("");
});
+243
View File
@@ -0,0 +1,243 @@
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 { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
/**
* Deleting an observed row discards runtime state the file never declared, so
* it stays live under file authority; deleting a hand-edited row contradicts
* the file and is the one client DELETE the server answers 403 (ruling 7).
*/
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",
},
right: {
textAlign: "right",
},
dash: {
color: colors.textMuted,
},
/**
* A learned name is runtime state, not something the operator typed, so it
* reads muted and carries an outlined "learned" tag. The tag is real text —
* a screen reader announces it — because colour alone is not an affordance.
*/
learnedTag: {
marginLeft: "0.5rem",
borderWidth: 1,
borderStyle: "solid",
borderColor: colors.border,
borderRadius: "0.25rem",
paddingInline: "0.375rem",
paddingBlock: "0.125rem",
fontSize: "0.75rem",
lineHeight: "1rem",
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.
*/
});
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
const { data: prefixes } = useSuspenseQuery(clientPrefixesQuery());
const { data: groups } = useSuspenseQuery(groupsQuery());
const queryClient = useQueryClient();
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
const [editing, setEditing] = useState<Client | null>(null);
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const readOnly = useReadOnlyConfig();
return (
<section>
<h1 {...stylex.props(styles.heading)}>Clients</h1>
{clients.length === 0 ? (
<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 {...stylex.props(shared.tableWrap)}>
<table {...stylex.props(styles.table)}>
<thead>
<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(shared.srOnly)}>Actions</span>
</th>
</tr>
</thead>
<tbody>
{clients.map((client) => (
<tr key={client.id} {...stylex.props(styles.bodyRow)}>
<td {...stylex.props(styles.cell, shared.mono)}>{client.ip}</td>
<td {...stylex.props(styles.cell)}>
{client.name !== "" ? (
client.name
) : client.learned_name !== "" ? (
<span {...stylex.props(styles.dash)}>
{client.learned_name}
<span {...stylex.props(styles.learnedTag)}>learned</span>
</span>
) : (
<span {...stylex.props(styles.dash)}></span>
)}
{client.hand_edited && <span {...stylex.props(styles.badge)}>edited</span>}
</td>
<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 {...stylex.props(styles.confirmGroup)}>
<span {...stylex.props(styles.note)}>
Deleted clients re-materialize on their next DNS query.
</span>
<button
type="button"
onClick={() => {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
{...stylex.props(
shared.smallButton,
styles.dangerText,
shared.focusRing,
)}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
{...stylex.props(shared.smallButton, shared.focusRing)}
>
Cancel
</button>
</span>
) : (
<span {...stylex.props(styles.actionGroup)}>
<button
type="button"
onClick={() => setEditing(client)}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(
shared.smallButton,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Edit
</button>
<button
type="button"
onClick={() => setConfirmingId(client.id)}
disabled={readOnly && client.hand_edited}
title={
readOnly && client.hand_edited
? DECLARED_CLIENT_NOTE
: undefined
}
{...stylex.props(
shared.smallButton,
styles.dangerText,
styles.dimWhenDisabled,
shared.focusRing,
)}
>
Delete
</button>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<InlineError error={deleteMutation.error} />
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
<PrefixesEditor prefixes={prefixes} groups={groups} />
</section>
);
}
@@ -0,0 +1,196 @@
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 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 {
prefixes: ClientPrefix[];
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));
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
const [validation, setValidation] = useState<string | null>(null);
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);
setValidation(problem);
if (problem !== null) return;
mutation.mutate(toInputs(state.rows), {
onSuccess: (stored) => dispatch({ type: "reset", prefixes: stored }),
});
};
return (
<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 {...stylex.props(styles.empty)}>No prefixes configured.</p>
) : (
<ul {...stylex.props(styles.rows)}>
{state.rows.map((row, index) => (
<li key={index} {...stylex.props(styles.row)}>
<input
type="text"
aria-label={`Prefix ${index + 1}`}
placeholder="192.168.1.0/24"
value={row.prefix}
onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
}
{...stylex.props(shared.smallInput, styles.prefixInput, shared.focusRing)}
/>
<Select
aria-label={`Group for prefix ${index + 1}`}
variant="inline"
value={String(row.group_id)}
onChange={(value) =>
dispatch({ type: "edit", index, patch: { group_id: Number(value) } })
}
options={groupOptions}
/>
<input
type="text"
inputMode="numeric"
aria-label={`Priority for prefix ${index + 1}`}
placeholder="100"
value={row.priority}
onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
}
{...stylex.props(shared.smallInput, styles.priorityInput, shared.focusRing)}
/>
<button
type="button"
onClick={() => dispatch({ type: "remove", index })}
{...stylex.props(styles.removeButton, shared.focusRing)}
>
Remove
</button>
</li>
))}
</ul>
)}
{validation !== null && (
<p role="alert" {...stylex.props(styles.validation)}>
{validation}
</p>
)}
<InlineError error={mutation.error} />
<div {...stylex.props(styles.actions)}>
<button
type="button"
onClick={() => dispatch({ type: "add", groupId: fallbackGroupId })}
{...stylex.props(shared.button, shared.focusRing)}
>
Add prefix
</button>
<button
type="button"
onClick={save}
disabled={!dirty || mutation.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
{...stylex.props(shared.primaryButton, shared.focusRing)}
>
Save prefixes
</button>
{dirty && (
<button
type="button"
onClick={() => {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
{...stylex.props(shared.button, shared.focusRing)}
>
Discard changes
</button>
)}
</div>
</section>
);
}
@@ -0,0 +1,86 @@
import type { ClientPrefix } from "@/lib/types";
import {
firstProblem,
initPrefixEditor,
isDirty,
prefixEditorReducer,
toInputs,
type PrefixEditorState,
} from "./prefixEditor";
const server: ClientPrefix[] = [
{ id: 1, prefix: "192.168.1.0/24", group_id: 1, group: "default", priority: 100 },
{ id: 2, prefix: "10.0.0.0/8", group_id: 2, group: "kids", priority: 50 },
];
test("init mirrors the server rows into baseline and rows", () => {
const state = initPrefixEditor(server);
expect(state.rows).toEqual([
{ prefix: "192.168.1.0/24", group_id: 1, priority: "100" },
{ prefix: "10.0.0.0/8", group_id: 2, priority: "50" },
]);
expect(state.baseline).toEqual(state.rows);
expect(isDirty(state)).toBe(false);
});
test("add appends an empty row with the given group and marks dirty", () => {
const state = prefixEditorReducer(initPrefixEditor(server), { type: "add", groupId: 1 });
expect(state.rows).toHaveLength(3);
expect(state.rows[2]).toEqual({ prefix: "", group_id: 1, priority: "" });
expect(isDirty(state)).toBe(true);
});
test("remove drops the row at the index", () => {
const state = prefixEditorReducer(initPrefixEditor(server), { type: "remove", index: 0 });
expect(state.rows).toEqual([{ prefix: "10.0.0.0/8", group_id: 2, priority: "50" }]);
expect(isDirty(state)).toBe(true);
});
test("edit patches a single row", () => {
const state = prefixEditorReducer(initPrefixEditor(server), {
type: "edit",
index: 1,
patch: { group_id: 1, priority: "10" },
});
expect(state.rows[1]).toEqual({ prefix: "10.0.0.0/8", group_id: 1, priority: "10" });
expect(state.rows[0]).toEqual(state.baseline[0]);
expect(isDirty(state)).toBe(true);
});
test("editing a field back to its baseline value is clean again", () => {
let state: PrefixEditorState = initPrefixEditor(server);
state = prefixEditorReducer(state, { type: "edit", index: 0, patch: { priority: "7" } });
expect(isDirty(state)).toBe(true);
state = prefixEditorReducer(state, { type: "edit", index: 0, patch: { priority: "100" } });
expect(isDirty(state)).toBe(false);
});
test("reset adopts new server rows and clears dirtiness", () => {
let state = prefixEditorReducer(initPrefixEditor(server), { type: "add", groupId: 1 });
state = prefixEditorReducer(state, { type: "reset", prefixes: server });
expect(isDirty(state)).toBe(false);
expect(state.rows).toHaveLength(2);
});
test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
expect(
toInputs([
{ prefix: " 192.168.1.0/24 ", group_id: 1, priority: "25" },
{ prefix: "10.0.0.0/8", group_id: 2, priority: "" },
]),
).toEqual([
{ prefix: "192.168.1.0/24", group_id: 1, priority: 25 },
{ prefix: "10.0.0.0/8", group_id: 2 },
]);
});
test("firstProblem flags empty prefixes and non-integer priorities", () => {
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
expect(
firstProblem([
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
]),
).toBe("Row 2: priority must be a whole number.");
});
@@ -0,0 +1,74 @@
import type { ClientPrefix, ClientPrefixInput } from "@/lib/types";
export interface PrefixRow {
prefix: string;
group_id: number;
/** Raw input text; empty means "use the server default (100)". */
priority: string;
}
export interface PrefixEditorState {
baseline: PrefixRow[];
rows: PrefixRow[];
}
export type PrefixEditorAction =
| { type: "reset"; prefixes: ClientPrefix[] }
| { type: "add"; groupId: number }
| { type: "remove"; index: number }
| { type: "edit"; index: number; patch: Partial<PrefixRow> };
function fromServer(prefixes: ClientPrefix[]): PrefixRow[] {
return prefixes.map((p) => ({ prefix: p.prefix, group_id: p.group_id, priority: String(p.priority) }));
}
export function initPrefixEditor(prefixes: ClientPrefix[]): PrefixEditorState {
const rows = fromServer(prefixes);
return { baseline: rows, rows };
}
export function prefixEditorReducer(state: PrefixEditorState, action: PrefixEditorAction): PrefixEditorState {
switch (action.type) {
case "reset":
return initPrefixEditor(action.prefixes);
case "add":
return { ...state, rows: [...state.rows, { prefix: "", group_id: action.groupId, priority: "" }] };
case "remove":
return { ...state, rows: state.rows.filter((_, i) => i !== action.index) };
case "edit":
return {
...state,
rows: state.rows.map((row, i) => (i === action.index ? { ...row, ...action.patch } : row)),
};
}
}
function sameRow(a: PrefixRow, b: PrefixRow): boolean {
return a.prefix === b.prefix && a.group_id === b.group_id && a.priority === b.priority;
}
export function isDirty(state: PrefixEditorState): boolean {
if (state.rows.length !== state.baseline.length) return true;
return state.rows.some((row, i) => {
const base = state.baseline[i];
return base === undefined || !sameRow(row, base);
});
}
export function firstProblem(rows: PrefixRow[]): string | null {
for (const [i, row] of rows.entries()) {
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
const priority = row.priority.trim();
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
}
return null;
}
export function toInputs(rows: PrefixRow[]): ClientPrefixInput[] {
return rows.map((row) => {
const input: ClientPrefixInput = { prefix: row.prefix.trim(), group_id: row.group_id };
const priority = row.priority.trim();
if (priority !== "") input.priority = Number(priority);
return input;
});
}