milestone 20: declarative configuration for iac
This commit is contained in:
@@ -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("");
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user