@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.
@@ -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
- const options = useMemo(
105
- () =>
106
- optionsData && Array.isArray(optionsData)
107
- ? optionsData.filter(
108
- (opt): opt is { value: string; label: string; hasMatches: boolean } => opt != null && 'value' in opt,
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 = (newValues: string[], newMin: string, newMax: string) => {
189
- const values = newValues.length > 0 ? newValues.map((v) => (v === '' ? '' : Number(v))) : undefined
190
- const min = newMin === '' ? null : Number(newMin)
191
- 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)
192
223
 
193
- if (!values && min === null && max === null) {
194
- onChange(undefined)
195
- } else {
196
- onChange({ values, min, max })
197
- }
198
- }
199
- const handleDateChange = (newValues: string[], newStart: string, newEnd: string) => {
200
- const values = newValues.length > 0 ? newValues : undefined
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 handleStringFilterChange = (
212
- newValues: string[],
213
- contains: string,
214
- startsWith: string,
215
- endsWith: string,
216
- exclude: string,
217
- ) => {
218
- const values = newValues.length > 0 ? newValues : undefined
219
- const containsVal = contains === '' ? null : contains
220
- const startsWithVal = startsWith === '' ? null : startsWith
221
- const endsWithVal = endsWith === '' ? null : endsWith
222
- const excludeVal = exclude === '' ? null : exclude
223
-
224
- if (!values && !containsVal && !startsWithVal && !endsWithVal && !excludeVal) {
225
- onChange(undefined)
226
- } else {
227
- onChange({ values, contains: containsVal, startsWith: startsWithVal, endsWith: endsWithVal, exclude: excludeVal })
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 = (newSelected: Set<string>) => {
263
- const newVals = Array.from(newSelected).map(String)
264
- if (fieldType === 'number') {
265
- handleNumberChange(newVals, minVal, maxVal)
266
- } else if (fieldType === 'date') {
267
- handleDateChange(newVals, minVal, maxVal)
268
- } else if (isStringFilter) {
269
- handleStringFilterChange(newVals, containsVal, startsWithVal, endsWithVal, excludeVal)
270
- } else {
271
- onChange(newVals)
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
- <Button
426
- variant="ghost"
427
- size="xs"
428
- className="w-full justify-start px-2 py-1 rounded-sm text-sm font-normal"
429
- __e2e_test_id__={testId(__e2e_test_id__, 'select-all')}
430
- onClick={() => {
431
- let newVals: string[] = []
432
- if (searchQuery.length > 0) {
433
- const newSelected = new Set(selectedValues)
434
- if (areAllFilteredSelected) {
435
- 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 = []
436
521
  } else {
437
- filteredOptions.forEach((o) => newSelected.add(o.value))
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
- if (fieldType === 'number') {
447
- handleNumberChange(newVals, minVal, maxVal)
448
- } else if (fieldType === 'date') {
449
- handleDateChange(newVals, minVal, maxVal)
450
- } else if (isStringFilter) {
451
- handleStringFilterChange(newVals, containsVal, startsWithVal, endsWithVal, excludeVal)
452
- } else {
453
- onChange(newVals)
454
- }
455
- }}
456
- >
457
- {searchQuery.length > 0 ? (
458
- <Checkbox
459
- readOnly
460
- checked={isAnyFilteredSelected}
461
- disabled={filteredOptions.length === 0}
462
- label="Select Search Results"
463
- className="pointer-events-none"
464
- checkboxSize="sm"
465
- variant={areAllFilteredSelected ? 'simple' : 'default'}
466
- iconStyle={areAllFilteredSelected ? 'simple' : 'solo'}
467
- checkIcon={areAllFilteredSelected ? 'check' : 'square'}
468
- />
469
- ) : (
470
- <Checkbox
471
- readOnly
472
- checked={selectedValues.size > 0}
473
- disabled={options.length === 0}
474
- label="Select All"
475
- className="pointer-events-none"
476
- checkboxSize="sm"
477
- variant={areAllSelected ? 'simple' : 'default'}
478
- iconStyle={areAllSelected ? 'simple' : 'solo'}
479
- checkIcon={areAllSelected ? 'check' : 'square'}
480
- />
481
- )}
482
- </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
+ )}
483
563
 
484
- {/* Options list */}
485
- {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 ? (
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: { label: string; value: string; hasMatches: boolean }
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.6",
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.6",
49
+ "@postxl/ui-components": "^1.3.7",
50
50
  "@postxl/utils": "^1.3.1"
51
51
  },
52
52
  "devDependencies": {},