milestone 9: react spa admin ui, frontend ci and embedded dist

This commit is contained in:
2026-08-02 13:04:09 +02:00
parent 5253c47303
commit 617cc966a2
82 changed files with 11833 additions and 17 deletions
@@ -0,0 +1,79 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError";
interface Props {
groupId: number;
blocklists: Blocklist[];
}
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
const queryClient = useQueryClient();
const sources = useQuery(groupSourcesQuery(groupId));
const mutation = useMutation(groupSourcesPutMutation(queryClient));
const [selected, setSelected] = useState<number[] | null>(null);
if (sources.isPending) {
return (
<p role="status" className="mt-3 text-sm text-zinc-500">
Loading sources
</p>
);
}
if (sources.isError) return <InlineError error={sources.error} />;
if (blocklists.length === 0) {
return (
<p className="mt-3 text-sm text-zinc-500">
No blocklist sources exist yet add them on the Blocklists page.
</p>
);
}
const current = selected ?? sources.data;
const dirty = !sameSet(current, sources.data);
return (
<div className="mt-3">
<ul className="space-y-1">
{blocklists.map((blocklist) => (
<li key={blocklist.id}>
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))}
/>
{blocklist.name}
</label>
</li>
))}
</ul>
<InlineError error={mutation.error} />
<div className="mt-3 flex gap-2">
<button
type="button"
disabled={!dirty || mutation.isPending}
onClick={() =>
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Save sources
</button>
{dirty && (
<button
type="button"
onClick={() => setSelected(null)}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
>
Discard
</button>
)}
</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import type { Mock } from "vitest";
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 BLOCKLISTS = {
blocklists: [
{
id: 1,
url: "https://example.com/ads.txt",
name: "Ads",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 100,
wildcard_count: 0,
skipped_regex_count: 0,
checksum: null,
},
{
id: 2,
url: "https://example.com/malware.txt",
name: "Malware",
enabled: true,
is_suggested: false,
last_updated: null,
domain_count: 50,
wildcard_count: 0,
skipped_regex_count: 0,
checksum: null,
},
],
};
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };
const BASE = {
"GET /api/groups": GROUPS,
"GET /api/blocklists": BLOCKLISTS,
"GET /api/version": VERSION,
"GET /api/groups/2/sources": { source_ids: [1] },
"PUT /api/groups/2/sources": { source_ids: [1, 2] },
};
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 renderGroupsPage(map: Record<string, unknown>) {
stubFetch(map);
const queryClient = createQueryClient();
const router = createAppRouter(createMemoryHistory({ initialEntries: ["/groups"] }), queryClient);
render(
<AuthProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AuthProvider>,
);
await screen.findByRole("heading", { name: "Groups" });
}
function groupRow(name: string): HTMLElement {
const row = screen.getByText(name).closest("li");
if (row === null) throw new Error(`no row for group ${name}`);
return row;
}
afterEach(() => {
vi.unstubAllGlobals();
});
test("lists groups; the default group blocks rename and delete client-side", async () => {
await renderGroupsPage(BASE);
const defaultRow = groupRow("default");
expect((within(defaultRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(true);
expect((within(defaultRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
expect(within(defaultRow).getByText("The default group cannot be renamed or deleted.")).toBeTruthy();
const kidsRow = groupRow("kids");
expect((within(kidsRow).getByRole("button", { name: "Rename" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(kidsRow).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
expect((within(kidsRow).getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
});
test("expanding sources loads the set, toggling saves the full set via PUT", async () => {
await renderGroupsPage(BASE);
const kidsRow = groupRow("kids");
fireEvent.click(within(kidsRow).getByRole("button", { name: "Sources" }));
const ads = (await within(kidsRow).findByRole("checkbox", { name: "Ads" })) as HTMLInputElement;
const malware = within(kidsRow).getByRole("checkbox", { name: "Malware" }) as HTMLInputElement;
expect(ads.checked).toBe(true);
expect(malware.checked).toBe(false);
const save = within(kidsRow).getByRole("button", { name: "Save sources" }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.click(malware);
expect(save.disabled).toBe(false);
fireEvent.click(save);
await waitFor(() => expect(save.disabled).toBe(true));
const calls = (fetch as unknown as Mock).mock.calls as [RequestInfo | URL, RequestInit | undefined][];
const put = calls.find(([, init]) => init?.method === "PUT");
expect(put).toBeTruthy();
expect(String(put![0])).toBe("/api/groups/2/sources");
expect(JSON.parse(String(put![1]?.body))).toEqual({ source_ids: [1, 2] });
expect(malware.checked).toBe(true);
});
+188
View File
@@ -0,0 +1,188 @@
import { useState } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import {
blocklistsQuery,
groupCreateMutation,
groupDeleteMutation,
groupsQuery,
groupUpdateMutation,
} from "@/lib/queries";
import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
const DEFAULT_GROUP_ID = 1;
const DEFAULT_GROUP_NOTE = "The default group cannot be renamed or deleted.";
const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
const buttonClass =
"rounded border border-zinc-300 px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 dark:border-zinc-700";
const primaryButtonClass =
"rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
export default function GroupsPage() {
const { data: groups } = useSuspenseQuery(groupsQuery());
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
const queryClient = useQueryClient();
const createMutation = useMutation(groupCreateMutation(queryClient));
const [newName, setNewName] = useState("");
return (
<section>
<h1 className="text-2xl font-semibold">Groups</h1>
<form
className="mt-4 flex flex-wrap items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
const name = newName.trim();
if (name === "") return;
createMutation.mutate({ name }, { onSuccess: () => setNewName("") });
}}
>
<label className="text-sm font-medium" htmlFor="new-group-name">
New group
</label>
<input
id="new-group-name"
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
className={inputClass}
/>
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
Create
</button>
</form>
<InlineError error={createMutation.error} />
<ul className="mt-6 space-y-4">
{groups.map((group) => (
<GroupRow key={group.id} group={group} blocklists={blocklists} />
))}
</ul>
</section>
);
}
function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[] }) {
const queryClient = useQueryClient();
const updateMutation = useMutation(groupUpdateMutation(queryClient));
const deleteMutation = useMutation(groupDeleteMutation(queryClient));
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(group.name);
const [confirming, setConfirming] = useState(false);
const [expanded, setExpanded] = useState(false);
const isDefault = group.id === DEFAULT_GROUP_ID;
return (
<li className="rounded border border-zinc-200 p-4 dark:border-zinc-700">
<div className="flex flex-wrap items-center gap-3">
{renaming ? (
<form
className="flex items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
const trimmed = name.trim();
if (trimmed === "") return;
updateMutation.mutate(
{ id: group.id, input: { name: trimmed, safe_search: group.safe_search } },
{ onSuccess: () => setRenaming(false) },
);
}}
>
<input
type="text"
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
autoFocus
/>
<button type="submit" disabled={updateMutation.isPending} className={buttonClass}>
Save
</button>
<button
type="button"
onClick={() => {
setName(group.name);
setRenaming(false);
}}
className={buttonClass}
>
Cancel
</button>
</form>
) : (
<span className="font-medium">{group.name}</span>
)}
<label className="inline-flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={group.safe_search}
disabled={updateMutation.isPending}
onChange={(event) =>
updateMutation.mutate({
id: group.id,
input: { name: group.name, safe_search: event.target.checked },
})
}
/>
Safe search
</label>
<span className="ml-auto inline-flex flex-wrap items-center gap-2">
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
className={buttonClass}
>
Sources
</button>
{!renaming && (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => {
setName(group.name);
setRenaming(true);
}}
className={buttonClass}
>
Rename
</button>
)}
{confirming ? (
<>
<button
type="button"
onClick={() => {
setConfirming(false);
deleteMutation.mutate(group.id);
}}
className={`${buttonClass} text-red-700 dark:text-red-400`}
>
Confirm delete
</button>
<button type="button" onClick={() => setConfirming(false)} className={buttonClass}>
Cancel
</button>
</>
) : (
<button
type="button"
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => setConfirming(true)}
className={`${buttonClass} text-red-700 dark:text-red-400`}
>
Delete
</button>
)}
</span>
</div>
{isDefault && <p className="mt-2 text-xs text-zinc-500">{DEFAULT_GROUP_NOTE}</p>}
<InlineError error={updateMutation.error ?? deleteMutation.error} />
{expanded && <GroupSourcesEditor groupId={group.id} blocklists={blocklists} />}
</li>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { sameSet, toggleSource } from "./sourceSet";
test("toggleSource adds a missing id keeping ascending order", () => {
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
expect(toggleSource([], 5)).toEqual([5]);
});
test("toggleSource removes a present id", () => {
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
expect(toggleSource([5], 5)).toEqual([]);
});
test("toggleSource twice is a no-op set-wise", () => {
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
});
test("sameSet compares regardless of order", () => {
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
expect(sameSet([], [])).toBe(true);
expect(sameSet([1, 2], [1, 2, 3])).toBe(false);
expect(sameSet([1, 2], [1, 4])).toBe(false);
});
+11
View File
@@ -0,0 +1,11 @@
export function toggleSource(ids: number[], id: number): number[] {
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
return [...ids, id].sort((a, b) => a - b);
}
export function sameSet(a: number[], b: number[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort((x, y) => x - y);
const sortedB = [...b].sort((x, y) => x - y);
return sortedA.every((value, i) => value === sortedB[i]);
}