@marimo-team/islands 0.23.7-dev47 → 0.23.7-dev48
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/{chat-ui-CufH8sfF.js → chat-ui-D8ZxPNTR.js} +3 -3
- package/dist/{code-visibility-C4KEMmUK.js → code-visibility-An0P9cL_.js} +1265 -935
- package/dist/{glide-data-editor-BK9s_dqy.js → glide-data-editor-DucgdjRo.js} +1 -1
- package/dist/{html-to-image-DxWM1HVj.js → html-to-image-DaPPaVDP.js} +1 -1
- package/dist/{input-Cc1Vvw9A.js → input-D4kjoQUB.js} +2 -0
- package/dist/main.js +8 -8
- package/dist/{process-output-DBYxXdrN.js → process-output-n0RJTxcC.js} +1 -1
- package/dist/{reveal-component-Dx7r_prC.js → reveal-component-B23qYh6r.js} +3 -3
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/data-table/__tests__/column-header.test.ts +3 -1
- package/src/components/data-table/__tests__/column-header.test.tsx +203 -0
- package/src/components/data-table/__tests__/filter-by-values-picker.test.tsx +112 -0
- package/src/components/data-table/__tests__/filter-pill-editor.test.tsx +175 -0
- package/src/components/data-table/__tests__/filters.test.ts +112 -36
- package/src/components/data-table/column-header.tsx +210 -157
- package/src/components/data-table/filter-by-values-picker.tsx +70 -9
- package/src/components/data-table/filter-pill-editor.tsx +289 -144
- package/src/components/data-table/filter-pills.tsx +49 -8
- package/src/components/data-table/filters.ts +131 -36
- package/src/components/data-table/header-items.tsx +8 -1
- package/src/components/data-table/operator-labels.ts +25 -0
- package/src/components/data-table/regex-input.tsx +61 -0
- package/src/components/ui/combobox.tsx +3 -2
- package/src/components/ui/number-field.tsx +2 -0
- package/src/plugins/impl/data-frames/forms/__tests__/__snapshots__/form.test.tsx.snap +24 -24
- package/src/plugins/impl/data-frames/schema.ts +4 -1
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
TextIcon,
|
|
10
10
|
XIcon,
|
|
11
11
|
} from "lucide-react";
|
|
12
|
-
import {
|
|
12
|
+
import { useState } from "react";
|
|
13
13
|
import { useLocale } from "react-aria";
|
|
14
14
|
import {
|
|
15
15
|
DropdownMenu,
|
|
@@ -26,25 +26,31 @@ import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin";
|
|
|
26
26
|
import type { OperatorType } from "@/plugins/impl/data-frames/utils/operators";
|
|
27
27
|
import { logNever } from "@/utils/assertNever";
|
|
28
28
|
import { cn } from "@/utils/cn";
|
|
29
|
-
import { capitalize } from "@/utils/strings";
|
|
30
29
|
import { Button } from "../ui/button";
|
|
31
30
|
import { DraggablePopover } from "../ui/draggable-popover";
|
|
32
31
|
import { Input } from "../ui/input";
|
|
32
|
+
import { RegexInput } from "./regex-input";
|
|
33
33
|
import { NumberField } from "../ui/number-field";
|
|
34
34
|
import { PopoverClose } from "../ui/popover";
|
|
35
35
|
import {
|
|
36
36
|
Select,
|
|
37
37
|
SelectContent,
|
|
38
38
|
SelectItem,
|
|
39
|
-
SelectSeparator,
|
|
40
39
|
SelectTrigger,
|
|
41
40
|
SelectValue,
|
|
42
41
|
} from "../ui/select";
|
|
43
42
|
import { FilterByValuesList } from "./filter-by-values-picker";
|
|
43
|
+
import { OPERATOR_LABELS } from "./operator-labels";
|
|
44
44
|
import {
|
|
45
45
|
type ColumnFilterForType,
|
|
46
46
|
type ColumnFilterValue,
|
|
47
47
|
Filter,
|
|
48
|
+
NUMBER_COMPARISON_OPS,
|
|
49
|
+
type NumberComparisonOp,
|
|
50
|
+
NUMBER_OPS,
|
|
51
|
+
TEXT_OPS,
|
|
52
|
+
TEXT_SCALAR_OPS,
|
|
53
|
+
type TextScalarOp,
|
|
48
54
|
} from "./filters";
|
|
49
55
|
import {
|
|
50
56
|
ClearFilterMenuItem,
|
|
@@ -148,7 +154,7 @@ export const DataTableColumnHeader = <TData, TValue>({
|
|
|
148
154
|
{renderColumnWrapping(column)}
|
|
149
155
|
{renderFormatOptions(column, locale)}
|
|
150
156
|
<DropdownMenuSeparator />
|
|
151
|
-
{renderMenuItemFilter(column)}
|
|
157
|
+
{renderMenuItemFilter(column, calculateTopKRows)}
|
|
152
158
|
{renderFilterByValues(column, setIsFilterValueOpen)}
|
|
153
159
|
{hasFilter && <ClearFilterMenuItem column={column} />}
|
|
154
160
|
</DropdownMenuContent>
|
|
@@ -211,6 +217,7 @@ const SortButton = <TData, TValue>({
|
|
|
211
217
|
|
|
212
218
|
export function renderMenuItemFilter<TData, TValue>(
|
|
213
219
|
column: Column<TData, TValue>,
|
|
220
|
+
calculateTopKRows?: CalculateTopKRows,
|
|
214
221
|
) {
|
|
215
222
|
const canFilter = column.getCanFilter();
|
|
216
223
|
if (!canFilter) {
|
|
@@ -248,7 +255,10 @@ export function renderMenuItemFilter<TData, TValue>(
|
|
|
248
255
|
{filterMenuItem}
|
|
249
256
|
<DropdownMenuPortal>
|
|
250
257
|
<DropdownMenuSubContent>
|
|
251
|
-
<
|
|
258
|
+
<TextFilterMenu
|
|
259
|
+
column={column}
|
|
260
|
+
calculateTopKRows={calculateTopKRows}
|
|
261
|
+
/>
|
|
252
262
|
</DropdownMenuSubContent>
|
|
253
263
|
</DropdownMenuPortal>
|
|
254
264
|
</DropdownMenuSub>
|
|
@@ -261,7 +271,7 @@ export function renderMenuItemFilter<TData, TValue>(
|
|
|
261
271
|
{filterMenuItem}
|
|
262
272
|
<DropdownMenuPortal>
|
|
263
273
|
<DropdownMenuSubContent>
|
|
264
|
-
<
|
|
274
|
+
<NumberFilterMenu column={column} />
|
|
265
275
|
</DropdownMenuSubContent>
|
|
266
276
|
</DropdownMenuPortal>
|
|
267
277
|
</DropdownMenuSub>
|
|
@@ -292,58 +302,28 @@ export function renderMenuItemFilter<TData, TValue>(
|
|
|
292
302
|
return null;
|
|
293
303
|
}
|
|
294
304
|
|
|
295
|
-
|
|
296
|
-
const NULL_FILTER_OPERATORS = {
|
|
297
|
-
is_null: "is_null",
|
|
298
|
-
is_not_null: "is_not_null",
|
|
299
|
-
} satisfies Record<string, OperatorType>;
|
|
300
|
-
|
|
301
|
-
const NullFilter = <TData, TValue>({
|
|
302
|
-
column,
|
|
303
|
-
defaultItem,
|
|
305
|
+
const OperatorSelect = ({
|
|
304
306
|
operator,
|
|
305
|
-
|
|
307
|
+
options,
|
|
308
|
+
onChange,
|
|
306
309
|
}: {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}) =>
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const isNullOrNotNull = operator === "is_null" || operator === "is_not_null";
|
|
320
|
-
|
|
321
|
-
return (
|
|
322
|
-
<Select
|
|
323
|
-
value={operator}
|
|
324
|
-
onValueChange={(value) => handleValueChange(value as OperatorType)}
|
|
325
|
-
>
|
|
326
|
-
<SelectTrigger
|
|
327
|
-
className={cn(
|
|
328
|
-
"border-border shadow-none! ring-0! w-full mb-0.5",
|
|
329
|
-
isNullOrNotNull && "mb-2",
|
|
330
|
-
)}
|
|
331
|
-
>
|
|
332
|
-
<SelectValue defaultValue={operator} />
|
|
333
|
-
</SelectTrigger>
|
|
334
|
-
<SelectContent>
|
|
335
|
-
{defaultItem && (
|
|
336
|
-
<SelectItem value={defaultItem}>{capitalize(defaultItem)}</SelectItem>
|
|
337
|
-
)}
|
|
338
|
-
<SelectSeparator />
|
|
339
|
-
<SelectItem value={NULL_FILTER_OPERATORS.is_null}>Is null</SelectItem>
|
|
340
|
-
<SelectItem value={NULL_FILTER_OPERATORS.is_not_null}>
|
|
341
|
-
Is not null
|
|
310
|
+
operator: OperatorType;
|
|
311
|
+
options: readonly OperatorType[];
|
|
312
|
+
onChange: (next: OperatorType) => void;
|
|
313
|
+
}) => (
|
|
314
|
+
<Select value={operator} onValueChange={(v) => onChange(v as OperatorType)}>
|
|
315
|
+
<SelectTrigger className="border-border shadow-none! ring-0! w-full mb-0.5">
|
|
316
|
+
<SelectValue />
|
|
317
|
+
</SelectTrigger>
|
|
318
|
+
<SelectContent>
|
|
319
|
+
{options.map((op) => (
|
|
320
|
+
<SelectItem key={op} value={op}>
|
|
321
|
+
{OPERATOR_LABELS[op]}
|
|
342
322
|
</SelectItem>
|
|
343
|
-
|
|
344
|
-
</
|
|
345
|
-
|
|
346
|
-
|
|
323
|
+
))}
|
|
324
|
+
</SelectContent>
|
|
325
|
+
</Select>
|
|
326
|
+
);
|
|
347
327
|
|
|
348
328
|
const BooleanFilter = <TData, TValue>({
|
|
349
329
|
column,
|
|
@@ -389,7 +369,21 @@ const BooleanFilter = <TData, TValue>({
|
|
|
389
369
|
);
|
|
390
370
|
};
|
|
391
371
|
|
|
392
|
-
const
|
|
372
|
+
const NUMBER_COMPARISON_SET: ReadonlySet<OperatorType> = new Set(
|
|
373
|
+
NUMBER_COMPARISON_OPS,
|
|
374
|
+
);
|
|
375
|
+
const isNumberComparisonOp = (op: OperatorType): op is NumberComparisonOp =>
|
|
376
|
+
NUMBER_COMPARISON_SET.has(op);
|
|
377
|
+
|
|
378
|
+
type NumberComparisonFilter = Extract<
|
|
379
|
+
ColumnFilterForType<"number">,
|
|
380
|
+
{ value: number }
|
|
381
|
+
>;
|
|
382
|
+
const isNumberComparisonFilter = (
|
|
383
|
+
filter: ColumnFilterForType<"number">,
|
|
384
|
+
): filter is NumberComparisonFilter => isNumberComparisonOp(filter.operator);
|
|
385
|
+
|
|
386
|
+
export const NumberFilterMenu = <TData, TValue>({
|
|
393
387
|
column,
|
|
394
388
|
}: {
|
|
395
389
|
column: Column<TData, TValue>;
|
|
@@ -399,149 +393,208 @@ const NumberRangeFilter = <TData, TValue>({
|
|
|
399
393
|
| undefined;
|
|
400
394
|
const hasFilter = currentFilter !== undefined;
|
|
401
395
|
|
|
402
|
-
const [operator, setOperator] = useState<OperatorType
|
|
396
|
+
const [operator, setOperator] = useState<OperatorType>(
|
|
403
397
|
currentFilter?.operator ?? "between",
|
|
404
398
|
);
|
|
405
|
-
const [min, setMin] = useState<number | undefined>(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
399
|
+
const [min, setMin] = useState<number | undefined>(
|
|
400
|
+
currentFilter?.operator === "between" ? currentFilter.min : undefined,
|
|
401
|
+
);
|
|
402
|
+
const [max, setMax] = useState<number | undefined>(
|
|
403
|
+
currentFilter?.operator === "between" ? currentFilter.max : undefined,
|
|
404
|
+
);
|
|
405
|
+
const [value, setValue] = useState<number | undefined>(
|
|
406
|
+
currentFilter !== undefined && isNumberComparisonFilter(currentFilter)
|
|
407
|
+
? currentFilter.value
|
|
408
|
+
: undefined,
|
|
409
|
+
);
|
|
409
410
|
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
411
|
+
const isComparison = isNumberComparisonOp(operator);
|
|
412
|
+
const isNullish = operator === "is_null" || operator === "is_not_null";
|
|
413
|
+
|
|
414
|
+
const applyDisabled =
|
|
415
|
+
(operator === "between" && (min === undefined || max === undefined)) ||
|
|
416
|
+
(isComparison && value === undefined);
|
|
417
|
+
|
|
418
|
+
const handleApply = () => {
|
|
419
|
+
if (isNullish) {
|
|
420
|
+
column.setFilterValue(Filter.number({ operator }));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (operator === "between" && min !== undefined && max !== undefined) {
|
|
424
|
+
column.setFilterValue(Filter.number({ operator: "between", min, max }));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (isComparison && value !== undefined) {
|
|
428
|
+
column.setFilterValue(Filter.number({ operator, value }));
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const handleClear = () => {
|
|
433
|
+
setMin(undefined);
|
|
434
|
+
setMax(undefined);
|
|
435
|
+
setValue(undefined);
|
|
436
|
+
column.setFilterValue(undefined);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const handleOperatorChange = (next: OperatorType) => {
|
|
440
|
+
setOperator(next);
|
|
418
441
|
};
|
|
419
442
|
|
|
420
443
|
return (
|
|
421
444
|
<div className="flex flex-col gap-1 pt-3 px-2">
|
|
422
|
-
<
|
|
423
|
-
column={column}
|
|
424
|
-
defaultItem="between"
|
|
445
|
+
<OperatorSelect
|
|
425
446
|
operator={operator}
|
|
426
|
-
|
|
447
|
+
options={NUMBER_OPS}
|
|
448
|
+
onChange={handleOperatorChange}
|
|
427
449
|
/>
|
|
428
450
|
{operator === "between" && (
|
|
429
|
-
|
|
430
|
-
<
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
maxRef.current?.focus();
|
|
445
|
-
}
|
|
446
|
-
}}
|
|
447
|
-
className="shadow-none! border-border hover:shadow-none!"
|
|
448
|
-
/>
|
|
449
|
-
<MinusIcon className="h-5 w-5 text-muted-foreground" />
|
|
450
|
-
<NumberField
|
|
451
|
-
ref={maxRef}
|
|
452
|
-
value={max}
|
|
453
|
-
onChange={(value) => setMax(value)}
|
|
454
|
-
aria-label="max"
|
|
455
|
-
onKeyDown={(e) => {
|
|
456
|
-
if (e.key === "Enter") {
|
|
457
|
-
handleApply({
|
|
458
|
-
max: Number.parseFloat(e.currentTarget.value),
|
|
459
|
-
});
|
|
460
|
-
}
|
|
461
|
-
if (e.key === "Tab") {
|
|
462
|
-
minRef.current?.focus();
|
|
463
|
-
}
|
|
464
|
-
}}
|
|
465
|
-
placeholder="max"
|
|
466
|
-
className="shadow-none! border-border hover:shadow-none!"
|
|
467
|
-
/>
|
|
468
|
-
</div>
|
|
469
|
-
<FilterButtons
|
|
470
|
-
onApply={handleApply}
|
|
471
|
-
onClear={() => {
|
|
472
|
-
setMin(undefined);
|
|
473
|
-
setMax(undefined);
|
|
474
|
-
column.setFilterValue(undefined);
|
|
475
|
-
}}
|
|
476
|
-
clearButtonDisabled={!hasFilter}
|
|
451
|
+
<div className="flex gap-1 items-center">
|
|
452
|
+
<NumberField
|
|
453
|
+
value={min}
|
|
454
|
+
onChange={setMin}
|
|
455
|
+
aria-label="min"
|
|
456
|
+
placeholder="min"
|
|
457
|
+
className="shadow-none! border-border hover:shadow-none!"
|
|
458
|
+
/>
|
|
459
|
+
<MinusIcon className="h-5 w-5 text-muted-foreground" />
|
|
460
|
+
<NumberField
|
|
461
|
+
value={max}
|
|
462
|
+
onChange={setMax}
|
|
463
|
+
aria-label="max"
|
|
464
|
+
placeholder="max"
|
|
465
|
+
className="shadow-none! border-border hover:shadow-none!"
|
|
477
466
|
/>
|
|
478
|
-
|
|
467
|
+
</div>
|
|
468
|
+
)}
|
|
469
|
+
{isComparison && (
|
|
470
|
+
<NumberField
|
|
471
|
+
value={value}
|
|
472
|
+
onChange={setValue}
|
|
473
|
+
aria-label="value"
|
|
474
|
+
placeholder="value"
|
|
475
|
+
className="shadow-none! border-border hover:shadow-none!"
|
|
476
|
+
/>
|
|
479
477
|
)}
|
|
478
|
+
<FilterButtons
|
|
479
|
+
onApply={handleApply}
|
|
480
|
+
onClear={handleClear}
|
|
481
|
+
clearButtonDisabled={!hasFilter}
|
|
482
|
+
applyButtonDisabled={applyDisabled}
|
|
483
|
+
/>
|
|
480
484
|
</div>
|
|
481
485
|
);
|
|
482
486
|
};
|
|
483
487
|
|
|
484
|
-
const
|
|
488
|
+
const TEXT_SCALAR_SET: ReadonlySet<OperatorType> = new Set(TEXT_SCALAR_OPS);
|
|
489
|
+
const isTextScalarOp = (op: OperatorType): op is TextScalarOp =>
|
|
490
|
+
TEXT_SCALAR_SET.has(op);
|
|
491
|
+
|
|
492
|
+
export const TextFilterMenu = <TData, TValue>({
|
|
485
493
|
column,
|
|
494
|
+
calculateTopKRows,
|
|
486
495
|
}: {
|
|
487
496
|
column: Column<TData, TValue>;
|
|
497
|
+
calculateTopKRows?: CalculateTopKRows;
|
|
488
498
|
}) => {
|
|
489
499
|
const currentFilter = column.getFilterValue() as
|
|
490
500
|
| ColumnFilterForType<"text">
|
|
491
501
|
| undefined;
|
|
492
502
|
const hasFilter = currentFilter !== undefined;
|
|
493
|
-
|
|
503
|
+
|
|
494
504
|
const [operator, setOperator] = useState<OperatorType>(
|
|
495
505
|
currentFilter?.operator ?? "contains",
|
|
496
506
|
);
|
|
507
|
+
const [text, setText] = useState<string>(
|
|
508
|
+
currentFilter && "text" in currentFilter ? currentFilter.text : "",
|
|
509
|
+
);
|
|
510
|
+
const [values, setValues] = useState<string[]>(
|
|
511
|
+
currentFilter && "values" in currentFilter ? [...currentFilter.values] : [],
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const isScalar = isTextScalarOp(operator);
|
|
515
|
+
const isMulti = operator === "in" || operator === "not_in";
|
|
516
|
+
const isNullish =
|
|
517
|
+
operator === "is_null" ||
|
|
518
|
+
operator === "is_not_null" ||
|
|
519
|
+
operator === "is_empty";
|
|
520
|
+
|
|
521
|
+
const applyDisabled =
|
|
522
|
+
(isScalar && text === "") || (isMulti && values.length === 0);
|
|
497
523
|
|
|
498
524
|
const handleApply = () => {
|
|
499
|
-
if (
|
|
525
|
+
if (isNullish) {
|
|
500
526
|
column.setFilterValue(Filter.text({ operator }));
|
|
501
527
|
return;
|
|
502
528
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
column.setFilterValue(undefined);
|
|
529
|
+
if (isScalar && text !== "") {
|
|
530
|
+
column.setFilterValue(Filter.text({ operator, text }));
|
|
506
531
|
return;
|
|
507
532
|
}
|
|
533
|
+
if (isMulti && values.length > 0) {
|
|
534
|
+
column.setFilterValue(Filter.text({ operator, values }));
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const handleClear = () => {
|
|
539
|
+
setText("");
|
|
540
|
+
setValues([]);
|
|
541
|
+
column.setFilterValue(undefined);
|
|
542
|
+
};
|
|
508
543
|
|
|
509
|
-
|
|
544
|
+
const handleOperatorChange = (next: OperatorType) => {
|
|
545
|
+
setOperator(next);
|
|
510
546
|
};
|
|
511
547
|
|
|
512
548
|
return (
|
|
513
549
|
<div className="flex flex-col gap-1 pt-3 px-2">
|
|
514
|
-
<
|
|
515
|
-
column={column}
|
|
516
|
-
defaultItem="contains"
|
|
550
|
+
<OperatorSelect
|
|
517
551
|
operator={operator}
|
|
518
|
-
|
|
552
|
+
options={TEXT_OPS}
|
|
553
|
+
onChange={handleOperatorChange}
|
|
519
554
|
/>
|
|
520
|
-
{operator === "
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
}}
|
|
533
|
-
className="shadow-none! border-border hover:shadow-none!"
|
|
534
|
-
/>
|
|
535
|
-
<FilterButtons
|
|
536
|
-
onApply={handleApply}
|
|
537
|
-
onClear={() => {
|
|
538
|
-
setValue("");
|
|
539
|
-
column.setFilterValue(undefined);
|
|
540
|
-
}}
|
|
541
|
-
clearButtonDisabled={!hasFilter}
|
|
542
|
-
/>
|
|
543
|
-
</>
|
|
555
|
+
{isScalar && operator === "regex" && (
|
|
556
|
+
<RegexInput
|
|
557
|
+
value={text}
|
|
558
|
+
onChange={setText}
|
|
559
|
+
onKeyDown={(e) => {
|
|
560
|
+
e.stopPropagation();
|
|
561
|
+
if (e.key === "Enter") {
|
|
562
|
+
handleApply();
|
|
563
|
+
}
|
|
564
|
+
}}
|
|
565
|
+
/>
|
|
544
566
|
)}
|
|
567
|
+
{isScalar && operator !== "regex" && (
|
|
568
|
+
<Input
|
|
569
|
+
type="text"
|
|
570
|
+
icon={<TextIcon className="h-3 w-3 text-muted-foreground mb-1" />}
|
|
571
|
+
value={text}
|
|
572
|
+
onChange={(e) => setText(e.target.value)}
|
|
573
|
+
placeholder="Text..."
|
|
574
|
+
onKeyDown={(e) => {
|
|
575
|
+
e.stopPropagation();
|
|
576
|
+
if (e.key === "Enter") {
|
|
577
|
+
handleApply();
|
|
578
|
+
}
|
|
579
|
+
}}
|
|
580
|
+
className="shadow-none! border-border hover:shadow-none!"
|
|
581
|
+
/>
|
|
582
|
+
)}
|
|
583
|
+
{isMulti && (
|
|
584
|
+
<FilterByValuesList
|
|
585
|
+
column={column}
|
|
586
|
+
calculateTopKRows={calculateTopKRows}
|
|
587
|
+
chosenValues={new Set(values)}
|
|
588
|
+
onChange={(next) => setValues(next.map(String))}
|
|
589
|
+
creatable={true}
|
|
590
|
+
/>
|
|
591
|
+
)}
|
|
592
|
+
<FilterButtons
|
|
593
|
+
onApply={handleApply}
|
|
594
|
+
onClear={handleClear}
|
|
595
|
+
clearButtonDisabled={!hasFilter}
|
|
596
|
+
applyButtonDisabled={applyDisabled}
|
|
597
|
+
/>
|
|
545
598
|
</div>
|
|
546
599
|
);
|
|
547
600
|
};
|
|
@@ -32,6 +32,7 @@ interface Props<TData, TValue> {
|
|
|
32
32
|
calculateTopKRows?: CalculateTopKRows;
|
|
33
33
|
chosenValues: unknown[];
|
|
34
34
|
onChange: (values: unknown[]) => void;
|
|
35
|
+
creatable?: boolean;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export const FilterByValuesPicker = <TData, TValue>({
|
|
@@ -39,6 +40,7 @@ export const FilterByValuesPicker = <TData, TValue>({
|
|
|
39
40
|
calculateTopKRows,
|
|
40
41
|
chosenValues,
|
|
41
42
|
onChange,
|
|
43
|
+
creatable = false,
|
|
42
44
|
}: Props<TData, TValue>) => {
|
|
43
45
|
const [open, setOpen] = useState(false);
|
|
44
46
|
|
|
@@ -58,6 +60,7 @@ export const FilterByValuesPicker = <TData, TValue>({
|
|
|
58
60
|
<Popover open={open} onOpenChange={setOpen}>
|
|
59
61
|
<PopoverTrigger asChild={true}>
|
|
60
62
|
<Button
|
|
63
|
+
type="button"
|
|
61
64
|
variant="outline"
|
|
62
65
|
size="xs"
|
|
63
66
|
className="h-6 mb-1 w-full justify-between font-normal"
|
|
@@ -79,6 +82,7 @@ export const FilterByValuesPicker = <TData, TValue>({
|
|
|
79
82
|
calculateTopKRows={calculateTopKRows}
|
|
80
83
|
chosenValues={chosenValuesSet}
|
|
81
84
|
onChange={onChange}
|
|
85
|
+
creatable={creatable}
|
|
82
86
|
/>
|
|
83
87
|
</PopoverContent>
|
|
84
88
|
</Popover>
|
|
@@ -90,6 +94,7 @@ interface FilterByValuesListProps<TData, TValue> {
|
|
|
90
94
|
calculateTopKRows?: CalculateTopKRows;
|
|
91
95
|
chosenValues: Set<unknown>;
|
|
92
96
|
onChange: (values: unknown[]) => void;
|
|
97
|
+
creatable?: boolean;
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
/**
|
|
@@ -100,6 +105,7 @@ export const FilterByValuesList = <TData, TValue>({
|
|
|
100
105
|
calculateTopKRows,
|
|
101
106
|
chosenValues,
|
|
102
107
|
onChange,
|
|
108
|
+
creatable = false,
|
|
103
109
|
}: FilterByValuesListProps<TData, TValue>) => {
|
|
104
110
|
const [query, setQuery] = useState<string>("");
|
|
105
111
|
|
|
@@ -133,13 +139,48 @@ export const FilterByValuesList = <TData, TValue>({
|
|
|
133
139
|
}
|
|
134
140
|
}, [data, query]);
|
|
135
141
|
|
|
142
|
+
// Surface chosen values that aren't in the top-K so they stay visible/uncheckable.
|
|
143
|
+
// Count is undefined for these rows; the cell renders an em-dash.
|
|
144
|
+
const mergedData = useMemo<Array<[unknown, number | undefined]>>(() => {
|
|
145
|
+
const seen = new Set(filteredData.map(([v]) => v));
|
|
146
|
+
const extras: Array<[unknown, number | undefined]> = [];
|
|
147
|
+
for (const chosen of chosenValues) {
|
|
148
|
+
if (seen.has(chosen)) {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const str = String(chosen);
|
|
152
|
+
const matches =
|
|
153
|
+
query.length === 0 ||
|
|
154
|
+
smartMatch(query, str) ||
|
|
155
|
+
str.toLowerCase().includes(query.toLowerCase());
|
|
156
|
+
if (matches) {
|
|
157
|
+
extras.push([chosen, undefined]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...filteredData, ...extras];
|
|
161
|
+
}, [filteredData, chosenValues, query]);
|
|
162
|
+
|
|
136
163
|
const handleToggle = (value: unknown) => {
|
|
137
164
|
onChange([...Sets.toggle(chosenValues, value)]);
|
|
138
165
|
};
|
|
139
166
|
|
|
167
|
+
const trimmedQuery = query.trim();
|
|
168
|
+
const canCreate =
|
|
169
|
+
creatable &&
|
|
170
|
+
trimmedQuery !== "" &&
|
|
171
|
+
!mergedData.some(([v]) => String(v) === trimmedQuery);
|
|
172
|
+
|
|
173
|
+
const commitCreate = () => {
|
|
174
|
+
if (!canCreate) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
onChange([...chosenValues, trimmedQuery]);
|
|
178
|
+
setQuery("");
|
|
179
|
+
};
|
|
180
|
+
|
|
140
181
|
const allVisibleChecked =
|
|
141
|
-
|
|
142
|
-
|
|
182
|
+
mergedData.length > 0 &&
|
|
183
|
+
mergedData.every(([value]) => chosenValues.has(value));
|
|
143
184
|
|
|
144
185
|
const selectAllState: boolean | "indeterminate" = allVisibleChecked
|
|
145
186
|
? true
|
|
@@ -153,11 +194,11 @@ export const FilterByValuesList = <TData, TValue>({
|
|
|
153
194
|
}
|
|
154
195
|
const next = new Set(chosenValues);
|
|
155
196
|
if (allVisibleChecked) {
|
|
156
|
-
for (const [value] of
|
|
197
|
+
for (const [value] of mergedData) {
|
|
157
198
|
next.delete(value);
|
|
158
199
|
}
|
|
159
200
|
} else {
|
|
160
|
-
for (const [value] of
|
|
201
|
+
for (const [value] of mergedData) {
|
|
161
202
|
next.add(value);
|
|
162
203
|
}
|
|
163
204
|
}
|
|
@@ -183,13 +224,24 @@ export const FilterByValuesList = <TData, TValue>({
|
|
|
183
224
|
return (
|
|
184
225
|
<Command className="text-sm outline-hidden" shouldFilter={false}>
|
|
185
226
|
<CommandInput
|
|
186
|
-
placeholder={
|
|
227
|
+
placeholder={
|
|
228
|
+
creatable
|
|
229
|
+
? "Search or add a value…"
|
|
230
|
+
: `Search among the top ${data.length} values`
|
|
231
|
+
}
|
|
187
232
|
autoFocus={true}
|
|
188
|
-
|
|
233
|
+
value={query}
|
|
234
|
+
onValueChange={setQuery}
|
|
235
|
+
onKeyDown={(e) => {
|
|
236
|
+
if (e.key === "Enter" && canCreate) {
|
|
237
|
+
e.preventDefault();
|
|
238
|
+
commitCreate();
|
|
239
|
+
}
|
|
240
|
+
}}
|
|
189
241
|
/>
|
|
190
242
|
<CommandEmpty>No results found.</CommandEmpty>
|
|
191
243
|
<CommandList>
|
|
192
|
-
{
|
|
244
|
+
{mergedData.length > 0 && (
|
|
193
245
|
<CommandItem
|
|
194
246
|
value="__select-all__"
|
|
195
247
|
className="border-b rounded-none px-3"
|
|
@@ -204,7 +256,7 @@ export const FilterByValuesList = <TData, TValue>({
|
|
|
204
256
|
<span className="font-bold">Count</span>
|
|
205
257
|
</CommandItem>
|
|
206
258
|
)}
|
|
207
|
-
{
|
|
259
|
+
{mergedData.map(([value, count]) => {
|
|
208
260
|
const isSelected = chosenValues.has(value);
|
|
209
261
|
const valueString = stringifyUnknownValue({ value });
|
|
210
262
|
const sentinel = detectSentinel(
|
|
@@ -226,10 +278,19 @@ export const FilterByValuesList = <TData, TValue>({
|
|
|
226
278
|
<span className="flex-1 overflow-hidden max-h-20 line-clamp-3">
|
|
227
279
|
{sentinel ? <SentinelCell sentinel={sentinel} /> : valueString}
|
|
228
280
|
</span>
|
|
229
|
-
<span className="ml-3">{count}</span>
|
|
281
|
+
<span className="ml-3">{count === undefined ? "—" : count}</span>
|
|
230
282
|
</CommandItem>
|
|
231
283
|
);
|
|
232
284
|
})}
|
|
285
|
+
{canCreate && (
|
|
286
|
+
<CommandItem
|
|
287
|
+
value={`__create__:${trimmedQuery}`}
|
|
288
|
+
className="border-t rounded-none px-3 italic"
|
|
289
|
+
onSelect={commitCreate}
|
|
290
|
+
>
|
|
291
|
+
+ Add "{trimmedQuery}"
|
|
292
|
+
</CommandItem>
|
|
293
|
+
)}
|
|
233
294
|
</CommandList>
|
|
234
295
|
{data.length === TOP_K_ROWS && (
|
|
235
296
|
<span className="text-xs text-muted-foreground py-1.5 text-center">
|