Skip to content

Commit 4775775

Browse files
feat: editable filter pills (#9349)
## 📝 Summary - Click a column-filter pill in a data-table to open an inline editor: pick column, operator, and value; Apply / Close / Remove. - Editable types: number / text / boolean / select. Date/datetime/time remain read-only — separate PR. - Also: new `FilterByValuesPicker` (controlled top-K dropdown, used in the select value slot); `Combobox` forwards `id` onto its trigger so `<label htmlFor>` works. ## Scope - `filter-pill-editor.tsx` (new): popover with Combobox / Select / value slot. Snapshot-based rehydration restores the pre-edit values when the user navigates back to the same column + operator. - `filter-by-values-picker.tsx` (new): fully-controlled, emits on every toggle. Intended to replace the column-header popover in a follow-up. - `filter-pills.tsx`: three-segment pill (col | op | val), focusable button trigger, `calculateTopKRows` threaded through to the select value slot. - `combobox.tsx`: new optional `id` prop forwarded to the trigger. https://github.com/user-attachments/assets/5722c3d9-5023-4c40-838e-ccc0cb9912eb <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Make filter pills editable via an inline popover editor. Users can change column, operator, and value directly from the pill; date/datetime/time pills remain read‑only. - **New Features** - Inline editor on pill click with Apply, Close, and Remove actions. - Supported types: number, text, boolean, select; others render read‑only. - Three-segment pills show column | operator | value for quick scanning. - New `FilterByValuesPicker`: controlled top‑K values dropdown for select filters, powered by `calculateTopKRows`. - `DataTable` accepts `calculateTopKRows` and passes it to pills; `DataTablePlugin` wires `calculate_top_k_rows`. - `Combobox` forwards `id` to its trigger to enable `<label htmlFor>` association. - **Bug Fixes** - `FilterByValuesPicker`: fixed async deps; smarter search (smartMatch + includes); Select All affects only visible items with indeterminate state; correct truncation notice; stable keys. - `FilterPillEditor`: `is_null`/`is_not_null` preserve column type across round‑trips; `Checkbox` now supports an indeterminate state (minus icon) used by Select All. <sup>Written for commit 7f2a1f2. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent b9ba3e5 commit 4775775

10 files changed

Lines changed: 940 additions & 62 deletions

File tree

frontend/src/components/data-table/data-table.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import React, { memo } from "react";
2222
import { useLocale } from "react-aria";
2323

2424
import { Table } from "@/components/ui/table";
25-
import type { GetRowIds } from "@/plugins/impl/DataTablePlugin";
25+
import type {
26+
CalculateTopKRows,
27+
GetRowIds,
28+
} from "@/plugins/impl/DataTablePlugin";
2629
import { cn } from "@/utils/cn";
2730
import {
2831
PANEL_TYPES,
@@ -89,6 +92,7 @@ interface DataTableProps<TData> extends Partial<ExportActionProps> {
8992
showFilters?: boolean;
9093
filters?: ColumnFiltersState;
9194
onFiltersChange?: OnChangeFn<ColumnFiltersState>;
95+
calculateTopKRows?: CalculateTopKRows;
9296
reloading?: boolean;
9397
// Columns
9498
freezeColumnsLeft?: string[];
@@ -139,6 +143,7 @@ const DataTableInternal = <TData,>({
139143
showFilters = false,
140144
filters,
141145
onFiltersChange,
146+
calculateTopKRows,
142147
reloading,
143148
freezeColumnsLeft,
144149
freezeColumnsRight,
@@ -282,7 +287,11 @@ const DataTableInternal = <TData,>({
282287

283288
return (
284289
<div className={cn(wrapperClassName, "flex flex-col space-y-1")}>
285-
<FilterPills filters={filters} table={table} />
290+
<FilterPills
291+
filters={filters}
292+
table={table}
293+
calculateTopKRows={calculateTopKRows}
294+
/>
286295
<CellSelectionProvider>
287296
<div
288297
part="table-wrapper"
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/* Copyright 2026 Marimo. All rights reserved. */
2+
"use no memo";
3+
4+
import type { Column } from "@tanstack/react-table";
5+
import { ChevronDownIcon } from "lucide-react";
6+
import { useMemo, useState } from "react";
7+
import { useAsyncData } from "@/hooks/useAsyncData";
8+
import { ErrorBanner } from "@/plugins/impl/common/error-banner";
9+
import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin";
10+
import { cn } from "@/utils/cn";
11+
import { Logger } from "@/utils/Logger";
12+
import { Sets } from "@/utils/sets";
13+
import { smartMatch } from "@/utils/smartMatch";
14+
import { Spinner } from "../icons/spinner";
15+
import { Button } from "../ui/button";
16+
import { Checkbox } from "../ui/checkbox";
17+
import {
18+
Command,
19+
CommandEmpty,
20+
CommandInput,
21+
CommandItem,
22+
CommandList,
23+
} from "../ui/command";
24+
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
25+
import { SentinelCell } from "./sentinel-cell";
26+
import { detectSentinel, stringifyUnknownValue } from "./utils";
27+
28+
const TOP_K_ROWS = 30;
29+
30+
interface Props<TData, TValue> {
31+
column: Column<TData, TValue>;
32+
calculateTopKRows?: CalculateTopKRows;
33+
chosenValues: unknown[];
34+
onChange: (values: unknown[]) => void;
35+
}
36+
37+
export const FilterByValuesPicker = <TData, TValue>({
38+
column,
39+
calculateTopKRows,
40+
chosenValues,
41+
onChange,
42+
}: Props<TData, TValue>) => {
43+
const [open, setOpen] = useState(false);
44+
45+
const chosenValuesSet = useMemo(() => new Set(chosenValues), [chosenValues]);
46+
47+
const selectedValuesStr = useMemo(() => {
48+
if (chosenValuesSet.size === 0) {
49+
return "Select values…";
50+
}
51+
const items = [...chosenValuesSet].map((v) =>
52+
stringifyUnknownValue({ value: v }),
53+
);
54+
return `[${items.join(", ")}]`;
55+
}, [chosenValuesSet]);
56+
57+
return (
58+
<Popover open={open} onOpenChange={setOpen}>
59+
<PopoverTrigger asChild={true}>
60+
<Button
61+
variant="outline"
62+
size="xs"
63+
className="h-6 mb-1 w-full justify-between font-normal"
64+
>
65+
<span
66+
className={cn(
67+
"truncate",
68+
chosenValuesSet.size === 0 && "text-muted-foreground",
69+
)}
70+
>
71+
{selectedValuesStr}
72+
</span>
73+
<ChevronDownIcon className="h-4 w-4 opacity-50 shrink-0" />
74+
</Button>
75+
</PopoverTrigger>
76+
<PopoverContent className="w-80 p-0">
77+
<PickerBody
78+
column={column}
79+
calculateTopKRows={calculateTopKRows}
80+
chosenValues={chosenValuesSet}
81+
onChange={onChange}
82+
/>
83+
</PopoverContent>
84+
</Popover>
85+
);
86+
};
87+
88+
interface PickerBodyProps<TData, TValue> {
89+
column: Column<TData, TValue>;
90+
calculateTopKRows?: CalculateTopKRows;
91+
chosenValues: Set<unknown>;
92+
onChange: (values: unknown[]) => void;
93+
}
94+
95+
const PickerBody = <TData, TValue>({
96+
column,
97+
calculateTopKRows,
98+
chosenValues,
99+
onChange,
100+
}: PickerBodyProps<TData, TValue>) => {
101+
const [query, setQuery] = useState<string>("");
102+
103+
const { data, isPending, error } = useAsyncData(async () => {
104+
if (!calculateTopKRows) {
105+
return null;
106+
}
107+
const res = await calculateTopKRows({ column: column.id, k: TOP_K_ROWS });
108+
return res.data;
109+
}, [calculateTopKRows, column.id]);
110+
111+
const filteredData = useMemo(() => {
112+
if (!data) {
113+
return [];
114+
}
115+
try {
116+
// try to do includes and also smart match for prefixes
117+
return data.filter(([value, _count]) => {
118+
if (value === undefined) {
119+
return false;
120+
}
121+
const str = String(value);
122+
return (
123+
smartMatch(query, str) ||
124+
str.toLowerCase().includes(query.toLowerCase())
125+
);
126+
});
127+
} catch (error_) {
128+
Logger.error("Error filtering data", error_);
129+
return [];
130+
}
131+
}, [data, query]);
132+
133+
const handleToggle = (value: unknown) => {
134+
onChange([...Sets.toggle(chosenValues, value)]);
135+
};
136+
137+
const allVisibleChecked =
138+
filteredData.length > 0 &&
139+
filteredData.every(([value]) => chosenValues.has(value));
140+
141+
const selectAllState: boolean | "indeterminate" = allVisibleChecked
142+
? true
143+
: chosenValues.size > 0
144+
? "indeterminate"
145+
: false;
146+
147+
const handleToggleAll = () => {
148+
if (!data) {
149+
return;
150+
}
151+
const next = new Set(chosenValues);
152+
if (allVisibleChecked) {
153+
for (const [value] of filteredData) {
154+
next.delete(value);
155+
}
156+
} else {
157+
for (const [value] of filteredData) {
158+
next.add(value);
159+
}
160+
}
161+
onChange([...next]);
162+
};
163+
164+
if (isPending) {
165+
return <Spinner size="medium" className="mx-auto mt-12 mb-10" />;
166+
}
167+
168+
if (error) {
169+
return <ErrorBanner error={error} className="my-10 mx-4" />;
170+
}
171+
172+
if (!data) {
173+
return (
174+
<div className="py-6 px-4 text-sm text-muted-foreground text-center">
175+
No values available
176+
</div>
177+
);
178+
}
179+
180+
return (
181+
<Command className="text-sm outline-hidden" shouldFilter={false}>
182+
<CommandInput
183+
placeholder={`Search among the top ${data.length} values`}
184+
autoFocus={true}
185+
onValueChange={(value) => setQuery(value.trim())}
186+
/>
187+
<CommandEmpty>No results found.</CommandEmpty>
188+
<CommandList>
189+
{filteredData.length > 0 && (
190+
<CommandItem
191+
value="__select-all__"
192+
className="border-b rounded-none px-3"
193+
onSelect={handleToggleAll}
194+
>
195+
<Checkbox
196+
checked={selectAllState}
197+
aria-label="Select all"
198+
className="mr-3 h-3.5 w-3.5"
199+
/>
200+
<span className="font-bold flex-1">{column.id}</span>
201+
<span className="font-bold">Count</span>
202+
</CommandItem>
203+
)}
204+
{filteredData.map(([value, count]) => {
205+
const isSelected = chosenValues.has(value);
206+
const valueString = stringifyUnknownValue({ value });
207+
const sentinel = detectSentinel(
208+
value,
209+
column.columnDef.meta?.dataType,
210+
);
211+
return (
212+
<CommandItem
213+
key={valueString}
214+
value={valueString}
215+
className="not-last:border-b rounded-none px-3"
216+
onSelect={() => handleToggle(value)}
217+
>
218+
<Checkbox
219+
checked={isSelected}
220+
aria-label="Select row"
221+
className="mr-3 h-3.5 w-3.5"
222+
/>
223+
<span className="flex-1 overflow-hidden max-h-20 line-clamp-3">
224+
{sentinel ? <SentinelCell sentinel={sentinel} /> : valueString}
225+
</span>
226+
<span className="ml-3">{count}</span>
227+
</CommandItem>
228+
);
229+
})}
230+
</CommandList>
231+
{data.length === TOP_K_ROWS && (
232+
<span className="text-xs text-muted-foreground py-1.5 text-center">
233+
Only showing the top {TOP_K_ROWS} values
234+
</span>
235+
)}
236+
</Command>
237+
);
238+
};

0 commit comments

Comments
 (0)