mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user