@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/DataTable.js +149 -53
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/filters/ActiveFilterChips.js +142 -0
- package/dist/backend/filters/ActiveFilterChips.js.map +7 -0
- package/dist/backend/filters/AdvancedFilterBuilder.js +689 -163
- package/dist/backend/filters/AdvancedFilterBuilder.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +354 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +7 -0
- package/dist/backend/filters/FilterEmptyState.js +41 -0
- package/dist/backend/filters/FilterEmptyState.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +148 -0
- package/dist/backend/filters/FilterFieldPicker.js.map +7 -0
- package/dist/backend/filters/FilteredEmptyResults.js +26 -0
- package/dist/backend/filters/FilteredEmptyResults.js.map +7 -0
- package/dist/backend/filters/QuickFilters.js +41 -0
- package/dist/backend/filters/QuickFilters.js.map +7 -0
- package/dist/backend/filters/filterValidation.js +47 -0
- package/dist/backend/filters/filterValidation.js.map +7 -0
- package/dist/backend/filters/treeReducer.js +159 -0
- package/dist/backend/filters/treeReducer.js.map +7 -0
- package/dist/backend/hooks/useAdvancedFilter.js +82 -1
- package/dist/backend/hooks/useAdvancedFilter.js.map +2 -2
- package/dist/backend/utils/api.js +18 -1
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +1 -0
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/backend/utils/useCurrentUserId.js +31 -0
- package/dist/backend/utils/useCurrentUserId.js.map +7 -0
- package/dist/primitives/dialog.js +7 -5
- package/dist/primitives/dialog.js.map +2 -2
- package/dist/primitives/tag.js +4 -2
- package/dist/primitives/tag.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +307 -63
- package/src/backend/__tests__/DataTable.extensions.test.tsx +170 -0
- package/src/backend/filters/ActiveFilterChips.tsx +179 -0
- package/src/backend/filters/AdvancedFilterBuilder.tsx +840 -237
- package/src/backend/filters/AdvancedFilterPanel.tsx +456 -0
- package/src/backend/filters/FilterEmptyState.tsx +51 -0
- package/src/backend/filters/FilterFieldPicker.tsx +139 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +40 -0
- package/src/backend/filters/QuickFilters.tsx +62 -0
- package/src/backend/filters/__tests__/ActiveFilterChips.test.tsx +75 -0
- package/src/backend/filters/__tests__/AdvancedFilterBuilder.test.tsx +210 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +212 -0
- package/src/backend/filters/__tests__/FilterEmptyState.test.tsx +31 -0
- package/src/backend/filters/__tests__/FilterFieldPicker.test.tsx +61 -0
- package/src/backend/filters/__tests__/FilteredEmptyResults.test.tsx +35 -0
- package/src/backend/filters/__tests__/QuickFilters.test.tsx +34 -0
- package/src/backend/filters/__tests__/filterValidation.test.ts +69 -0
- package/src/backend/filters/__tests__/treeReducer.test.ts +189 -0
- package/src/backend/filters/filterValidation.ts +61 -0
- package/src/backend/filters/treeReducer.ts +201 -0
- package/src/backend/hooks/__tests__/useAdvancedFilter.test.tsx +213 -0
- package/src/backend/hooks/useAdvancedFilter.ts +130 -0
- package/src/backend/utils/api.ts +27 -2
- package/src/backend/utils/useAutoDiscoveredFields.ts +2 -0
- package/src/backend/utils/useCurrentUserId.ts +40 -0
- package/src/primitives/__tests__/zindex-overlay.test.tsx +22 -0
- package/src/primitives/dialog.tsx +18 -7
- 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 {
|
|
57
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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:
|
|
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((
|
|
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
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
2330
|
+
const baseList = effectiveBaseFilters || []
|
|
2114
2331
|
const existing = new Set(baseList.map((f) => f.id))
|
|
2115
|
-
const cfOnly = (
|
|
2116
|
-
const injectedOnly =
|
|
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 =
|
|
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
|
|
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.
|
|
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:
|
|
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
|
-
{
|
|
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
|