milestone 20: declarative configuration for iac

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 2f29121e27
commit d76afc147a
74 changed files with 6722 additions and 1949 deletions
@@ -10,7 +10,9 @@ test("swallowMutationError drops an ApiError and rethrows anything else", () =>
test("a rejected submit leaves the typed values in place; a resolved one clears them", async () => {
const rejecting = vi.fn(() => Promise.reject(new ApiError(400, "bad url")));
const { rerender } = render(<BlocklistForm busy={false} error={null} onSubmit={rejecting} onCancel={undefined} />);
const { rerender } = render(
<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={rejecting} onCancel={undefined} />,
);
const url = screen.getByLabelText("URL") as HTMLInputElement;
const name = screen.getByLabelText("Name") as HTMLInputElement;
fireEvent.change(url, { target: { value: "https://example.com/list.txt" } });
@@ -22,7 +24,7 @@ test("a rejected submit leaves the typed values in place; a resolved one clears
expect(name.value).toBe("Example");
const resolving = vi.fn(() => Promise.resolve());
rerender(<BlocklistForm busy={false} error={null} onSubmit={resolving} onCancel={undefined} />);
rerender(<BlocklistForm busy={false} readOnly={false} error={null} onSubmit={resolving} onCancel={undefined} />);
fireEvent.click(screen.getByRole("button", { name: "Add source" }));
await waitFor(() => expect(url.value).toBe(""));
expect(name.value).toBe("");
+10 -2
View File
@@ -3,6 +3,7 @@ import { ApiError } from "@/lib/api";
import InlineError from "@/lib/InlineError";
import type { Blocklist, BlocklistInput } from "@/lib/types";
import { buttonClass, focusRing, inputClass, primaryButtonClass } from "@/ui/classes";
import { READ_ONLY_HINT } from "@/features/settings/authority";
/**
* Drops the rejection the page already renders inline below the form. Anything
@@ -17,12 +18,14 @@ export function swallowMutationError(error: unknown): void {
interface BlocklistFormProps {
initial?: Blocklist;
busy: boolean;
/** File authority: the server answers 403, so the submit stays down. */
readOnly: boolean;
error: Error | null;
onSubmit: (input: BlocklistInput) => Promise<void>;
onCancel?: () => void;
}
export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel }: BlocklistFormProps) {
export default function BlocklistForm({ initial, busy, readOnly, error, onSubmit, onCancel }: BlocklistFormProps) {
const [url, setUrl] = useState(initial?.url ?? "");
const [name, setName] = useState(initial?.name ?? "");
const [enabled, setEnabled] = useState(initial?.enabled ?? true);
@@ -81,7 +84,12 @@ export default function BlocklistForm({ initial, busy, error, onSubmit, onCancel
Enabled
</label>
<div className="flex items-center gap-2">
<button type="submit" disabled={busy} className={primaryButtonClass}>
<button
type="submit"
disabled={busy || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={primaryButtonClass}
>
{initial === undefined ? "Add source" : "Save changes"}
</button>
{onCancel !== undefined && (
+12 -3
View File
@@ -22,6 +22,7 @@ import {
tdClass,
thClass,
} from "@/ui/classes";
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
export default function BlocklistsPage() {
const queryClient = useQueryClient();
@@ -36,6 +37,9 @@ export default function BlocklistsPage() {
const sources = useRefreshStatus();
const namesById = new Map(blocklists.map((b) => [b.id, b.name]));
// The refresh below re-fetches the sources the config already declares, so
// it stays live in file mode; every other control here writes config.
const readOnly = useReadOnlyConfig();
async function submitForm(input: BlocklistInput) {
if (editing === null) {
@@ -122,7 +126,8 @@ export default function BlocklistsPage() {
type="checkbox"
aria-label={`${b.name} enabled`}
checked={b.enabled}
disabled={toggle.isPending}
disabled={toggle.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
onChange={() => toggleEnabled(b)}
className={focusRing}
/>
@@ -138,14 +143,17 @@ export default function BlocklistsPage() {
<button
type="button"
onClick={() => setEditing(b)}
className={linkButtonClass}
disabled={readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={`${linkButtonClass} disabled:opacity-50`}
>
Edit
</button>
<button
type="button"
onClick={() => deleteBlocklist(b)}
disabled={remove.isPending}
disabled={remove.isPending || readOnly}
title={readOnly ? READ_ONLY_HINT : undefined}
className={dangerLinkButtonClass}
>
Delete
@@ -164,6 +172,7 @@ export default function BlocklistsPage() {
key={editing?.id ?? "add"}
initial={editing ?? undefined}
busy={editing === null ? create.isPending : save.isPending}
readOnly={readOnly}
error={formError}
onSubmit={submitForm}
onCancel={editing === null ? undefined : () => setEditing(null)}