milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 5m6s
CI / frontend (push) Successful in 45s
CI / cross (push) Successful in 7m53s
CI / docker (push) Failing after 1h10m57s

This commit is contained in:
2026-08-07 18:20:30 +02:00
parent c50c6d285a
commit 6f67940995
82 changed files with 3167 additions and 3114 deletions
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react";
import { useRouter, useSearch } from "@tanstack/react-router";
import { ApiError } from "@/lib/api";
import { useAuth } from "@/auth/store";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
export function safeRedirect(raw: string | undefined): string {
if (raw === undefined) return "/";
@@ -94,13 +95,13 @@ export default function LoginPage() {
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900"
className={inputClass}
/>
</div>
<button
type="submit"
disabled={busy || lockedOut}
className="w-full rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
className={`w-full ${largePrimaryButtonClass}`}
>
{busy ? "Logging in…" : "Log in"}
</button>
+11 -16
View File
@@ -1,9 +1,7 @@
import { useState, type FormEvent } from "react";
import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types";
const INPUT_CLASS =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
interface BlocklistFormProps {
initial?: Blocklist;
@@ -45,7 +43,7 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
required
value={url}
onChange={(event) => setUrl(event.target.value)}
className={INPUT_CLASS}
className={inputClass}
/>
</div>
<div>
@@ -58,27 +56,24 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
required
value={name}
onChange={(event) => setName(event.target.value)}
className={INPUT_CLASS}
className={inputClass}
/>
</div>
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className={focusRing}
/>
Enabled
</label>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy}
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"
>
<button type="submit" disabled={busy} className={primaryButtonClass}>
{initial === undefined ? "Add source" : "Save changes"}
</button>
{onCancel !== undefined && (
<button
type="button"
onClick={onCancel}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
>
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
Cancel
</button>
)}
+30 -23
View File
@@ -13,9 +13,15 @@ import {
import type { Blocklist, BlocklistInput, SourceStatus } from "@/lib/types";
import BlocklistForm from "./BlocklistForm";
import SourceStatusSection from "./SourceStatusSection";
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
import {
dangerLinkButtonClass,
focusRing,
linkButtonClass,
primaryButtonClass,
tableWrapClass,
tdClass,
thClass,
} from "@/ui/classes";
export default function BlocklistsPage() {
const queryClient = useQueryClient();
@@ -66,7 +72,7 @@ export default function BlocklistsPage() {
type="button"
onClick={() => updateNow.mutate()}
disabled={updateNow.isPending}
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"
className={primaryButtonClass}
>
{updateNow.isPending ? "Updating…" : "Update now"}
</button>
@@ -81,18 +87,18 @@ export default function BlocklistsPage() {
{blocklists.length === 0 ? (
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
) : (
<div className="mt-4 overflow-x-auto">
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<thead>
<tr>
<th className={TH_CLASS}>Name</th>
<th className={TH_CLASS}>URL</th>
<th className={TH_CLASS}>Enabled</th>
<th className={TH_CLASS}>Domains</th>
<th className={TH_CLASS}>Wildcards</th>
<th className={TH_CLASS}>Skipped regex</th>
<th className={TH_CLASS}>Last updated</th>
<th className={TH_CLASS}>
<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>
</tr>
@@ -100,7 +106,7 @@ export default function BlocklistsPage() {
<tbody>
{blocklists.map((b) => (
<tr key={b.id}>
<td className={TD_CLASS}>
<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">
@@ -108,32 +114,33 @@ export default function BlocklistsPage() {
</span>
)}
</td>
<td className={TD_CLASS}>
<td className={tdClass}>
<span className="block max-w-72 truncate" title={b.url}>
{b.url}
</span>
</td>
<td className={TD_CLASS}>
<td className={tdClass}>
<input
type="checkbox"
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending}
onChange={() => toggleEnabled(b)}
className={focusRing}
/>
</td>
<td className={`${TD_CLASS} tabular-nums`}>{b.domain_count}</td>
<td className={`${TD_CLASS} tabular-nums`}>{b.wildcard_count}</td>
<td className={`${TD_CLASS} tabular-nums`}>{b.skipped_regex_count}</td>
<td className={TD_CLASS}>
<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}>
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
</td>
<td className={TD_CLASS}>
<td className={tdClass}>
<div className="flex gap-3">
<button
type="button"
onClick={() => setEditing(b)}
className="text-sm font-medium text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400"
className={linkButtonClass}
>
Edit
</button>
@@ -141,7 +148,7 @@ export default function BlocklistsPage() {
type="button"
onClick={() => deleteBlocklist(b)}
disabled={remove.isPending}
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
className={dangerLinkButtonClass}
>
Delete
</button>
@@ -1,8 +1,6 @@
import { formatTime } from "@/lib/format";
import type { SourceStatus } from "@/lib/types";
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
import { tdClass, thClass } from "@/ui/classes";
function formatAttempt(unixSeconds: number): string {
return unixSeconds === 0 ? "never" : formatTime(unixSeconds);
@@ -28,26 +26,26 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
<table className="w-full min-w-max border-collapse text-sm">
<thead>
<tr>
<th className={TH_CLASS}>Source</th>
<th className={TH_CLASS}>State</th>
<th className={TH_CLASS}>Last attempt</th>
<th className={TH_CLASS}>Last success</th>
<th className={TH_CLASS}>Domains</th>
<th className={TH_CLASS}>Wildcards</th>
<th className={TH_CLASS}>Skipped regex</th>
<th className={TH_CLASS}>Last error</th>
<th className={thClass}>Source</th>
<th className={thClass}>State</th>
<th className={thClass}>Last attempt</th>
<th className={thClass}>Last success</th>
<th className={thClass}>Domains</th>
<th className={thClass}>Wildcards</th>
<th className={thClass}>Skipped regex</th>
<th className={thClass}>Last error</th>
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr key={source.id}>
<td className={TD_CLASS}>
<td className={tdClass}>
<span className="font-medium">{namesById.get(source.id) ?? source.url}</span>
<span className="mt-0.5 block max-w-64 truncate text-xs text-zinc-500">
{source.url}
</span>
</td>
<td className={TD_CLASS}>
<td className={tdClass}>
<span
className={
source.loaded
@@ -58,12 +56,12 @@ export default function SourceStatusSection({ sources, namesById }: SourceStatus
{source.state}
</span>
</td>
<td className={TD_CLASS}>{formatAttempt(source.last_attempt)}</td>
<td className={TD_CLASS}>{formatAttempt(source.last_success)}</td>
<td className={`${TD_CLASS} tabular-nums`}>{source.domains}</td>
<td className={`${TD_CLASS} tabular-nums`}>{source.wildcards}</td>
<td className={`${TD_CLASS} tabular-nums`}>{source.skipped_regex}</td>
<td className={TD_CLASS}>
<td className={tdClass}>{formatAttempt(source.last_attempt)}</td>
<td className={tdClass}>{formatAttempt(source.last_success)}</td>
<td className={`${tdClass} tabular-nums`}>{source.domains}</td>
<td className={`${tdClass} tabular-nums`}>{source.wildcards}</td>
<td className={`${tdClass} tabular-nums`}>{source.skipped_regex}</td>
<td className={tdClass}>
{source.last_error === "" ? (
<span className="text-zinc-400"></span>
) : (
+6 -14
View File
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { clientUpdateMutation } from "@/lib/queries";
import type { Client, Group } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { buttonClass, primaryButtonClass, smallInputClass } from "@/ui/classes";
interface Props {
client: Client;
@@ -10,8 +11,7 @@ interface Props {
onClose: () => void;
}
const inputClass =
"mt-1 w-full rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
const dialogInputClass = `mt-1 w-full ${smallInputClass}`;
export default function ClientEditDialog({ client, groups, onClose }: Props) {
const queryClient = useQueryClient();
@@ -44,7 +44,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
className={dialogInputClass}
autoFocus
/>
</label>
@@ -53,7 +53,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
<select
value={String(groupId)}
onChange={(event) => setGroupId(Number(event.target.value))}
className={inputClass}
className={dialogInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
@@ -64,18 +64,10 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
</label>
<InlineError error={mutation.error} />
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
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"
>
<button type="button" onClick={onClose} className={buttonClass}>
Cancel
</button>
<button
type="submit"
disabled={mutation.isPending}
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"
>
<button type="submit" disabled={mutation.isPending} className={primaryButtonClass}>
Save
</button>
</div>
+6 -7
View File
@@ -6,10 +6,9 @@ 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";
const cellClass = "px-3 py-2";
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 dark:border-zinc-700";
export default function ClientsPage() {
const { data: clients } = useSuspenseQuery(clientsQuery());
@@ -29,7 +28,7 @@ export default function ClientsPage() {
nothing to create by hand.
</p>
) : (
<div className="mt-4 overflow-x-auto">
<div className={tableWrapClass}>
<table className="w-full min-w-[48rem] text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-700">
@@ -70,14 +69,14 @@ export default function ClientsPage() {
setConfirmingId(null);
deleteMutation.mutate(client.id);
}}
className={`${buttonClass} text-red-700 dark:text-red-400`}
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
>
Confirm delete
</button>
<button
type="button"
onClick={() => setConfirmingId(null)}
className={buttonClass}
className={smallButtonClass}
>
Cancel
</button>
@@ -87,14 +86,14 @@ export default function ClientsPage() {
<button
type="button"
onClick={() => setEditing(client)}
className={buttonClass}
className={smallButtonClass}
>
Edit
</button>
<button
type="button"
onClick={() => setConfirmingId(client.id)}
className={`${buttonClass} text-red-700 dark:text-red-400`}
className={`${smallButtonClass} text-red-700 dark:text-red-400`}
>
Delete
</button>
+8 -9
View File
@@ -4,14 +4,13 @@ import { clientPrefixesPutMutation } from "@/lib/queries";
import type { ClientPrefix, Group } from "@/lib/types";
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass, smallInputClass } from "@/ui/classes";
interface Props {
prefixes: ClientPrefix[];
groups: Group[];
}
const inputClass = "rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm dark:border-zinc-700 dark:bg-zinc-900";
export default function PrefixesEditor({ prefixes, groups }: Props) {
const queryClient = useQueryClient();
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
@@ -50,7 +49,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { prefix: event.target.value } })
}
className={`${inputClass} w-52`}
className={`${smallInputClass} w-52`}
/>
<select
aria-label={`Group for prefix ${index + 1}`}
@@ -58,7 +57,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { group_id: Number(event.target.value) } })
}
className={inputClass}
className={smallInputClass}
>
{groups.map((group) => (
<option key={group.id} value={String(group.id)}>
@@ -75,12 +74,12 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
onChange={(event) =>
dispatch({ type: "edit", index, patch: { priority: event.target.value } })
}
className={`${inputClass} w-20`}
className={`${smallInputClass} w-20`}
/>
<button
type="button"
onClick={() => dispatch({ type: "remove", index })}
className="rounded border border-zinc-300 px-2 py-1.5 text-sm text-red-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:text-red-400"
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`}
>
Remove
</button>
@@ -98,7 +97,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
<button
type="button"
onClick={() => dispatch({ type: "add", groupId: defaultGroupId })}
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"
className={buttonClass}
>
Add prefix
</button>
@@ -106,7 +105,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
type="button"
onClick={save}
disabled={!dirty || mutation.isPending}
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"
className={primaryButtonClass}
>
Save prefixes
</button>
@@ -117,7 +116,7 @@ export default function PrefixesEditor({ prefixes, groups }: Props) {
setValidation(null);
dispatch({ type: "reset", prefixes });
}}
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"
className={buttonClass}
>
Discard changes
</button>
+3 -14
View File
@@ -1,8 +1,9 @@
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ApiError } from "@/lib/api";
import { healthQuery, statsQuery, timeseriesQuery, upstreamHealthQuery } from "@/lib/queries";
import type { Period } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { focusRing } from "@/ui/classes";
import DiskCard from "./DiskCard";
import HealthBanners from "./HealthBanners";
import StatCards from "./StatCards";
@@ -20,7 +21,7 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
type="button"
aria-pressed={option === period}
onClick={() => onChange(option)}
className={`rounded px-2.5 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${
className={`rounded px-2.5 py-1 text-sm ${focusRing} ${
option === period
? "bg-zinc-200 font-medium text-zinc-900 dark:bg-zinc-700 dark:text-zinc-50"
: "text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800"
@@ -33,18 +34,6 @@ function PeriodPicker({ period, onChange }: { period: Period; onChange: (period:
);
}
function InlineError({ error, onRetry }: { error: unknown; onRetry: () => void }) {
const message = error instanceof ApiError ? error.message : "request failed";
return (
<div className="rounded border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-200">
Failed to load: {message}{" "}
<button type="button" onClick={onRetry} className="font-medium underline">
Retry
</button>
</div>
);
}
function Skeleton({ height }: { height: number }) {
return <div aria-hidden="true" className="animate-pulse rounded bg-zinc-200 dark:bg-zinc-800" style={{ height }} />;
}
@@ -4,6 +4,7 @@ import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
import type { Blocklist } from "@/lib/types";
import { sameSet, toggleSource } from "./sourceSet";
import InlineError from "@/lib/InlineError";
import { buttonClass, focusRing, primaryButtonClass } from "@/ui/classes";
interface Props {
groupId: number;
@@ -46,6 +47,7 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
type="checkbox"
checked={current.includes(blocklist.id)}
onChange={() => setSelected(toggleSource(current, blocklist.id))}
className={focusRing}
/>
{blocklist.name}
</label>
@@ -60,16 +62,12 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
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"
className={primaryButtonClass}
>
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"
>
<button type="button" onClick={() => setSelected(null)} className={buttonClass}>
Discard
</button>
)}
+12 -14
View File
@@ -10,15 +10,12 @@ import {
import type { Blocklist, Group } from "@/lib/types";
import GroupSourcesEditor from "./GroupSourcesEditor";
import InlineError from "@/lib/InlineError";
import { focusRing, primaryButtonClass, smallButtonClass, smallInputClass } from "@/ui/classes";
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";
const groupButtonClass = `${smallButtonClass} disabled:opacity-50`;
export default function GroupsPage() {
const { data: groups } = useSuspenseQuery(groupsQuery());
@@ -47,7 +44,7 @@ export default function GroupsPage() {
type="text"
value={newName}
onChange={(event) => setNewName(event.target.value)}
className={inputClass}
className={smallInputClass}
/>
<button type="submit" disabled={createMutation.isPending} className={primaryButtonClass}>
Create
@@ -94,10 +91,10 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
aria-label={`New name for ${group.name}`}
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClass}
className={smallInputClass}
autoFocus
/>
<button type="submit" disabled={updateMutation.isPending} className={buttonClass}>
<button type="submit" disabled={updateMutation.isPending} className={groupButtonClass}>
Save
</button>
<button
@@ -106,7 +103,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name);
setRenaming(false);
}}
className={buttonClass}
className={groupButtonClass}
>
Cancel
</button>
@@ -119,6 +116,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
type="checkbox"
checked={group.safe_search}
disabled={updateMutation.isPending}
className={focusRing}
onChange={(event) =>
updateMutation.mutate({
id: group.id,
@@ -133,7 +131,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((open) => !open)}
className={buttonClass}
className={groupButtonClass}
>
Sources
</button>
@@ -146,7 +144,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setName(group.name);
setRenaming(true);
}}
className={buttonClass}
className={groupButtonClass}
>
Rename
</button>
@@ -159,11 +157,11 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
setConfirming(false);
deleteMutation.mutate(group.id);
}}
className={`${buttonClass} text-red-700 dark:text-red-400`}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
>
Confirm delete
</button>
<button type="button" onClick={() => setConfirming(false)} className={buttonClass}>
<button type="button" onClick={() => setConfirming(false)} className={groupButtonClass}>
Cancel
</button>
</>
@@ -173,7 +171,7 @@ function GroupRow({ group, blocklists }: { group: Group; blocklists: Blocklist[]
disabled={isDefault}
title={isDefault ? DEFAULT_GROUP_NOTE : undefined}
onClick={() => setConfirming(true)}
className={`${buttonClass} text-red-700 dark:text-red-400`}
className={`${groupButtonClass} text-red-700 dark:text-red-400`}
>
Delete
</button>
+10 -9
View File
@@ -1,9 +1,9 @@
import { QueryCells, QueryTableHead } from "@/features/queries/QueryLogPage";
import { RING_CAPACITY } from "./ringBuffer";
import { useLiveQueries, type EventSourceFactory, type StreamStatus } from "./useLiveQueries";
import { buttonClass, retryButtonClass, tableWrapClass } from "@/ui/classes";
const buttonClass =
"rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
const toolbarButtonClass = `${buttonClass} font-medium`;
function StatusPill({ status }: { status: StreamStatus }) {
const styles: Record<StreamStatus, [string, string]> = {
@@ -30,7 +30,12 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold">Live</h1>
<StatusPill status={live.status} />
<button type="button" onClick={live.toggleFreeze} aria-pressed={live.frozen} className={buttonClass}>
<button
type="button"
onClick={live.toggleFreeze}
aria-pressed={live.frozen}
className={toolbarButtonClass}
>
{live.frozen ? "Resume" : "Freeze"}
</button>
</div>
@@ -73,11 +78,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
The connection failed repeatedly possibly too many live viewers (the server caps streams per
address), or the server is unreachable.
</p>
<button
type="button"
onClick={live.retry}
className="mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-red-800 dark:text-red-200"
>
<button type="button" onClick={live.retry} className={retryButtonClass}>
Retry
</button>
</div>
@@ -91,7 +92,7 @@ export default function LiveLogPage({ createEventSource }: { createEventSource?:
)
) : (
<>
<div className="mt-4 overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800">
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
<table className="w-full text-sm">
<QueryTableHead />
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import RecordsTab from "@/features/local/RecordsTab";
import ZonesTab from "@/features/local/ZonesTab";
import { focusRing } from "@/ui/classes";
type Tab = "records" | "zones";
@@ -25,7 +26,7 @@ function TabButton({
aria-controls={controls}
aria-selected={selected}
onClick={onClick}
className={`-mb-px border-b-2 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 ${
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"
+28 -54
View File
@@ -1,5 +1,5 @@
import { useId, useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
localRecordCreateMutation,
localRecordDeleteMutation,
@@ -8,20 +8,18 @@ import {
} from "@/lib/queries";
import type { LocalRecord, LocalRecordInput, LocalRecordType } from "@/lib/types";
import InlineError from "@/lib/InlineError";
import { useCrudForm } from "@/ui/useCrudForm";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
const RTYPES: readonly LocalRecordType[] = ["A", "AAAA", "CNAME"];
const inputClass =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
const primaryButtonClass =
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
const secondaryButtonClass =
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
const rowButtonClass =
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
type FormState = { mode: "create" } | { mode: "edit"; record: LocalRecord };
function RecordForm({
initial,
busy,
@@ -49,10 +47,7 @@ function RecordForm({
}
return (
<form
onSubmit={submit}
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
>
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New record" : `Edit ${initial.name}`}</h3>
<div>
<label htmlFor={`${id}-name`} className="block text-sm font-medium">
@@ -114,10 +109,10 @@ function RecordForm({
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={secondaryButtonClass}>
<button type="button" onClick={onCancel} className={largeButtonClass}>
Cancel
</button>
</div>
@@ -128,50 +123,29 @@ function RecordForm({
export default function RecordsTab() {
const records = useSuspenseQuery(localRecordsQuery()).data;
const queryClient = useQueryClient();
const create = useMutation(localRecordCreateMutation(queryClient));
const update = useMutation(localRecordUpdateMutation(queryClient));
const remove = useMutation(localRecordDeleteMutation(queryClient));
const [form, setForm] = useState<FormState | null>(null);
function openForm(next: FormState) {
create.reset();
update.reset();
setForm(next);
}
function onSubmit(input: LocalRecordInput) {
if (form === null) return;
if (form.mode === "create") {
create.mutate(input, { onSuccess: () => setForm(null) });
} else {
update.mutate({ id: form.record.id, input }, { onSuccess: () => setForm(null) });
}
}
function onDelete(record: LocalRecord) {
if (!window.confirm(`Delete record "${record.name}"?`)) return;
remove.mutate(record.id);
}
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
LocalRecord,
LocalRecordInput
>({
create: localRecordCreateMutation,
update: localRecordUpdateMutation,
remove: localRecordDeleteMutation,
confirmDelete: (record) => `Delete record "${record.name}"?`,
});
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>
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
Add record
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<RecordForm
busy={create.isPending}
error={create.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
<RecordForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
)}
<div className="mt-4 overflow-x-auto">
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
@@ -202,14 +176,14 @@ export default function RecordsTab() {
)}
{records.map((record) => (
<tr key={record.id} className="border-b border-zinc-100 dark:border-zinc-900">
{form?.mode === "edit" && form.record.id === record.id ? (
{form?.mode === "edit" && form.entity.id === record.id ? (
<td colSpan={5}>
<RecordForm
initial={record}
busy={update.isPending}
error={update.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
onCancel={closeForm}
/>
</td>
) : (
@@ -221,7 +195,7 @@ export default function RecordsTab() {
<td className="py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => openForm({ mode: "edit", record })}
onClick={() => openForm({ mode: "edit", entity: record })}
className={rowButtonClass}
>
Edit
+28 -54
View File
@@ -1,5 +1,5 @@
import { useId, useState, type FormEvent } from "react";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import {
forwardZoneCreateMutation,
forwardZoneDeleteMutation,
@@ -8,17 +8,15 @@ import {
} from "@/lib/queries";
import type { ForwardZone, ForwardZoneInput } from "@/lib/types";
import InlineError from "@/lib/InlineError";
const inputClass =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
const primaryButtonClass =
"rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
const secondaryButtonClass =
"rounded border border-zinc-300 px-3 py-2 font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
const rowButtonClass =
"rounded px-2 py-1 text-sm text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400";
type FormState = { mode: "create" } | { mode: "edit"; zone: ForwardZone };
import { useCrudForm } from "@/ui/useCrudForm";
import {
formCardClass,
inputClass,
largeButtonClass,
largePrimaryButtonClass,
rowButtonClass,
tableWrapClass,
} from "@/ui/classes";
function ZoneForm({
initial,
@@ -43,10 +41,7 @@ function ZoneForm({
}
return (
<form
onSubmit={submit}
className="mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800"
>
<form onSubmit={submit} className={formCardClass}>
<h3 className="font-medium">{initial === undefined ? "New forward zone" : `Edit ${initial.zone}`}</h3>
<div>
<label htmlFor={`${id}-zone`} className="block text-sm font-medium">
@@ -75,10 +70,10 @@ function ZoneForm({
/>
</div>
<div className="flex gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
<button type="submit" disabled={busy} className={largePrimaryButtonClass}>
{busy ? "Saving…" : "Save"}
</button>
<button type="button" onClick={onCancel} className={secondaryButtonClass}>
<button type="button" onClick={onCancel} className={largeButtonClass}>
Cancel
</button>
</div>
@@ -89,31 +84,15 @@ function ZoneForm({
export default function ZonesTab() {
const zones = useSuspenseQuery(forwardZonesQuery()).data;
const queryClient = useQueryClient();
const create = useMutation(forwardZoneCreateMutation(queryClient));
const update = useMutation(forwardZoneUpdateMutation(queryClient));
const remove = useMutation(forwardZoneDeleteMutation(queryClient));
const [form, setForm] = useState<FormState | null>(null);
function openForm(next: FormState) {
create.reset();
update.reset();
setForm(next);
}
function onSubmit(input: ForwardZoneInput) {
if (form === null) return;
if (form.mode === "create") {
create.mutate(input, { onSuccess: () => setForm(null) });
} else {
update.mutate({ id: form.zone.id, input }, { onSuccess: () => setForm(null) });
}
}
function onDelete(zone: ForwardZone) {
if (!window.confirm(`Delete forward zone "${zone.zone}"?`)) return;
remove.mutate(zone.id);
}
const { create, update, remove, form, openForm, closeForm, onSubmit, onDelete } = useCrudForm<
ForwardZone,
ForwardZoneInput
>({
create: forwardZoneCreateMutation,
update: forwardZoneUpdateMutation,
remove: forwardZoneDeleteMutation,
confirmDelete: (zone) => `Delete forward zone "${zone.zone}"?`,
});
return (
<div>
@@ -121,20 +100,15 @@ export default function ZonesTab() {
<p className="text-sm text-zinc-500">
Names under these zones go to their own resolver. Changes apply live.
</p>
<button type="button" onClick={() => openForm({ mode: "create" })} className={primaryButtonClass}>
<button type="button" onClick={() => openForm({ mode: "create" })} className={largePrimaryButtonClass}>
Add zone
</button>
</div>
<InlineError error={remove.error} />
{form?.mode === "create" && (
<ZoneForm
busy={create.isPending}
error={create.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
/>
<ZoneForm busy={create.isPending} error={create.error} onSubmit={onSubmit} onCancel={closeForm} />
)}
<div className="mt-4 overflow-x-auto">
<div className={tableWrapClass}>
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-200 text-zinc-500 dark:border-zinc-800">
@@ -159,14 +133,14 @@ export default function ZonesTab() {
)}
{zones.map((zone) => (
<tr key={zone.id} className="border-b border-zinc-100 dark:border-zinc-900">
{form?.mode === "edit" && form.zone.id === zone.id ? (
{form?.mode === "edit" && form.entity.id === zone.id ? (
<td colSpan={3}>
<ZoneForm
initial={zone}
busy={update.isPending}
error={update.error}
onSubmit={onSubmit}
onCancel={() => setForm(null)}
onCancel={closeForm}
/>
</td>
) : (
@@ -176,7 +150,7 @@ export default function ZonesTab() {
<td className="py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => openForm({ mode: "edit", zone })}
onClick={() => openForm({ mode: "edit", entity: zone })}
className={rowButtonClass}
>
Edit
+2 -8
View File
@@ -3,9 +3,7 @@ import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { ApiError } from "@/lib/api";
import { groupsQuery, lookupQuery } from "@/lib/queries";
import type { Group, LookupResult } from "@/lib/types";
const inputClass =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
interface Submitted {
domain: string;
@@ -189,11 +187,7 @@ export default function LookupPage() {
))}
</select>
</div>
<button
type="submit"
className="rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
disabled={lookup.isFetching}
>
<button type="submit" className={largePrimaryButtonClass} disabled={lookup.isFetching}>
Look up
</button>
</form>
+6 -6
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { pauseMutation, pauseQuery } from "@/lib/queries";
import InlineError from "@/lib/InlineError";
import { buttonClass, insetFocusRing } from "@/ui/classes";
const DURATIONS = [
{ label: "60 seconds", seconds: 60 },
@@ -34,8 +35,7 @@ function useNowSeconds(active: boolean): number {
return now;
}
const BUTTON_CLASS =
"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 disabled:text-zinc-400 dark:border-zinc-700 dark:disabled:text-zinc-600";
const triggerButtonClass = `${buttonClass} disabled:text-zinc-400 dark:disabled:text-zinc-600`;
export default function PauseWidget() {
const queryClient = useQueryClient();
@@ -52,7 +52,7 @@ export default function PauseWidget() {
if (data === undefined) {
return (
<button type="button" disabled className={BUTTON_CLASS}>
<button type="button" disabled className={triggerButtonClass}>
Pause
</button>
);
@@ -69,7 +69,7 @@ export default function PauseWidget() {
type="button"
onClick={() => mutation.mutate({ paused: false })}
disabled={mutation.isPending}
className={BUTTON_CLASS}
className={triggerButtonClass}
>
Resume
</button>
@@ -92,7 +92,7 @@ export default function PauseWidget() {
aria-controls="pause-menu"
onClick={() => setMenuOpen((open) => !open)}
disabled={mutation.isPending}
className={BUTTON_CLASS}
className={triggerButtonClass}
>
Pause
</button>
@@ -111,7 +111,7 @@ export default function PauseWidget() {
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
);
}}
className="px-3 py-1.5 text-left text-sm hover:bg-zinc-100 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600 dark:hover:bg-zinc-800"
className={`px-3 py-1.5 text-left text-sm hover:bg-zinc-100 ${insetFocusRing} dark:hover:bg-zinc-800`}
>
{label}
</button>
+16 -13
View File
@@ -5,11 +5,10 @@ import { formatMicros, formatTime } from "@/lib/format";
import { queriesInfiniteQuery } from "@/lib/queries";
import type { QueriesFilter, QueryRow } from "@/lib/types";
import { qtypeName } from "./qtype";
import { buttonClass, smallInputClass, tableWrapClass } from "@/ui/classes";
const inputClass =
"mt-1 w-full 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-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700";
const filterInputClass = `mt-1 w-full ${smallInputClass}`;
const toolbarButtonClass = `${buttonClass} font-medium`;
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
@@ -134,7 +133,7 @@ export default function QueryLogPage() {
type="text"
value={domain}
onChange={(event) => setDomain(event.target.value)}
className={inputClass}
className={filterInputClass}
/>
</label>
<label className="block text-sm">
@@ -143,12 +142,16 @@ export default function QueryLogPage() {
type="text"
value={client}
onChange={(event) => setClient(event.target.value)}
className={inputClass}
className={filterInputClass}
/>
</label>
<label className="block text-sm">
Status
<select value={blocked} onChange={(event) => setBlocked(event.target.value)} className={inputClass}>
<select
value={blocked}
onChange={(event) => setBlocked(event.target.value)}
className={filterInputClass}
>
<option value="any">All</option>
<option value="blocked">Blocked only</option>
<option value="allowed">Allowed only</option>
@@ -160,7 +163,7 @@ export default function QueryLogPage() {
type="datetime-local"
value={since}
onChange={(event) => setSince(event.target.value)}
className={inputClass}
className={filterInputClass}
/>
</label>
<label className="block text-sm">
@@ -169,14 +172,14 @@ export default function QueryLogPage() {
type="datetime-local"
value={until}
onChange={(event) => setUntil(event.target.value)}
className={inputClass}
className={filterInputClass}
/>
</label>
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-5">
<button type="submit" className={buttonClass}>
<button type="submit" className={toolbarButtonClass}>
Apply filters
</button>
<button type="button" onClick={clearFilters} className={buttonClass}>
<button type="button" onClick={clearFilters} className={toolbarButtonClass}>
Clear
</button>
{base.isFetching && (
@@ -197,7 +200,7 @@ export default function QueryLogPage() {
</p>
) : (
<>
<div className="mt-4 overflow-x-auto rounded border border-zinc-200 dark:border-zinc-800">
<div className={`${tableWrapClass} rounded border border-zinc-200 dark:border-zinc-800`}>
<table className="w-full text-sm">
<QueryTableHead />
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
@@ -219,7 +222,7 @@ export default function QueryLogPage() {
type="button"
onClick={loadMore}
disabled={base.isFetchingNextPage || base.isPlaceholderData}
className={buttonClass}
className={toolbarButtonClass}
>
{base.isFetchingNextPage ? "Loading…" : "Load more"}
</button>
+20 -28
View File
@@ -4,11 +4,7 @@ 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";
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
const INPUT_CLASS =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
import { dangerLinkButtonClass, inputClass, primaryButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
export default function RulesPage() {
const queryClient = useQueryClient();
@@ -44,16 +40,16 @@ export default function RulesPage() {
{rules.length === 0 ? (
<p className="mt-4 text-zinc-500">No allow or block rules yet. Create one below.</p>
) : (
<div className="mt-4 overflow-x-auto">
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<thead>
<tr>
<th className={TH_CLASS}>Pattern</th>
<th className={TH_CLASS}>Kind</th>
<th className={TH_CLASS}>Action</th>
<th className={TH_CLASS}>Group</th>
<th className={TH_CLASS}>Created</th>
<th className={TH_CLASS}>
<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>
</tr>
@@ -61,9 +57,9 @@ export default function RulesPage() {
<tbody>
{rules.map((rule) => (
<tr key={rule.id}>
<td className={`${TD_CLASS} font-medium`}>{rule.pattern}</td>
<td className={TD_CLASS}>{rule.kind}</td>
<td className={TD_CLASS}>
<td className={`${tdClass} font-medium`}>{rule.pattern}</td>
<td className={tdClass}>{rule.kind}</td>
<td className={tdClass}>
<span
className={
rule.action === "allow"
@@ -74,14 +70,14 @@ export default function RulesPage() {
{rule.action}
</span>
</td>
<td className={TD_CLASS}>{rule.group}</td>
<td className={TD_CLASS}>{formatTime(rule.created_at)}</td>
<td className={TD_CLASS}>
<td className={tdClass}>{rule.group}</td>
<td className={tdClass}>{formatTime(rule.created_at)}</td>
<td className={tdClass}>
<button
type="button"
onClick={() => deleteRule(rule)}
disabled={remove.isPending}
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
className={dangerLinkButtonClass}
>
Delete
</button>
@@ -107,7 +103,7 @@ export default function RulesPage() {
value={pattern}
onChange={(event) => setPattern(event.target.value)}
placeholder="ads.example.com or *.example.com"
className={INPUT_CLASS}
className={inputClass}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
@@ -119,7 +115,7 @@ export default function RulesPage() {
id="rule-kind"
value={kind}
onChange={(event) => setKind(event.target.value as RuleKind)}
className={INPUT_CLASS}
className={inputClass}
>
<option value="exact">exact</option>
<option value="wildcard">wildcard</option>
@@ -133,7 +129,7 @@ export default function RulesPage() {
id="rule-action"
value={action}
onChange={(event) => setAction(event.target.value as RuleAction)}
className={INPUT_CLASS}
className={inputClass}
>
<option value="allow">allow</option>
<option value="block">block</option>
@@ -147,7 +143,7 @@ export default function RulesPage() {
id="rule-group"
value={groupId}
onChange={(event) => setGroupId(Number(event.target.value))}
className={INPUT_CLASS}
className={inputClass}
>
{groups.map((group) => (
<option key={group.id} value={group.id}>
@@ -157,11 +153,7 @@ export default function RulesPage() {
</select>
</div>
</div>
<button
type="submit"
disabled={create.isPending}
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"
>
<button type="submit" disabled={create.isPending} className={primaryButtonClass}>
{create.isPending ? "Creating…" : "Create rule"}
</button>
<InlineError error={create.error} />
+2 -1
View File
@@ -1,4 +1,5 @@
import { dismissRestartBanner, useRestartBanner } from "./restartBanner";
import { focusRing } from "@/ui/classes";
export default function RestartBanner() {
const raised = useRestartBanner();
@@ -12,7 +13,7 @@ export default function RestartBanner() {
<button
type="button"
onClick={dismissRestartBanner}
className="rounded border border-amber-400 px-2 py-1 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-amber-700"
className={`rounded border border-amber-400 px-2 py-1 text-xs ${focusRing} dark:border-amber-700`}
>
Dismiss
</button>
+9 -9
View File
@@ -5,6 +5,7 @@ import { settingsPutMutation, settingsQuery } from "@/lib/queries";
import { buildSettingsPatch } from "@/lib/settingsDiff";
import type { Settings, SettingsPatch } from "@/lib/types";
import { raiseRestartBanner } from "./restartBanner";
import { focusRing } from "@/ui/classes";
/** True when the patch touches anything besides the write-only `web.password` (ruling 11). */
export function patchRequiresRestart(patch: SettingsPatch): boolean {
@@ -120,8 +121,7 @@ const SECTIONS: readonly SectionDef[] = [
];
const LABEL_CLASS = "text-sm text-zinc-700 dark:text-zinc-300";
const INPUT_CLASS =
"rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
const fieldInputClass = `rounded border border-zinc-300 bg-white px-2 py-1 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
function FieldRow({
section,
@@ -143,7 +143,7 @@ function FieldRow({
type="checkbox"
checked={value as boolean}
onChange={(e) => onChange(e.target.checked)}
className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
className={focusRing}
/>
<label htmlFor={id} className={LABEL_CLASS}>
{def.key}
@@ -161,7 +161,7 @@ function FieldRow({
id={id}
value={value as string}
onChange={(e) => onChange(e.target.value)}
className={INPUT_CLASS}
className={fieldInputClass}
>
{def.kind.map((option) => (
<option key={option} value={option}>
@@ -184,7 +184,7 @@ function FieldRow({
type="number"
value={Number.isNaN(numeric) ? "" : numeric}
onChange={(e) => onChange(e.target.valueAsNumber)}
className={INPUT_CLASS}
className={fieldInputClass}
/>
</div>
);
@@ -199,7 +199,7 @@ function FieldRow({
type="text"
value={value as string}
onChange={(e) => onChange(e.target.value)}
className={INPUT_CLASS}
className={fieldInputClass}
/>
</div>
);
@@ -280,7 +280,7 @@ export default function SettingsPage() {
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={INPUT_CLASS}
className={fieldInputClass}
/>
</div>
<div className="flex flex-col gap-1">
@@ -293,7 +293,7 @@ export default function SettingsPage() {
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className={INPUT_CLASS}
className={fieldInputClass}
/>
</div>
{password !== "" && (
@@ -316,7 +316,7 @@ export default function SettingsPage() {
<button
type="submit"
disabled={saveDisabled}
className="rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800"
className={`rounded bg-blue-600 px-4 py-1.5 text-sm font-medium text-white ${focusRing} disabled:bg-zinc-300 disabled:text-zinc-500 dark:disabled:bg-zinc-800`}
>
{mutation.isPending ? "Saving…" : "Save"}
</button>
+12 -17
View File
@@ -1,9 +1,7 @@
import { useState, type FormEvent } from "react";
import InlineError from "@/lib/InlineError";
import type { Upstream, UpstreamInput } from "@/lib/types";
const INPUT_CLASS =
"mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700 dark:bg-zinc-900";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
const DEFAULT_PRIORITY = "100";
@@ -57,7 +55,7 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="udp://1.1.1.1:53"
className={INPUT_CLASS}
className={inputClass}
/>
</div>
<div>
@@ -70,7 +68,7 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
min={0}
value={priority}
onChange={(event) => setPriority(event.target.value)}
className={INPUT_CLASS}
className={inputClass}
/>
</div>
<div>
@@ -83,30 +81,27 @@ export default function UpstreamForm({ initial, busy, error, onSubmit, onCancel
value={tlsName}
onChange={(event) => setTlsName(event.target.value)}
placeholder="one.one.one.one"
className={INPUT_CLASS}
className={inputClass}
/>
<p className="mt-1 text-xs text-zinc-500">
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">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
<input
type="checkbox"
checked={enabled}
onChange={(event) => setEnabled(event.target.checked)}
className={focusRing}
/>
Enabled
</label>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={busy}
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"
>
<button type="submit" disabled={busy} className={primaryButtonClass}>
{initial === undefined ? "Add upstream" : "Save changes"}
</button>
{onCancel !== undefined && (
<button
type="button"
onClick={onCancel}
className="rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-zinc-700"
>
<button type="button" onClick={onCancel} className={`${buttonClass} font-medium`}>
Cancel
</button>
)}
+15 -16
View File
@@ -5,9 +5,7 @@ import { upstreamCreateMutation, upstreamDeleteMutation, upstreamUpdateMutation,
import type { Upstream, UpstreamInput } from "@/lib/types";
import { raiseRestartBanner } from "../settings/restartBanner";
import UpstreamForm from "./UpstreamForm";
const TH_CLASS = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
const TD_CLASS = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
import { dangerLinkButtonClass, focusRing, linkButtonClass, tableWrapClass, tdClass, thClass } from "@/ui/classes";
export default function UpstreamsPage() {
const queryClient = useQueryClient();
@@ -59,15 +57,15 @@ export default function UpstreamsPage() {
{upstreams.length === 0 ? (
<p className="mt-4 text-zinc-500">No upstreams yet. Add one below.</p>
) : (
<div className="mt-4 overflow-x-auto">
<div className={tableWrapClass}>
<table className="w-full min-w-max border-collapse text-sm">
<thead>
<tr>
<th className={TH_CLASS}>URL</th>
<th className={TH_CLASS}>Priority</th>
<th className={TH_CLASS}>Enabled</th>
<th className={TH_CLASS}>TLS name</th>
<th className={TH_CLASS}>
<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>
</tr>
@@ -75,28 +73,29 @@ export default function UpstreamsPage() {
<tbody>
{upstreams.map((u) => (
<tr key={u.id}>
<td className={TD_CLASS}>
<td className={tdClass}>
<span className="block max-w-72 truncate font-medium" title={u.url}>
{u.url}
</span>
</td>
<td className={`${TD_CLASS} tabular-nums`}>{u.priority}</td>
<td className={TD_CLASS}>
<td className={`${tdClass} tabular-nums`}>{u.priority}</td>
<td className={tdClass}>
<input
type="checkbox"
aria-label={`${u.url} enabled`}
checked={u.enabled}
disabled={toggle.isPending}
onChange={() => toggleEnabled(u)}
className={focusRing}
/>
</td>
<td className={TD_CLASS}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td className={TD_CLASS}>
<td className={tdClass}>{u.tls_name === "" ? "—" : u.tls_name}</td>
<td className={tdClass}>
<div className="flex gap-3">
<button
type="button"
onClick={() => setEditing(u)}
className="text-sm font-medium text-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-blue-400"
className={linkButtonClass}
>
Edit
</button>
@@ -104,7 +103,7 @@ export default function UpstreamsPage() {
type="button"
onClick={() => deleteUpstream(u)}
disabled={remove.isPending}
className="text-sm font-medium text-red-600 disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:text-red-400"
className={dangerLinkButtonClass}
>
Delete
</button>
+24
View File
@@ -0,0 +1,24 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { ApiError } from "@/lib/api";
import InlineError from "./InlineError";
test("no retry button without onRetry", () => {
render(<InlineError error={new ApiError(409, "already exists")} />);
expect(screen.getByRole("alert").textContent).toBe("already exists");
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
});
test("onRetry renders a focusable retry button that calls back", () => {
const onRetry = vi.fn();
render(<InlineError error={new ApiError(500, "internal")} onRetry={onRetry} />);
const button = screen.getByRole("button", { name: "Retry" });
expect(button.className).toContain("focus-visible:outline-2");
fireEvent.click(button);
expect(onRetry).toHaveBeenCalledTimes(1);
});
test("a null error renders nothing even with onRetry", () => {
const { container } = render(<InlineError error={null} onRetry={() => undefined} />);
expect(container.innerHTML).toBe("");
});
+14 -2
View File
@@ -1,8 +1,12 @@
import { useEffect, useState } from "react";
import { ApiError } from "@/lib/api";
import { focusRing } from "@/ui/classes";
/** Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with countdown. */
export default function InlineError({ error }: { error: unknown }) {
/**
* Inline mutation error per ruling 17: 400/409 messages verbatim, 429 with
* countdown. Pass `onRetry` to append a retry button for a failed query.
*/
export default function InlineError({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
const retryAfter = error instanceof ApiError && error.status === 429 ? (error.retryAfter ?? null) : null;
const [remaining, setRemaining] = useState<number | null>(retryAfter);
@@ -34,6 +38,14 @@ export default function InlineError({ error }: { error: unknown }) {
return (
<p role="alert" className="mt-2 text-sm text-red-600 dark:text-red-400">
{message}
{onRetry !== undefined && (
<>
{" "}
<button type="button" onClick={onRetry} className={`font-medium underline ${focusRing}`}>
Retry
</button>
</>
)}
</p>
);
}
+2 -5
View File
@@ -27,6 +27,7 @@ import {
upstreamHealthQuery,
upstreamsQuery,
} from "@/lib/queries";
import { retryButtonClass } from "@/ui/classes";
export interface RouterContext {
queryClient: QueryClient;
@@ -64,11 +65,7 @@ function RouteError({ error }: ErrorComponentProps) {
>
<h2 className="font-semibold text-red-800 dark:text-red-200">{title}</h2>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">{detail}</p>
<button
type="button"
onClick={() => void router.invalidate()}
className="mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:border-red-800 dark:text-red-200"
>
<button type="button" onClick={() => void router.invalidate()} className={retryButtonClass}>
Retry
</button>
</div>
+4 -3
View File
@@ -6,6 +6,7 @@ import InlineError from "@/lib/InlineError";
import { versionQuery } from "@/lib/queries";
import PauseWidget from "../features/pause/PauseWidget";
import RestartBanner from "../features/settings/RestartBanner";
import { buttonClass, focusRing } from "@/ui/classes";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard" },
@@ -38,7 +39,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
className:
"text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100",
}}
className="block rounded px-3 py-1.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
className={`block rounded px-3 py-1.5 ${focusRing}`}
>
{item.label}
</Link>
@@ -73,7 +74,7 @@ function LogoutButton() {
(logoutError: unknown) => setError(logoutError),
);
}}
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"
className={buttonClass}
>
Log out
</button>
@@ -97,7 +98,7 @@ export default function AppShell() {
<header className="flex items-center gap-3 border-b border-zinc-200 px-4 py-2 dark:border-zinc-800">
<button
type="button"
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 md:hidden dark:border-zinc-700"
className={`${buttonClass} md:hidden`}
aria-expanded={drawerOpen}
aria-controls="mobile-nav"
onClick={() => setDrawerOpen((open) => !open)}
+32
View File
@@ -0,0 +1,32 @@
/**
* The shared Tailwind class vocabulary (milestone 18, ruling 10).
*
* Every interactive element must carry `focusRing`; that is the milestone-9
* accessibility floor. Compose these constants for one-off variants
* (`` `${buttonClass} md:hidden` ``) instead of re-spelling the literal.
*/
export const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600";
/** The ring drawn inside the element, for controls flush against a panel edge. */
export const insetFocusRing = "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-600";
export const inputClass = `mt-1 w-full rounded border border-zinc-300 bg-white px-3 py-2 ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
export const smallInputClass = `rounded border border-zinc-300 bg-white px-2 py-1.5 text-sm ${focusRing} dark:border-zinc-700 dark:bg-zinc-900`;
export const buttonClass = `rounded border border-zinc-300 px-3 py-1.5 text-sm ${focusRing} dark:border-zinc-700`;
export const smallButtonClass = `rounded border border-zinc-300 px-2 py-1 text-sm ${focusRing} dark:border-zinc-700`;
export const largeButtonClass = `rounded border border-zinc-300 px-3 py-2 font-medium ${focusRing} dark:border-zinc-700`;
export const primaryButtonClass = `rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50 ${focusRing}`;
export const largePrimaryButtonClass = `rounded bg-blue-600 px-3 py-2 font-medium text-white disabled:opacity-50 ${focusRing}`;
export const rowButtonClass = `rounded px-2 py-1 text-sm text-blue-600 ${focusRing} dark:text-blue-400`;
export const linkButtonClass = `text-sm font-medium text-blue-600 ${focusRing} dark:text-blue-400`;
export const dangerLinkButtonClass = `text-sm font-medium text-red-600 disabled:opacity-50 ${focusRing} dark:text-red-400`;
export const retryButtonClass = `mt-3 rounded border border-red-300 px-3 py-1.5 text-sm font-medium text-red-800 ${focusRing} dark:border-red-800 dark:text-red-200`;
export const thClass = "border-b border-zinc-300 px-3 py-2 text-left font-medium dark:border-zinc-700";
export const tdClass = "border-b border-zinc-200 px-3 py-2 dark:border-zinc-800";
export const tableWrapClass = "mt-4 overflow-x-auto";
export const formCardClass = "mt-4 max-w-lg space-y-3 rounded border border-zinc-200 p-4 dark:border-zinc-800";
+118
View File
@@ -0,0 +1,118 @@
import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider, type QueryClient as QC } from "@tanstack/react-query";
import { useCrudForm } from "./useCrudForm";
interface Row {
id: number;
name: string;
}
function setup() {
const created: string[] = [];
const updated: { id: number; input: string }[] = [];
const removed: number[] = [];
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const hook = renderHook(
() =>
useCrudForm<Row, string>({
create: (_qc: QC) => ({
mutationFn: async (input: string) => {
created.push(input);
},
}),
update: (_qc: QC) => ({
mutationFn: async (vars: { id: number; input: string }) => {
updated.push(vars);
},
}),
remove: (_qc: QC) => ({
mutationFn: async (id: number) => {
removed.push(id);
},
}),
confirmDelete: (row) => `Delete "${row.name}"?`,
}),
{ wrapper },
);
return { hook, created, updated, removed };
}
afterEach(() => vi.unstubAllGlobals());
test("the create form submits through the create mutation and closes", async () => {
const { hook, created, updated } = setup();
act(() => hook.result.current.openForm({ mode: "create" }));
expect(hook.result.current.form).toEqual({ mode: "create" });
act(() => hook.result.current.onSubmit("alpha"));
await waitFor(() => expect(hook.result.current.form).toBeNull());
expect(created).toEqual(["alpha"]);
expect(updated).toEqual([]);
});
test("the edit form submits the row id through the update mutation", async () => {
const { hook, created, updated } = setup();
act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 7, name: "beta" } }));
act(() => hook.result.current.onSubmit("beta2"));
await waitFor(() => expect(hook.result.current.form).toBeNull());
expect(updated).toEqual([{ id: 7, input: "beta2" }]);
expect(created).toEqual([]);
});
test("submitting with no form open does nothing", () => {
const { hook, created, updated } = setup();
act(() => hook.result.current.onSubmit("ignored"));
expect(created).toEqual([]);
expect(updated).toEqual([]);
});
test("openForm clears a stale create error so the reopened form starts clean", async () => {
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const hook = renderHook(
() =>
useCrudForm<Row, string>({
create: (_qc: QC) => ({ mutationFn: () => Promise.reject(new Error("boom")) }),
update: (_qc: QC) => ({ mutationFn: async () => undefined }),
remove: (_qc: QC) => ({ mutationFn: async () => undefined }),
confirmDelete: () => "?",
}),
{ wrapper },
);
act(() => hook.result.current.openForm({ mode: "create" }));
act(() => hook.result.current.onSubmit("alpha"));
await waitFor(() => expect(hook.result.current.create.error).not.toBeNull());
expect(hook.result.current.form).toEqual({ mode: "create" });
act(() => hook.result.current.openForm({ mode: "create" }));
await waitFor(() => expect(hook.result.current.create.error).toBeNull());
});
test("delete asks for confirmation and only removes when confirmed", async () => {
const { hook, removed } = setup();
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
expect(confirm).toHaveBeenCalledWith('Delete "gamma"?');
expect(removed).toEqual([]);
confirm.mockReturnValue(true);
act(() => hook.result.current.onDelete({ id: 3, name: "gamma" }));
await waitFor(() => expect(removed).toEqual([3]));
});
test("closeForm closes whichever form is open", () => {
const { hook } = setup();
act(() => hook.result.current.openForm({ mode: "edit", entity: { id: 1, name: "delta" } }));
act(() => hook.result.current.closeForm());
expect(hook.result.current.form).toBeNull();
});
+54
View File
@@ -0,0 +1,54 @@
import { useState } from "react";
import { useMutation, useQueryClient, type QueryClient, type UseMutationOptions } from "@tanstack/react-query";
/** Which form is open: none, the create form, or the edit form for one row. */
export type CrudFormState<Entity> = { mode: "create" } | { mode: "edit"; entity: Entity };
type MutationFactory<Variables> = (queryClient: QueryClient) => UseMutationOptions<unknown, Error, Variables>;
export interface CrudFormSpec<Entity extends { id: number }, Input> {
create: MutationFactory<Input>;
update: MutationFactory<{ id: number; input: Input }>;
remove: MutationFactory<number>;
/** The `window.confirm` text shown before a delete. */
confirmDelete: (entity: Entity) => string;
}
/**
* The create/edit/delete plumbing shared by the local DNS tabs (ruling 10):
* three mutations, the open-form state, and the three handlers. The field JSX
* and the table stay in the caller.
*/
export function useCrudForm<Entity extends { id: number }, Input>(spec: CrudFormSpec<Entity, Input>) {
const queryClient = useQueryClient();
const create = useMutation(spec.create(queryClient));
const update = useMutation(spec.update(queryClient));
const remove = useMutation(spec.remove(queryClient));
const [form, setForm] = useState<CrudFormState<Entity> | null>(null);
function openForm(next: CrudFormState<Entity>) {
create.reset();
update.reset();
setForm(next);
}
function closeForm() {
setForm(null);
}
function onSubmit(input: Input) {
if (form === null) return;
if (form.mode === "create") {
create.mutate(input, { onSuccess: () => setForm(null) });
} else {
update.mutate({ id: form.entity.id, input }, { onSuccess: () => setForm(null) });
}
}
function onDelete(entity: Entity) {
if (!window.confirm(spec.confirmDelete(entity))) return;
remove.mutate(entity.id);
}
return { create, update, remove, form, openForm, closeForm, onSubmit, onDelete };
}