@open-mercato/ui 0.6.1-develop.3260.1.dc719cac84 → 0.6.1-develop.3278.1.a4d26475e4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/DataTable.js +149 -53
  3. package/dist/backend/DataTable.js.map +2 -2
  4. package/dist/backend/filters/ActiveFilterChips.js +142 -0
  5. package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
  6. package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
  7. package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
  8. package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
  9. package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
  10. package/dist/backend/filters/FilterEmptyState.js +41 -0
  11. package/dist/backend/filters/FilterEmptyState.js.map +7 -0
  12. package/dist/backend/filters/FilterFieldPicker.js +148 -0
  13. package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
  14. package/dist/backend/filters/FilteredEmptyResults.js +26 -0
  15. package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
  16. package/dist/backend/filters/QuickFilters.js +41 -0
  17. package/dist/backend/filters/QuickFilters.js.map +7 -0
  18. package/dist/backend/filters/filterValidation.js +47 -0
  19. package/dist/backend/filters/filterValidation.js.map +7 -0
  20. package/dist/backend/filters/treeReducer.js +159 -0
  21. package/dist/backend/filters/treeReducer.js.map +7 -0
  22. package/dist/backend/hooks/useAdvancedFilter.js +82 -1
  23. package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
  24. package/dist/backend/utils/api.js +18 -1
  25. package/dist/backend/utils/api.js.map +2 -2
  26. package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
  27. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  28. package/dist/backend/utils/useCurrentUserId.js +31 -0
  29. package/dist/backend/utils/useCurrentUserId.js.map +7 -0
  30. package/dist/primitives/dialog.js +7 -5
  31. package/dist/primitives/dialog.js.map +2 -2
  32. package/dist/primitives/tag.js +4 -2
  33. package/dist/primitives/tag.js.map +2 -2
  34. package/package.json +3 -3
  35. package/src/backend/DataTable.tsx +307 -63
  36. package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
  37. package/src/backend/filters/ActiveFilterChips.tsx +179 -0
  38. package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
  39. package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
  40. package/src/backend/filters/FilterEmptyState.tsx +51 -0
  41. package/src/backend/filters/FilterFieldPicker.tsx +139 -0
  42. package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
  43. package/src/backend/filters/QuickFilters.tsx +62 -0
  44. package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
  45. package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
  46. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
  47. package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
  48. package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
  49. package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
  50. package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
  51. package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
  52. package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
  53. package/src/backend/filters/filterValidation.ts +61 -0
  54. package/src/backend/filters/treeReducer.ts +201 -0
  55. package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
  56. package/src/backend/hooks/useAdvancedFilter.ts +130 -0
  57. package/src/backend/utils/api.ts +27 -2
  58. package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
  59. package/src/backend/utils/useCurrentUserId.ts +40 -0
  60. package/src/primitives/__tests__/zindex-overlay.test.tsx +22 -0
  61. package/src/primitives/dialog.tsx +18 -7
  62. package/src/primitives/tag.tsx +3 -1
@@ -20,6 +20,7 @@ import { EmptyState } from '../primitives/empty-state'
20
20
  import { TooltipProvider } from '../primitives/tooltip'
21
21
  import { TruncatedCell } from './TruncatedCell'
22
22
  import { FilterBar, type FilterDef, type FilterValues } from './FilterBar'
23
+ import { FilteredEmptyResults } from './filters/FilteredEmptyResults'
23
24
  import { useCustomFieldFilterDefs } from './utils/customFieldFilters'
24
25
  import { fetchCustomFieldDefinitionsPayload, type CustomFieldsetDto } from './utils/customFieldDefs'
25
26
  import { RowActions, type RowActionItem } from './RowActions'
@@ -53,8 +54,20 @@ import type {
53
54
  import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'
54
55
  import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
55
56
  import { useVirtualizer } from '@tanstack/react-virtual'
56
- import type { AdvancedFilterState, FilterFieldDef as AdvancedFilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
57
- import { createEmptyCondition, getDefaultOperator } from '@open-mercato/shared/lib/query/advanced-filter'
57
+ import type {
58
+ FilterFieldDef as AdvancedFilterFieldDef,
59
+ AdvancedFilterState,
60
+ } from '@open-mercato/shared/lib/query/advanced-filter'
61
+ import { getDefaultOperator, isAdvancedFilterState, flatToTree } from '@open-mercato/shared/lib/query/advanced-filter'
62
+ import type { AdvancedFilterTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
63
+ import {
64
+ createEmptyTree,
65
+ serializeTreeForPersist,
66
+ deserializeTreeFromPersist,
67
+ isPersistedFilterTree,
68
+ treeToFlat,
69
+ } from '@open-mercato/shared/lib/query/advanced-filter-tree'
70
+ import { treeReducer } from './filters/treeReducer'
58
71
  import { AdvancedFilterBuilder } from './filters/AdvancedFilterBuilder'
59
72
  import { type ColumnChooserField } from './columns/ColumnChooserPanel'
60
73
  import { useAutoDiscoveredFields } from './utils/useAutoDiscoveredFields'
@@ -239,18 +252,86 @@ export type DataTableProps<T> = {
239
252
  virtualized?: boolean
240
253
  virtualizedMaxHeight?: number | string
241
254
  virtualizedOverscan?: number
242
- advancedFilter?: {
243
- fields?: AdvancedFilterFieldDef[]
244
- auto?: boolean
245
- value: AdvancedFilterState
246
- onChange: (state: AdvancedFilterState) => void
247
- onApply: () => void
248
- onClear: () => void
249
- }
255
+ /**
256
+ * Advanced filter configuration. Accepts either the v2 tree shape (preferred)
257
+ * or the legacy flat `AdvancedFilterState` shape as a backward-compatibility
258
+ * bridge. The bridge is provided for one minor version; legacy callers SHOULD
259
+ * migrate to the tree shape — see the spec
260
+ * `.ai/specs/2026-05-10-crm-list-filter-redesign.md` "Migration & Backward
261
+ * Compatibility" section and `RELEASE_NOTES.md`.
262
+ *
263
+ * When the legacy flat shape is detected, DataTable converts it to a tree via
264
+ * `flatToTree` for internal rendering and converts any user edits back via
265
+ * `treeToFlat` before calling `onChange`. The back-conversion flattens nested
266
+ * groups into the top level (lossy for sub-group structure), so consumers
267
+ * that need full tree semantics MUST migrate.
268
+ */
269
+ advancedFilter?:
270
+ | {
271
+ fields?: AdvancedFilterFieldDef[]
272
+ auto?: boolean
273
+ value: AdvancedFilterTree
274
+ onChange: (state: AdvancedFilterTree) => void
275
+ onApply: () => void
276
+ onClear: () => void
277
+ /**
278
+ * Optional ref forwarded to the internal Filters trigger button. When set,
279
+ * external popovers (e.g. AdvancedFilterPanel) can anchor to this trigger.
280
+ * The internal popover is suppressed when externalPopover is true.
281
+ */
282
+ triggerRef?: React.RefObject<HTMLButtonElement | null>
283
+ /**
284
+ * When true, DataTable suppresses its internal advanced filter popover/builder
285
+ * and only renders the trigger button. The host page is responsible for
286
+ * rendering an external popover (e.g. AdvancedFilterPanel) and toggling
287
+ * its open state via onTriggerClick.
288
+ */
289
+ externalPopover?: boolean
290
+ onTriggerClick?: () => void
291
+ /**
292
+ * Optional callback invoked when a saved perspective contains a persisted
293
+ * advanced-filter tree (`{v:2, root:...}`). The host page receives the
294
+ * restored tree and is responsible for replacing both its local state and
295
+ * the `useAdvancedFilterTree` hook's tree. When omitted, perspectives that
296
+ * carry a tree-shape `filters` payload are ignored on load (and the legacy
297
+ * `onFiltersApply` callback is called with an empty record).
298
+ */
299
+ onApplyTree?: (tree: AdvancedFilterTree) => void
300
+ }
301
+ | {
302
+ /**
303
+ * @deprecated Legacy flat `AdvancedFilterState` shape. Convert to the
304
+ * v2 tree shape above before the next minor version. The bridge will be
305
+ * removed per the deprecation protocol in `BACKWARD_COMPATIBILITY.md`.
306
+ */
307
+ fields?: AdvancedFilterFieldDef[]
308
+ auto?: boolean
309
+ value: AdvancedFilterState
310
+ onChange: (state: AdvancedFilterState) => void
311
+ onApply: () => void
312
+ onClear: () => void
313
+ }
250
314
  columnChooser?: {
251
315
  availableColumns?: ColumnChooserField[]
252
316
  auto?: boolean
253
317
  }
318
+ /**
319
+ * Slot rendered between the toolbar and the table body when filters are active
320
+ * and the popover is closed. Use ActiveFilterChips from filters/.
321
+ */
322
+ activeFilterChips?: React.ReactNode
323
+ /**
324
+ * When provided AND .active is true, replaces the generic empty state with the
325
+ * filter-aware FilteredEmptyResults. Pages set this when their filter tree has
326
+ * rules and the table body is empty.
327
+ */
328
+ filterAwareEmptyState?: {
329
+ active: boolean
330
+ entityNamePlural: string
331
+ canRemoveLast: boolean
332
+ onClearAll: () => void
333
+ onRemoveLast: () => void
334
+ }
254
335
  }
255
336
 
256
337
  const DEFAULT_EXPORT_FORMATS: DataTableExportFormat[] = ['csv', 'json', 'xml', 'markdown']
@@ -894,8 +975,10 @@ export function DataTable<T>({
894
975
  virtualized = false,
895
976
  virtualizedMaxHeight,
896
977
  virtualizedOverscan = 10,
897
- advancedFilter,
978
+ advancedFilter: advancedFilterInput,
898
979
  columnChooser,
980
+ activeFilterChips,
981
+ filterAwareEmptyState,
899
982
  }: DataTableProps<T>) {
900
983
  const t = useT()
901
984
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
@@ -904,6 +987,53 @@ export function DataTable<T>({
904
987
  const containerRef = React.useRef<HTMLDivElement>(null)
905
988
  const lastScopeRef = React.useRef<OrganizationScopeChangedDetail | null>(null)
906
989
  const hasInitializedScopeRef = React.useRef(false)
990
+
991
+ // BC bridge: legacy callers may pass the flat `AdvancedFilterState` shape on
992
+ // `advancedFilter.value`. Normalize to the tree shape that the rest of
993
+ // DataTable expects, and back-convert on `onChange` so the caller's typed
994
+ // callback still receives the shape it declared. Tree-only fields
995
+ // (`triggerRef`, `externalPopover`, `onApplyTree`, `onTriggerClick`) are
996
+ // undefined for legacy callers — they were added in this PR and didn't exist
997
+ // in the legacy contract.
998
+ // See spec `.ai/specs/2026-05-10-crm-list-filter-redesign.md` ("Migration &
999
+ // Backward Compatibility") and BACKWARD_COMPATIBILITY.md §3.
1000
+ type AdvancedFilterNormalized = {
1001
+ fields?: AdvancedFilterFieldDef[]
1002
+ auto?: boolean
1003
+ value: AdvancedFilterTree
1004
+ onChange: (state: AdvancedFilterTree) => void
1005
+ onApply: () => void
1006
+ onClear: () => void
1007
+ triggerRef?: React.RefObject<HTMLButtonElement | null>
1008
+ externalPopover?: boolean
1009
+ onTriggerClick?: () => void
1010
+ onApplyTree?: (tree: AdvancedFilterTree) => void
1011
+ }
1012
+ const legacyAdvancedFilterWarnedRef = React.useRef(false)
1013
+ const advancedFilter: AdvancedFilterNormalized | undefined = React.useMemo(() => {
1014
+ if (!advancedFilterInput) return undefined
1015
+ if (!isAdvancedFilterState(advancedFilterInput.value)) {
1016
+ return advancedFilterInput as AdvancedFilterNormalized
1017
+ }
1018
+ if (!legacyAdvancedFilterWarnedRef.current && process.env.NODE_ENV !== 'production') {
1019
+ legacyAdvancedFilterWarnedRef.current = true
1020
+ // eslint-disable-next-line no-console
1021
+ console.warn(
1022
+ '[DataTable] `advancedFilter.value` was passed as the legacy `AdvancedFilterState` shape. ' +
1023
+ 'This bridge will be removed in the next minor version — migrate to the tree shape ' +
1024
+ '(`AdvancedFilterTree`, see `@open-mercato/shared/lib/query/advanced-filter-tree`).',
1025
+ )
1026
+ }
1027
+ const legacy = advancedFilterInput as Extract<typeof advancedFilterInput, { value: AdvancedFilterState }>
1028
+ return {
1029
+ fields: legacy.fields,
1030
+ auto: legacy.auto,
1031
+ value: flatToTree(legacy.value),
1032
+ onChange: (next: AdvancedFilterTree) => legacy.onChange(treeToFlat(next)),
1033
+ onApply: legacy.onApply,
1034
+ onClear: legacy.onClear,
1035
+ }
1036
+ }, [advancedFilterInput])
907
1037
  React.useEffect(() => {
908
1038
  return subscribeOrganizationScopeChanged((detail) => {
909
1039
  const prev = lastScopeRef.current
@@ -1395,21 +1525,47 @@ export function DataTable<T>({
1395
1525
  visibility[key] = value
1396
1526
  }
1397
1527
  }
1398
- const filtersRecord: Record<string, unknown> = {}
1399
- for (const [key, value] of Object.entries(filterValues ?? {})) {
1400
- if (typeof key === 'string') filtersRecord[key] = value
1528
+ // When the host page wires an advanced-filter tree, persist that as the
1529
+ // single source of truth for `filters`. The tree wins over legacy
1530
+ // `filterValues` because the CRM redesign (SPEC-048) absorbed all simple
1531
+ // filters into the tree on those pages — keeping both shapes in sync
1532
+ // would re-introduce the dual-state bug the redesign deliberately fixed.
1533
+ let filtersPayload: Record<string, unknown> | undefined
1534
+ if (advancedFilter) {
1535
+ const persisted = serializeTreeForPersist(advancedFilter.value)
1536
+ filtersPayload = persisted.root.children.length > 0
1537
+ ? (persisted as unknown as Record<string, unknown>)
1538
+ : undefined
1539
+ } else {
1540
+ const filtersRecord: Record<string, unknown> = {}
1541
+ for (const [key, value] of Object.entries(filterValues ?? {})) {
1542
+ if (typeof key === 'string') filtersRecord[key] = value
1543
+ }
1544
+ if (Object.keys(filtersRecord).length) filtersPayload = filtersRecord
1401
1545
  }
1402
1546
  const candidate: PerspectiveSettings = {
1403
1547
  columnOrder,
1404
1548
  columnVisibility: visibility,
1405
1549
  sorting,
1406
- filters: filtersRecord,
1550
+ filters: filtersPayload,
1407
1551
  searchValue,
1408
1552
  }
1409
1553
  return sanitizePerspectiveSettings(candidate) ?? {}
1410
- }, [columnOrder, columnVisibility, sorting, filterValues, searchValue])
1411
-
1412
- const applyPerspectiveSettings = React.useCallback((settings: PerspectiveSettings, nextId: string | null) => {
1554
+ }, [columnOrder, columnVisibility, sorting, filterValues, searchValue, advancedFilter])
1555
+
1556
+ const applyPerspectiveSettings = React.useCallback((
1557
+ settings: PerspectiveSettings,
1558
+ nextId: string | null,
1559
+ options?: {
1560
+ /** When true, do NOT touch the host's advanced-filter tree or the legacy
1561
+ * filter callback. Used by the mount-time snapshot restore so a stale
1562
+ * localStorage snapshot can't override URL-derived filter state on
1563
+ * pages that own filter persistence (People/Companies/Deals). Other
1564
+ * callsites — explicit perspective selection, "No view" clear — leave
1565
+ * this off so the user's intent (apply this view / clear) wins. */
1566
+ preserveAdvancedFilter?: boolean
1567
+ },
1568
+ ) => {
1413
1569
  const normalized = sanitizePerspectiveSettings(settings) ?? {}
1414
1570
  if (normalized.columnOrder && normalized.columnOrder.length) {
1415
1571
  setColumnOrder(normalized.columnOrder)
@@ -1430,8 +1586,31 @@ export function DataTable<T>({
1430
1586
  setSorting([])
1431
1587
  onSortingChange?.([])
1432
1588
  }
1433
- if (onFiltersApply) {
1434
- onFiltersApply((normalized.filters ?? {}) as FilterValues)
1589
+ // Two filter shapes can live in `settings.filters`:
1590
+ // 1. Persisted advanced-filter tree: `{ v: 2, root: {...} }`
1591
+ // 2. Legacy flat FilterValues record: arbitrary `{ key: value, ... }`
1592
+ // Tree wins when both a tree-shape payload and an `onApplyTree` callback
1593
+ // are present (the host owns an `AdvancedFilterTree`). For pages that
1594
+ // still drive the legacy FilterBar we fall back to `onFiltersApply`.
1595
+ if (!options?.preserveAdvancedFilter) {
1596
+ const restoredTree = isPersistedFilterTree(normalized.filters)
1597
+ ? deserializeTreeFromPersist(normalized.filters)
1598
+ : null
1599
+ if (advancedFilter?.onApplyTree) {
1600
+ advancedFilter.onApplyTree(restoredTree ?? createEmptyTree())
1601
+ // Clear any legacy callback so a stale FilterValues map doesn't override
1602
+ // the tree on the next render. Selecting "No view" also clears the
1603
+ // external advanced-filter tree instead of leaving the prior view's
1604
+ // filters visible.
1605
+ if (onFiltersApply) onFiltersApply({} as FilterValues)
1606
+ } else if (onFiltersApply) {
1607
+ // Either no tree was saved, or the page doesn't accept trees. Pass the
1608
+ // legacy filters through unchanged. A tree-shape payload reaching this
1609
+ // branch (no `onApplyTree`) is intentionally dropped — the host page
1610
+ // would need the wiring to consume it.
1611
+ const legacy = restoredTree ? {} : (normalized.filters ?? {})
1612
+ onFiltersApply(legacy as FilterValues)
1613
+ }
1435
1614
  }
1436
1615
  if (onSearchChange) {
1437
1616
  onSearchChange(normalized.searchValue ?? '')
@@ -1448,7 +1627,7 @@ export function DataTable<T>({
1448
1627
  initialSnapshotRef.current = null
1449
1628
  }
1450
1629
  }
1451
- }, [onFiltersApply, onSearchChange, onSortingChange, perspectiveTableId, table])
1630
+ }, [onFiltersApply, onSearchChange, onSortingChange, perspectiveTableId, table, advancedFilter])
1452
1631
 
1453
1632
  React.useLayoutEffect(() => {
1454
1633
  if (!perspectiveTableId) return
@@ -1457,9 +1636,24 @@ export function DataTable<T>({
1457
1636
  const snapshot = readPerspectiveSnapshot(perspectiveTableId)
1458
1637
  if (!snapshot) return
1459
1638
  initialSnapshotRef.current = snapshot
1460
- applyPerspectiveSettings(snapshot.settings, snapshot.perspectiveId ?? null)
1639
+ // When the host page wired an advanced-filter tree (`advancedFilter.onApplyTree`),
1640
+ // the host owns filter persistence — typically by hydrating from / writing to the
1641
+ // URL (see CRM People/Companies/Deals lazy useState initializers + URL writer
1642
+ // effects). The mount-time snapshot from a prior session can be arbitrarily
1643
+ // stale and MUST NOT override the host's filter — including the empty case
1644
+ // (Clear all → refresh would otherwise resurrect the previously-saved rules).
1645
+ // The snapshot still drives non-filter settings: column order, visibility,
1646
+ // sorting, search. Explicit perspective selection and "No view" go through
1647
+ // a different applyPerspectiveSettings call without this option, so they
1648
+ // still update the host filter as expected.
1649
+ const preserveAdvancedFilter = !!advancedFilter?.onApplyTree
1650
+ applyPerspectiveSettings(
1651
+ snapshot.settings,
1652
+ snapshot.perspectiveId ?? null,
1653
+ { preserveAdvancedFilter },
1654
+ )
1461
1655
  initialPerspectiveAppliedRef.current = true
1462
- }, [perspectiveTableId, applyPerspectiveSettings])
1656
+ }, [perspectiveTableId, applyPerspectiveSettings, advancedFilter])
1463
1657
 
1464
1658
  type SavePerspectivePayload = {
1465
1659
  name: string
@@ -1709,7 +1903,7 @@ export function DataTable<T>({
1709
1903
  if (!canUsePerspectives) return
1710
1904
  if (!perspectiveTableId) return
1711
1905
  if (initialSnapshotRef.current) return
1712
- if (initialPerspectiveAppliedRef.current && activePerspectiveId != null) return
1906
+ if (initialPerspectiveAppliedRef.current) return
1713
1907
 
1714
1908
  const source = perspectiveData ?? perspectiveConfig?.initialState?.response
1715
1909
  if (!source) return
@@ -1969,6 +2163,19 @@ export function DataTable<T>({
1969
2163
  const resolvedAdvancedFilterFields = isAutoAdvancedFilter
1970
2164
  ? autoDiscovered.advancedFilterFields
1971
2165
  : advancedFilter?.fields ?? []
2166
+
2167
+ const advancedFilterRuleCount = React.useMemo<number>(() => {
2168
+ if (!advancedFilter) return 0
2169
+ function countRules(group: AdvancedFilterTree['root']): number {
2170
+ let n = 0
2171
+ for (const c of group.children) {
2172
+ if (c.type === 'rule') n += 1
2173
+ else n += countRules(c)
2174
+ }
2175
+ return n
2176
+ }
2177
+ return countRules(advancedFilter.value.root)
2178
+ }, [advancedFilter])
1972
2179
  const resolvedColumnChooserFields = isAutoColumnChooser
1973
2180
  ? autoDiscovered.columnChooserFields
1974
2181
  : columnChooser?.availableColumns ?? []
@@ -2106,15 +2313,63 @@ export function DataTable<T>({
2106
2313
  const builtToolbar = React.useMemo(() => {
2107
2314
  if (toolbar) return toolbar
2108
2315
  const anySearch = onSearchChange != null
2109
- const anyFilters = (baseFilters && baseFilters.length > 0) || (cfFilters && cfFilters.length > 0) || injectedFilters.length > 0
2316
+ // When the host wires the V2 advanced-filter popover externally, suppress the
2317
+ // FilterBar's own auto-discovered filter trigger. The V2 popover is the
2318
+ // single filter UI; surfacing the legacy FilterOverlay here would show two
2319
+ // filter triggers side-by-side and confuse the user.
2320
+ const suppressFilterBarFilters = !!(advancedFilter && advancedFilter.externalPopover)
2321
+ const effectiveBaseFilters = suppressFilterBarFilters ? [] : baseFilters
2322
+ const effectiveCfFilters = suppressFilterBarFilters ? [] : cfFilters
2323
+ const effectiveInjectedFilters = suppressFilterBarFilters ? [] : injectedFilters
2324
+ const anyFilters = (effectiveBaseFilters && effectiveBaseFilters.length > 0) || (effectiveCfFilters && effectiveCfFilters.length > 0) || effectiveInjectedFilters.length > 0
2110
2325
  const hasBulkButtons = hasInjectedBulkActions || hasPropBulkActions
2111
- if (!anySearch && !anyFilters && !hasBulkButtons) return null
2326
+ const hasAdvancedFilterButton = Boolean(advancedFilter)
2327
+ const hasPerspectiveButton = canUsePerspectives
2328
+ if (!anySearch && !anyFilters && !hasBulkButtons && !hasAdvancedFilterButton && !hasPerspectiveButton) return null
2112
2329
  // Merge base filters with CF filters, preferring base definitions when ids collide
2113
- const baseList = baseFilters || []
2330
+ const baseList = effectiveBaseFilters || []
2114
2331
  const existing = new Set(baseList.map((f) => f.id))
2115
- const cfOnly = (cfFilters || []).filter((f) => !existing.has(f.id))
2116
- const injectedOnly = injectedFilters.filter((f) => !existing.has(f.id) && !cfOnly.some((cf) => cf.id === f.id))
2332
+ const cfOnly = (effectiveCfFilters || []).filter((f) => !existing.has(f.id))
2333
+ const injectedOnly = effectiveInjectedFilters.filter((f) => !existing.has(f.id) && !cfOnly.some((cf) => cf.id === f.id))
2117
2334
  const combined: FilterDef[] = [...baseList, ...cfOnly, ...injectedOnly]
2335
+ const advancedFilterButton = advancedFilter ? (
2336
+ <Button
2337
+ ref={advancedFilter.triggerRef}
2338
+ type="button"
2339
+ variant={advancedFilterRuleCount > 0 ? 'default' : 'outline'}
2340
+ size="default"
2341
+ className={advancedFilterRuleCount > 0 ? 'bg-foreground text-background hover:bg-foreground/90' : ''}
2342
+ onClick={() => {
2343
+ if (advancedFilter.externalPopover) {
2344
+ advancedFilter.onTriggerClick?.()
2345
+ return
2346
+ }
2347
+ const opening = !isAdvancedFilterOpen
2348
+ if (opening && advancedFilterRuleCount === 0) {
2349
+ const defaultField = resolvedAdvancedFilterFields[0]
2350
+ const seeded = treeReducer(advancedFilter.value, {
2351
+ type: 'addRule',
2352
+ groupId: advancedFilter.value.root.id,
2353
+ defaultField: defaultField?.key,
2354
+ defaultOperator: defaultField ? getDefaultOperator(defaultField.type) : undefined,
2355
+ })
2356
+ advancedFilter.onChange(seeded)
2357
+ }
2358
+ setAdvancedFilterOpen(opening)
2359
+ }}
2360
+ aria-label={t('ui.advancedFilter.toggle', 'Advanced filters')}
2361
+ title={t('ui.advancedFilter.toggle', 'Advanced filters')}
2362
+ data-testid="advanced-filter-trigger"
2363
+ >
2364
+ <Filter className="h-4 w-4" />
2365
+ <span>{t('ui.dataTable.filters', 'Filters')}</span>
2366
+ {advancedFilterRuleCount > 0 ? (
2367
+ <span className="ml-1 inline-flex h-5 min-w-5 px-1.5 items-center justify-center rounded-full bg-muted-foreground/30 text-background text-xs">
2368
+ {advancedFilterRuleCount}
2369
+ </span>
2370
+ ) : null}
2371
+ </Button>
2372
+ ) : null
2118
2373
  const perspectiveButton = canUsePerspectives ? (
2119
2374
  <ViewSwitcherDropdown
2120
2375
  activePerspectiveId={activePerspectiveId}
@@ -2151,7 +2406,12 @@ export function DataTable<T>({
2151
2406
  </div>
2152
2407
  )
2153
2408
  : null
2154
- const leadingItems = perspectiveButton ? <div className="flex items-center gap-2">{perspectiveButton}</div> : null
2409
+ const leadingItems = advancedFilterButton || perspectiveButton ? (
2410
+ <div className="flex items-center gap-2">
2411
+ {advancedFilterButton}
2412
+ {perspectiveButton}
2413
+ </div>
2414
+ ) : null
2155
2415
  const trailingItems = hasBulkButtons ? (
2156
2416
  <div className="flex flex-wrap items-center gap-2">
2157
2417
  {selectedRows.length > 0 ? (
@@ -2244,6 +2504,10 @@ export function DataTable<T>({
2244
2504
  runPropBulkAction,
2245
2505
  searchTrailingInjectionSpotId,
2246
2506
  resolvedInjectionContext,
2507
+ advancedFilter,
2508
+ advancedFilterRuleCount,
2509
+ isAdvancedFilterOpen,
2510
+ resolvedAdvancedFilterFields,
2247
2511
  ])
2248
2512
 
2249
2513
  const hasTitle = title != null
@@ -2256,7 +2520,7 @@ export function DataTable<T>({
2256
2520
  const hasRefreshButton = Boolean(refreshButtonConfig)
2257
2521
  const hasToolbar = builtToolbar != null
2258
2522
  const hasToolbarInjection = Boolean(toolbarInjectionSpotId)
2259
- const shouldRenderActionsWrapper = hasActions || hasRefreshButton || shouldReserveActionsSpace || hasExport || hasToolbarInjection || Boolean(advancedFilter)
2523
+ const shouldRenderActionsWrapper = hasActions || hasRefreshButton || shouldReserveActionsSpace || hasExport || hasToolbarInjection
2260
2524
  const renderToolbarInline = embedded && hasToolbar
2261
2525
  const shouldRenderToolbarBelow = hasToolbar && !renderToolbarInline
2262
2526
  const shouldRenderHeader = hasTitle || renderToolbarInline || shouldRenderActionsWrapper || shouldRenderToolbarBelow
@@ -2321,34 +2585,6 @@ export function DataTable<T>({
2321
2585
  <span className="sr-only">{refreshButtonConfig.label}</span>
2322
2586
  </Button>
2323
2587
  ) : null}
2324
- {advancedFilter ? (
2325
- <Button
2326
- type="button"
2327
- variant={advancedFilter.value.conditions.length > 0 ? 'secondary' : 'ghost'}
2328
- size="icon"
2329
- onClick={() => {
2330
- const opening = !isAdvancedFilterOpen
2331
- if (opening && advancedFilter.value.conditions.length === 0) {
2332
- const newCondition = createEmptyCondition()
2333
- if (resolvedAdvancedFilterFields.length > 0) {
2334
- newCondition.field = resolvedAdvancedFilterFields[0].key
2335
- newCondition.operator = getDefaultOperator(resolvedAdvancedFilterFields[0].type)
2336
- }
2337
- advancedFilter.onChange({ ...advancedFilter.value, conditions: [newCondition] })
2338
- }
2339
- setAdvancedFilterOpen(opening)
2340
- }}
2341
- aria-label={t('ui.advancedFilter.toggle', 'Advanced filters')}
2342
- title={t('ui.advancedFilter.toggle', 'Advanced filters')}
2343
- >
2344
- <Filter className="h-4 w-4" />
2345
- {advancedFilter.value.conditions.length > 0 ? (
2346
- <span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-overline text-primary-foreground">
2347
- {advancedFilter.value.conditions.length}
2348
- </span>
2349
- ) : null}
2350
- </Button>
2351
- ) : null}
2352
2588
  {canUsePerspectives ? (
2353
2589
  <Button
2354
2590
  type="button"
@@ -2379,7 +2615,7 @@ export function DataTable<T>({
2379
2615
  ) : null}
2380
2616
  </div>
2381
2617
  )}
2382
- {advancedFilter && isAdvancedFilterOpen ? (
2618
+ {advancedFilter && !advancedFilter.externalPopover && isAdvancedFilterOpen ? (
2383
2619
  <div className="border-b">
2384
2620
  <AdvancedFilterBuilder
2385
2621
  fields={resolvedAdvancedFilterFields}
@@ -2390,10 +2626,10 @@ export function DataTable<T>({
2390
2626
  />
2391
2627
  </div>
2392
2628
  ) : null}
2393
- {advancedFilter && advancedFilter.value.conditions.length > 0 && !isAdvancedFilterOpen ? (
2629
+ {advancedFilter && !advancedFilter.externalPopover && advancedFilterRuleCount > 0 && !isAdvancedFilterOpen ? (
2394
2630
  <div className="flex items-center gap-2 flex-wrap px-4 py-2 border-b text-sm">
2395
2631
  <span className="text-muted-foreground">
2396
- {t('ui.advancedFilter.activeCount', '{count} active filters', { count: advancedFilter.value.conditions.length })}
2632
+ {t('ui.advancedFilter.activeCount', '{count} active filters', { count: advancedFilterRuleCount })}
2397
2633
  </span>
2398
2634
  <Button type="button" variant="ghost" size="sm" className="h-auto px-1 py-0.5 text-xs" onClick={() => setAdvancedFilterOpen(true)}>
2399
2635
  {t('ui.advancedFilter.edit', 'Edit')}
@@ -2403,6 +2639,7 @@ export function DataTable<T>({
2403
2639
  </Button>
2404
2640
  </div>
2405
2641
  ) : null}
2642
+ {activeFilterChips}
2406
2643
  <HeaderDndWrapper
2407
2644
  enabled={enableHeaderDnd}
2408
2645
  contextId={`${stableDndContextId}-headers`}
@@ -2614,7 +2851,14 @@ export function DataTable<T>({
2614
2851
  ) : (
2615
2852
  <TableRow>
2616
2853
  <TableCell colSpan={mergedColumns.length + (rowActions || injectedRowActions.length > 0 ? 1 : 0) + (hasInjectedBulkActions ? 1 : 0)} className="py-6">
2617
- {emptyState && typeof emptyState !== 'string' ? (
2854
+ {filterAwareEmptyState?.active ? (
2855
+ <FilteredEmptyResults
2856
+ entityNamePlural={filterAwareEmptyState.entityNamePlural}
2857
+ canRemoveLast={filterAwareEmptyState.canRemoveLast}
2858
+ onClearAll={filterAwareEmptyState.onClearAll}
2859
+ onRemoveLast={filterAwareEmptyState.onRemoveLast}
2860
+ />
2861
+ ) : emptyState && typeof emptyState !== 'string' ? (
2618
2862
  emptyState
2619
2863
  ) : (
2620
2864
  <EmptyState