milestone 9: react spa admin ui, frontend ci and embedded dist
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user