selected states stop changing font weight, buttons gain a pressed scale and scoped 120ms transitions with a reduced-motion override, every loading and empty state reserves its height, charts measure before first paint, hit targets rise to the 44px enhanced target where layout permits, long domains clamp to two lines on an unpadded inner span, chips and name cells truncate, tabular figures on counts and time columns, a z-index layer scale replaces magic numbers and fixes the dialog-over-confirm tie, history's empty state gains a clear-filters action, page headings balance, font smoothing and color-scheme land on the html reset.
111 lines
3.1 KiB
TypeScript
111 lines
3.1 KiB
TypeScript
import { useState } from "react";
|
|
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 } 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";
|
|
|
|
interface Props {
|
|
groupId: number;
|
|
blocklists: Blocklist[];
|
|
}
|
|
|
|
const styles = stylex.create({
|
|
note: {
|
|
marginTop: "0.75rem",
|
|
fontSize: "0.875rem",
|
|
lineHeight: "1.25rem",
|
|
color: colors.textMuted,
|
|
},
|
|
/**
|
|
* Room for a few checkboxes and the button row, so the sections below the
|
|
* panel do not jump up the page when the assignment lands.
|
|
*/
|
|
loading: {
|
|
minHeight: "8rem",
|
|
},
|
|
root: {
|
|
marginTop: "0.75rem",
|
|
},
|
|
list: {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0.25rem",
|
|
},
|
|
buttonRow: {
|
|
marginTop: "0.75rem",
|
|
display: "flex",
|
|
gap: "0.5rem",
|
|
},
|
|
});
|
|
|
|
export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
|
const queryClient = useQueryClient();
|
|
const sources = useQuery(groupSourcesQuery(groupId));
|
|
const mutation = useMutation(groupSourcesPutMutation(queryClient));
|
|
const [selected, setSelected] = useState<number[] | null>(null);
|
|
|
|
if (sources.isPending) {
|
|
return (
|
|
<p role="status" {...stylex.props(styles.note, styles.loading)}>
|
|
Loading sources…
|
|
</p>
|
|
);
|
|
}
|
|
if (sources.isError) return <InlineError error={sources.error} />;
|
|
|
|
if (blocklists.length === 0) {
|
|
return <p {...stylex.props(styles.note)}>No blocklist sources exist yet — add them on the Sources tab.</p>;
|
|
}
|
|
|
|
const current = selected ?? sources.data;
|
|
const dirty = !sameSet(current, sources.data);
|
|
|
|
return (
|
|
<div {...stylex.props(styles.root)}>
|
|
{/* 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
|
|
type="button"
|
|
disabled={!dirty || mutation.isPending}
|
|
onClick={() =>
|
|
mutation.mutate({ id: groupId, sourceIds: current }, { onSuccess: () => setSelected(null) })
|
|
}
|
|
{...stylex.props(shared.primaryButton, shared.focusRing)}
|
|
>
|
|
Save sources
|
|
</button>
|
|
{dirty && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelected(null)}
|
|
{...stylex.props(shared.button, shared.focusRing)}
|
|
>
|
|
Discard
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|