@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.
- package/dist/assets/{CellStatus-BTL_sgUY.js → CellStatus-PIeZtHEl.js} +1 -1
- package/dist/assets/JsonOutput-BVAcY5xS.js +49 -0
- package/dist/assets/{MarimoErrorOutput-BBD405a6.js → MarimoErrorOutput-B9_RO5eX.js} +2 -2
- package/dist/assets/{RenderHTML-BT9obKLc.js → RenderHTML-DR6lqJGS.js} +1 -1
- package/dist/assets/{add-cell-with-ai-qEfAzC8R.js → add-cell-with-ai-DjfY8mF1.js} +9 -9
- package/dist/assets/{add-connection-dialog-HCShSlr3.js → add-connection-dialog-gd2yD9Qo.js} +2 -2
- package/dist/assets/{agent-panel-CoBHxHpJ.js → agent-panel-TetMUXWP.js} +3 -3
- package/dist/assets/{ai-model-dropdown-CYx9ZfdS.js → ai-model-dropdown-By4Ohb0R.js} +3 -3
- package/dist/assets/{app-config-button-DC5UlXZA.js → app-config-button-YS8lpoUa.js} +1 -1
- package/dist/assets/{cell-editor-BMjfXh0J.js → cell-editor-87LAVsOR.js} +11 -11
- package/dist/assets/{cell-link-BA7Demf0.js → cell-link-d38hFEon.js} +1 -1
- package/dist/assets/{cells-CJlo_hG2.js → cells-A-ljPpuj.js} +55 -55
- package/dist/assets/{chat-display-DbztpRD4.js → chat-display-Be0U92hE.js} +1 -1
- package/dist/assets/{chat-panel-H-xFzATI.js → chat-panel-BEz970S3.js} +1 -1
- package/dist/assets/{chat-ui-CFqAy2ck.js → chat-ui-CdW7awFa.js} +1 -1
- package/dist/assets/{column-preview-DzN2QumC.js → column-preview-_5uYiy74.js} +1 -1
- package/dist/assets/{command-DbT_zkRP.js → command-CzIDL1VI.js} +1 -1
- package/dist/assets/{command-palette-DSSR50KF.js → command-palette-DfotChyr.js} +1 -1
- package/dist/assets/{common-CquNY-ok.js → common-DC9LEIym.js} +1 -1
- package/dist/assets/{components-BXKuiPiF.js → components-BDnRtsA5.js} +1 -1
- package/dist/assets/{components-BiBOcq1x.js → components-FepcpFGL.js} +1 -1
- package/dist/assets/{datasource-BKDU-4D5.js → datasource-BCnGIjqn.js} +2 -2
- package/dist/assets/{dependency-graph-panel-ayo-Mm2j.js → dependency-graph-panel-Yk1Ch_Gj.js} +1 -1
- package/dist/assets/{documentation-panel-ph6755dz.js → documentation-panel-BMrGuByB.js} +1 -1
- package/dist/assets/{download-mzsKQgiy.js → download-Iyng0xCz.js} +4 -4
- package/dist/assets/{edit-page-Bxa5DWoE.js → edit-page-yF7xM43H.js} +6 -6
- package/dist/assets/{error-panel-BfG_JeMA.js → error-panel-CHSsnRFF.js} +1 -1
- package/dist/assets/{file-explorer-panel-D7-d0Puf.js → file-explorer-panel-Dlwspu2Y.js} +3 -3
- package/dist/assets/{file-icons-BNrh8MRG.js → file-icons-DTNZv05h.js} +1 -1
- package/dist/assets/{floating-outline-BMB4_phA.js → floating-outline-CSNGDMAm.js} +1 -1
- package/dist/assets/{focus-BX3gXJxx.js → focus-Baz7rlo0.js} +1 -1
- package/dist/assets/form-Dr-_EXr8.js +2 -0
- package/dist/assets/{home-page-B1ELDhn8.js → home-page-QvO22-4u.js} +1 -1
- package/dist/assets/{hooks-b3J1eFE-.js → hooks-CNPv5U-N.js} +1 -1
- package/dist/assets/{html-to-image-Cz8lDF-Y.js → html-to-image-tbPEMBJg.js} +1 -1
- package/dist/assets/index-DH5UUBE4.css +2 -0
- package/dist/assets/{index-BjiE1T38.js → index-z8Uh9i5Z.js} +8 -8
- package/dist/assets/{kiosk-mode-Dxbs5i5g.js → kiosk-mode-cWTXpegb.js} +1 -1
- package/dist/assets/{layout-lxwMDUgI.js → layout-DDh3d8oW.js} +5 -5
- package/dist/assets/{logs-panel-BW6S5AI1.js → logs-panel-Dgkr6V7q.js} +1 -1
- package/dist/assets/loro_wasm_bg-Bu4Ofw1K.js +2 -0
- package/dist/assets/{loro_wasm_bg-DXuHQ2hl.js → loro_wasm_bg-DWJCS9pK.js} +1 -1
- package/dist/assets/{markdown-renderer-CeHY2KoQ.js → markdown-renderer-CFrgiLqu.js} +1 -1
- package/dist/assets/{name-cell-input-B1BCybeo.js → name-cell-input-C5LXTI3T.js} +1 -1
- package/dist/assets/{outline-panel-DgMz83Mx.js → outline-panel-0lyts6z4.js} +1 -1
- package/dist/assets/{packages-panel-CbjavFh2.js → packages-panel-BhI1lbfw.js} +1 -1
- package/dist/assets/{panels-BPzk3EbR.js → panels-CbRenLjk.js} +1 -1
- package/dist/assets/{process-output-CggRIZ6s.js → process-output-BwZ5VxXO.js} +1 -1
- package/dist/assets/{readonly-python-code-Ccd8HM-7.js → readonly-python-code-B0nrQE3U.js} +1 -1
- package/dist/assets/{reveal-component-BamXsTkr.js → reveal-component-CLjsFHmT.js} +1 -1
- package/dist/assets/{run-page-C5cZvltI.js → run-page-C1Wd8dr8.js} +1 -1
- package/dist/assets/{scratchpad-panel-PMFH4Ktp.js → scratchpad-panel-B8kRNql6.js} +1 -1
- package/dist/assets/{session-panel-CUTSr9rt.js → session-panel-BvCIuxgU.js} +1 -1
- package/dist/assets/{slide-form-Bi8ZYBHP.js → slide-form-CXqvfwGg.js} +1 -1
- package/dist/assets/{snippets-panel-CdhDsAII.js → snippets-panel-COBTGJcr.js} +1 -1
- package/dist/assets/{state-C5AUgyZT.js → state-Bsz477is.js} +1 -1
- package/dist/assets/{state-DHlRrwyY.js → state-LyvUXW6A.js} +2 -2
- package/dist/assets/{textarea-BXPC1-kb.js → textarea-Dd0InTQJ.js} +1 -1
- package/dist/assets/{tracing-wSufGcF9.js → tracing-DjZGgWTo.js} +1 -1
- package/dist/assets/{tracing-panel-BO6MMAXa.js → tracing-panel-8eFzEjG0.js} +2 -2
- package/dist/assets/{useAddCell-CqBbGhrY.js → useAddCell-De9xD63_.js} +1 -1
- package/dist/assets/{useCellActionButton-DYwx4pGE.js → useCellActionButton-Bjxun3wK.js} +1 -1
- package/dist/assets/{useDeleteCell-BJlGbsbV.js → useDeleteCell-C2Stbb6Q.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-rkAIQ6zL.js → useDependencyPanelTab-DFZNQ3a8.js} +1 -1
- package/dist/assets/{useNotebookActions-Dga3qEHF.js → useNotebookActions-F5fGMe9N.js} +1 -1
- package/dist/assets/{useRunCells-B531RIUE.js → useRunCells-DU1tawR-.js} +1 -1
- package/dist/assets/{useSplitCell-BnuJW5uo.js → useSplitCell-BZPuhd8z.js} +1 -1
- package/dist/index.html +25 -25
- package/package.json +4 -4
- package/src/components/data-table/data-table.tsx +11 -2
- package/src/components/data-table/filter-by-values-picker.tsx +238 -0
- package/src/components/data-table/filter-pill-editor.tsx +470 -0
- package/src/components/data-table/filter-pills.tsx +177 -41
- package/src/components/ui/checkbox.tsx +8 -4
- package/src/components/ui/combobox.tsx +3 -0
- package/src/css/app/Cell.css +4 -0
- package/src/plugins/impl/DataTablePlugin.tsx +1 -0
- package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +15 -15
- package/src/utils/sets.ts +13 -0
- package/dist/assets/JsonOutput-DXnOS_Hk.js +0 -49
- package/dist/assets/form-CPDlIjdV.js +0 -2
- package/dist/assets/index-BYLYJcAY.css +0 -2
- 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
|
+
}
|