@postxl/generators 1.11.6 → 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/table-filter.stories.tsx +265 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.tsx +224 -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
|
*/
|
|
@@ -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,8 @@ import {
|
|
|
23
24
|
Popover,
|
|
24
25
|
PopoverContent,
|
|
25
26
|
PopoverTrigger,
|
|
27
|
+
SlicerHierarchyItem,
|
|
28
|
+
SlicerFilterOption,
|
|
26
29
|
testId,
|
|
27
30
|
} from '@postxl/ui-components'
|
|
28
31
|
|
|
@@ -46,6 +49,9 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
46
49
|
const fieldConfig = config[field]
|
|
47
50
|
const { filterKey, valueType: fieldType } = fieldConfig
|
|
48
51
|
|
|
52
|
+
// Check if this is a hierarchy filter
|
|
53
|
+
const isHierarchyFilter = fieldConfig.kind === 'hierarchy'
|
|
54
|
+
|
|
49
55
|
// We exclude the current field from the filters passed to the query.
|
|
50
56
|
// This prevents the options from refetching (and resetting scroll position) when the user selects an item within the same filter.
|
|
51
57
|
const queryFilters = useMemo(() => {
|
|
@@ -101,20 +107,40 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
101
107
|
enabled: open,
|
|
102
108
|
})
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
[optionsData],
|
|
112
|
-
)
|
|
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])
|
|
113
117
|
|
|
114
118
|
const [searchQuery, setSearchQuery] = useState('')
|
|
115
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
|
+
|
|
116
142
|
// Check if this field uses StringFilter (all string-type fields including ID, relation, enum, discriminatedUnion)
|
|
117
|
-
const isStringFilter = fieldType === 'string'
|
|
143
|
+
const isStringFilter = fieldType === 'string' && !isHierarchyFilter
|
|
118
144
|
|
|
119
145
|
const filteredOptions = useMemo(() => {
|
|
120
146
|
if (!searchQuery) {
|
|
@@ -139,13 +165,17 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
139
165
|
if (fieldType === 'boolean') {
|
|
140
166
|
return (val as boolean[]).map(String)
|
|
141
167
|
}
|
|
168
|
+
if (fieldType === 'hierarchy') {
|
|
169
|
+
// Hierarchy filters use StringFilter format
|
|
170
|
+
return ((val as StringFilter).values || []).map(String)
|
|
171
|
+
}
|
|
142
172
|
if (isStringFilter) {
|
|
143
173
|
return ((val as StringFilter).values || []).map(String)
|
|
144
174
|
}
|
|
145
175
|
return val as string[]
|
|
146
176
|
}, [fieldConfig.filterKey, fieldType, filters, isStringFilter])
|
|
147
177
|
|
|
148
|
-
const selectedValues = new Set(value)
|
|
178
|
+
const selectedValues = useMemo(() => new Set(value), [value])
|
|
149
179
|
const areAllSelected = options.length > 0 && options.every((option) => selectedValues.has(option.value))
|
|
150
180
|
|
|
151
181
|
const areAllFilteredSelected =
|
|
@@ -185,48 +215,58 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
185
215
|
}
|
|
186
216
|
}, [open, filters, fieldType, fieldConfig.filterKey, isStringFilter])
|
|
187
217
|
|
|
188
|
-
const handleNumberChange = (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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)
|
|
192
223
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const start = newStart === '' ? null : newStart
|
|
202
|
-
const end = newEnd === '' ? null : newEnd
|
|
203
|
-
|
|
204
|
-
if (!values && start === null && end === null) {
|
|
205
|
-
onChange(undefined)
|
|
206
|
-
} else {
|
|
207
|
-
onChange({ values, start, end })
|
|
208
|
-
}
|
|
209
|
-
}
|
|
224
|
+
if (!values && min === null && max === null) {
|
|
225
|
+
onChange(undefined)
|
|
226
|
+
} else {
|
|
227
|
+
onChange({ values, min, max })
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
[onChange],
|
|
231
|
+
)
|
|
210
232
|
|
|
211
|
-
const
|
|
212
|
-
newValues: string[],
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
)
|
|
230
270
|
|
|
231
271
|
const commitMinMax = () => {
|
|
232
272
|
let nextMin = minVal
|
|
@@ -259,18 +299,51 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
259
299
|
}
|
|
260
300
|
|
|
261
301
|
// Unified callback for FilterItem to handle value changes
|
|
262
|
-
const handleValueChange = (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
+
)
|
|
274
347
|
|
|
275
348
|
return (
|
|
276
349
|
<div className="w-full flex flex-col" data-test-id={__e2e_test_id__}>
|
|
@@ -297,6 +370,8 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
297
370
|
} else if (fieldType === 'date') {
|
|
298
371
|
handleDateChange([], '', '')
|
|
299
372
|
}
|
|
373
|
+
} else if (fieldType === 'hierarchy') {
|
|
374
|
+
onChange(undefined)
|
|
300
375
|
} else if (isStringFilter) {
|
|
301
376
|
setContainsVal('')
|
|
302
377
|
setStartsWithVal('')
|
|
@@ -421,68 +496,92 @@ export const TableFilter = <TField extends FilterFieldName, TFilters extends Fil
|
|
|
421
496
|
<div className="w-full border-b border-b-border/70" />
|
|
422
497
|
</div>
|
|
423
498
|
|
|
424
|
-
{/* Select all / all search results button
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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 = []
|
|
436
521
|
} else {
|
|
437
|
-
|
|
522
|
+
newVals = options.map((o) => o.value)
|
|
438
523
|
}
|
|
439
|
-
newVals = Array.from(newSelected)
|
|
440
|
-
} else if (areAllSelected) {
|
|
441
|
-
newVals = []
|
|
442
|
-
} else {
|
|
443
|
-
newVals = options.map((o) => o.value)
|
|
444
|
-
}
|
|
445
524
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
)}
|
|
483
563
|
|
|
484
|
-
{/* Options list */}
|
|
485
|
-
{
|
|
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 ? (
|
|
486
585
|
<div className="py-6 text-center text-sm">No results found.</div>
|
|
487
586
|
) : (
|
|
488
587
|
<div className="px-2 py-1 max-h-[200px] overflow-auto">
|
|
@@ -676,13 +775,14 @@ const FilterItem = ({
|
|
|
676
775
|
selectedValues,
|
|
677
776
|
onValueChange,
|
|
678
777
|
}: {
|
|
679
|
-
option:
|
|
778
|
+
option: FilterOption
|
|
680
779
|
isSelected: boolean
|
|
681
780
|
fieldType: FieldValueType
|
|
682
781
|
selectedValues: Set<string>
|
|
683
782
|
onValueChange: (newSelected: Set<string>) => void
|
|
684
783
|
}) => {
|
|
685
784
|
let displayLabel = option.label
|
|
785
|
+
const hasMatches = option.hasMatches ?? true
|
|
686
786
|
|
|
687
787
|
// needs to happen in frontend (and can't be directly provided by backend) because of localization
|
|
688
788
|
if (option.value !== '') {
|
|
@@ -708,11 +808,7 @@ const FilterItem = ({
|
|
|
708
808
|
}}
|
|
709
809
|
checkIcon="check"
|
|
710
810
|
checkboxSize="sm"
|
|
711
|
-
className={cn(
|
|
712
|
-
'whitespace-nowrap py-px text-sm',
|
|
713
|
-
!option.hasMatches && 'opacity-50',
|
|
714
|
-
option.value === '' && 'italic',
|
|
715
|
-
)}
|
|
811
|
+
className={cn('whitespace-nowrap py-px text-sm', !hasMatches && 'opacity-50', option.value === '' && 'italic')}
|
|
716
812
|
label={displayLabel}
|
|
717
813
|
/>
|
|
718
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": {},
|