@spree/dashboard-core 6.0.0 → 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.
- package/package.json +11 -6
- package/src/api-client.ts +4 -0
- package/src/components/app-shell.tsx +4 -0
- package/src/components/app-sidebar.tsx +7 -1
- package/src/components/invitation-acceptance.tsx +411 -0
- package/src/components/record-metadata.tsx +118 -0
- package/src/components/table-toolbar.tsx +384 -122
- package/src/index.ts +2 -0
- package/src/locales/ar.json +31 -1
- package/src/locales/de.json +31 -1
- package/src/locales/en.json +31 -1
- package/src/locales/es.json +31 -1
- package/src/locales/fr.json +31 -1
- package/src/locales/ja.json +2122 -0
- package/src/locales/pl.json +31 -1
- package/src/locales/zh-CN.json +31 -1
- package/src/pages/package-types.tsx +2 -0
- package/src/pages/stock-locations.tsx +2 -0
|
@@ -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
|
-
{/*
|
|
298
|
-
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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">{
|
|
492
|
-
{
|
|
493
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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-
|
|
757
|
-
{options
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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-
|
|
860
|
-
{options
|
|
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
|
-
<
|
|
1457
|
+
<CheckRow
|
|
1273
1458
|
key={option.value}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
</
|
|
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
|
|
1371
|
-
const firstOperator = getOperators(col?.filterType ?? 'string')[0].value
|
|
1592
|
+
const seed = seedFor(key)
|
|
1372
1593
|
setField(key)
|
|
1373
|
-
setOperator(
|
|
1374
|
-
|
|
1375
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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,
|
|
1548
|
-
|
|
1549
|
-
values can mean, and
|
|
1550
|
-
|
|
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={
|
|
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
|
-
|
|
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" />
|