admin: group sources and safe search become rac checkboxes
the assigned sources list is a rac checkboxgroup and safe search uses the same drawn checkbox, extracted to a shared ui component with grouped and standalone modes enforced by a discriminated union. the label carries a 44px pointer-target floor on both axes, the focus ring is driven from rac's focus-visible state and guarded by a test, and toggleSource is gone because the group hands back the whole set.
This commit is contained in:
@@ -3,7 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||
import type { Blocklist } from "@/lib/types";
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
import { sameSet } from "./sourceSet";
|
||||
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -28,13 +29,6 @@ const styles = stylex.create({
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
buttonRow: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
@@ -66,21 +60,22 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.root)}>
|
||||
<ul {...stylex.props(styles.list)}>
|
||||
{blocklists.map((blocklist) => (
|
||||
<li key={blocklist.id}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={current.includes(blocklist.id)}
|
||||
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
{blocklist.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* The section's own "Assigned sources" heading is the visible label; a
|
||||
Label here would put the same words on screen twice. Ids cross the
|
||||
React Aria boundary as strings, the same convention as Select. */}
|
||||
<CheckboxGroup
|
||||
aria-label="Assigned sources"
|
||||
value={current.map(String)}
|
||||
onChange={(values) => setSelected(values.map(Number).sort((a, b) => a - b))}
|
||||
>
|
||||
<ul {...stylex.props(styles.list)}>
|
||||
{blocklists.map((blocklist) => (
|
||||
<li key={blocklist.id}>
|
||||
<Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CheckboxGroup>
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
rulesQuery,
|
||||
} from "@/lib/queries";
|
||||
import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from "@/lib/types";
|
||||
import Checkbox from "@/ui/Checkbox";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import DefinitionList from "@/ui/DefinitionList";
|
||||
import Select from "@/ui/Select";
|
||||
@@ -68,13 +69,6 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
spacer: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
@@ -355,21 +349,15 @@ function GroupDetailEditable({ group }: { group: Group }) {
|
||||
)}
|
||||
|
||||
<div {...stylex.props(styles.controlRow)}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={group.safe_search}
|
||||
disabled={update.isPending}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
onChange={(event) =>
|
||||
update.mutate({
|
||||
id: group.id,
|
||||
input: { name: group.name, safe_search: event.target.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
isSelected={group.safe_search}
|
||||
isDisabled={update.isPending}
|
||||
onChange={(safeSearch) =>
|
||||
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
|
||||
}
|
||||
>
|
||||
Safe search
|
||||
</label>
|
||||
</Checkbox>
|
||||
<span {...stylex.props(styles.spacer)}>
|
||||
{!renaming && (
|
||||
<button
|
||||
|
||||
@@ -89,6 +89,29 @@ test("the source assignment saves the full set via PUT", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("the source checkboxes form one named group, and each carries its own state", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
// The section heading names the group; the boxes belong to it rather than
|
||||
// sitting loose beside the group's other checkboxes.
|
||||
const group = await screen.findByRole("group", { name: "Assigned sources" });
|
||||
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
|
||||
expect(ads.checked).toBe(false);
|
||||
// Safe search is the group's own field, not one of its sources.
|
||||
expect(within(group).queryByRole("checkbox", { name: "Safe search" })).toBeNull();
|
||||
|
||||
fireEvent.click(ads);
|
||||
await waitFor(() => expect(ads.checked).toBe(true));
|
||||
|
||||
// Discard returns the group to the server's set rather than clearing it.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||
await waitFor(() =>
|
||||
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
|
||||
);
|
||||
expect(writes("PUT")).toEqual([]);
|
||||
});
|
||||
|
||||
test("only the selected group's rules are listed", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
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]);
|
||||
});
|
||||
import { sameSet } from "./sourceSet";
|
||||
|
||||
test("sameSet compares regardless of order", () => {
|
||||
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user