@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.
@@ -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
- const options = useMemo(
102
- () =>
103
- optionsData && Array.isArray(optionsData)
104
- ? optionsData.filter(
105
- (opt): opt is { value: string; label: string; hasMatches: boolean } => opt != null && 'value' in opt,
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 = (newValues: string[], newMin: string, newMax: string) => {
186
- const values = newValues.length > 0 ? newValues.map((v) => (v === '' ? '' : Number(v))) : undefined
187
- const min = newMin === '' ? null : Number(newMin)
188
- const max = newMax === '' ? null : Number(newMax)
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
- if (!values && min === null && max === null) {
191
- onChange(undefined)
192
- } else {
193
- onChange({ values, min, max })
194
- }
195
- }
196
- const handleDateChange = (newValues: string[], newStart: string, newEnd: string) => {
197
- const values = newValues.length > 0 ? newValues : undefined
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 handleStringFilterChange = (
209
- newValues: string[],
210
- contains: string,
211
- startsWith: string,
212
- endsWith: string,
213
- exclude: string,
214
- ) => {
215
- const values = newValues.length > 0 ? newValues : undefined
216
- const containsVal = contains === '' ? null : contains
217
- const startsWithVal = startsWith === '' ? null : startsWith
218
- const endsWithVal = endsWith === '' ? null : endsWith
219
- const excludeVal = exclude === '' ? null : exclude
220
-
221
- if (!values && !containsVal && !startsWithVal && !endsWithVal && !excludeVal) {
222
- onChange(undefined)
223
- } else {
224
- onChange({ values, contains: containsVal, startsWith: startsWithVal, endsWith: endsWithVal, exclude: excludeVal })
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 = (newSelected: Set<string>) => {
260
- const newVals = Array.from(newSelected).map(String)
261
- if (fieldType === 'number') {
262
- handleNumberChange(newVals, minVal, maxVal)
263
- } else if (fieldType === 'date') {
264
- handleDateChange(newVals, minVal, maxVal)
265
- } else if (isStringFilter) {
266
- handleStringFilterChange(newVals, containsVal, startsWithVal, endsWithVal, excludeVal)
267
- } else {
268
- onChange(newVals)
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
- <Button
413
- variant="ghost"
414
- size="xs"
415
- className="w-full justify-start px-2 py-1 rounded-sm text-sm font-normal"
416
- onClick={() => {
417
- let newVals: string[] = []
418
- if (searchQuery.length > 0) {
419
- const newSelected = new Set(selectedValues)
420
- if (areAllFilteredSelected) {
421
- filteredOptions.forEach((o) => newSelected.delete(o.value))
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
- filteredOptions.forEach((o) => newSelected.add(o.value))
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
- if (fieldType === 'number') {
433
- handleNumberChange(newVals, minVal, maxVal)
434
- } else if (fieldType === 'date') {
435
- handleDateChange(newVals, minVal, maxVal)
436
- } else if (isStringFilter) {
437
- handleStringFilterChange(newVals, containsVal, startsWithVal, endsWithVal, excludeVal)
438
- } else {
439
- onChange(newVals)
440
- }
441
- }}
442
- >
443
- {searchQuery.length > 0 ? (
444
- <Checkbox
445
- readOnly
446
- checked={isAnyFilteredSelected}
447
- disabled={filteredOptions.length === 0}
448
- label="Select Search Results"
449
- className="pointer-events-none"
450
- checkboxSize="sm"
451
- variant={areAllFilteredSelected ? 'simple' : 'default'}
452
- iconStyle={areAllFilteredSelected ? 'simple' : 'solo'}
453
- checkIcon={areAllFilteredSelected ? 'check' : 'square'}
454
- />
455
- ) : (
456
- <Checkbox
457
- readOnly
458
- checked={selectedValues.size > 0}
459
- disabled={options.length === 0}
460
- label="Select All"
461
- className="pointer-events-none"
462
- checkboxSize="sm"
463
- variant={areAllSelected ? 'simple' : 'default'}
464
- iconStyle={areAllSelected ? 'simple' : 'solo'}
465
- checkIcon={areAllSelected ? 'check' : 'square'}
466
- />
467
- )}
468
- </Button>
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
- {filteredOptions.length === 0 ? (
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: { label: string; value: string; hasMatches: boolean }
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.5",
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.5",
49
+ "@postxl/ui-components": "^1.3.7",
50
50
  "@postxl/utils": "^1.3.1"
51
51
  },
52
52
  "devDependencies": {},