milestone 23 s2: react aria primitives and their cluster
This commit is contained in:
@@ -41,14 +41,20 @@ const RESPONSES: Record<string, unknown> = {
|
||||
};
|
||||
|
||||
let resolveUpdate: ((response: Response) => void) | null;
|
||||
let deleted: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
clearRefreshStatus();
|
||||
resolveUpdate = null;
|
||||
deleted = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (init?.method === "DELETE") {
|
||||
deleted.push(url);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (url === "/api/blocklists/update" && init?.method === "POST") {
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
@@ -227,3 +233,27 @@ test("the refresh snapshot outlives the query cache's gcTime", async () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("delete asks for confirmation, and cancelling sends no request", async () => {
|
||||
renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]!);
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog.textContent).toContain('Delete blocklist "StevenBlack"? Its domains stop being blocked.');
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
test("confirming the delete dialog issues the DELETE for that source", async () => {
|
||||
renderBlocklistsRoute();
|
||||
await screen.findByRole("heading", { name: "Blocklists" });
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
|
||||
await screen.findByRole("alertdialog");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import {
|
||||
@@ -13,21 +14,89 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import BlocklistForm from "./BlocklistForm";
|
||||
import { useRefreshStatus } from "./refreshStore";
|
||||
import SourceStatusSection from "./SourceStatusSection";
|
||||
import {
|
||||
dangerLinkButtonClass,
|
||||
focusRing,
|
||||
linkButtonClass,
|
||||
primaryButtonClass,
|
||||
tableWrapClass,
|
||||
tdClass,
|
||||
thClass,
|
||||
} from "@/ui/classes";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { READ_ONLY_HINT, useReadOnlyConfig } from "@/features/settings/authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
header: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
lineHeight: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
done: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
empty: {
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
minWidth: "max-content",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
name: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
badge: {
|
||||
marginLeft: "0.5rem",
|
||||
borderRadius: "0.25rem",
|
||||
backgroundColor: colors.border,
|
||||
paddingInline: "0.375rem",
|
||||
paddingBlock: "0.125rem",
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.text,
|
||||
},
|
||||
url: {
|
||||
display: "block",
|
||||
maxWidth: "18rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
numeric: {
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
},
|
||||
actions: {
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
dimWhenDisabled: {
|
||||
opacity: { default: 1, ":disabled": 0.5 },
|
||||
},
|
||||
srOnly: {
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clipPath: "inset(50%)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default function BlocklistsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: blocklists } = useSuspenseQuery(blocklistsQuery());
|
||||
const [editing, setEditing] = useState<Blocklist | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<Blocklist | null>(null);
|
||||
|
||||
const create = useMutation(blocklistCreateMutation(queryClient));
|
||||
const save = useMutation(blocklistUpdateMutation(queryClient));
|
||||
@@ -57,10 +126,10 @@ export default function BlocklistsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
function deleteBlocklist(b: Blocklist) {
|
||||
if (window.confirm(`Delete blocklist "${b.name}"? Its domains stop being blocked.`)) {
|
||||
remove.mutate(b.id);
|
||||
}
|
||||
function confirmDelete() {
|
||||
if (pendingDelete === null) return;
|
||||
remove.mutate(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
|
||||
const formError = editing === null ? create.error : save.error;
|
||||
@@ -68,60 +137,56 @@ export default function BlocklistsPage() {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold">Blocklists</h1>
|
||||
<div {...stylex.props(styles.header)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Blocklists</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateNow.mutate()}
|
||||
disabled={updateNow.isPending}
|
||||
className={primaryButtonClass}
|
||||
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
||||
>
|
||||
{updateNow.isPending ? "Updating…" : "Update now"}
|
||||
</button>
|
||||
</div>
|
||||
{updateNow.isSuccess && !updateNow.isPending && (
|
||||
<p className="mt-2 text-sm text-green-700 dark:text-green-400" role="status">
|
||||
<p {...stylex.props(styles.done)} role="status">
|
||||
Update completed; source status refreshed below.
|
||||
</p>
|
||||
)}
|
||||
<InlineError error={updateNow.error} />
|
||||
|
||||
{blocklists.length === 0 ? (
|
||||
<p className="mt-4 text-zinc-500">No blocklist sources yet. Add one below.</p>
|
||||
<p {...stylex.props(styles.empty)}>No blocklist sources yet. Add one below.</p>
|
||||
) : (
|
||||
<div className={tableWrapClass}>
|
||||
<table className="w-full min-w-max border-collapse text-sm">
|
||||
<div {...stylex.props(shared.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<thead>
|
||||
<tr>
|
||||
<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 {...stylex.props(shared.th)}>Name</th>
|
||||
<th {...stylex.props(shared.th)}>URL</th>
|
||||
<th {...stylex.props(shared.th)}>Enabled</th>
|
||||
<th {...stylex.props(shared.th)}>Domains</th>
|
||||
<th {...stylex.props(shared.th)}>Wildcards</th>
|
||||
<th {...stylex.props(shared.th)}>Skipped regex</th>
|
||||
<th {...stylex.props(shared.th)}>Last updated</th>
|
||||
<th {...stylex.props(shared.th)}>
|
||||
<span {...stylex.props(styles.srOnly)}>Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blocklists.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<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">
|
||||
Suggested
|
||||
</span>
|
||||
)}
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(styles.name)}>{b.name}</span>
|
||||
{b.is_suggested && <span {...stylex.props(styles.badge)}>Suggested</span>}
|
||||
</td>
|
||||
<td className={tdClass}>
|
||||
<span className="block max-w-72 truncate" title={b.url}>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<span {...stylex.props(styles.url)} title={b.url}>
|
||||
{b.url}
|
||||
</span>
|
||||
</td>
|
||||
<td className={tdClass}>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${b.name} enabled`}
|
||||
@@ -129,32 +194,36 @@ export default function BlocklistsPage() {
|
||||
disabled={toggle.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
onChange={() => toggleEnabled(b)}
|
||||
className={focusRing}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<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}>
|
||||
<td {...stylex.props(shared.td, styles.numeric)}>{b.domain_count}</td>
|
||||
<td {...stylex.props(shared.td, styles.numeric)}>{b.wildcard_count}</td>
|
||||
<td {...stylex.props(shared.td, styles.numeric)}>{b.skipped_regex_count}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
{b.last_updated === null ? "never" : formatTime(b.last_updated)}
|
||||
</td>
|
||||
<td className={tdClass}>
|
||||
<div className="flex gap-3">
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<div {...stylex.props(styles.actions)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(b)}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={`${linkButtonClass} disabled:opacity-50`}
|
||||
{...stylex.props(
|
||||
shared.linkButton,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteBlocklist(b)}
|
||||
onClick={() => setPendingDelete(b)}
|
||||
disabled={remove.isPending || readOnly}
|
||||
title={readOnly ? READ_ONLY_HINT : undefined}
|
||||
className={dangerLinkButtonClass}
|
||||
{...stylex.props(shared.dangerLinkButton, shared.focusRing)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -179,6 +248,19 @@ export default function BlocklistsPage() {
|
||||
/>
|
||||
|
||||
<SourceStatusSection sources={sources} namesById={namesById} />
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDelete !== null}
|
||||
title="Delete blocklist"
|
||||
message={
|
||||
pendingDelete === null
|
||||
? ""
|
||||
: `Delete blocklist "${pendingDelete.name}"? Its domains stop being blocked.`
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user