refactor(filter-bar): saved views as auto-named cards, not a name-prompt menu

Replace the modal "type a name to save" flow with a one-click Save that
auto-generates the label from the active filters (describeQuery, reusing
the same formatter FilterPill uses for pill text — one source of truth,
nothing to type, nothing to fall out of sync). Saved views render as a
grid of cards at the top of the filter bar instead of being buried in a
dropdown.

- useSavedViews: localStorage state, extracted so FilterBar owns one
  instance instead of two components each subscribing separately
- SaveViewButton: filled (not outline) since it's the one action-y
  control among a row of filter pills; flashes to a disabled "Saved" +
  checkmark state for 1.5s on click, in addition to the corner toast —
  the toast alone wasn't registering as feedback
- SavedViewCards: click to apply, per-card delete, active one highlighted
- moved from the wrapping filter-pill row into the pinned right-hand zone
  (with sort) behind its own divider, so it can't shove pills around
- Save icon: BookmarkPlus -> Save (floppy disk reads as "save" faster)
- More filters button: outline/gray -> default variant — same low-
  contrast-outline problem the inactive filter pill had
- Clear button font bumped xs -> sm to match the rest of the bar
This commit is contained in:
Nathnael
2026-08-14 13:48:54 +00:00
parent 4a4d3077d7
commit bec9575311
9 changed files with 227 additions and 141 deletions

View File

@@ -6,8 +6,10 @@ import type { FilterDef, SortOption } from "./types";
import type { UseFilters } from "./useFilters";
import { FilterPill } from "./FilterPill";
import { MoreFiltersMenu } from "./MoreFiltersMenu";
import { SavedViews } from "./SavedViews";
import { SaveViewButton } from "./SaveViewButton";
import { SavedViewCards } from "./SavedViewCards";
import { SortControl } from "./SortControl";
import { useSavedViews } from "./useSavedViews";
export interface FilterBarProps {
defs: FilterDef[];
@@ -44,22 +46,32 @@ export function FilterBar({
...pinned.filter((d) => !controls.values[d.key]),
];
return (
// Two independent flex zones, not one big wrapping Group: the left side
// (search + pills + more filters + clear) wraps to as many lines as it
// needs; the right side (sort) stays put on the first line — `nowrap` +
// `flexShrink: 0` on the right zone stop it from ever getting pushed
// down when the left side overflows.
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, flexWrap: "nowrap" }}>
<Group gap="xs" wrap="wrap" align="center" style={{ flex: 1, minWidth: 0 }}>
{viewId && (
<SavedViews
viewId={viewId}
currentQueryString={controls.currentQueryString}
applyQueryString={controls.applyQueryString}
/>
)}
// Unconditional call (rules of hooks) — viewId is a per-page constant, and
// the hook is a no-op storage key when saved views aren't wired up.
const savedViews = useSavedViews(viewId ?? "__unset__");
const activeQuery = controls.currentQueryString();
const hasMatchingView = savedViews.views.some((v) => v.query === activeQuery);
const canSaveView = Boolean(viewId) && activeQuery.length > 0 && !hasMatchingView;
return (
<div>
{viewId && (
<SavedViewCards
defs={defs}
views={savedViews.views}
activeQuery={activeQuery}
applyQueryString={controls.applyQueryString}
onRemove={savedViews.remove}
/>
)}
{/* Two independent flex zones, not one big wrapping Group: the left side
(search + pills + more filters + clear) wraps to as many lines as it
needs; the right side (sort) stays put on the first line — `nowrap` +
`flexShrink: 0` on the right zone stop it from ever getting pushed
down when the left side overflows. */}
<div style={{ display: "flex", alignItems: "flex-start", gap: 8, flexWrap: "nowrap" }}>
<Group gap="xs" wrap="wrap" align="center" style={{ flex: 1, minWidth: 0 }}>
{showSearch && (
<TextInput
placeholder={searchPlaceholder}
@@ -89,7 +101,7 @@ export function FilterBar({
{controls.activeCount > 0 && (
<Anchor
size="xs"
size="sm"
c="red.6"
underline="never"
onClick={() => {
@@ -98,7 +110,7 @@ export function FilterBar({
}}
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
<Trash2 size={13} />
<Trash2 size={14} />
Clear
</Anchor>
)}
@@ -115,7 +127,14 @@ export function FilterBar({
<SortControl options={sortOptions} value={controls.sort} onChange={controls.setSort} />
</>
)}
{canSaveView && (
<>
<Divider orientation="vertical" />
<SaveViewButton defs={defs} query={activeQuery} onSave={savedViews.save} />
</>
)}
</Group>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { ActionIcon, Button, Popover } from "@mantine/core";
import { Plus, X } from "lucide-react";
import type { FilterDef, FilterValue } from "./types";
import { formatFilterValue } from "./format";
import { BooleanBody } from "./bodies/BooleanBody";
import { DateBody } from "./bodies/DateBody";
import { EnumBody } from "./bodies/EnumBody";
@@ -17,18 +18,6 @@ const BODIES: Record<FilterDef["type"], React.ComponentType<any>> = {
boolean: BooleanBody,
};
function formatValue(def: FilterDef, value: FilterValue): string {
if (def.format) return def.format(value, def);
if (def.type === "enum") {
const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v);
return labels.join(", ");
}
if (def.type === "date" && value.v.length === 2) {
return `${value.v[0].slice(0, 10)}${value.v[1].slice(0, 10)}`;
}
return value.v.join(", ");
}
export interface FilterPillProps {
def: FilterDef;
value: FilterValue | undefined;
@@ -77,7 +66,7 @@ export function FilterPill({ def, value, onChange, autoOpen }: FilterPillProps)
}
onClick={() => setOpened((o) => !o)}
>
{active ? `${def.label} | ${formatValue(def, value!)}` : def.label}
{active ? `${def.label} | ${formatFilterValue(def, value!)}` : def.label}
</Button>
</Popover.Target>
<Popover.Dropdown miw={260} p="xs">

View File

@@ -29,8 +29,9 @@ export function MoreFiltersMenu({ defs, onPick }: MoreFiltersMenuProps) {
<Button
size="xs"
radius="xl"
variant="outline"
color="gray"
// "default" (opaque border + solid text), not "outline" (faint
// color-tinted border/text) — same fix as the inactive filter pill.
variant="default"
leftSection={<Plus size={16} />}
onClick={() => setOpened((o) => !o)}
>

View File

@@ -0,0 +1,46 @@
import { useState } from "react";
import { Button } from "@mantine/core";
import { Check, Save } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import type { FilterDef } from "./types";
import { describeQuery } from "./format";
import type { SavedView } from "./useSavedViews";
export interface SaveViewButtonProps {
defs: FilterDef[];
query: string;
onSave: (query: string) => SavedView;
}
/** Filled, not outline — this is the one action-y button in the bar (every
* other control here is a filter), so it needs to actually look like a
* button. One click, no name prompt: the card grid's label is generated
* from the active filters (see `describeQuery`). */
export function SaveViewButton({ defs, query, onSave }: SaveViewButtonProps) {
const { toast } = useToast();
const [justSaved, setJustSaved] = useState(false);
const handleSave = () => {
onSave(query);
toast({ title: "View saved", description: describeQuery(defs, query), duration: 4000 });
// The toast is in the corner; this flash is right where the eye already
// is — the actual confirmation that "the saving" registered.
setJustSaved(true);
setTimeout(() => setJustSaved(false), 1500);
};
return (
<Button
size="sm"
radius="xl"
variant="filled"
color={justSaved ? "teal" : "edr-green"}
leftSection={justSaved ? <Check size={15} /> : <Save size={15} />}
onClick={handleSave}
disabled={justSaved}
>
{justSaved ? "Saved" : "Save"}
</Button>
);
}

View File

@@ -0,0 +1,67 @@
import { ActionIcon, Card, SimpleGrid, Text } from "@mantine/core";
import { Trash2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import type { FilterDef } from "./types";
import { describeQuery } from "./format";
import type { SavedView } from "./useSavedViews";
export interface SavedViewCardsProps {
defs: FilterDef[];
views: SavedView[];
activeQuery: string;
applyQueryString: (query: string) => void;
onRemove: (id: string) => void;
}
/** Saved views up front as a grid of cards — not one more item buried in a
* dropdown nobody opens. Renders nothing until there's at least one saved. */
export function SavedViewCards({ defs, views, activeQuery, applyQueryString, onRemove }: SavedViewCardsProps) {
const { toast } = useToast();
if (views.length === 0) return null;
return (
<SimpleGrid cols={{ base: 2, sm: 3, md: 4, lg: 5 }} spacing="xs" mb="sm">
{views.map((v) => {
const active = v.query === activeQuery;
const label = describeQuery(defs, v.query);
return (
<Card
key={v.id}
withBorder
padding="xs"
radius="md"
onClick={() => {
applyQueryString(v.query);
toast({ title: `Switched to "${label}"` });
}}
style={{
cursor: "pointer",
borderColor: active ? "var(--mantine-color-edr-green-6)" : undefined,
borderWidth: active ? 2 : 1,
backgroundColor: active ? "var(--mantine-color-edr-green-0)" : undefined,
}}
>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 6 }}>
<Text size="xs" fw={500} lineClamp={2} style={{ flex: 1 }}>
{label}
</Text>
<ActionIcon
size="xs"
color="red"
variant="subtle"
onClick={(e) => {
e.stopPropagation();
onRemove(v.id);
toast({ title: "View deleted", description: label, variant: "destructive" });
}}
>
<Trash2 size={12} />
</ActionIcon>
</div>
</Card>
);
})}
</SimpleGrid>
);
}

View File

@@ -1,107 +0,0 @@
import { useState } from "react";
import { ActionIcon, Button, Menu, Modal, Stack, Text, TextInput } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { Bookmark, Check, Save, Trash2 } from "lucide-react";
interface SavedView {
id: string;
name: string;
query: string;
}
export interface SavedViewsProps {
/** localStorage namespace — one page, not one user (single staff login per
* browser profile). ponytail: add ":<userId>" if shared-terminal login appears. */
viewId: string;
currentQueryString: () => string;
applyQueryString: (query: string) => void;
}
/** URL always wins: this menu only ever WRITES the URL, on click. Nothing
* reads a saved view at mount, so a shared link always beats a saved view —
* there is no "which one applies" branch to get wrong. */
export function SavedViews({ viewId, currentQueryString, applyQueryString }: SavedViewsProps) {
const [views, setViews] = useLocalStorage<SavedView[]>({
key: `edr:saved-views:${viewId}`,
defaultValue: [],
});
const [saveOpen, setSaveOpen] = useState(false);
const [name, setName] = useState("");
const activeQuery = currentQueryString();
const active = views.find((v) => v.query === activeQuery);
const save = () => {
if (!name.trim()) return;
setViews((prev) => [
...prev,
{ id: crypto.randomUUID(), name: name.trim(), query: currentQueryString() },
]);
setName("");
setSaveOpen(false);
};
const remove = (id: string) => setViews((prev) => prev.filter((v) => v.id !== id));
return (
<>
<Menu position="bottom-start" withinPortal shadow="md">
<Menu.Target>
<Button size="xs" variant="subtle" color="gray" leftSection={<Bookmark size={14} />}>
{active?.name ?? "All"}
</Button>
</Menu.Target>
<Menu.Dropdown miw={220}>
{views.length === 0 && (
<Menu.Item disabled>
<Text size="xs" c="dimmed">
No saved views yet
</Text>
</Menu.Item>
)}
{views.map((v) => (
<Menu.Item
key={v.id}
leftSection={v.id === active?.id ? <Check size={14} /> : <span style={{ width: 14 }} />}
rightSection={
<ActionIcon
size="xs"
color="red"
variant="subtle"
onClick={(e) => {
e.stopPropagation();
remove(v.id);
}}
>
<Trash2 size={12} />
</ActionIcon>
}
onClick={() => applyQueryString(v.query)}
>
{v.name}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item leftSection={<Save size={14} />} onClick={() => setSaveOpen(true)}>
Save current view
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Modal opened={saveOpen} onClose={() => setSaveOpen(false)} title="Save current view" size="sm">
<Stack gap="sm">
<TextInput
placeholder="View name"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && save()}
autoFocus
/>
<Button onClick={save} disabled={!name.trim()}>
Save
</Button>
</Stack>
</Modal>
</>
);
}

View File

@@ -0,0 +1,36 @@
import { parseFilters } from "./url";
import type { FilterDef, FilterValue } from "./types";
/** Human-readable text for one filter's current value — same text a
* FilterPill shows, and what a saved view's auto-generated label is built
* from, so both read identically with zero duplicated logic. */
export function formatFilterValue(def: FilterDef, value: FilterValue): string {
if (def.format) return def.format(value, def);
if (def.type === "enum") {
const labels = value.v.map((v) => def.options.find((o) => o.value === v)?.label ?? v);
return labels.join(", ");
}
if (def.type === "date" && value.v.length === 2) {
return `${value.v[0].slice(0, 10)}${value.v[1].slice(0, 10)}`;
}
return value.v.join(", ");
}
/**
* Auto-generated label for a saved view — "Status: Active, Draft · Direction:
* Import" — built straight from the filters it holds, instead of asking the
* user to type a name (which drifts out of sync with what the view actually
* filters the moment they edit it). Falls back to "All" when nothing decodes,
* though a view is only ever offered for saving with at least one active filter.
*
* Namespace-aware pages (`useFilters({ ns })`, for a second table on the same
* page) aren't decoded here — every current saved-view page is single-table.
* Thread `ns` through if/when that changes.
*/
export function describeQuery(defs: FilterDef[], query: string): string {
const values = parseFilters(defs, new URLSearchParams(query));
const parts = defs
.filter((d) => values[d.key])
.map((d) => `${d.label}: ${formatFilterValue(d, values[d.key])}`);
return parts.length ? parts.join(" · ") : "All";
}

View File

@@ -1,10 +1,13 @@
export * from "./types";
export * from "./url";
export * from "./dates";
export * from "./format";
export * from "./useFilters";
export * from "./useSavedViews";
export { FilterBar } from "./FilterBar";
export type { FilterBarProps } from "./FilterBar";
export { FilterPill } from "./FilterPill";
export { SortControl } from "./SortControl";
export { SavedViews } from "./SavedViews";
export { SaveViewButton } from "./SaveViewButton";
export { SavedViewCards } from "./SavedViewCards";
export { MoreFiltersMenu } from "./MoreFiltersMenu";

View File

@@ -0,0 +1,32 @@
import { useLocalStorage } from "@mantine/hooks";
export interface SavedView {
id: string;
/** Raw query string ("statuses=ACTIVE&sort=createdAt:DESC") — the label is
* derived from this at render time (see format.ts's describeQuery), so
* there's nothing else to keep in sync. */
query: string;
savedAt: number;
}
/**
* localStorage namespace is per PAGE (viewId), not per user — this is a
* backoffice, one staff login per browser profile.
* ponytail: add ":<userId>" if shared-terminal login appears.
*/
export function useSavedViews(viewId: string) {
const [views, setViews] = useLocalStorage<SavedView[]>({
key: `edr:saved-views:${viewId}`,
defaultValue: [],
});
const save = (query: string): SavedView => {
const view: SavedView = { id: crypto.randomUUID(), query, savedAt: Date.now() };
setViews((prev) => [...prev, view]);
return view;
};
const remove = (id: string) => setViews((prev) => prev.filter((v) => v.id !== id));
return { views, save, remove };
}