@spree/dashboard-core 6.0.0-rc.7 → 6.0.1

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.
@@ -37,7 +37,7 @@ import {
37
37
  XIcon,
38
38
  } from '@spree/dashboard-ui/icons'
39
39
  import { useQuery } from '@tanstack/react-query'
40
- import { Fragment, useCallback, useDeferredValue, useMemo, useState } from 'react'
40
+ import { Fragment, type ReactNode, useCallback, useDeferredValue, useMemo, useState } from 'react'
41
41
  import { useTranslation } from 'react-i18next'
42
42
  import { getApiClient } from '../api-client'
43
43
  import { useAuth } from '../hooks/use-auth'
@@ -184,6 +184,8 @@ export function TableToolbar({
184
184
  }: TableToolbarProps) {
185
185
  const { t } = useTranslation()
186
186
  const [filterOpen, setFilterOpen] = useState(false)
187
+ // The rule the panel opens on when its chip is clicked; null opens the field list.
188
+ const [panelRule, setPanelRule] = useState<FilterRule | null>(null)
187
189
 
188
190
  const allCols = allColumns ?? columns
189
191
  // Memoize so `FilterPanel`'s `useMemo` deps stay stable across parent
@@ -226,6 +228,7 @@ export function TableToolbar({
226
228
  () => filterableColumns.filter((c) => !quickFilterKeys.has(c.key)),
227
229
  [filterableColumns, quickFilterKeys],
228
230
  )
231
+ const panelKeys = useMemo(() => new Set(panelColumns.map((c) => c.key)), [panelColumns])
229
232
  const chipFilters = useMemo(() => {
230
233
  const covered = new Set(
231
234
  quickFilterColumns.flatMap((c) =>
@@ -294,24 +297,43 @@ export function TableToolbar({
294
297
  className="hidden h-[2.125rem] w-[240px] bg-card text-sm lg:flex"
295
298
  />
296
299
 
297
- {/* Reads as a verb rather than an icon: it is the way *in* to
298
- everything the quick controls beside it don't cover, so it has to
299
- look like an action and not a toggle. */}
300
+ {/* Icon only, like sort and columns: the quick controls beside it
301
+ already carry the row's words, and the tooltip names it. */}
300
302
  {/* Hidden when every filterable field already has a quick control:
301
303
  the button would open onto an empty list. */}
302
304
  {panelColumns.length > 0 && (
303
- <Popover open={filterOpen} onOpenChange={setFilterOpen}>
304
- <PopoverTrigger asChild>
305
- <Button variant="outline" size="sm" className="h-11 gap-1.5 lg:h-[2.125rem]">
306
- <ListFilter className="size-3.5" />
307
- {t('admin.components.table_toolbar.add_filter')}
308
- </Button>
309
- </PopoverTrigger>
305
+ <Popover
306
+ open={filterOpen}
307
+ onOpenChange={(next) => {
308
+ setFilterOpen(next)
309
+ // Cleared on opening too: Apply closes the panel through
310
+ // `open`, which does not call this, so the trigger is the
311
+ // first chance to forget the chip that opened it last.
312
+ setPanelRule(null)
313
+ }}
314
+ >
315
+ <Tooltip>
316
+ <TooltipTrigger asChild>
317
+ <PopoverTrigger asChild>
318
+ <Button
319
+ variant="outline"
320
+ size="sm"
321
+ className="h-11 lg:h-[2.125rem]"
322
+ aria-label={t('admin.components.table_toolbar.add_filter')}
323
+ >
324
+ <ListFilter className="size-4" />
325
+ </Button>
326
+ </PopoverTrigger>
327
+ </TooltipTrigger>
328
+ <TooltipContent>{t('admin.components.table_toolbar.add_filter')}</TooltipContent>
329
+ </Tooltip>
310
330
  {/* `min(480px, …)` rather than a flat 480px: the panel is wider
311
331
  than a phone, so a fixed width runs its right edge and the
312
332
  Apply button off the screen. */}
313
333
  <PopoverContent align="start" className="w-[min(480px,calc(100vw-1rem))] p-0">
314
334
  <FilterPanel
335
+ key={panelRule?.id ?? ''}
336
+ initialRule={panelRule}
315
337
  columns={panelColumns}
316
338
  filters={filters}
317
339
  onApply={(f) => {
@@ -374,6 +396,14 @@ export function TableToolbar({
374
396
  key={entry.key}
375
397
  rules={entry.rules}
376
398
  col={allCols.find((c) => c.key === entry.rules[0].field)}
399
+ onEdit={
400
+ panelKeys.has(entry.rules[0].field)
401
+ ? () => {
402
+ setPanelRule(entry.rules[0])
403
+ setFilterOpen(true)
404
+ }
405
+ : undefined
406
+ }
377
407
  onRemove={() => {
378
408
  const removing = new Set(entry.rules.map((r) => r.id))
379
409
  onFiltersChange(filters.filter((f) => !removing.has(f.id)))
@@ -430,11 +460,14 @@ export function TableToolbar({
430
460
  function FilterChip({
431
461
  rules,
432
462
  col,
463
+ onEdit,
433
464
  onRemove,
434
465
  }: {
435
466
  /** Usually one rule; a bounded date range is two, shown as one chip. */
436
467
  rules: FilterRule[]
437
468
  col: ColumnDef | undefined
469
+ /** Reopens the filter panel on this field. Absent when the panel cannot edit it. */
470
+ onEdit?: () => void
438
471
  onRemove: () => void
439
472
  }) {
440
473
  const { t, i18n } = useTranslation()
@@ -465,6 +498,50 @@ function FilterChip({
465
498
  const opLabelKey = getOperatorLabelKey(col?.filterType ?? 'string', first.operator)
466
499
  const opLabel = opLabelKey ? t(opLabelKey) : first.operator
467
500
  const showValue = !noValueOperators.includes(first.operator)
501
+ // "Status Ready, Error" reads better than "Status is any of Ready, Error";
502
+ // every other operator, `not_in` included, still says what it does.
503
+ const showOperator = first.operator !== 'in'
504
+ const listedIds = ['in', 'not_in'].includes(first.operator) ? parseFilterIds(first.value) : []
505
+
506
+ const body = isRange ? (
507
+ // "1 Aug – 29 Aug 2026" rather than two chips of ISO bounds: the range
508
+ // is what was asked for, and it is read far more often than it is
509
+ // edited.
510
+ <span>
511
+ {formatDate(from)} – {formatDate(to)}
512
+ </span>
513
+ ) : (
514
+ <>
515
+ {showOperator && <span className="text-muted-foreground">{opLabel}</span>}
516
+ {showValue &&
517
+ (listedIds.length > 2 ? (
518
+ <span>{t('admin.components.table_toolbar.n_selected', { count: listedIds.length })}</span>
519
+ ) : col?.filterType === 'date' ? (
520
+ <span>{formatDate(first.value)}</span>
521
+ ) : col?.filterType === 'resource' && col.filterResource ? (
522
+ <ResourceFilterValue value={first.value} config={col.filterResource} />
523
+ ) : col?.filterType === 'tags' ? (
524
+ <span>{listedIds.join(', ')}</span>
525
+ ) : col?.filterType === 'locale' ? (
526
+ <span>
527
+ {listedIds.map((code, index) => (
528
+ <Fragment key={code}>
529
+ {index > 0 && ', '}
530
+ <LocaleLabel code={code} />
531
+ </Fragment>
532
+ ))}
533
+ </span>
534
+ ) : col?.filterType === 'enum' && col.filterOptions ? (
535
+ <span>
536
+ {parseFilterIds(first.value)
537
+ .map((v) => col.filterOptions?.find((o) => o.value === v)?.label ?? v)
538
+ .join(', ')}
539
+ </span>
540
+ ) : (
541
+ <span>{first.value}</span>
542
+ ))}
543
+ </>
544
+ )
468
545
 
469
546
  return (
470
547
  // Deliberately shaped like the quick-filter buttons beside it rather than
@@ -477,44 +554,21 @@ function FilterChip({
477
554
  // outlined controls reads as a different kind of object, when the only
478
555
  // real difference is that this one is set and they are not: the solid
479
556
  // border already says that, against the dashed one an unused control wears.
480
- <span className="inline-flex h-11 shrink-0 items-center gap-1.5 rounded-md border border-border bg-card py-1 pr-1 pl-2.5 text-sm shadow-xs lg:h-[2.125rem]">
481
- <span className="text-muted-foreground">{label}</span>
482
- {isRange ? (
483
- // "1 Aug – 29 Aug 2026" rather than two chips of ISO bounds: the range
484
- // is what was asked for, and it is read far more often than it is
485
- // edited.
486
- <span>
487
- {formatDate(from)} – {formatDate(to)}
488
- </span>
557
+ <span className="inline-flex h-11 shrink-0 items-center gap-0.5 rounded-md border border-border bg-card pr-1 text-sm shadow-xs lg:h-[2.125rem]">
558
+ {onEdit ? (
559
+ <button
560
+ type="button"
561
+ className="inline-flex h-full items-center gap-1.5 rounded-l-md pl-2.5 pr-1 transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
562
+ onClick={onEdit}
563
+ >
564
+ <span className="text-muted-foreground">{label}</span>
565
+ {body}
566
+ </button>
489
567
  ) : (
490
- <>
491
- <span className="text-muted-foreground">{opLabel}</span>
492
- {showValue &&
493
- (col?.filterType === 'date' ? (
494
- <span>{formatDate(first.value)}</span>
495
- ) : col?.filterType === 'resource' && col.filterResource ? (
496
- <ResourceFilterValue value={first.value} config={col.filterResource} />
497
- ) : col?.filterType === 'tags' ? (
498
- <span>{parseFilterIds(first.value).join(', ')}</span>
499
- ) : col?.filterType === 'locale' ? (
500
- <span>
501
- {parseFilterIds(first.value).map((code, index) => (
502
- <Fragment key={code}>
503
- {index > 0 && ', '}
504
- <LocaleLabel code={code} />
505
- </Fragment>
506
- ))}
507
- </span>
508
- ) : col?.filterType === 'enum' && col.filterOptions ? (
509
- <span>
510
- {parseFilterIds(first.value)
511
- .map((v) => col.filterOptions?.find((o) => o.value === v)?.label ?? v)
512
- .join(', ')}
513
- </span>
514
- ) : (
515
- <span>{first.value}</span>
516
- ))}
517
- </>
568
+ <span className="inline-flex items-center gap-1.5 pl-2.5 pr-1">
569
+ <span className="text-muted-foreground">{label}</span>
570
+ {body}
571
+ </span>
518
572
  )}
519
573
  {/* 20px box around a 12px glyph: the chip's own height caps how large
520
574
  this can be, and the chips are spaced by `gap-2`, which is what the
@@ -678,6 +732,85 @@ function quickFilterTriggerClass(unset: boolean) {
678
732
  return cn('h-11 gap-1.5 lg:h-[2.125rem]', unset && 'border-dashed')
679
733
  }
680
734
 
735
+ /** "6/7" on a quick filter that narrows the list. */
736
+ function SelectedCount({ selected, total }: { selected: number; total: number }) {
737
+ return (
738
+ <span className="rounded-full bg-muted px-1.5 py-px text-xs font-medium tabular-nums">
739
+ {selected}/{total}
740
+ </span>
741
+ )
742
+ }
743
+
744
+ /**
745
+ * Hover shortcut on a multi-select row: "Only" narrows to that value alone,
746
+ * and on the row that already is the only one, "Select all" widens back.
747
+ *
748
+ * Pointer-only and hidden from assistive technology: the row itself stays the
749
+ * control, and ticking or unticking the others reaches the same state.
750
+ */
751
+ function RowAction({ label, onSelect }: { label: string; onSelect: () => void }) {
752
+ return (
753
+ <span
754
+ aria-hidden
755
+ data-slot="filter-row-action"
756
+ className="ml-auto shrink-0 cursor-pointer pl-3 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/row:opacity-100 group-data-[highlighted]/checkbox-item:opacity-100"
757
+ // Stopped here so the row underneath does not also toggle.
758
+ onMouseUp={(event) => event.stopPropagation()}
759
+ onClick={(event) => {
760
+ event.preventDefault()
761
+ event.stopPropagation()
762
+ onSelect()
763
+ }}
764
+ >
765
+ {label}
766
+ </span>
767
+ )
768
+ }
769
+
770
+ /**
771
+ * The menu rows shared by the enum and resource quick filters. `selected` is
772
+ * what the list currently shows, which is every value when nothing is set.
773
+ */
774
+ function QuickFilterItems({
775
+ options,
776
+ selected,
777
+ dots,
778
+ onChange,
779
+ }: {
780
+ options: { value: string; label: string }[]
781
+ selected: string[]
782
+ dots: boolean
783
+ onChange: (values: string[]) => void
784
+ }) {
785
+ const { t } = useTranslation()
786
+ return options.map((option) => {
787
+ const checked = selected.includes(option.value)
788
+ const sole = checked && selected.length === 1
789
+ return (
790
+ <DropdownMenuCheckboxItem
791
+ key={option.value}
792
+ checked={checked}
793
+ onCheckedChange={(next) =>
794
+ onChange(next ? [...selected, option.value] : selected.filter((v) => v !== option.value))
795
+ }
796
+ >
797
+ {dots && <StatusDot status={option.value} />}
798
+ <span className="truncate">{option.label}</span>
799
+ {options.length > 1 && (
800
+ <RowAction
801
+ label={t(
802
+ sole
803
+ ? 'admin.components.table_toolbar.select_all'
804
+ : 'admin.components.table_toolbar.only',
805
+ )}
806
+ onSelect={() => onChange(sole ? options.map((o) => o.value) : [option.value])}
807
+ />
808
+ )}
809
+ </DropdownMenuCheckboxItem>
810
+ )
811
+ })
812
+ }
813
+
681
814
  /**
682
815
  * Always-visible control for one heavily-used filter.
683
816
  *
@@ -746,28 +879,27 @@ function QuickEnumFilter({
746
879
  <DropdownMenu>
747
880
  <DropdownMenuTrigger asChild>
748
881
  <Button variant="outline" size="sm" className={quickFilterTriggerClass(allSelected)}>
749
- <span className="text-muted-foreground">{column.label}</span>
750
- <span className="tabular-nums">
751
- {allSelected ? t('admin.common.all') : `${selected.length}/${options.length}`}
882
+ {/* The colours of what is let through, so a narrowed status reads
883
+ at a glance without opening the menu. */}
884
+ <span aria-hidden className="flex -space-x-1">
885
+ {options
886
+ .filter((o) => selected.includes(o.value))
887
+ .slice(0, 4)
888
+ .map((o) => (
889
+ <StatusDot key={o.value} status={o.value} className="size-2.5 ring-2 ring-card" />
890
+ ))}
752
891
  </span>
892
+ <span className="text-muted-foreground">{column.label}</span>
893
+ {allSelected ? (
894
+ <span>{t('admin.common.all')}</span>
895
+ ) : (
896
+ <SelectedCount selected={selected.length} total={options.length} />
897
+ )}
753
898
  <ChevronDownIcon className="size-3.5 text-muted-foreground" />
754
899
  </Button>
755
900
  </DropdownMenuTrigger>
756
- <DropdownMenuContent align="start" className="min-w-52">
757
- {options.map((option) => (
758
- <DropdownMenuCheckboxItem
759
- key={option.value}
760
- checked={selected.includes(option.value)}
761
- onCheckedChange={(checked) =>
762
- apply(
763
- checked ? [...selected, option.value] : selected.filter((v) => v !== option.value),
764
- )
765
- }
766
- >
767
- <StatusDot status={option.value} />
768
- {option.label}
769
- </DropdownMenuCheckboxItem>
770
- ))}
901
+ <DropdownMenuContent align="start" className="min-w-56">
902
+ <QuickFilterItems options={options} selected={selected} dots onChange={apply} />
771
903
  </DropdownMenuContent>
772
904
  </DropdownMenu>
773
905
  )
@@ -837,11 +969,15 @@ function QuickResourceFilter({
837
969
  // No rule means no constraint, which is every option — not none.
838
970
  const active = selected.length > 0 ? selected : options.map((o) => o.value)
839
971
  const summary =
840
- selected.length === 0
841
- ? t('admin.common.all')
842
- : selected.length === 1
843
- ? (options.find((o) => o.value === selected[0])?.label ?? '1')
844
- : `${selected.length}/${options.length}`
972
+ selected.length === 0 ? (
973
+ <span>{t('admin.common.all')}</span>
974
+ ) : selected.length === 1 ? (
975
+ <span className="max-w-40 truncate">
976
+ {options.find((o) => o.value === selected[0])?.label ?? '1'}
977
+ </span>
978
+ ) : (
979
+ <SelectedCount selected={selected.length} total={options.length} />
980
+ )
845
981
 
846
982
  return (
847
983
  <DropdownMenu>
@@ -852,22 +988,12 @@ function QuickResourceFilter({
852
988
  className={quickFilterTriggerClass(selected.length === 0)}
853
989
  >
854
990
  <span className="text-muted-foreground">{column.label}</span>
855
- <span className="max-w-40 truncate">{summary}</span>
991
+ {summary}
856
992
  <ChevronDownIcon className="size-3.5 text-muted-foreground" />
857
993
  </Button>
858
994
  </DropdownMenuTrigger>
859
- <DropdownMenuContent align="start" className="min-w-52">
860
- {options.map((option) => (
861
- <DropdownMenuCheckboxItem
862
- key={option.value}
863
- checked={active.includes(option.value)}
864
- onCheckedChange={(checked) =>
865
- apply(checked ? [...active, option.value] : active.filter((v) => v !== option.value))
866
- }
867
- >
868
- {option.label}
869
- </DropdownMenuCheckboxItem>
870
- ))}
995
+ <DropdownMenuContent align="start" className="min-w-56">
996
+ <QuickFilterItems options={options} selected={active} dots={false} onChange={apply} />
871
997
  </DropdownMenuContent>
872
998
  </DropdownMenu>
873
999
  )
@@ -1104,6 +1230,63 @@ function ColumnSelector({
1104
1230
  // ============================================================================
1105
1231
  // Filter Drawer
1106
1232
  // ============================================================================
1233
+
1234
+ const panelRowClass =
1235
+ 'group/row flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50'
1236
+
1237
+ /** One value in a multi-select list of the filter panel. */
1238
+ function CheckRow({
1239
+ checked,
1240
+ sole,
1241
+ onToggle,
1242
+ onOnly,
1243
+ children,
1244
+ }: {
1245
+ checked: boolean
1246
+ /** The only value ticked — its "Only" would do nothing, so it offers none. */
1247
+ sole: boolean
1248
+ onToggle: () => void
1249
+ onOnly: () => void
1250
+ children: ReactNode
1251
+ }) {
1252
+ const { t } = useTranslation()
1253
+ return (
1254
+ <button
1255
+ type="button"
1256
+ data-slot="filter-panel-item"
1257
+ aria-pressed={checked}
1258
+ className={panelRowClass}
1259
+ onClick={onToggle}
1260
+ >
1261
+ <Checkbox checked={checked} tabIndex={-1} className="pointer-events-none" />
1262
+ {children}
1263
+ {!sole && <RowAction label={t('admin.components.table_toolbar.only')} onSelect={onOnly} />}
1264
+ </button>
1265
+ )
1266
+ }
1267
+
1268
+ /** "Any Status": the row that clears this field, ticked while nothing is set. */
1269
+ function AnyRow({
1270
+ label,
1271
+ active,
1272
+ onSelect,
1273
+ }: {
1274
+ label: string
1275
+ active: boolean
1276
+ onSelect: () => void
1277
+ }) {
1278
+ return (
1279
+ <button
1280
+ type="button"
1281
+ data-slot="filter-panel-item"
1282
+ className={cn(panelRowClass, 'justify-between')}
1283
+ onClick={onSelect}
1284
+ >
1285
+ {label}
1286
+ {active && <CheckIcon className="size-4 shrink-0" />}
1287
+ </button>
1288
+ )
1289
+ }
1107
1290
  /**
1108
1291
  * Inline, searchable, multi-select list of a filter's candidate values.
1109
1292
  *
@@ -1121,12 +1304,14 @@ function InlineValueList({
1121
1304
  query,
1122
1305
  selected,
1123
1306
  onToggle,
1307
+ onOnly,
1124
1308
  }: {
1125
1309
  column: ColumnDef
1126
1310
  tenantId: string | undefined
1127
1311
  query: string
1128
1312
  selected: string[]
1129
1313
  onToggle: (value: string) => void
1314
+ onOnly: (value: string) => void
1130
1315
  }) {
1131
1316
  const { t } = useTranslation()
1132
1317
  const { isAuthenticated } = useAuth()
@@ -1269,15 +1454,15 @@ function InlineValueList({
1269
1454
  return (
1270
1455
  <>
1271
1456
  {ordered.map((option) => (
1272
- <button
1457
+ <CheckRow
1273
1458
  key={option.value}
1274
- type="button"
1275
- className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1276
- onClick={() => onToggle(option.value)}
1459
+ checked={selected.includes(option.value)}
1460
+ sole={selected.length === 1 && selected[0] === option.value}
1461
+ onToggle={() => onToggle(option.value)}
1462
+ onOnly={() => onOnly(option.value)}
1277
1463
  >
1278
- <Checkbox checked={selected.includes(option.value)} className="pointer-events-none" />
1279
1464
  <span className="truncate">{option.label}</span>
1280
- </button>
1465
+ </CheckRow>
1281
1466
  ))}
1282
1467
  </>
1283
1468
  )
@@ -1295,11 +1480,14 @@ function InlineValueList({
1295
1480
  * duplicated that list in a second place, in a different notation.
1296
1481
  */
1297
1482
  function FilterPanel({
1483
+ initialRule = null,
1298
1484
  columns,
1299
1485
  filters,
1300
1486
  onApply,
1301
1487
  onChange,
1302
1488
  }: {
1489
+ /** Open straight on this rule's field and value, as when its chip is clicked. */
1490
+ initialRule?: FilterRule | null
1303
1491
  /** The fields with no quick control of their own. A field that has one is
1304
1492
  * filtered there alone — its checkbox list already says "is" and "is not",
1305
1493
  * so offering it here too only listed the same field twice. */
@@ -1314,10 +1502,39 @@ function FilterPanel({
1314
1502
  const tenantId = useTenantId()
1315
1503
  const store = useOptionalStore()
1316
1504
  const timezone = store?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
1505
+ /**
1506
+ * What picking a field starts from: the rule already set on it, so reopening
1507
+ * a filter shows the values it is filtering by. Without this a list reads as
1508
+ * empty, and the next tick would replace the existing set rather than extend
1509
+ * it.
1510
+ */
1511
+ function seedFor(key: string | null, rule?: FilterRule | null) {
1512
+ const col = columns.find((c) => c.key === key)
1513
+ if (!col) return { operator: '', value: '', editing: null }
1514
+ const ops = getOperators(col.filterType ?? 'string')
1515
+ const editable = (f: FilterRule) =>
1516
+ f.field === key && col.filterType !== 'date' && ops.some((o) => o.value === f.operator)
1517
+ // The clicked chip's own rule first: a field can carry two typed rules,
1518
+ // and editing "equals b" must not open on "contains a".
1519
+ const applied =
1520
+ (rule && editable(rule) ? rule : undefined) ??
1521
+ filters.find((f) => f.field === key && f.operator === 'in') ??
1522
+ filters.find(editable)
1523
+ return {
1524
+ operator: applied && applied.operator !== 'in' ? applied.operator : ops[0].value,
1525
+ value: applied?.value ?? '',
1526
+ // The typed rule being edited, replaced on Apply even if its operator
1527
+ // changes — otherwise "contains a" edited to "equals b" keeps both.
1528
+ editing: applied && applied.operator !== 'in' ? applied.id : null,
1529
+ }
1530
+ }
1531
+ const [initial] = useState(() => seedFor(initialRule?.field ?? null, initialRule))
1532
+
1317
1533
  // The field being built, or null while the field list is showing.
1318
- const [field, setField] = useState<string | null>(null)
1319
- const [operator, setOperator] = useState('')
1320
- const [value, setValue] = useState('')
1534
+ const [field, setField] = useState<string | null>(initialRule?.field ?? null)
1535
+ const [operator, setOperator] = useState(initial.operator)
1536
+ const [value, setValue] = useState(initial.value)
1537
+ const [editing, setEditing] = useState<string | null>(initial.editing)
1321
1538
  const [query, setQuery] = useState('')
1322
1539
  // Set once the user picks "custom" from the date presets, revealing the two
1323
1540
  // date fields. Kept separate from `value` so backing out of custom does not
@@ -1338,6 +1555,11 @@ function FilterPanel({
1338
1555
  // accumulate a set and need an explicit Apply; the single ones commit on the
1339
1556
  // click that picks them.
1340
1557
  const multi = type === 'tags' || type === 'resource' || type === 'currency' || type === 'locale'
1558
+ // An enum is ticked like the others and sent as `in`. Not one backed by a
1559
+ // Ransack scope: a scope takes the value as its argument, and most of them
1560
+ // answer a yes-or-no question that a list of values would not fit.
1561
+ const enumMulti = type === 'enum' && !column?.ransackScope
1562
+ const checked = parseFilterIds(value)
1341
1563
  // Dates are their own shape: a list of relative presets, with the calendar
1342
1564
  // behind "custom". Same presets the quick filter offers, so a range means
1343
1565
  // the same thing wherever it was set.
@@ -1367,15 +1589,11 @@ function FilterPanel({
1367
1589
  }, [columns, query])
1368
1590
 
1369
1591
  function chooseField(key: string) {
1370
- const col = columns.find((c) => c.key === key)
1371
- const firstOperator = getOperators(col?.filterType ?? 'string')[0].value
1592
+ const seed = seedFor(key)
1372
1593
  setField(key)
1373
- setOperator(firstOperator)
1374
- // Seed from what is already applied, so reopening a multi-select shows the
1375
- // values it is filtering by. Without this the list reads as empty and the
1376
- // next tick would replace the existing set rather than extend it.
1377
- const applied = filters.find((f) => f.field === key && f.operator === 'in')
1378
- setValue(applied?.value ?? '')
1594
+ setOperator(seed.operator)
1595
+ setValue(seed.value)
1596
+ setEditing(seed.editing)
1379
1597
  setQuery('')
1380
1598
  setCustomRange(null)
1381
1599
  }
@@ -1387,6 +1605,7 @@ function FilterPanel({
1387
1605
  setField(null)
1388
1606
  setOperator('')
1389
1607
  setValue('')
1608
+ setEditing(null)
1390
1609
  setQuery('')
1391
1610
  setCustomRange(null)
1392
1611
  }
@@ -1404,7 +1623,9 @@ function FilterPanel({
1404
1623
  function commit(nextValue: string, nextOperator = operator) {
1405
1624
  if (!field) return
1406
1625
  if (!noValueOperators.includes(nextOperator) && nextValue.trim() === '') return
1407
- const others = filters.filter((f) => !(f.field === field && f.operator === nextOperator))
1626
+ const others = filters.filter(
1627
+ (f) => !(f.field === field && f.operator === nextOperator) && f.id !== editing,
1628
+ )
1408
1629
  onApply([
1409
1630
  ...others,
1410
1631
  { id: crypto.randomUUID(), field, operator: nextOperator, value: nextValue },
@@ -1436,15 +1657,18 @@ function FilterPanel({
1436
1657
  * unticking the last one removes it, rather than leaving an empty rule that
1437
1658
  * would match nothing.
1438
1659
  */
1439
- function toggleValue(option: string) {
1660
+ function setValues(next: string[]) {
1440
1661
  if (!field) return
1441
- const current = parseFilterIds(value)
1442
- const next = current.includes(option)
1443
- ? current.filter((v) => v !== option)
1444
- : [...current, option]
1445
1662
  setValue(next.join(','))
1663
+ // A reopened `status is active` is shown ticked, so the set that replaces
1664
+ // it must replace that rule too, or the old `eq` keeps narrowing the list.
1665
+ // Only for enums: a `not_in` tag rule is a separate constraint.
1666
+ const replaced = enumMulti ? editing : null
1667
+ setEditing(null)
1446
1668
 
1447
- const others = filters.filter((f) => !(f.field === field && f.operator === 'in'))
1669
+ const others = filters.filter(
1670
+ (f) => !(f.field === field && f.operator === 'in') && f.id !== replaced,
1671
+ )
1448
1672
  onChange(
1449
1673
  next.length === 0
1450
1674
  ? others
@@ -1452,6 +1676,17 @@ function FilterPanel({
1452
1676
  )
1453
1677
  }
1454
1678
 
1679
+ /** "Any …": no constraint on this field at all, `is not` rules included. */
1680
+ function clearValues() {
1681
+ setValue('')
1682
+ setEditing(null)
1683
+ onChange(filters.filter((f) => f.field !== field))
1684
+ }
1685
+
1686
+ function toggleValue(option: string) {
1687
+ setValues(checked.includes(option) ? checked.filter((v) => v !== option) : [...checked, option])
1688
+ }
1689
+
1455
1690
  return (
1456
1691
  // `rounded-xl` to match the popover, and `overflow-hidden` to enforce it:
1457
1692
  // the panel is mounted with `p-0`, so the header meets the popover's edge
@@ -1469,19 +1704,12 @@ function FilterPanel({
1469
1704
  size when it departs from it. */}
1470
1705
  <div className="flex items-stretch border-b text-base">
1471
1706
  {field ? (
1472
- // The field name is the way back, not a second picker. It used to
1473
- // open a dropdown over the value list underneath — a menu on top of
1474
- // the menu it was covering, offering the same fields the screen
1475
- // behind it was already showing. Clicking it now simply returns to
1476
- // that list, which is where changing subject was always going to
1477
- // end up.
1707
+ // The field name is the way back to the field list.
1478
1708
  <button
1479
1709
  type="button"
1480
1710
  className="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-none border-r px-3 py-2 font-medium transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
1481
1711
  onClick={clearField}
1482
1712
  >
1483
- {/* Leads the label rather than trailing it: a back arrow points at
1484
- what it returns to, and after the word it pointed at nothing. */}
1485
1713
  <ChevronLeftIcon className="size-4 shrink-0 text-muted-foreground" />
1486
1714
  {column?.label ?? field}
1487
1715
  </button>
@@ -1544,13 +1772,39 @@ function FilterPanel({
1544
1772
  </button>
1545
1773
  ))}
1546
1774
 
1547
- {/* Stage two, list-shaped types: the values themselves, one click to
1548
- apply. No operator control — "is" is the only thing a list of
1549
- values can mean, and offering "is not" here would double the list's
1550
- length for a case the panel's other branch already covers. */}
1775
+ {/* Stage two, list-shaped types: the values themselves, ticked and
1776
+ applied as they go. No operator control — "is any of" is the only
1777
+ thing a list of values can mean, and "is not" lives in the typed
1778
+ branch below. */}
1779
+ {field && (multi || enumMulti) && column && (
1780
+ <AnyRow
1781
+ label={t('admin.components.table_toolbar.any_field', { field: column.label })}
1782
+ active={!filters.some((f) => f.field === field)}
1783
+ onSelect={clearValues}
1784
+ />
1785
+ )}
1786
+
1787
+ {field &&
1788
+ enumMulti &&
1789
+ listOptions.map((option) => (
1790
+ <CheckRow
1791
+ key={option.value}
1792
+ checked={checked.includes(option.value)}
1793
+ sole={checked.length === 1 && checked[0] === option.value}
1794
+ onToggle={() => toggleValue(option.value)}
1795
+ onOnly={() => setValues([option.value])}
1796
+ >
1797
+ <StatusDot status={option.value} />
1798
+ <span className="truncate">{option.label}</span>
1799
+ </CheckRow>
1800
+ ))}
1801
+
1802
+ {/* Booleans and scope-backed enums answer one question, so one click
1803
+ picks the answer and closes. */}
1551
1804
  {field &&
1552
1805
  listed &&
1553
1806
  !multi &&
1807
+ !enumMulti &&
1554
1808
  listOptions.map((option) => (
1555
1809
  <button
1556
1810
  key={option.value}
@@ -1576,8 +1830,9 @@ function FilterPanel({
1576
1830
  column={column}
1577
1831
  tenantId={tenantId}
1578
1832
  query={query}
1579
- selected={parseFilterIds(value)}
1833
+ selected={checked}
1580
1834
  onToggle={toggleValue}
1835
+ onOnly={(option) => setValues([option])}
1581
1836
  />
1582
1837
  )}
1583
1838
 
@@ -1602,7 +1857,14 @@ function FilterPanel({
1602
1857
  type="button"
1603
1858
  data-slot="filter-panel-item"
1604
1859
  className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
1605
- onClick={() => setCustomRange({ from: '', to: '' })}
1860
+ // Seeded with the bounds applied, so editing one end keeps the other.
1861
+ onClick={() =>
1862
+ setCustomRange({
1863
+ from:
1864
+ filters.find((f) => f.field === field && f.operator === 'gteq')?.value ?? '',
1865
+ to: filters.find((f) => f.field === field && f.operator === 'lteq')?.value ?? '',
1866
+ })
1867
+ }
1606
1868
  >
1607
1869
  {t('admin.components.table_toolbar.date_presets.custom')}
1608
1870
  <ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground" />