@postxl/generators 1.11.5 → 1.11.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend-view/template/query.utils.ts +77 -0
- package/dist/frontend-core/template/src/components/admin/admin-slicer.tsx +3 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.stories.tsx +265 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.tsx +247 -128
- package/dist/types/template/query.types.ts +19 -1
- package/package.json +2 -2
|
@@ -158,6 +158,83 @@ export function hasTextMatching(filter: StringFilter | undefined): boolean {
|
|
|
158
158
|
return !!(filter.contains || filter.startsWith || filter.endsWith || filter.exclude)
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Type for hierarchy node data used in filter expansion.
|
|
163
|
+
* Must have id and childIds fields for tree traversal.
|
|
164
|
+
*/
|
|
165
|
+
export type HierarchyNodeLike = {
|
|
166
|
+
id: string
|
|
167
|
+
childIds: string[]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Expands a set of hierarchy node IDs to include all descendant IDs.
|
|
172
|
+
* Used for hierarchy filtering where selecting a parent should include all children.
|
|
173
|
+
*
|
|
174
|
+
* @param selectedIds - The IDs selected in the filter
|
|
175
|
+
* @param hierarchyNodesMap - Map of all hierarchy nodes with their childIds
|
|
176
|
+
* @returns Set of IDs including selected nodes and all their descendants
|
|
177
|
+
*/
|
|
178
|
+
export function expandHierarchyFilterValues(
|
|
179
|
+
selectedIds: string[] | undefined,
|
|
180
|
+
hierarchyNodesMap: Map<string, HierarchyNodeLike> | undefined,
|
|
181
|
+
): Set<string> {
|
|
182
|
+
const expandedIds = new Set<string>()
|
|
183
|
+
|
|
184
|
+
if (!selectedIds || selectedIds.length === 0 || !hierarchyNodesMap) {
|
|
185
|
+
return expandedIds
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Recursive function to add a node and all its descendants
|
|
189
|
+
const addWithDescendants = (nodeId: string) => {
|
|
190
|
+
if (expandedIds.has(nodeId)) {
|
|
191
|
+
return // Already processed
|
|
192
|
+
}
|
|
193
|
+
expandedIds.add(nodeId)
|
|
194
|
+
|
|
195
|
+
const node = hierarchyNodesMap.get(nodeId)
|
|
196
|
+
if (node?.childIds) {
|
|
197
|
+
for (const childId of node.childIds) {
|
|
198
|
+
addWithDescendants(childId)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Process each selected ID
|
|
204
|
+
for (const id of selectedIds) {
|
|
205
|
+
addWithDescendants(id)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return expandedIds
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Matches a hierarchy value against a filter, expanding to include descendants.
|
|
213
|
+
* When a parent node is selected in the filter, all descendants are considered matches.
|
|
214
|
+
*
|
|
215
|
+
* @param itemValue - The hierarchy node ID on the item being filtered
|
|
216
|
+
* @param filter - The filter containing selected node IDs in the values array
|
|
217
|
+
* @param hierarchyNodesMap - Map of all hierarchy nodes for expansion
|
|
218
|
+
*/
|
|
219
|
+
export function matchHierarchyFilter(
|
|
220
|
+
itemValue: string | null | undefined,
|
|
221
|
+
filter: StringFilter | undefined,
|
|
222
|
+
hierarchyNodesMap: Map<string, HierarchyNodeLike> | undefined,
|
|
223
|
+
): boolean {
|
|
224
|
+
if (!filter?.values?.length) {
|
|
225
|
+
return true
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Expand selected values to include descendants
|
|
229
|
+
const expandedValues = expandHierarchyFilterValues(filter.values, hierarchyNodesMap)
|
|
230
|
+
|
|
231
|
+
// Check if item value matches any expanded value
|
|
232
|
+
if (itemValue === null || itemValue === undefined || itemValue === '') {
|
|
233
|
+
return expandedValues.has('')
|
|
234
|
+
}
|
|
235
|
+
return expandedValues.has(itemValue)
|
|
236
|
+
}
|
|
237
|
+
|
|
161
238
|
/**
|
|
162
239
|
* Applies range filters for number and date fields.
|
|
163
240
|
*/
|
|
@@ -14,6 +14,7 @@ export const AdminSlicer = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
14
14
|
title,
|
|
15
15
|
optionsHeight,
|
|
16
16
|
className,
|
|
17
|
+
__e2e_test_id__,
|
|
17
18
|
}: {
|
|
18
19
|
field: TField
|
|
19
20
|
filters: TFilters
|
|
@@ -23,6 +24,7 @@ export const AdminSlicer = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
23
24
|
title?: string
|
|
24
25
|
optionsHeight?: number
|
|
25
26
|
className?: string
|
|
27
|
+
__e2e_test_id__?: string
|
|
26
28
|
}) => {
|
|
27
29
|
const fieldConfig = config[field]
|
|
28
30
|
const { filterKey, valueType: fieldType } = fieldConfig
|
|
@@ -183,6 +185,7 @@ export const AdminSlicer = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
183
185
|
isLoading={isLoading}
|
|
184
186
|
optionsHeight={optionsHeight}
|
|
185
187
|
className={className}
|
|
188
|
+
__e2e_test_id__={__e2e_test_id__}
|
|
186
189
|
/>
|
|
187
190
|
)
|
|
188
191
|
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { FilterConfig, FilterFieldName, FilterOption, FilterState, FilterValue } from '@types'
|
|
6
|
+
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger, Button } from '@postxl/ui-components'
|
|
8
|
+
|
|
9
|
+
import { TableFilter } from './table-filter'
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: 'Admin/TableFilter',
|
|
13
|
+
component: TableFilter,
|
|
14
|
+
tags: ['autodocs'],
|
|
15
|
+
parameters: {
|
|
16
|
+
layout: 'centered',
|
|
17
|
+
},
|
|
18
|
+
decorators: [
|
|
19
|
+
(Story) => {
|
|
20
|
+
const queryClient = new QueryClient({
|
|
21
|
+
defaultOptions: {
|
|
22
|
+
queries: {
|
|
23
|
+
retry: false,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
return (
|
|
28
|
+
<QueryClientProvider client={queryClient}>
|
|
29
|
+
<Story />
|
|
30
|
+
</QueryClientProvider>
|
|
31
|
+
)
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
} satisfies Meta<typeof TableFilter>
|
|
35
|
+
export default meta
|
|
36
|
+
|
|
37
|
+
type Story = StoryObj<typeof meta>
|
|
38
|
+
|
|
39
|
+
// Mock data for different filter types
|
|
40
|
+
const stringOptions = [
|
|
41
|
+
{ value: '', label: 'Empty', hasMatches: true },
|
|
42
|
+
{ value: 'apple', label: 'Apple', hasMatches: true },
|
|
43
|
+
{ value: 'banana', label: 'Banana', hasMatches: true },
|
|
44
|
+
{ value: 'cherry', label: 'Cherry', hasMatches: true },
|
|
45
|
+
{ value: 'date', label: 'Date', hasMatches: true },
|
|
46
|
+
{ value: 'elderberry', label: 'Elderberry', hasMatches: true },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
const numberOptions = [
|
|
50
|
+
{ value: '10', label: '10', hasMatches: true },
|
|
51
|
+
{ value: '20', label: '20', hasMatches: true },
|
|
52
|
+
{ value: '30', label: '30', hasMatches: true },
|
|
53
|
+
{ value: '40', label: '40', hasMatches: true },
|
|
54
|
+
{ value: '50', label: '50', hasMatches: true },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
const booleanOptions = [
|
|
58
|
+
{ value: 'true', label: 'Yes', hasMatches: true },
|
|
59
|
+
{ value: 'false', label: 'No', hasMatches: true },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const dateOptions = [
|
|
63
|
+
{ value: '2024-01-15', label: '2024-01-15', hasMatches: true },
|
|
64
|
+
{ value: '2024-02-20', label: '2024-02-20', hasMatches: true },
|
|
65
|
+
{ value: '2024-03-25', label: '2024-03-25', hasMatches: true },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
const hierarchyOptions: FilterOption[] = [
|
|
69
|
+
{
|
|
70
|
+
value: 'europe',
|
|
71
|
+
label: 'Europe',
|
|
72
|
+
hasMatches: true,
|
|
73
|
+
children: [
|
|
74
|
+
{
|
|
75
|
+
value: 'germany',
|
|
76
|
+
label: 'Germany',
|
|
77
|
+
hasMatches: true,
|
|
78
|
+
children: [
|
|
79
|
+
{ value: 'berlin', label: 'Berlin', hasMatches: true },
|
|
80
|
+
{ value: 'munich', label: 'Munich', hasMatches: true },
|
|
81
|
+
{ value: 'hamburg', label: 'Hamburg', hasMatches: true },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
value: 'france',
|
|
86
|
+
label: 'France',
|
|
87
|
+
hasMatches: true,
|
|
88
|
+
children: [
|
|
89
|
+
{ value: 'paris', label: 'Paris', hasMatches: true },
|
|
90
|
+
{ value: 'lyon', label: 'Lyon', hasMatches: true },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
value: 'spain',
|
|
95
|
+
label: 'Spain',
|
|
96
|
+
hasMatches: true,
|
|
97
|
+
children: [
|
|
98
|
+
{ value: 'madrid', label: 'Madrid', hasMatches: true },
|
|
99
|
+
{ value: 'barcelona', label: 'Barcelona', hasMatches: true },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
value: 'asia',
|
|
106
|
+
label: 'Asia',
|
|
107
|
+
hasMatches: true,
|
|
108
|
+
children: [
|
|
109
|
+
{
|
|
110
|
+
value: 'japan',
|
|
111
|
+
label: 'Japan',
|
|
112
|
+
hasMatches: true,
|
|
113
|
+
children: [
|
|
114
|
+
{ value: 'tokyo', label: 'Tokyo', hasMatches: true },
|
|
115
|
+
{ value: 'osaka', label: 'Osaka', hasMatches: true },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
{ value: 'singapore', label: 'Singapore', hasMatches: true },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
// Mock filter configs
|
|
124
|
+
type MockFilterState = FilterState & {
|
|
125
|
+
stringField?: { values?: string[] }
|
|
126
|
+
numberField?: { values?: number[]; min?: number; max?: number }
|
|
127
|
+
booleanField?: boolean[]
|
|
128
|
+
dateField?: { values?: string[]; start?: string; end?: string }
|
|
129
|
+
hierarchyField?: { values?: string[] }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const stringFilterConfig: FilterConfig<FilterFieldName, MockFilterState> = {
|
|
133
|
+
stringField: {
|
|
134
|
+
filterKey: 'stringField',
|
|
135
|
+
valueType: 'string',
|
|
136
|
+
kind: 'scalar',
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const numberFilterConfig: FilterConfig<FilterFieldName, MockFilterState> = {
|
|
141
|
+
numberField: {
|
|
142
|
+
filterKey: 'numberField',
|
|
143
|
+
valueType: 'number',
|
|
144
|
+
kind: 'scalar',
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const booleanFilterConfig: FilterConfig<FilterFieldName, MockFilterState> = {
|
|
149
|
+
booleanField: {
|
|
150
|
+
filterKey: 'booleanField',
|
|
151
|
+
valueType: 'boolean',
|
|
152
|
+
kind: 'scalar',
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dateFilterConfig: FilterConfig<FilterFieldName, MockFilterState> = {
|
|
157
|
+
dateField: {
|
|
158
|
+
filterKey: 'dateField',
|
|
159
|
+
valueType: 'date',
|
|
160
|
+
kind: 'scalar',
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const hierarchyFilterConfig: FilterConfig<FilterFieldName, MockFilterState> = {
|
|
165
|
+
hierarchyField: {
|
|
166
|
+
filterKey: 'hierarchyField',
|
|
167
|
+
valueType: 'hierarchy',
|
|
168
|
+
kind: 'hierarchy',
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Mock getFilterOptions function
|
|
173
|
+
const createMockGetFilterOptions =
|
|
174
|
+
(options: FilterOption[]) => (_args: { field: FilterFieldName; filters: MockFilterState }) => ({
|
|
175
|
+
queryKey: ['mockFilterOptions'],
|
|
176
|
+
queryFn: async () => options,
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// Wrapper component for stories
|
|
180
|
+
const FilterWrapper = ({
|
|
181
|
+
field,
|
|
182
|
+
config,
|
|
183
|
+
options,
|
|
184
|
+
initialFilters = {},
|
|
185
|
+
}: {
|
|
186
|
+
field: FilterFieldName
|
|
187
|
+
config: FilterConfig<FilterFieldName, MockFilterState>
|
|
188
|
+
options: FilterOption[]
|
|
189
|
+
initialFilters?: MockFilterState
|
|
190
|
+
}) => {
|
|
191
|
+
const [filters, setFilters] = useState<MockFilterState>(initialFilters)
|
|
192
|
+
const [open, setOpen] = useState(false)
|
|
193
|
+
|
|
194
|
+
const handleChange = (val: FilterValue) => {
|
|
195
|
+
const fieldConfig = config[field]
|
|
196
|
+
if (fieldConfig) {
|
|
197
|
+
setFilters((prev) => ({
|
|
198
|
+
...prev,
|
|
199
|
+
[fieldConfig.filterKey]: val,
|
|
200
|
+
}))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div className="flex flex-col gap-4 items-center">
|
|
206
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
207
|
+
<PopoverTrigger asChild>
|
|
208
|
+
<Button variant="outline">{open ? 'Filter Open' : 'Open Filter'}</Button>
|
|
209
|
+
</PopoverTrigger>
|
|
210
|
+
<PopoverContent className="w-[280px] p-0">
|
|
211
|
+
<TableFilter
|
|
212
|
+
open={open}
|
|
213
|
+
field={field}
|
|
214
|
+
filters={filters}
|
|
215
|
+
onChange={handleChange}
|
|
216
|
+
config={config}
|
|
217
|
+
getFilterOptions={createMockGetFilterOptions(options)}
|
|
218
|
+
/>
|
|
219
|
+
</PopoverContent>
|
|
220
|
+
</Popover>
|
|
221
|
+
|
|
222
|
+
<div className="text-xs bg-muted p-2 rounded">
|
|
223
|
+
<pre>{JSON.stringify(filters, null, 2)}</pre>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export const StringFilter: Story = {
|
|
230
|
+
args: {} as any,
|
|
231
|
+
render: () => <FilterWrapper field="stringField" config={stringFilterConfig} options={stringOptions} />,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export const NumberFilter: Story = {
|
|
235
|
+
args: {} as any,
|
|
236
|
+
render: () => <FilterWrapper field="numberField" config={numberFilterConfig} options={numberOptions} />,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const BooleanFilter: Story = {
|
|
240
|
+
args: {} as any,
|
|
241
|
+
render: () => <FilterWrapper field="booleanField" config={booleanFilterConfig} options={booleanOptions} />,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const DateFilter: Story = {
|
|
245
|
+
args: {} as any,
|
|
246
|
+
render: () => <FilterWrapper field="dateField" config={dateFilterConfig} options={dateOptions} />,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const HierarchyFilter: Story = {
|
|
250
|
+
args: {} as any,
|
|
251
|
+
render: () => <FilterWrapper field="hierarchyField" config={hierarchyFilterConfig} options={hierarchyOptions} />,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const WithOutMatchesOnCrossFiltering: Story = {
|
|
255
|
+
args: {} as any,
|
|
256
|
+
render: () => (
|
|
257
|
+
<FilterWrapper
|
|
258
|
+
field="stringField"
|
|
259
|
+
config={stringFilterConfig}
|
|
260
|
+
options={stringOptions
|
|
261
|
+
.map((opt) => (opt.value === 'banana' || opt.value === 'elderberry' ? { ...opt, hasMatches: false } : opt))
|
|
262
|
+
.sort((a, b) => (a.hasMatches && !b.hasMatches ? -1 : !a.hasMatches && b.hasMatches ? 1 : 0))} // Simulate "banana" and "elderberry" having no matches due to cross-filtering
|
|
263
|
+
/>
|
|
264
|
+
),
|
|
265
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FieldValueType,
|
|
5
5
|
FilterConfig,
|
|
6
6
|
FilterFieldName,
|
|
7
|
+
FilterOption,
|
|
7
8
|
FilterState,
|
|
8
9
|
FilterValue,
|
|
9
10
|
NumberFilter,
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
} from '@types'
|
|
12
13
|
|
|
13
14
|
import { CalendarIcon, FilterX, TrashIcon } from 'lucide-react'
|
|
14
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
15
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
15
16
|
|
|
16
17
|
import { cn } from '@lib/utils'
|
|
17
18
|
import {
|
|
@@ -23,6 +24,9 @@ import {
|
|
|
23
24
|
Popover,
|
|
24
25
|
PopoverContent,
|
|
25
26
|
PopoverTrigger,
|
|
27
|
+
SlicerHierarchyItem,
|
|
28
|
+
SlicerFilterOption,
|
|
29
|
+
testId,
|
|
26
30
|
} from '@postxl/ui-components'
|
|
27
31
|
|
|
28
32
|
export const TableFilter = <TField extends FilterFieldName, TFilters extends FilterState>({
|
|
@@ -32,6 +36,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
32
36
|
onChange,
|
|
33
37
|
config,
|
|
34
38
|
getFilterOptions,
|
|
39
|
+
__e2e_test_id__,
|
|
35
40
|
}: {
|
|
36
41
|
open?: boolean
|
|
37
42
|
field: TField
|
|
@@ -39,10 +44,14 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
39
44
|
onChange: (val: FilterValue) => void
|
|
40
45
|
config: FilterConfig<TField, TFilters>
|
|
41
46
|
getFilterOptions: (args: { field: TField; filters: TFilters }) => any
|
|
47
|
+
__e2e_test_id__?: string
|
|
42
48
|
}) => {
|
|
43
49
|
const fieldConfig = config[field]
|
|
44
50
|
const { filterKey, valueType: fieldType } = fieldConfig
|
|
45
51
|
|
|
52
|
+
// Check if this is a hierarchy filter
|
|
53
|
+
const isHierarchyFilter = fieldConfig.kind === 'hierarchy'
|
|
54
|
+
|
|
46
55
|
// We exclude the current field from the filters passed to the query.
|
|
47
56
|
// This prevents the options from refetching (and resetting scroll position) when the user selects an item within the same filter.
|
|
48
57
|
const queryFilters = useMemo(() => {
|
|
@@ -98,20 +107,40 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
98
107
|
enabled: open,
|
|
99
108
|
})
|
|
100
109
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
[optionsData],
|
|
109
|
-
)
|
|
110
|
+
// Options from backend - same type for both flat and hierarchy (hierarchy has children)
|
|
111
|
+
const options = useMemo<FilterOption[]>(() => {
|
|
112
|
+
if (!optionsData || !Array.isArray(optionsData)) {
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
return optionsData.filter((opt): opt is FilterOption => opt != null && 'value' in opt)
|
|
116
|
+
}, [optionsData])
|
|
110
117
|
|
|
111
118
|
const [searchQuery, setSearchQuery] = useState('')
|
|
112
119
|
|
|
120
|
+
// Expanded state for hierarchy filter
|
|
121
|
+
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
|
122
|
+
|
|
123
|
+
// Auto-expand single top-level item when hierarchy options load
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (isHierarchyFilter && options.length === 1 && options[0].children?.length) {
|
|
126
|
+
setExpandedIds(new Set([options[0].value]))
|
|
127
|
+
}
|
|
128
|
+
}, [isHierarchyFilter, options])
|
|
129
|
+
|
|
130
|
+
const toggleExpand = useCallback((id: string) => {
|
|
131
|
+
setExpandedIds((prev) => {
|
|
132
|
+
const next = new Set(prev)
|
|
133
|
+
if (next.has(id)) {
|
|
134
|
+
next.delete(id)
|
|
135
|
+
} else {
|
|
136
|
+
next.add(id)
|
|
137
|
+
}
|
|
138
|
+
return next
|
|
139
|
+
})
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
113
142
|
// Check if this field uses StringFilter (all string-type fields including ID, relation, enum, discriminatedUnion)
|
|
114
|
-
const isStringFilter = fieldType === 'string'
|
|
143
|
+
const isStringFilter = fieldType === 'string' && !isHierarchyFilter
|
|
115
144
|
|
|
116
145
|
const filteredOptions = useMemo(() => {
|
|
117
146
|
if (!searchQuery) {
|
|
@@ -136,13 +165,17 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
136
165
|
if (fieldType === 'boolean') {
|
|
137
166
|
return (val as boolean[]).map(String)
|
|
138
167
|
}
|
|
168
|
+
if (fieldType === 'hierarchy') {
|
|
169
|
+
// Hierarchy filters use StringFilter format
|
|
170
|
+
return ((val as StringFilter).values || []).map(String)
|
|
171
|
+
}
|
|
139
172
|
if (isStringFilter) {
|
|
140
173
|
return ((val as StringFilter).values || []).map(String)
|
|
141
174
|
}
|
|
142
175
|
return val as string[]
|
|
143
176
|
}, [fieldConfig.filterKey, fieldType, filters, isStringFilter])
|
|
144
177
|
|
|
145
|
-
const selectedValues = new Set(value)
|
|
178
|
+
const selectedValues = useMemo(() => new Set(value), [value])
|
|
146
179
|
const areAllSelected = options.length > 0 && options.every((option) => selectedValues.has(option.value))
|
|
147
180
|
|
|
148
181
|
const areAllFilteredSelected =
|
|
@@ -182,48 +215,58 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
182
215
|
}
|
|
183
216
|
}, [open, filters, fieldType, fieldConfig.filterKey, isStringFilter])
|
|
184
217
|
|
|
185
|
-
const handleNumberChange = (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
218
|
+
const handleNumberChange = useCallback(
|
|
219
|
+
(newValues: string[], newMin: string, newMax: string) => {
|
|
220
|
+
const values = newValues.length > 0 ? newValues.map((v) => (v === '' ? '' : Number(v))) : undefined
|
|
221
|
+
const min = newMin === '' ? null : Number(newMin)
|
|
222
|
+
const max = newMax === '' ? null : Number(newMax)
|
|
189
223
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const start = newStart === '' ? null : newStart
|
|
199
|
-
const end = newEnd === '' ? null : newEnd
|
|
200
|
-
|
|
201
|
-
if (!values && start === null && end === null) {
|
|
202
|
-
onChange(undefined)
|
|
203
|
-
} else {
|
|
204
|
-
onChange({ values, start, end })
|
|
205
|
-
}
|
|
206
|
-
}
|
|
224
|
+
if (!values && min === null && max === null) {
|
|
225
|
+
onChange(undefined)
|
|
226
|
+
} else {
|
|
227
|
+
onChange({ values, min, max })
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
[onChange],
|
|
231
|
+
)
|
|
207
232
|
|
|
208
|
-
const
|
|
209
|
-
newValues: string[],
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
233
|
+
const handleDateChange = useCallback(
|
|
234
|
+
(newValues: string[], newStart: string, newEnd: string) => {
|
|
235
|
+
const values = newValues.length > 0 ? newValues : undefined
|
|
236
|
+
const start = newStart === '' ? null : newStart
|
|
237
|
+
const end = newEnd === '' ? null : newEnd
|
|
238
|
+
|
|
239
|
+
if (!values && start === null && end === null) {
|
|
240
|
+
onChange(undefined)
|
|
241
|
+
} else {
|
|
242
|
+
onChange({ values, start, end })
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
[onChange],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const handleStringFilterChange = useCallback(
|
|
249
|
+
(newValues: string[], contains: string, startsWith: string, endsWith: string, exclude: string) => {
|
|
250
|
+
const values = newValues.length > 0 ? newValues : undefined
|
|
251
|
+
const containsVal = contains === '' ? null : contains
|
|
252
|
+
const startsWithVal = startsWith === '' ? null : startsWith
|
|
253
|
+
const endsWithVal = endsWith === '' ? null : endsWith
|
|
254
|
+
const excludeVal = exclude === '' ? null : exclude
|
|
255
|
+
|
|
256
|
+
if (!values && !containsVal && !startsWithVal && !endsWithVal && !excludeVal) {
|
|
257
|
+
onChange(undefined)
|
|
258
|
+
} else {
|
|
259
|
+
onChange({
|
|
260
|
+
values,
|
|
261
|
+
contains: containsVal,
|
|
262
|
+
startsWith: startsWithVal,
|
|
263
|
+
endsWith: endsWithVal,
|
|
264
|
+
exclude: excludeVal,
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
[onChange],
|
|
269
|
+
)
|
|
227
270
|
|
|
228
271
|
const commitMinMax = () => {
|
|
229
272
|
let nextMin = minVal
|
|
@@ -256,25 +299,59 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
256
299
|
}
|
|
257
300
|
|
|
258
301
|
// Unified callback for FilterItem to handle value changes
|
|
259
|
-
const handleValueChange = (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
302
|
+
const handleValueChange = useCallback(
|
|
303
|
+
(newSelected: Set<string>) => {
|
|
304
|
+
const newVals = Array.from(newSelected).map(String)
|
|
305
|
+
if (fieldType === 'number') {
|
|
306
|
+
handleNumberChange(newVals, minVal, maxVal)
|
|
307
|
+
} else if (fieldType === 'date') {
|
|
308
|
+
handleDateChange(newVals, minVal, maxVal)
|
|
309
|
+
} else if (fieldType === 'hierarchy') {
|
|
310
|
+
// Hierarchy uses StringFilter format but without text filters
|
|
311
|
+
onChange(newVals.length > 0 ? { values: newVals } : undefined)
|
|
312
|
+
} else if (isStringFilter) {
|
|
313
|
+
handleStringFilterChange(newVals, containsVal, startsWithVal, endsWithVal, excludeVal)
|
|
314
|
+
} else {
|
|
315
|
+
onChange(newVals)
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
[
|
|
319
|
+
fieldType,
|
|
320
|
+
isStringFilter,
|
|
321
|
+
minVal,
|
|
322
|
+
maxVal,
|
|
323
|
+
containsVal,
|
|
324
|
+
startsWithVal,
|
|
325
|
+
endsWithVal,
|
|
326
|
+
excludeVal,
|
|
327
|
+
handleNumberChange,
|
|
328
|
+
handleDateChange,
|
|
329
|
+
handleStringFilterChange,
|
|
330
|
+
onChange,
|
|
331
|
+
],
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
// Handle hierarchy option toggle
|
|
335
|
+
const handleHierarchySelect = useCallback(
|
|
336
|
+
(value: string) => {
|
|
337
|
+
const newSelected = new Set(selectedValues)
|
|
338
|
+
if (newSelected.has(value)) {
|
|
339
|
+
newSelected.delete(value)
|
|
340
|
+
} else {
|
|
341
|
+
newSelected.add(value)
|
|
342
|
+
}
|
|
343
|
+
handleValueChange(newSelected)
|
|
344
|
+
},
|
|
345
|
+
[selectedValues, handleValueChange],
|
|
346
|
+
)
|
|
271
347
|
|
|
272
348
|
return (
|
|
273
|
-
<div className="w-full flex flex-col">
|
|
349
|
+
<div className="w-full flex flex-col" data-test-id={__e2e_test_id__}>
|
|
274
350
|
{/* Clear filter button */}
|
|
275
351
|
<Button
|
|
276
352
|
variant="ghost"
|
|
277
353
|
size="xs"
|
|
354
|
+
__e2e_test_id__={testId(__e2e_test_id__, 'clear')}
|
|
278
355
|
disabled={
|
|
279
356
|
selectedValues.size === 0 &&
|
|
280
357
|
minVal === '' &&
|
|
@@ -293,6 +370,8 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
293
370
|
} else if (fieldType === 'date') {
|
|
294
371
|
handleDateChange([], '', '')
|
|
295
372
|
}
|
|
373
|
+
} else if (fieldType === 'hierarchy') {
|
|
374
|
+
onChange(undefined)
|
|
296
375
|
} else if (isStringFilter) {
|
|
297
376
|
setContainsVal('')
|
|
298
377
|
setStartsWithVal('')
|
|
@@ -318,6 +397,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
318
397
|
label="At least"
|
|
319
398
|
placeholder="min"
|
|
320
399
|
onCommit={commitMinMax}
|
|
400
|
+
data-test-id={testId(__e2e_test_id__, 'min')}
|
|
321
401
|
/>
|
|
322
402
|
<NumberMinMaxInput
|
|
323
403
|
value={maxVal}
|
|
@@ -325,6 +405,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
325
405
|
label="At most"
|
|
326
406
|
placeholder="max"
|
|
327
407
|
onCommit={commitMinMax}
|
|
408
|
+
data-test-id={testId(__e2e_test_id__, 'max')}
|
|
328
409
|
/>
|
|
329
410
|
</>
|
|
330
411
|
)}
|
|
@@ -341,6 +422,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
341
422
|
handleDateChange(Array.from(selectedValues).map(String), '', maxVal)
|
|
342
423
|
}}
|
|
343
424
|
onCommit={commitMinMax}
|
|
425
|
+
data-test-id={testId(__e2e_test_id__, 'from')}
|
|
344
426
|
/>
|
|
345
427
|
<DateMinMaxInput
|
|
346
428
|
value={maxVal}
|
|
@@ -354,6 +436,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
354
436
|
setTime={(date: Date) => {
|
|
355
437
|
date.setHours(23, 59, 59, 999)
|
|
356
438
|
}}
|
|
439
|
+
data-test-id={testId(__e2e_test_id__, 'to')}
|
|
357
440
|
/>
|
|
358
441
|
</>
|
|
359
442
|
)}
|
|
@@ -367,6 +450,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
367
450
|
label="Contains"
|
|
368
451
|
placeholder="text"
|
|
369
452
|
onCommit={commitStringFilter}
|
|
453
|
+
data-test-id={testId(__e2e_test_id__, 'contains')}
|
|
370
454
|
/>
|
|
371
455
|
<StringFilterInput
|
|
372
456
|
value={startsWithVal}
|
|
@@ -374,6 +458,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
374
458
|
label="Starts with"
|
|
375
459
|
placeholder="prefix"
|
|
376
460
|
onCommit={commitStringFilter}
|
|
461
|
+
data-test-id={testId(__e2e_test_id__, 'starts-with')}
|
|
377
462
|
/>
|
|
378
463
|
<StringFilterInput
|
|
379
464
|
value={endsWithVal}
|
|
@@ -381,6 +466,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
381
466
|
label="Ends with"
|
|
382
467
|
placeholder="suffix"
|
|
383
468
|
onCommit={commitStringFilter}
|
|
469
|
+
data-test-id={testId(__e2e_test_id__, 'ends-with')}
|
|
384
470
|
/>
|
|
385
471
|
<StringFilterInput
|
|
386
472
|
value={excludeVal}
|
|
@@ -388,6 +474,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
388
474
|
label="Exclude"
|
|
389
475
|
placeholder="text"
|
|
390
476
|
onCommit={commitStringFilter}
|
|
477
|
+
data-test-id={testId(__e2e_test_id__, 'exclude')}
|
|
391
478
|
/>
|
|
392
479
|
</>
|
|
393
480
|
)}
|
|
@@ -401,6 +488,7 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
401
488
|
placeholder="Search..."
|
|
402
489
|
value={searchQuery}
|
|
403
490
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
491
|
+
data-test-id={testId(__e2e_test_id__, 'search')}
|
|
404
492
|
/>
|
|
405
493
|
</div>
|
|
406
494
|
|
|
@@ -408,67 +496,92 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
408
496
|
<div className="w-full border-b border-b-border/70" />
|
|
409
497
|
</div>
|
|
410
498
|
|
|
411
|
-
{/* Select all / all search results button
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
499
|
+
{/* Select all / all search results button - hidden for hierarchy mode because "select all"
|
|
500
|
+
is ambiguous: would it select all items at every level, only top-level items (implicitly
|
|
501
|
+
selecting everything underneath), only visible/expanded items, or only leaf nodes? Each
|
|
502
|
+
interpretation has different filtering effects, so we omit it to avoid confusion. */}
|
|
503
|
+
{!isHierarchyFilter && (
|
|
504
|
+
<Button
|
|
505
|
+
variant="ghost"
|
|
506
|
+
size="xs"
|
|
507
|
+
className="w-full justify-start px-2 py-1 rounded-sm text-sm font-normal"
|
|
508
|
+
__e2e_test_id__={testId(__e2e_test_id__, 'select-all')}
|
|
509
|
+
onClick={() => {
|
|
510
|
+
let newVals: string[] = []
|
|
511
|
+
if (searchQuery.length > 0) {
|
|
512
|
+
const newSelected = new Set(selectedValues)
|
|
513
|
+
if (areAllFilteredSelected) {
|
|
514
|
+
filteredOptions.forEach((o) => newSelected.delete(o.value))
|
|
515
|
+
} else {
|
|
516
|
+
filteredOptions.forEach((o) => newSelected.add(o.value))
|
|
517
|
+
}
|
|
518
|
+
newVals = Array.from(newSelected)
|
|
519
|
+
} else if (areAllSelected) {
|
|
520
|
+
newVals = []
|
|
422
521
|
} else {
|
|
423
|
-
|
|
522
|
+
newVals = options.map((o) => o.value)
|
|
424
523
|
}
|
|
425
|
-
newVals = Array.from(newSelected)
|
|
426
|
-
} else if (areAllSelected) {
|
|
427
|
-
newVals = []
|
|
428
|
-
} else {
|
|
429
|
-
newVals = options.map((o) => o.value)
|
|
430
|
-
}
|
|
431
524
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
525
|
+
if (fieldType === 'number') {
|
|
526
|
+
handleNumberChange(newVals, minVal, maxVal)
|
|
527
|
+
} else if (fieldType === 'date') {
|
|
528
|
+
handleDateChange(newVals, minVal, maxVal)
|
|
529
|
+
} else if (isStringFilter) {
|
|
530
|
+
handleStringFilterChange(newVals, containsVal, startsWithVal, endsWithVal, excludeVal)
|
|
531
|
+
} else {
|
|
532
|
+
onChange(newVals)
|
|
533
|
+
}
|
|
534
|
+
}}
|
|
535
|
+
>
|
|
536
|
+
{searchQuery.length > 0 ? (
|
|
537
|
+
<Checkbox
|
|
538
|
+
readOnly
|
|
539
|
+
checked={isAnyFilteredSelected}
|
|
540
|
+
disabled={filteredOptions.length === 0}
|
|
541
|
+
label="Select Search Results"
|
|
542
|
+
className="pointer-events-none"
|
|
543
|
+
checkboxSize="sm"
|
|
544
|
+
variant={areAllFilteredSelected ? 'simple' : 'default'}
|
|
545
|
+
iconStyle={areAllFilteredSelected ? 'simple' : 'solo'}
|
|
546
|
+
checkIcon={areAllFilteredSelected ? 'check' : 'square'}
|
|
547
|
+
/>
|
|
548
|
+
) : (
|
|
549
|
+
<Checkbox
|
|
550
|
+
readOnly
|
|
551
|
+
checked={selectedValues.size > 0}
|
|
552
|
+
disabled={options.length === 0}
|
|
553
|
+
label="Select All"
|
|
554
|
+
className="pointer-events-none"
|
|
555
|
+
checkboxSize="sm"
|
|
556
|
+
variant={areAllSelected ? 'simple' : 'default'}
|
|
557
|
+
iconStyle={areAllSelected ? 'simple' : 'solo'}
|
|
558
|
+
checkIcon={areAllSelected ? 'check' : 'square'}
|
|
559
|
+
/>
|
|
560
|
+
)}
|
|
561
|
+
</Button>
|
|
562
|
+
)}
|
|
469
563
|
|
|
470
|
-
{/* Options list */}
|
|
471
|
-
{
|
|
564
|
+
{/* Options list - hierarchy tree view or flat list */}
|
|
565
|
+
{isHierarchyFilter ? (
|
|
566
|
+
options.length === 0 ? (
|
|
567
|
+
<div className="py-6 text-center text-sm">No options available.</div>
|
|
568
|
+
) : (
|
|
569
|
+
<div className="px-1 py-1 max-h-[250px] overflow-auto">
|
|
570
|
+
{options.map((option) => (
|
|
571
|
+
<SlicerHierarchyItem
|
|
572
|
+
key={option.value}
|
|
573
|
+
option={option as SlicerFilterOption<string>}
|
|
574
|
+
selectedValues={selectedValues}
|
|
575
|
+
inheritedSelected={false}
|
|
576
|
+
expandedIds={expandedIds}
|
|
577
|
+
onToggleExpand={toggleExpand}
|
|
578
|
+
onSelect={handleHierarchySelect}
|
|
579
|
+
searchTerm={searchQuery}
|
|
580
|
+
/>
|
|
581
|
+
))}
|
|
582
|
+
</div>
|
|
583
|
+
)
|
|
584
|
+
) : filteredOptions.length === 0 ? (
|
|
472
585
|
<div className="py-6 text-center text-sm">No results found.</div>
|
|
473
586
|
) : (
|
|
474
587
|
<div className="px-2 py-1 max-h-[200px] overflow-auto">
|
|
@@ -494,12 +607,14 @@ const NumberMinMaxInput = ({
|
|
|
494
607
|
label,
|
|
495
608
|
placeholder,
|
|
496
609
|
onCommit,
|
|
610
|
+
'data-test-id': dataTestId,
|
|
497
611
|
}: {
|
|
498
612
|
value: string
|
|
499
613
|
setValue: (val: string) => void
|
|
500
614
|
label: string
|
|
501
615
|
placeholder: string
|
|
502
616
|
onCommit: () => void
|
|
617
|
+
'data-test-id'?: string
|
|
503
618
|
}) => {
|
|
504
619
|
return (
|
|
505
620
|
<div className="flex items-center gap-2 px-2 pt-0.5">
|
|
@@ -514,6 +629,7 @@ const NumberMinMaxInput = ({
|
|
|
514
629
|
onChange={(e) => setValue(e === undefined ? '' : String(e))}
|
|
515
630
|
onBlur={onCommit}
|
|
516
631
|
onEnter={onCommit}
|
|
632
|
+
data-test-id={dataTestId}
|
|
517
633
|
/>
|
|
518
634
|
</div>
|
|
519
635
|
)
|
|
@@ -538,6 +654,7 @@ const DateMinMaxInput = ({
|
|
|
538
654
|
onCommit,
|
|
539
655
|
onDelete,
|
|
540
656
|
setTime, // to use last ms of the day for 'To' field
|
|
657
|
+
'data-test-id': dataTestId,
|
|
541
658
|
}: {
|
|
542
659
|
value: string
|
|
543
660
|
setValue: (val: string) => void
|
|
@@ -545,6 +662,7 @@ const DateMinMaxInput = ({
|
|
|
545
662
|
onCommit: () => void
|
|
546
663
|
onDelete: () => void
|
|
547
664
|
setTime?: (date: Date) => void
|
|
665
|
+
'data-test-id'?: string
|
|
548
666
|
}) => {
|
|
549
667
|
return (
|
|
550
668
|
<div className="flex items-center gap-2 px-2 pt-0.5">
|
|
@@ -565,6 +683,7 @@ const DateMinMaxInput = ({
|
|
|
565
683
|
}}
|
|
566
684
|
onBlur={onCommit}
|
|
567
685
|
onEnter={onCommit}
|
|
686
|
+
data-test-id={dataTestId}
|
|
568
687
|
/>
|
|
569
688
|
<Popover
|
|
570
689
|
onOpenChange={(open) => {
|
|
@@ -622,12 +741,14 @@ const StringFilterInput = ({
|
|
|
622
741
|
label,
|
|
623
742
|
placeholder,
|
|
624
743
|
onCommit,
|
|
744
|
+
'data-test-id': dataTestId,
|
|
625
745
|
}: {
|
|
626
746
|
value: string
|
|
627
747
|
setValue: (val: string) => void
|
|
628
748
|
label: string
|
|
629
749
|
placeholder: string
|
|
630
750
|
onCommit: () => void
|
|
751
|
+
'data-test-id'?: string
|
|
631
752
|
}) => {
|
|
632
753
|
return (
|
|
633
754
|
<div className="flex items-center gap-2 px-2 pt-0.5">
|
|
@@ -641,6 +762,7 @@ const StringFilterInput = ({
|
|
|
641
762
|
onChange={(e) => setValue(e.target.value)}
|
|
642
763
|
onBlur={onCommit}
|
|
643
764
|
onEnter={onCommit}
|
|
765
|
+
data-test-id={dataTestId}
|
|
644
766
|
/>
|
|
645
767
|
</div>
|
|
646
768
|
)
|
|
@@ -653,13 +775,14 @@ const FilterItem = ({
|
|
|
653
775
|
selectedValues,
|
|
654
776
|
onValueChange,
|
|
655
777
|
}: {
|
|
656
|
-
option:
|
|
778
|
+
option: FilterOption
|
|
657
779
|
isSelected: boolean
|
|
658
780
|
fieldType: FieldValueType
|
|
659
781
|
selectedValues: Set<string>
|
|
660
782
|
onValueChange: (newSelected: Set<string>) => void
|
|
661
783
|
}) => {
|
|
662
784
|
let displayLabel = option.label
|
|
785
|
+
const hasMatches = option.hasMatches ?? true
|
|
663
786
|
|
|
664
787
|
// needs to happen in frontend (and can't be directly provided by backend) because of localization
|
|
665
788
|
if (option.value !== '') {
|
|
@@ -685,11 +808,7 @@ const FilterItem = ({
|
|
|
685
808
|
}}
|
|
686
809
|
checkIcon="check"
|
|
687
810
|
checkboxSize="sm"
|
|
688
|
-
className={cn(
|
|
689
|
-
'whitespace-nowrap py-px text-sm',
|
|
690
|
-
!option.hasMatches && 'opacity-50',
|
|
691
|
-
option.value === '' && 'italic',
|
|
692
|
-
)}
|
|
811
|
+
className={cn('whitespace-nowrap py-px text-sm', !hasMatches && 'opacity-50', option.value === '' && 'italic')}
|
|
693
812
|
label={displayLabel}
|
|
694
813
|
/>
|
|
695
814
|
)
|
|
@@ -42,7 +42,20 @@ export const zStringFilter = z.object({
|
|
|
42
42
|
exclude: z.string().nullable().optional(),
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
-
export type FieldValueType = 'string' | 'number' | 'date' | 'boolean'
|
|
45
|
+
export type FieldValueType = 'string' | 'number' | 'date' | 'boolean' | 'hierarchy'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Filter option type - supports both flat and hierarchical structures.
|
|
49
|
+
* For hierarchy mode, items can have nested children forming a tree structure.
|
|
50
|
+
*/
|
|
51
|
+
export type FilterOption = {
|
|
52
|
+
value: string
|
|
53
|
+
label: string
|
|
54
|
+
/** Whether this option has matches in current cross-filter state. Defaults to true. */
|
|
55
|
+
hasMatches?: boolean
|
|
56
|
+
/** Child options for hierarchy mode. */
|
|
57
|
+
children?: FilterOption[]
|
|
58
|
+
}
|
|
46
59
|
|
|
47
60
|
export type FilterValue = string[] | boolean[] | NumberFilter | DateFilter | StringFilter | undefined
|
|
48
61
|
|
|
@@ -90,11 +103,16 @@ type ScalarFilterConfig<TFilters extends FilterState> = BaseFilterConfig<TFilter
|
|
|
90
103
|
kind: 'scalar' | 'id'
|
|
91
104
|
}
|
|
92
105
|
|
|
106
|
+
export type HierarchyFilterConfig<TFilters extends FilterState> = BaseFilterConfig<TFilters> & {
|
|
107
|
+
kind: 'hierarchy'
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
export type FilterConfigItem<TFilters extends FilterState> =
|
|
94
111
|
| RelationFilterConfig<TFilters>
|
|
95
112
|
| EnumFilterConfig<TFilters>
|
|
96
113
|
| DiscriminatedUnionFilterConfig<TFilters>
|
|
97
114
|
| ScalarFilterConfig<TFilters>
|
|
115
|
+
| HierarchyFilterConfig<TFilters>
|
|
98
116
|
|
|
99
117
|
export type FilterConfig<TField extends FilterFieldName, TFilters extends FilterState> = Record<
|
|
100
118
|
TField,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postxl/generators",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.7",
|
|
4
4
|
"description": "Code generators for PXL - generates backend, frontend, Prisma schemas, and more",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"exceljs": "^4.4.0",
|
|
47
47
|
"@postxl/generator": "^1.3.3",
|
|
48
48
|
"@postxl/schema": "^1.3.1",
|
|
49
|
-
"@postxl/ui-components": "^1.3.
|
|
49
|
+
"@postxl/ui-components": "^1.3.7",
|
|
50
50
|
"@postxl/utils": "^1.3.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {},
|