@marimo-team/frontend 0.23.4-dev0 → 0.23.4-dev5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/assets/{CellStatus-BTL_sgUY.js → CellStatus-PIeZtHEl.js} +1 -1
  2. package/dist/assets/JsonOutput-BVAcY5xS.js +49 -0
  3. package/dist/assets/{MarimoErrorOutput-BBD405a6.js → MarimoErrorOutput-B9_RO5eX.js} +2 -2
  4. package/dist/assets/{RenderHTML-BT9obKLc.js → RenderHTML-DR6lqJGS.js} +1 -1
  5. package/dist/assets/{add-cell-with-ai-qEfAzC8R.js → add-cell-with-ai-DjfY8mF1.js} +9 -9
  6. package/dist/assets/{add-connection-dialog-HCShSlr3.js → add-connection-dialog-gd2yD9Qo.js} +2 -2
  7. package/dist/assets/{agent-panel-CoBHxHpJ.js → agent-panel-TetMUXWP.js} +3 -3
  8. package/dist/assets/{ai-model-dropdown-CYx9ZfdS.js → ai-model-dropdown-By4Ohb0R.js} +3 -3
  9. package/dist/assets/{app-config-button-DC5UlXZA.js → app-config-button-YS8lpoUa.js} +1 -1
  10. package/dist/assets/{cell-editor-BMjfXh0J.js → cell-editor-87LAVsOR.js} +11 -11
  11. package/dist/assets/{cell-link-BA7Demf0.js → cell-link-d38hFEon.js} +1 -1
  12. package/dist/assets/{cells-CJlo_hG2.js → cells-A-ljPpuj.js} +55 -55
  13. package/dist/assets/{chat-display-DbztpRD4.js → chat-display-Be0U92hE.js} +1 -1
  14. package/dist/assets/{chat-panel-H-xFzATI.js → chat-panel-BEz970S3.js} +1 -1
  15. package/dist/assets/{chat-ui-CFqAy2ck.js → chat-ui-CdW7awFa.js} +1 -1
  16. package/dist/assets/{column-preview-DzN2QumC.js → column-preview-_5uYiy74.js} +1 -1
  17. package/dist/assets/{command-DbT_zkRP.js → command-CzIDL1VI.js} +1 -1
  18. package/dist/assets/{command-palette-DSSR50KF.js → command-palette-DfotChyr.js} +1 -1
  19. package/dist/assets/{common-CquNY-ok.js → common-DC9LEIym.js} +1 -1
  20. package/dist/assets/{components-BXKuiPiF.js → components-BDnRtsA5.js} +1 -1
  21. package/dist/assets/{components-BiBOcq1x.js → components-FepcpFGL.js} +1 -1
  22. package/dist/assets/{datasource-BKDU-4D5.js → datasource-BCnGIjqn.js} +2 -2
  23. package/dist/assets/{dependency-graph-panel-ayo-Mm2j.js → dependency-graph-panel-Yk1Ch_Gj.js} +1 -1
  24. package/dist/assets/{documentation-panel-ph6755dz.js → documentation-panel-BMrGuByB.js} +1 -1
  25. package/dist/assets/{download-mzsKQgiy.js → download-Iyng0xCz.js} +4 -4
  26. package/dist/assets/{edit-page-Bxa5DWoE.js → edit-page-yF7xM43H.js} +6 -6
  27. package/dist/assets/{error-panel-BfG_JeMA.js → error-panel-CHSsnRFF.js} +1 -1
  28. package/dist/assets/{file-explorer-panel-D7-d0Puf.js → file-explorer-panel-Dlwspu2Y.js} +3 -3
  29. package/dist/assets/{file-icons-BNrh8MRG.js → file-icons-DTNZv05h.js} +1 -1
  30. package/dist/assets/{floating-outline-BMB4_phA.js → floating-outline-CSNGDMAm.js} +1 -1
  31. package/dist/assets/{focus-BX3gXJxx.js → focus-Baz7rlo0.js} +1 -1
  32. package/dist/assets/form-Dr-_EXr8.js +2 -0
  33. package/dist/assets/{home-page-B1ELDhn8.js → home-page-QvO22-4u.js} +1 -1
  34. package/dist/assets/{hooks-b3J1eFE-.js → hooks-CNPv5U-N.js} +1 -1
  35. package/dist/assets/{html-to-image-Cz8lDF-Y.js → html-to-image-tbPEMBJg.js} +1 -1
  36. package/dist/assets/index-DH5UUBE4.css +2 -0
  37. package/dist/assets/{index-BjiE1T38.js → index-z8Uh9i5Z.js} +8 -8
  38. package/dist/assets/{kiosk-mode-Dxbs5i5g.js → kiosk-mode-cWTXpegb.js} +1 -1
  39. package/dist/assets/{layout-lxwMDUgI.js → layout-DDh3d8oW.js} +5 -5
  40. package/dist/assets/{logs-panel-BW6S5AI1.js → logs-panel-Dgkr6V7q.js} +1 -1
  41. package/dist/assets/loro_wasm_bg-Bu4Ofw1K.js +2 -0
  42. package/dist/assets/{loro_wasm_bg-DXuHQ2hl.js → loro_wasm_bg-DWJCS9pK.js} +1 -1
  43. package/dist/assets/{markdown-renderer-CeHY2KoQ.js → markdown-renderer-CFrgiLqu.js} +1 -1
  44. package/dist/assets/{name-cell-input-B1BCybeo.js → name-cell-input-C5LXTI3T.js} +1 -1
  45. package/dist/assets/{outline-panel-DgMz83Mx.js → outline-panel-0lyts6z4.js} +1 -1
  46. package/dist/assets/{packages-panel-CbjavFh2.js → packages-panel-BhI1lbfw.js} +1 -1
  47. package/dist/assets/{panels-BPzk3EbR.js → panels-CbRenLjk.js} +1 -1
  48. package/dist/assets/{process-output-CggRIZ6s.js → process-output-BwZ5VxXO.js} +1 -1
  49. package/dist/assets/{readonly-python-code-Ccd8HM-7.js → readonly-python-code-B0nrQE3U.js} +1 -1
  50. package/dist/assets/{reveal-component-BamXsTkr.js → reveal-component-CLjsFHmT.js} +1 -1
  51. package/dist/assets/{run-page-C5cZvltI.js → run-page-C1Wd8dr8.js} +1 -1
  52. package/dist/assets/{scratchpad-panel-PMFH4Ktp.js → scratchpad-panel-B8kRNql6.js} +1 -1
  53. package/dist/assets/{session-panel-CUTSr9rt.js → session-panel-BvCIuxgU.js} +1 -1
  54. package/dist/assets/{slide-form-Bi8ZYBHP.js → slide-form-CXqvfwGg.js} +1 -1
  55. package/dist/assets/{snippets-panel-CdhDsAII.js → snippets-panel-COBTGJcr.js} +1 -1
  56. package/dist/assets/{state-C5AUgyZT.js → state-Bsz477is.js} +1 -1
  57. package/dist/assets/{state-DHlRrwyY.js → state-LyvUXW6A.js} +2 -2
  58. package/dist/assets/{textarea-BXPC1-kb.js → textarea-Dd0InTQJ.js} +1 -1
  59. package/dist/assets/{tracing-wSufGcF9.js → tracing-DjZGgWTo.js} +1 -1
  60. package/dist/assets/{tracing-panel-BO6MMAXa.js → tracing-panel-8eFzEjG0.js} +2 -2
  61. package/dist/assets/{useAddCell-CqBbGhrY.js → useAddCell-De9xD63_.js} +1 -1
  62. package/dist/assets/{useCellActionButton-DYwx4pGE.js → useCellActionButton-Bjxun3wK.js} +1 -1
  63. package/dist/assets/{useDeleteCell-BJlGbsbV.js → useDeleteCell-C2Stbb6Q.js} +1 -1
  64. package/dist/assets/{useDependencyPanelTab-rkAIQ6zL.js → useDependencyPanelTab-DFZNQ3a8.js} +1 -1
  65. package/dist/assets/{useNotebookActions-Dga3qEHF.js → useNotebookActions-F5fGMe9N.js} +1 -1
  66. package/dist/assets/{useRunCells-B531RIUE.js → useRunCells-DU1tawR-.js} +1 -1
  67. package/dist/assets/{useSplitCell-BnuJW5uo.js → useSplitCell-BZPuhd8z.js} +1 -1
  68. package/dist/index.html +25 -25
  69. package/package.json +4 -4
  70. package/src/components/data-table/data-table.tsx +11 -2
  71. package/src/components/data-table/filter-by-values-picker.tsx +238 -0
  72. package/src/components/data-table/filter-pill-editor.tsx +470 -0
  73. package/src/components/data-table/filter-pills.tsx +177 -41
  74. package/src/components/ui/checkbox.tsx +8 -4
  75. package/src/components/ui/combobox.tsx +3 -0
  76. package/src/css/app/Cell.css +4 -0
  77. package/src/plugins/impl/DataTablePlugin.tsx +1 -0
  78. package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +15 -15
  79. package/src/utils/sets.ts +13 -0
  80. package/dist/assets/JsonOutput-DXnOS_Hk.js +0 -49
  81. package/dist/assets/form-CPDlIjdV.js +0 -2
  82. package/dist/assets/index-BYLYJcAY.css +0 -2
  83. package/dist/assets/loro_wasm_bg-Ds40eH8K.js +0 -2
@@ -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,0 +1,470 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ "use no memo";
3
+
4
+ import type { Column, Table } from "@tanstack/react-table";
5
+ import { CheckIcon, MinusIcon, Trash2Icon, XIcon } from "lucide-react";
6
+ import { useId, useState } from "react";
7
+ import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin";
8
+ import { Combobox, ComboboxItem } from "../ui/combobox";
9
+ import { Input } from "../ui/input";
10
+ import { NumberField } from "../ui/number-field";
11
+ import {
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ SelectValue,
17
+ } from "../ui/select";
18
+ import { Button } from "../ui/button";
19
+ import { FilterByValuesPicker } from "./filter-by-values-picker";
20
+ import { type ColumnFilterValue, Filter } from "./filters";
21
+ import { Tooltip } from "../ui/tooltip";
22
+
23
+ // Editable filter types in this editor — date/datetime/time are read-only
24
+ // Will add support for rest in next PR
25
+ type EditableFilterType = "number" | "text" | "boolean" | "select";
26
+
27
+ // UI-level operator for the operator dropdown. Today the committed filter
28
+ // value does not carry this operator for number ranges — ranges are
29
+ // converted to `>=` / `<=` condition pairs at the RPC boundary
30
+ // (`filterToFilterCondition`). The follow-up PR splits UI operators into
31
+ // distinct `<`, `>`, `between` variants and routes them through as-is.
32
+ type UiOperator =
33
+ | "between"
34
+ | "contains"
35
+ | "is_true"
36
+ | "is_false"
37
+ | "is_null"
38
+ | "is_not_null"
39
+ | "in"
40
+ | "not_in";
41
+
42
+ // will be expanded by a follow up PR
43
+ const OPERATORS_BY_TYPE: Record<EditableFilterType, UiOperator[]> = {
44
+ number: ["between", "is_null", "is_not_null"],
45
+ text: ["contains", "is_null", "is_not_null"],
46
+ boolean: ["is_true", "is_false", "is_null", "is_not_null"],
47
+ select: ["in", "not_in"],
48
+ };
49
+
50
+ const DEFAULT_OPERATOR: Record<EditableFilterType, UiOperator> = {
51
+ number: "between",
52
+ text: "contains",
53
+ boolean: "is_true",
54
+ select: "in",
55
+ };
56
+
57
+ const OPERATOR_LABELS: Record<UiOperator, string> = {
58
+ between: "Between",
59
+ contains: "Contains",
60
+ is_true: "Is true",
61
+ is_false: "Is false",
62
+ is_null: "Is null",
63
+ is_not_null: "Is not null",
64
+ in: "Is in",
65
+ not_in: "Not in",
66
+ };
67
+
68
+ const OPERATORS_WITHOUT_VALUE = new Set<UiOperator>([
69
+ "is_true",
70
+ "is_false",
71
+ "is_null",
72
+ "is_not_null",
73
+ ]);
74
+
75
+ interface DraftValue {
76
+ min?: number;
77
+ max?: number;
78
+ text?: string;
79
+ options?: unknown[];
80
+ }
81
+
82
+ interface Snapshot {
83
+ columnId: string;
84
+ value: ColumnFilterValue;
85
+ }
86
+
87
+ interface FilterPillEditorProps<TData> {
88
+ snapshot: Snapshot;
89
+ table: Table<TData>;
90
+ calculateTopKRows?: CalculateTopKRows;
91
+ onClose: () => void;
92
+ }
93
+
94
+ export const FilterPillEditor = <TData,>({
95
+ snapshot, // current state of filter pre-edit
96
+ table,
97
+ calculateTopKRows,
98
+ onClose,
99
+ }: FilterPillEditorProps<TData>) => {
100
+ const columnId = useId();
101
+ const operatorId = useId();
102
+ const valueId = useId();
103
+
104
+ const snapshotType = getEditableType(snapshot.value);
105
+ const snapshotOperator = getUiOperator(snapshot.value);
106
+ const snapshotDraft = toDraftValue(snapshot.value);
107
+
108
+ const [draftColumnId, setDraftColumnId] = useState<string>(snapshot.columnId);
109
+ const [draftType, setDraftType] = useState<EditableFilterType>(snapshotType);
110
+ const [draftOperator, setDraftOperator] =
111
+ useState<UiOperator>(snapshotOperator);
112
+ const [draftValue, setDraftValue] = useState<DraftValue>(snapshotDraft);
113
+
114
+ const editableColumns = table.getAllColumns().filter((c) => {
115
+ const ft = c.columnDef.meta?.filterType;
116
+ return (
117
+ ft === "number" || ft === "text" || ft === "boolean" || ft === "select"
118
+ );
119
+ });
120
+
121
+ // if we switch back to pre-edit column+operator
122
+ // restore the original value as well
123
+ const rehydrateIfMatchesSnapshot = (args: {
124
+ id: string;
125
+ type: EditableFilterType;
126
+ operator: UiOperator;
127
+ }) => {
128
+ if (
129
+ args.id === snapshot.columnId &&
130
+ args.type === snapshotType &&
131
+ args.operator === snapshotOperator
132
+ ) {
133
+ setDraftValue(snapshotDraft);
134
+ }
135
+ };
136
+
137
+ const handleColumnChange = (nextColumnId: string | null) => {
138
+ if (!nextColumnId) {
139
+ return;
140
+ }
141
+ const nextColumn = table.getColumn(nextColumnId);
142
+ const nextColumnType = (nextColumn?.columnDef.meta?.filterType ??
143
+ "text") as EditableFilterType;
144
+
145
+ let nextOperator = draftOperator;
146
+ if (nextColumnType !== draftType) {
147
+ nextOperator = DEFAULT_OPERATOR[nextColumnType];
148
+ setDraftType(nextColumnType);
149
+ setDraftOperator(nextOperator);
150
+ setDraftValue({});
151
+ }
152
+ setDraftColumnId(nextColumnId);
153
+ rehydrateIfMatchesSnapshot({
154
+ id: nextColumnId,
155
+ type: nextColumnType,
156
+ operator: nextOperator,
157
+ });
158
+ };
159
+
160
+ const handleOperatorChange = (nextOp: UiOperator) => {
161
+ setDraftOperator(nextOp);
162
+ rehydrateIfMatchesSnapshot({
163
+ id: draftColumnId,
164
+ type: draftType,
165
+ operator: nextOp,
166
+ });
167
+ };
168
+
169
+ const handleApply = () => {
170
+ const value = buildFilterValue({
171
+ type: draftType,
172
+ operator: draftOperator,
173
+ draft: draftValue,
174
+ });
175
+ if (!value) {
176
+ return;
177
+ }
178
+ table.setColumnFilters((filters) => {
179
+ const dropIds = new Set([snapshot.columnId, draftColumnId]);
180
+ const filtered = filters.filter((f) => !dropIds.has(f.id));
181
+ return [...filtered, { id: draftColumnId, value }];
182
+ });
183
+ onClose();
184
+ };
185
+
186
+ const handleClear = () => {
187
+ table.setColumnFilters((filters) =>
188
+ filters.filter((f) => f.id !== snapshot.columnId),
189
+ );
190
+ onClose();
191
+ };
192
+
193
+ const showValueSlot = !OPERATORS_WITHOUT_VALUE.has(draftOperator);
194
+
195
+ return (
196
+ <div className="flex flex-row gap-4 items-end p-3">
197
+ <div className="flex flex-col gap-1">
198
+ <label className="text-xs text-muted-foreground" htmlFor={columnId}>
199
+ Column
200
+ </label>
201
+ <Combobox<string>
202
+ id={columnId}
203
+ value={draftColumnId}
204
+ onValueChange={handleColumnChange}
205
+ multiple={false}
206
+ placeholder="Select column…"
207
+ displayValue={(id) => id}
208
+ >
209
+ {editableColumns.map((c) => (
210
+ <ComboboxItem key={c.id} value={c.id}>
211
+ {c.id}
212
+ </ComboboxItem>
213
+ ))}
214
+ </Combobox>
215
+ </div>
216
+ <div className="flex flex-col gap-1">
217
+ <label htmlFor={operatorId} className="text-xs text-muted-foreground">
218
+ Operator
219
+ </label>
220
+ <Select
221
+ value={draftOperator}
222
+ onValueChange={(v) => handleOperatorChange(v as UiOperator)}
223
+ >
224
+ <SelectTrigger id={operatorId} className="h-6 mb-1 bg-transparent">
225
+ <SelectValue />
226
+ </SelectTrigger>
227
+ <SelectContent>
228
+ {OPERATORS_BY_TYPE[draftType].map((op) => (
229
+ <SelectItem key={op} value={op}>
230
+ {OPERATOR_LABELS[op]}
231
+ </SelectItem>
232
+ ))}
233
+ </SelectContent>
234
+ </Select>
235
+ </div>
236
+ {showValueSlot && (
237
+ <div className="flex flex-col gap-1">
238
+ <label htmlFor={valueId} className="text-xs text-muted-foreground">
239
+ Value
240
+ </label>
241
+ <ValueSlot
242
+ id={valueId}
243
+ type={draftType}
244
+ value={draftValue}
245
+ onChange={setDraftValue}
246
+ column={table.getColumn(draftColumnId) ?? null}
247
+ calculateTopKRows={calculateTopKRows}
248
+ />
249
+ </div>
250
+ )}
251
+ <div className="flex gap-1 mb-1">
252
+ <Tooltip content="Apply filter">
253
+ <Button
254
+ type="button"
255
+ size="icon"
256
+ variant="ghost"
257
+ className="rounded-full text-primary hover:text-primary hover:bg-primary/10"
258
+ onClick={handleApply}
259
+ aria-label="Apply filter"
260
+ >
261
+ <CheckIcon className="h-3.5 w-3.5" aria-hidden={true} />
262
+ </Button>
263
+ </Tooltip>
264
+ <Tooltip content="Close without saving">
265
+ <Button
266
+ type="button"
267
+ size="icon"
268
+ variant="ghost"
269
+ className="rounded-full text-muted-foreground hover:text-foreground hover:bg-muted"
270
+ onClick={onClose}
271
+ aria-label="Close without saving"
272
+ >
273
+ <XIcon className="h-3.5 w-3.5" aria-hidden={true} />
274
+ </Button>
275
+ </Tooltip>
276
+ <Tooltip content="Remove filter">
277
+ <Button
278
+ type="button"
279
+ size="icon"
280
+ variant="ghost"
281
+ className="rounded-full text-destructive hover:text-destructive hover:bg-destructive/10"
282
+ onClick={handleClear}
283
+ aria-label="Remove filter"
284
+ >
285
+ <Trash2Icon className="h-3.5 w-3.5" aria-hidden={true} />
286
+ </Button>
287
+ </Tooltip>
288
+ </div>
289
+ </div>
290
+ );
291
+ };
292
+
293
+ interface ValueSlotProps<TData, TValue> {
294
+ id?: string;
295
+ type: EditableFilterType;
296
+ value: DraftValue;
297
+ onChange: (next: DraftValue) => void;
298
+ column: Column<TData, TValue> | null;
299
+ calculateTopKRows?: CalculateTopKRows;
300
+ }
301
+
302
+ const ValueSlot = <TData, TValue>({
303
+ id,
304
+ type,
305
+ value,
306
+ onChange,
307
+ column,
308
+ calculateTopKRows,
309
+ }: ValueSlotProps<TData, TValue>) => {
310
+ if (type === "number") {
311
+ return (
312
+ <div className="flex gap-1 items-center w-48">
313
+ <NumberField
314
+ id={id}
315
+ value={value.min}
316
+ onChange={(v) => onChange({ ...value, min: v })}
317
+ aria-label="min"
318
+ placeholder="min"
319
+ className="border-input flex-1 min-w-0"
320
+ />
321
+ <MinusIcon className="h-5 w-5 text-muted-foreground shrink-0" />
322
+ <NumberField
323
+ value={value.max}
324
+ onChange={(v) => onChange({ ...value, max: v })}
325
+ aria-label="max"
326
+ placeholder="max"
327
+ className="border-input flex-1 min-w-0"
328
+ />
329
+ </div>
330
+ );
331
+ }
332
+ if (type === "text") {
333
+ return (
334
+ <Input
335
+ id={id}
336
+ type="text"
337
+ value={value.text ?? ""}
338
+ onChange={(e) => onChange({ ...value, text: e.target.value })}
339
+ placeholder="Text…"
340
+ className="border-input min-w-0"
341
+ />
342
+ );
343
+ }
344
+ if (type === "select" && column) {
345
+ return (
346
+ <div className="flex w-48">
347
+ <FilterByValuesPicker
348
+ column={column}
349
+ calculateTopKRows={calculateTopKRows}
350
+ chosenValues={value.options ?? []}
351
+ onChange={(values) => onChange({ ...value, options: values })}
352
+ />
353
+ </div>
354
+ );
355
+ }
356
+ return null;
357
+ };
358
+
359
+ function getEditableType(value: ColumnFilterValue): EditableFilterType {
360
+ if (value.type === "number") {
361
+ return "number";
362
+ }
363
+ if (value.type === "text") {
364
+ return "text";
365
+ }
366
+ if (value.type === "boolean") {
367
+ return "boolean";
368
+ }
369
+ if (value.type === "select") {
370
+ return "select";
371
+ }
372
+ // date/datetime/time fall back to text; callers should guard. supported in future
373
+ return "text";
374
+ }
375
+
376
+ function getUiOperator(value: ColumnFilterValue): UiOperator {
377
+ if (value.operator === "is_null") {
378
+ return "is_null";
379
+ }
380
+ if (value.operator === "is_not_null") {
381
+ return "is_not_null";
382
+ }
383
+ if (value.type === "number") {
384
+ return "between";
385
+ }
386
+ if (value.type === "text") {
387
+ return "contains";
388
+ }
389
+ if (value.type === "boolean") {
390
+ return value.value ? "is_true" : "is_false";
391
+ }
392
+ if (value.type === "select") {
393
+ return value.operator === "not_in" ? "not_in" : "in";
394
+ }
395
+ return "contains";
396
+ }
397
+
398
+ function toDraftValue(value: ColumnFilterValue): DraftValue {
399
+ if (value.type === "number") {
400
+ return { min: value.min, max: value.max };
401
+ }
402
+ if (value.type === "text") {
403
+ return { text: value.text };
404
+ }
405
+ if (value.type === "select") {
406
+ return { options: [...value.options] };
407
+ }
408
+ return {};
409
+ }
410
+
411
+ function buildFilterValue({
412
+ type,
413
+ operator,
414
+ draft,
415
+ }: {
416
+ type: EditableFilterType;
417
+ operator: UiOperator;
418
+ draft: DraftValue;
419
+ }): ColumnFilterValue | undefined {
420
+ if (operator === "is_null" || operator === "is_not_null") {
421
+ const op = operator;
422
+ if (type === "number") {
423
+ return Filter.number({ operator: op });
424
+ }
425
+ if (type === "boolean") {
426
+ return Filter.boolean({ operator: op });
427
+ }
428
+ return Filter.text({ operator: op });
429
+ }
430
+ if (type === "number") {
431
+ if (draft.min === undefined && draft.max === undefined) {
432
+ return undefined;
433
+ }
434
+ return Filter.number({ min: draft.min, max: draft.max });
435
+ }
436
+ if (type === "text") {
437
+ if (!draft.text) {
438
+ return undefined;
439
+ }
440
+ return Filter.text({
441
+ text: draft.text,
442
+ operator: "contains",
443
+ });
444
+ }
445
+ if (type === "boolean") {
446
+ if (operator === "is_true") {
447
+ return Filter.boolean({
448
+ value: true,
449
+ operator: "is_true",
450
+ });
451
+ }
452
+ if (operator === "is_false") {
453
+ return Filter.boolean({
454
+ value: false,
455
+ operator: "is_false",
456
+ });
457
+ }
458
+ return undefined;
459
+ }
460
+ if (type === "select") {
461
+ if (!draft.options || draft.options.length === 0) {
462
+ return undefined;
463
+ }
464
+ return Filter.select({
465
+ options: draft.options,
466
+ operator: operator === "not_in" ? "not_in" : "in",
467
+ });
468
+ }
469
+ return undefined;
470
+ }