@postxl/generators 1.0.13 → 1.1.0
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-router-trpc/generators/model-routes.generator.js +24 -3
- package/dist/backend-router-trpc/generators/model-routes.generator.js.map +1 -1
- package/dist/backend-router-trpc/router-trpc.generator.d.ts +2 -0
- package/dist/backend-router-trpc/router-trpc.generator.js +2 -0
- package/dist/backend-router-trpc/router-trpc.generator.js.map +1 -1
- package/dist/backend-view/model-view-service.generator.js +276 -0
- package/dist/backend-view/model-view-service.generator.js.map +1 -1
- package/dist/backend-view/template/filter.utils.test.ts +387 -0
- package/dist/backend-view/template/filter.utils.ts +218 -0
- package/dist/backend-view/view.generator.js +5 -1
- package/dist/backend-view/view.generator.js.map +1 -1
- package/dist/frontend-admin/generators/model-admin-page.generator.js +38 -8
- package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
- package/dist/frontend-core/frontend.generator.js +1 -0
- package/dist/frontend-core/frontend.generator.js.map +1 -1
- package/dist/frontend-core/template/src/components/admin/table-filter-header-icon.tsx +30 -0
- package/dist/frontend-core/template/src/components/admin/table-filter.tsx +537 -0
- package/dist/frontend-core/template/src/components/ui/checkbox/checkbox.stories.tsx +2 -1
- package/dist/frontend-core/template/src/components/ui/checkbox/checkbox.tsx +10 -1
- package/dist/frontend-core/template/src/components/ui/data-grid/cell-variants/gantt-cell.tsx +5 -3
- package/dist/frontend-core/template/src/components/ui/data-grid/data-grid-column-header.tsx +32 -2
- package/dist/frontend-core/template/src/components/ui/data-grid/data-grid-context-menu.tsx +12 -3
- package/dist/frontend-core/template/src/components/ui/data-grid/data-grid-types.ts +10 -2
- package/dist/frontend-core/template/src/components/ui/data-grid/data-grid.stories.tsx +780 -0
- package/dist/frontend-core/template/src/components/ui/data-grid/hooks/use-data-grid.tsx +5 -1
- package/dist/frontend-core/template/src/styles/styles.css +14 -2
- package/dist/frontend-tables/generators/model-table.generator.js +69 -46
- package/dist/frontend-tables/generators/model-table.generator.js.map +1 -1
- package/dist/frontend-trpc-client/generators/model-hook.generator.js +33 -5
- package/dist/frontend-trpc-client/generators/model-hook.generator.js.map +1 -1
- package/dist/frontend-trpc-client/trpc-client.generator.d.ts +8 -0
- package/dist/frontend-trpc-client/trpc-client.generator.js +2 -0
- package/dist/frontend-trpc-client/trpc-client.generator.js.map +1 -1
- package/dist/types/generators/model-type.generator.d.ts +2 -0
- package/dist/types/generators/model-type.generator.js +241 -0
- package/dist/types/generators/model-type.generator.js.map +1 -1
- package/dist/types/template/filter.types.ts +70 -0
- package/dist/types/types.generator.d.ts +25 -0
- package/dist/types/types.generator.js +17 -0
- package/dist/types/types.generator.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { CalendarIcon, TrashIcon } from '@radix-ui/react-icons'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import {
|
|
4
|
+
DateFilter,
|
|
5
|
+
FilterConfig,
|
|
6
|
+
FilterFieldName,
|
|
7
|
+
FilterFieldType,
|
|
8
|
+
FilterState,
|
|
9
|
+
FilterValue,
|
|
10
|
+
NumberFilter,
|
|
11
|
+
} from '@types'
|
|
12
|
+
|
|
13
|
+
import { FilterX } from 'lucide-react'
|
|
14
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
15
|
+
|
|
16
|
+
import { Button } from '@components/ui/button/button'
|
|
17
|
+
import { Calendar } from '@components/ui/calendar/calendar'
|
|
18
|
+
import { Checkbox } from '@components/ui/checkbox/checkbox'
|
|
19
|
+
import { Input } from '@components/ui/input/input'
|
|
20
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover/popover'
|
|
21
|
+
import { cn } from '@lib/utils'
|
|
22
|
+
|
|
23
|
+
export const TableFilter = <TField extends FilterFieldName, TFilters extends FilterState>({
|
|
24
|
+
open = false,
|
|
25
|
+
field,
|
|
26
|
+
filters,
|
|
27
|
+
onChange,
|
|
28
|
+
config,
|
|
29
|
+
getFilterOptions,
|
|
30
|
+
}: {
|
|
31
|
+
open?: boolean
|
|
32
|
+
field: TField
|
|
33
|
+
filters: TFilters
|
|
34
|
+
onChange: (val: FilterValue) => void
|
|
35
|
+
config: FilterConfig<TField, TFilters>
|
|
36
|
+
getFilterOptions: (args: { field: TField; filters: TFilters }) => any
|
|
37
|
+
}) => {
|
|
38
|
+
const fieldConfig = config[field]
|
|
39
|
+
const { filterKey, valueType: fieldType } = fieldConfig
|
|
40
|
+
|
|
41
|
+
// We exclude the current field from the filters passed to the query.
|
|
42
|
+
// This prevents the options from refetching (and resetting scroll position) when the user selects an item within the same filter.
|
|
43
|
+
const queryFilters = useMemo(() => {
|
|
44
|
+
const key = filterKey
|
|
45
|
+
if (!key) {
|
|
46
|
+
return filters
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// For number filters, we want to include the min/max values in the query so that the backend can gray out options outside the range.
|
|
50
|
+
// However, we still want to exclude the selected values (checkboxes) to avoid filtering the options list by itself.
|
|
51
|
+
if (fieldType === 'number') {
|
|
52
|
+
const val = filters[key] as any
|
|
53
|
+
if (val && (val.min !== undefined || val.max !== undefined)) {
|
|
54
|
+
return {
|
|
55
|
+
...filters,
|
|
56
|
+
[key]: { min: val.min, max: val.max }, // Pass only min/max, exclude values
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (fieldType === 'date') {
|
|
61
|
+
const val = filters[key] as DateFilter | undefined
|
|
62
|
+
if (val && (val.start !== undefined || val.end !== undefined)) {
|
|
63
|
+
return {
|
|
64
|
+
...filters,
|
|
65
|
+
[key]: { start: val.start, end: val.end }, // Pass only start/end, exclude values
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { [key]: _ignored, ...rest } = filters
|
|
71
|
+
return rest as TFilters
|
|
72
|
+
}, [filterKey, filters, fieldType])
|
|
73
|
+
|
|
74
|
+
const { data: optionsData } = useQuery({
|
|
75
|
+
...getFilterOptions({ field, filters: queryFilters }),
|
|
76
|
+
enabled: open,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const options = useMemo(() => (optionsData && Array.isArray(optionsData) ? optionsData : []), [optionsData])
|
|
80
|
+
|
|
81
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
82
|
+
|
|
83
|
+
const filteredOptions = useMemo(() => {
|
|
84
|
+
if (!searchQuery) {
|
|
85
|
+
return options
|
|
86
|
+
}
|
|
87
|
+
const lowerQuery = searchQuery.toLowerCase()
|
|
88
|
+
return options.filter((option: any) => option.label.toLowerCase().includes(lowerQuery))
|
|
89
|
+
}, [options, searchQuery])
|
|
90
|
+
|
|
91
|
+
const value = useMemo(() => {
|
|
92
|
+
const key = fieldConfig.filterKey
|
|
93
|
+
const val = filters[key]
|
|
94
|
+
if (!val) {
|
|
95
|
+
return []
|
|
96
|
+
}
|
|
97
|
+
if (fieldType === 'number') {
|
|
98
|
+
return ((val as NumberFilter).values || []).map(String)
|
|
99
|
+
}
|
|
100
|
+
if (fieldType === 'date') {
|
|
101
|
+
return (val as DateFilter).values || []
|
|
102
|
+
}
|
|
103
|
+
if (fieldType === 'boolean') {
|
|
104
|
+
return (val as boolean[]).map(String)
|
|
105
|
+
}
|
|
106
|
+
return val as string[]
|
|
107
|
+
}, [fieldConfig.filterKey, fieldType, filters])
|
|
108
|
+
|
|
109
|
+
const selectedValues = new Set(value)
|
|
110
|
+
const areAllSelected = options.length > 0 && options.every((option) => selectedValues.has(option.value))
|
|
111
|
+
|
|
112
|
+
const areAllFilteredSelected =
|
|
113
|
+
filteredOptions.length > 0 && filteredOptions.every((option) => selectedValues.has(option.value))
|
|
114
|
+
const isAnyFilteredSelected = filteredOptions.some((option) => selectedValues.has(option.value))
|
|
115
|
+
|
|
116
|
+
// Min/Max state for number filters
|
|
117
|
+
const [minVal, setMinVal] = useState('')
|
|
118
|
+
const [maxVal, setMaxVal] = useState('')
|
|
119
|
+
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (open && fieldType === 'number') {
|
|
122
|
+
const key = fieldConfig.filterKey
|
|
123
|
+
const val = filters[key] as NumberFilter | undefined
|
|
124
|
+
setMinVal(val?.min?.toString() ?? '')
|
|
125
|
+
setMaxVal(val?.max?.toString() ?? '')
|
|
126
|
+
}
|
|
127
|
+
if (open && fieldType === 'date') {
|
|
128
|
+
const key = fieldConfig.filterKey
|
|
129
|
+
const val = filters[key] as DateFilter | undefined
|
|
130
|
+
setMinVal(val?.start ?? '')
|
|
131
|
+
setMaxVal(val?.end ?? '')
|
|
132
|
+
}
|
|
133
|
+
}, [open, filters, fieldType, fieldConfig.filterKey])
|
|
134
|
+
|
|
135
|
+
const handleNumberChange = (newValues: string[], newMin: string, newMax: string) => {
|
|
136
|
+
const min = newMin === '' ? null : Number(newMin)
|
|
137
|
+
const max = newMax === '' ? null : Number(newMax)
|
|
138
|
+
const values = newValues.length > 0 ? newValues.map((v) => (v === '' ? '' : Number(v))) : undefined
|
|
139
|
+
|
|
140
|
+
if (!values && min === null && max === null) {
|
|
141
|
+
onChange(undefined)
|
|
142
|
+
} else {
|
|
143
|
+
onChange({ values, min, max })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const handleDateChange = (newValues: string[], newStart: string, newEnd: string) => {
|
|
147
|
+
const start = newStart === '' ? null : newStart
|
|
148
|
+
const end = newEnd === '' ? null : newEnd
|
|
149
|
+
const values = newValues.length > 0 ? newValues.map((v) => (v === '' ? '' : v)) : undefined
|
|
150
|
+
|
|
151
|
+
if (!values && start === null && end === null) {
|
|
152
|
+
onChange(undefined)
|
|
153
|
+
} else {
|
|
154
|
+
onChange({ values, start, end })
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const commitMinMax = () => {
|
|
159
|
+
let nextMin = minVal
|
|
160
|
+
let nextMax = maxVal
|
|
161
|
+
if (
|
|
162
|
+
minVal !== '' &&
|
|
163
|
+
maxVal !== '' &&
|
|
164
|
+
((fieldType === 'number' && Number(minVal) > Number(maxVal)) || (fieldType === 'date' && minVal > maxVal))
|
|
165
|
+
) {
|
|
166
|
+
nextMin = maxVal
|
|
167
|
+
nextMax = minVal
|
|
168
|
+
setMinVal(nextMin)
|
|
169
|
+
setMaxVal(nextMax)
|
|
170
|
+
}
|
|
171
|
+
if (fieldType === 'number') {
|
|
172
|
+
handleNumberChange(Array.from(selectedValues).map(String), nextMin, nextMax)
|
|
173
|
+
} else if (fieldType === 'date') {
|
|
174
|
+
handleDateChange(Array.from(selectedValues).map(String), nextMin, nextMax)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div className="w-full flex flex-col">
|
|
180
|
+
{/* Clear filter button */}
|
|
181
|
+
<Button
|
|
182
|
+
variant="ghost"
|
|
183
|
+
size="xs"
|
|
184
|
+
disabled={selectedValues.size === 0 && minVal === '' && maxVal === ''}
|
|
185
|
+
onClick={() => {
|
|
186
|
+
if (fieldType === 'number' || fieldType === 'date') {
|
|
187
|
+
setMinVal('')
|
|
188
|
+
setMaxVal('')
|
|
189
|
+
if (fieldType === 'number') {
|
|
190
|
+
handleNumberChange([], '', '')
|
|
191
|
+
} else if (fieldType === 'date') {
|
|
192
|
+
handleDateChange([], '', '')
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
onChange([])
|
|
196
|
+
}
|
|
197
|
+
}}
|
|
198
|
+
className="w-full justify-start px-2 rounded-sm text-sm h-7 font-normal"
|
|
199
|
+
>
|
|
200
|
+
<FilterX className="text-muted-foreground" />
|
|
201
|
+
Clear Filter
|
|
202
|
+
</Button>
|
|
203
|
+
|
|
204
|
+
{/* Min/Max inputs for number fields */}
|
|
205
|
+
{fieldType === 'number' && (
|
|
206
|
+
<>
|
|
207
|
+
<NumberMinMaxInput
|
|
208
|
+
value={minVal}
|
|
209
|
+
setValue={setMinVal}
|
|
210
|
+
label="At least"
|
|
211
|
+
placeholder="min"
|
|
212
|
+
onCommit={commitMinMax}
|
|
213
|
+
/>
|
|
214
|
+
<NumberMinMaxInput
|
|
215
|
+
value={maxVal}
|
|
216
|
+
setValue={setMaxVal}
|
|
217
|
+
label="At most"
|
|
218
|
+
placeholder="max"
|
|
219
|
+
onCommit={commitMinMax}
|
|
220
|
+
/>
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{/* From/to inputs for date fields */}
|
|
225
|
+
{fieldType === 'date' && (
|
|
226
|
+
<>
|
|
227
|
+
<DateMinMaxInput
|
|
228
|
+
value={minVal}
|
|
229
|
+
setValue={setMinVal}
|
|
230
|
+
label="From"
|
|
231
|
+
onDelete={() => {
|
|
232
|
+
setMinVal('')
|
|
233
|
+
handleDateChange(Array.from(selectedValues).map(String), '', maxVal)
|
|
234
|
+
}}
|
|
235
|
+
onCommit={commitMinMax}
|
|
236
|
+
/>
|
|
237
|
+
<DateMinMaxInput
|
|
238
|
+
value={maxVal}
|
|
239
|
+
setValue={setMaxVal}
|
|
240
|
+
label="To"
|
|
241
|
+
onDelete={() => {
|
|
242
|
+
setMaxVal('')
|
|
243
|
+
handleDateChange(Array.from(selectedValues).map(String), minVal, '')
|
|
244
|
+
}}
|
|
245
|
+
onCommit={commitMinMax}
|
|
246
|
+
setTime={(date: Date) => {
|
|
247
|
+
date.setHours(23, 59, 59, 999)
|
|
248
|
+
}}
|
|
249
|
+
/>
|
|
250
|
+
</>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{/* Search input */}
|
|
254
|
+
<Input
|
|
255
|
+
autoFocus
|
|
256
|
+
className="border-none shadow-none rounded-sm text-sm py-0 h-6.5"
|
|
257
|
+
variant="simple"
|
|
258
|
+
placeholder="Search..."
|
|
259
|
+
value={searchQuery}
|
|
260
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
261
|
+
/>
|
|
262
|
+
|
|
263
|
+
<div className="ps-1.5 pe-3 h-1">
|
|
264
|
+
<div className="w-full border-b border-b-border/70" />
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* Select all / all search results button */}
|
|
268
|
+
<Button
|
|
269
|
+
variant="ghost"
|
|
270
|
+
size="xs"
|
|
271
|
+
className="w-full justify-start px-2 py-1 rounded-sm text-sm font-normal"
|
|
272
|
+
onClick={() => {
|
|
273
|
+
let newVals: string[] = []
|
|
274
|
+
if (searchQuery.length > 0) {
|
|
275
|
+
const newSelected = new Set(selectedValues)
|
|
276
|
+
if (areAllFilteredSelected) {
|
|
277
|
+
filteredOptions.forEach((o) => newSelected.delete(o.value))
|
|
278
|
+
} else {
|
|
279
|
+
filteredOptions.forEach((o) => newSelected.add(o.value))
|
|
280
|
+
}
|
|
281
|
+
newVals = Array.from(newSelected)
|
|
282
|
+
} else if (areAllSelected) {
|
|
283
|
+
newVals = []
|
|
284
|
+
} else {
|
|
285
|
+
newVals = options.map((o) => o.value)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (fieldType === 'number') {
|
|
289
|
+
handleNumberChange(newVals, minVal, maxVal)
|
|
290
|
+
} else if (fieldType === 'date') {
|
|
291
|
+
handleDateChange(newVals, minVal, maxVal)
|
|
292
|
+
} else {
|
|
293
|
+
onChange(newVals)
|
|
294
|
+
}
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
{searchQuery.length > 0 ? (
|
|
298
|
+
<Checkbox
|
|
299
|
+
readOnly
|
|
300
|
+
checked={isAnyFilteredSelected}
|
|
301
|
+
disabled={filteredOptions.length === 0}
|
|
302
|
+
label="Select Search Results"
|
|
303
|
+
className="pointer-events-none"
|
|
304
|
+
checkboxSize="sm"
|
|
305
|
+
variant={areAllFilteredSelected ? 'simple' : 'default'}
|
|
306
|
+
iconStyle={areAllFilteredSelected ? 'simple' : 'solo'}
|
|
307
|
+
checkIcon={areAllFilteredSelected ? 'check' : 'square'}
|
|
308
|
+
/>
|
|
309
|
+
) : (
|
|
310
|
+
<Checkbox
|
|
311
|
+
readOnly
|
|
312
|
+
checked={selectedValues.size > 0}
|
|
313
|
+
disabled={options.length === 0}
|
|
314
|
+
label="Select All"
|
|
315
|
+
className="pointer-events-none"
|
|
316
|
+
checkboxSize="sm"
|
|
317
|
+
variant={areAllSelected ? 'simple' : 'default'}
|
|
318
|
+
iconStyle={areAllSelected ? 'simple' : 'solo'}
|
|
319
|
+
checkIcon={areAllSelected ? 'check' : 'square'}
|
|
320
|
+
/>
|
|
321
|
+
)}
|
|
322
|
+
</Button>
|
|
323
|
+
|
|
324
|
+
{/* Options list */}
|
|
325
|
+
{filteredOptions.length === 0 ? (
|
|
326
|
+
<div className="py-6 text-center text-sm">No results found.</div>
|
|
327
|
+
) : (
|
|
328
|
+
<div className="px-2 py-1 max-h-[200px] overflow-auto">
|
|
329
|
+
{filteredOptions.map((option) => (
|
|
330
|
+
<FilterItem
|
|
331
|
+
key={option.value}
|
|
332
|
+
option={option}
|
|
333
|
+
isSelected={selectedValues.has(option.value)}
|
|
334
|
+
fieldType={fieldType}
|
|
335
|
+
selectedValues={selectedValues}
|
|
336
|
+
minVal={minVal}
|
|
337
|
+
maxVal={maxVal}
|
|
338
|
+
handleNumberChange={handleNumberChange}
|
|
339
|
+
handleDateChange={handleDateChange}
|
|
340
|
+
onChange={onChange}
|
|
341
|
+
/>
|
|
342
|
+
))}
|
|
343
|
+
</div>
|
|
344
|
+
)}
|
|
345
|
+
</div>
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const NumberMinMaxInput = ({
|
|
350
|
+
value,
|
|
351
|
+
setValue,
|
|
352
|
+
label,
|
|
353
|
+
placeholder,
|
|
354
|
+
onCommit,
|
|
355
|
+
}: {
|
|
356
|
+
value: string
|
|
357
|
+
setValue: (val: string) => void
|
|
358
|
+
label: string
|
|
359
|
+
placeholder: string
|
|
360
|
+
onCommit: () => void
|
|
361
|
+
}) => {
|
|
362
|
+
return (
|
|
363
|
+
<div className="flex items-center gap-2 px-2 pt-0.5">
|
|
364
|
+
<span className="whitespace-nowrap w-16">{label}</span>
|
|
365
|
+
<Input
|
|
366
|
+
type="number"
|
|
367
|
+
variant="simple"
|
|
368
|
+
className="py-0 h-5.5 w-full text-center text-sm focus:text-start placeholder:text-center rounded italic not-hover:not-focus:border-transparent not-hover:not-focus:shadow-none hover:bg-accent/30"
|
|
369
|
+
placeholder={placeholder}
|
|
370
|
+
value={value}
|
|
371
|
+
onChange={(e) => setValue(e.target.value)}
|
|
372
|
+
onBlur={onCommit}
|
|
373
|
+
onEnter={onCommit}
|
|
374
|
+
/>
|
|
375
|
+
</div>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Helper to convert ISO string to only YYYY-MM-DD for min/max date fields
|
|
380
|
+
const isoToDateInput = (iso: string | null | undefined) => {
|
|
381
|
+
if (!iso) {
|
|
382
|
+
return ''
|
|
383
|
+
}
|
|
384
|
+
if (iso.length === 10) {
|
|
385
|
+
return iso
|
|
386
|
+
}
|
|
387
|
+
const date = new Date(iso)
|
|
388
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const DateMinMaxInput = ({
|
|
392
|
+
value,
|
|
393
|
+
setValue,
|
|
394
|
+
label,
|
|
395
|
+
onCommit,
|
|
396
|
+
onDelete,
|
|
397
|
+
setTime, // to use last ms of the day for 'To' field
|
|
398
|
+
}: {
|
|
399
|
+
value: string
|
|
400
|
+
setValue: (val: string) => void
|
|
401
|
+
label: string
|
|
402
|
+
onCommit: () => void
|
|
403
|
+
onDelete: () => void
|
|
404
|
+
setTime?: (date: Date) => void
|
|
405
|
+
}) => {
|
|
406
|
+
return (
|
|
407
|
+
<div className="flex items-center gap-2 px-2 pt-0.5">
|
|
408
|
+
<span className="whitespace-nowrap w-12">{label}</span>
|
|
409
|
+
<div className="relative w-full group">
|
|
410
|
+
<Input
|
|
411
|
+
type="date"
|
|
412
|
+
variant="simple"
|
|
413
|
+
className={cn(
|
|
414
|
+
'no-date-icon py-0 h-5.5 ps-8 w-full text-primary rounded italic text-sm not-group-hover:not-focus:border-transparent not-group-hover:not-focus:shadow-none group-hover:bg-accent/30',
|
|
415
|
+
value ? 'text-foreground' : 'text-muted-foreground',
|
|
416
|
+
)}
|
|
417
|
+
value={isoToDateInput(value)}
|
|
418
|
+
onChange={(e) => {
|
|
419
|
+
const date = new Date(e.target.value)
|
|
420
|
+
setTime?.(date)
|
|
421
|
+
setValue(date.toISOString())
|
|
422
|
+
}}
|
|
423
|
+
onBlur={onCommit}
|
|
424
|
+
onEnter={onCommit}
|
|
425
|
+
/>
|
|
426
|
+
<Popover
|
|
427
|
+
onOpenChange={(open) => {
|
|
428
|
+
if (!open) {
|
|
429
|
+
onCommit()
|
|
430
|
+
}
|
|
431
|
+
}}
|
|
432
|
+
>
|
|
433
|
+
<PopoverTrigger asChild>
|
|
434
|
+
<Button
|
|
435
|
+
variant="extraGhost2"
|
|
436
|
+
size="iconSm"
|
|
437
|
+
className="absolute right-1 top-1/2 transform -translate-y-1/2 size-6 rounded"
|
|
438
|
+
>
|
|
439
|
+
<CalendarIcon />
|
|
440
|
+
</Button>
|
|
441
|
+
</PopoverTrigger>
|
|
442
|
+
<PopoverContent className="w-auto p-0" align="start">
|
|
443
|
+
<Calendar
|
|
444
|
+
showYearNavigation={true}
|
|
445
|
+
autoFocus
|
|
446
|
+
captionLayout="dropdown"
|
|
447
|
+
mode="single"
|
|
448
|
+
selected={new Date(value)}
|
|
449
|
+
onSelect={(date) => {
|
|
450
|
+
if (!date) {
|
|
451
|
+
setValue('')
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
setTime?.(date)
|
|
455
|
+
setValue(date?.toISOString() ?? '')
|
|
456
|
+
}}
|
|
457
|
+
defaultMonth={Number.isNaN(new Date(value).getTime()) ? undefined : new Date(value)}
|
|
458
|
+
/>
|
|
459
|
+
</PopoverContent>
|
|
460
|
+
</Popover>
|
|
461
|
+
{value && (
|
|
462
|
+
<Button
|
|
463
|
+
variant="extraGhost2"
|
|
464
|
+
size="iconSm"
|
|
465
|
+
className="absolute right-7 top-1/2 transform -translate-y-1/2 size-6 rounded opacity-40"
|
|
466
|
+
onClick={onDelete}
|
|
467
|
+
>
|
|
468
|
+
<TrashIcon />
|
|
469
|
+
</Button>
|
|
470
|
+
)}
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const FilterItem = ({
|
|
477
|
+
option,
|
|
478
|
+
isSelected,
|
|
479
|
+
fieldType,
|
|
480
|
+
selectedValues,
|
|
481
|
+
minVal,
|
|
482
|
+
maxVal,
|
|
483
|
+
handleNumberChange,
|
|
484
|
+
handleDateChange,
|
|
485
|
+
onChange,
|
|
486
|
+
}: {
|
|
487
|
+
option: { label: string; value: string; hasMatches: boolean }
|
|
488
|
+
isSelected: boolean
|
|
489
|
+
fieldType: FilterFieldType
|
|
490
|
+
selectedValues: Set<string>
|
|
491
|
+
minVal?: string
|
|
492
|
+
maxVal?: string
|
|
493
|
+
handleNumberChange: (values: string[], min: string, max: string) => void
|
|
494
|
+
handleDateChange: (values: string[], from: string, to: string) => void
|
|
495
|
+
onChange: (values: string[]) => void
|
|
496
|
+
}) => {
|
|
497
|
+
let displayLabel = option.label
|
|
498
|
+
|
|
499
|
+
if (option.value !== '') {
|
|
500
|
+
if (fieldType === 'date') {
|
|
501
|
+
displayLabel = new Date(option.label).toLocaleDateString()
|
|
502
|
+
} else if (fieldType === 'number') {
|
|
503
|
+
displayLabel = Number(option.label).toLocaleString()
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return (
|
|
508
|
+
<Checkbox
|
|
509
|
+
key={option.value}
|
|
510
|
+
checked={isSelected}
|
|
511
|
+
onChange={(e) => {
|
|
512
|
+
const newSelected = new Set(selectedValues)
|
|
513
|
+
if (e.target.checked) {
|
|
514
|
+
newSelected.add(option.value)
|
|
515
|
+
} else {
|
|
516
|
+
newSelected.delete(option.value)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (fieldType === 'number' && minVal !== undefined && maxVal !== undefined) {
|
|
520
|
+
handleNumberChange(Array.from(newSelected).map(String), minVal, maxVal)
|
|
521
|
+
} else if (fieldType === 'date' && minVal !== undefined && maxVal !== undefined) {
|
|
522
|
+
handleDateChange(Array.from(newSelected).map(String), minVal, maxVal)
|
|
523
|
+
} else {
|
|
524
|
+
onChange(Array.from(newSelected))
|
|
525
|
+
}
|
|
526
|
+
}}
|
|
527
|
+
checkIcon="check"
|
|
528
|
+
checkboxSize="sm"
|
|
529
|
+
className={cn(
|
|
530
|
+
'whitespace-nowrap py-px text-sm',
|
|
531
|
+
!option.hasMatches && 'opacity-50',
|
|
532
|
+
option.value === '' && 'italic',
|
|
533
|
+
)}
|
|
534
|
+
label={displayLabel}
|
|
535
|
+
/>
|
|
536
|
+
)
|
|
537
|
+
}
|
|
@@ -21,7 +21,7 @@ const meta = {
|
|
|
21
21
|
},
|
|
22
22
|
checkIcon: {
|
|
23
23
|
control: 'select',
|
|
24
|
-
options: ['default', 'check'],
|
|
24
|
+
options: ['default', 'check', 'square'],
|
|
25
25
|
},
|
|
26
26
|
iconStyle: {
|
|
27
27
|
control: 'select',
|
|
@@ -85,6 +85,7 @@ export const CheckIcons: Story = {
|
|
|
85
85
|
<div className="flex flex-wrap gap-2">
|
|
86
86
|
<Checkbox defaultChecked={true} label="Default" checkIcon="default" id="default-icon" />
|
|
87
87
|
<Checkbox defaultChecked={true} label="Check" checkIcon="check" />
|
|
88
|
+
<Checkbox defaultChecked={true} label="Square" checkIcon="square" />
|
|
88
89
|
</div>
|
|
89
90
|
),
|
|
90
91
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CheckIcon, Cross1Icon } from '@radix-ui/react-icons'
|
|
2
2
|
|
|
3
3
|
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
import { SquareIcon } from 'lucide-react'
|
|
4
5
|
|
|
5
6
|
import { cn } from '@lib/utils'
|
|
6
7
|
|
|
@@ -30,6 +31,7 @@ const checkboxVariants = cva(
|
|
|
30
31
|
checkIcon: {
|
|
31
32
|
default: 'cross',
|
|
32
33
|
check: 'check',
|
|
34
|
+
square: 'square',
|
|
33
35
|
},
|
|
34
36
|
iconStyle: {
|
|
35
37
|
default: 'text-foreground',
|
|
@@ -58,6 +60,13 @@ function Checkbox({
|
|
|
58
60
|
}: CheckboxProps) {
|
|
59
61
|
const inputId = props.id ?? (label ? `${label}-checkbox` : undefined)
|
|
60
62
|
|
|
63
|
+
let icon = <Cross1Icon />
|
|
64
|
+
if (checkIcon === 'check') {
|
|
65
|
+
icon = <CheckIcon />
|
|
66
|
+
} else if (checkIcon === 'square') {
|
|
67
|
+
icon = <SquareIcon fill="var(--muted-foreground)" className="size-1/3" />
|
|
68
|
+
}
|
|
69
|
+
|
|
61
70
|
return (
|
|
62
71
|
<div className={cn('relative flex gap-2 items-center', className)} data-test-id={__e2e_test_id__}>
|
|
63
72
|
<input {...props} type="checkbox" id={inputId} className={cn(checkboxVariants({ variant, checkboxSize }))} />
|
|
@@ -67,7 +76,7 @@ function Checkbox({
|
|
|
67
76
|
'absolute opacity-0 peer-checked:opacity-100 top-1/2 left-0 transform -translate-y-1/2 pointer-events-none flex items-center justify-center border-transparent',
|
|
68
77
|
)}
|
|
69
78
|
>
|
|
70
|
-
{
|
|
79
|
+
{icon}
|
|
71
80
|
</span>
|
|
72
81
|
<label htmlFor={inputId} className="cursor-pointer select-none">
|
|
73
82
|
{label}
|
package/dist/frontend-core/template/src/components/ui/data-grid/cell-variants/gantt-cell.tsx
CHANGED
|
@@ -39,9 +39,11 @@ export function GanttCell<TData>({
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const timelineDurationMs = timelineEndMs - timelineStartMs
|
|
42
|
-
const
|
|
42
|
+
const hasValidDates = initialValue && initialValue.start instanceof Date && initialValue.end instanceof Date
|
|
43
|
+
|
|
44
|
+
const msUntilStart = hasValidDates ? initialValue.start.getTime() - timelineStartMs : 0
|
|
43
45
|
// end - start time considering timeline bounds
|
|
44
|
-
const barWidthMs =
|
|
46
|
+
const barWidthMs = hasValidDates
|
|
45
47
|
? Math.min(initialValue.end.getTime(), timelineEndMs) - Math.max(initialValue.start.getTime(), timelineStartMs)
|
|
46
48
|
: 0
|
|
47
49
|
|
|
@@ -58,7 +60,7 @@ export function GanttCell<TData>({
|
|
|
58
60
|
className="px-1"
|
|
59
61
|
>
|
|
60
62
|
<div className="size-full flex overflow-hidden">
|
|
61
|
-
{
|
|
63
|
+
{hasValidDates && (
|
|
62
64
|
<>
|
|
63
65
|
<div
|
|
64
66
|
className="shrink-0"
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from 'lucide-react'
|
|
18
18
|
import * as React from 'react'
|
|
19
19
|
|
|
20
|
-
import type { Cell } from '@components/ui/data-grid/data-grid-types'
|
|
20
|
+
import type { Cell, ColumnMenuRendererFunction } from '@components/ui/data-grid/data-grid-types'
|
|
21
21
|
import {
|
|
22
22
|
DropdownMenu,
|
|
23
23
|
DropdownMenuCheckboxItem,
|
|
@@ -67,6 +67,8 @@ export function DataGridColumnHeader<TData, TValue>({
|
|
|
67
67
|
onPointerDown,
|
|
68
68
|
...props
|
|
69
69
|
}: DataGridColumnHeaderProps<TData, TValue>) {
|
|
70
|
+
const [open, setOpen] = React.useState(false)
|
|
71
|
+
|
|
70
72
|
const column = header.column
|
|
71
73
|
const label = column.columnDef.meta?.label
|
|
72
74
|
? column.columnDef.meta.label
|
|
@@ -137,7 +139,7 @@ export function DataGridColumnHeader<TData, TValue>({
|
|
|
137
139
|
|
|
138
140
|
return (
|
|
139
141
|
<>
|
|
140
|
-
<DropdownMenu>
|
|
142
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
141
143
|
<DropdownMenuTrigger
|
|
142
144
|
className={cn(
|
|
143
145
|
'flex size-full items-center justify-between gap-2 p-2 text-sm bg-sidebar-accent/80 font-bold hover:bg-secondary/40 data-[state=open]:bg-secondary/40 [&_svg]:size-4',
|
|
@@ -232,6 +234,18 @@ export function DataGridColumnHeader<TData, TValue>({
|
|
|
232
234
|
</DropdownMenuCheckboxItem>
|
|
233
235
|
</>
|
|
234
236
|
)}
|
|
237
|
+
|
|
238
|
+
{/* Render header menu footer (custom optional component) */}
|
|
239
|
+
{column.columnDef.meta?.headerMenuFooter && (
|
|
240
|
+
<>
|
|
241
|
+
<DropdownMenuSeparator />
|
|
242
|
+
<div className="dropdown-footer">
|
|
243
|
+
{isMenuRendererFunction(column.columnDef.meta.headerMenuFooter)
|
|
244
|
+
? column.columnDef.meta.headerMenuFooter({ column, open, onOpenChange: setOpen })
|
|
245
|
+
: column.columnDef.meta.headerMenuFooter}
|
|
246
|
+
</div>
|
|
247
|
+
</>
|
|
248
|
+
)}
|
|
235
249
|
</DropdownMenuContent>
|
|
236
250
|
</DropdownMenu>
|
|
237
251
|
{/* Render any variant-provided header component from HeaderComponents. */}
|
|
@@ -244,6 +258,16 @@ export function DataGridColumnHeader<TData, TValue>({
|
|
|
244
258
|
|
|
245
259
|
return null
|
|
246
260
|
})()}
|
|
261
|
+
|
|
262
|
+
{/* Render custom column header component (optional columnDef.meta) */}
|
|
263
|
+
{column.columnDef.meta?.headerCustomComponent && (
|
|
264
|
+
<>
|
|
265
|
+
{isMenuRendererFunction(column.columnDef.meta.headerCustomComponent)
|
|
266
|
+
? column.columnDef.meta.headerCustomComponent({ column })
|
|
267
|
+
: column.columnDef.meta.headerCustomComponent}
|
|
268
|
+
</>
|
|
269
|
+
)}
|
|
270
|
+
|
|
247
271
|
{header.column.getCanResize() && <DataGridColumnResizer header={header} table={table} label={label} />}
|
|
248
272
|
</>
|
|
249
273
|
)
|
|
@@ -300,6 +324,12 @@ function DataGridColumnResizerImpl<TData, TValue>({ header, table, label }: Data
|
|
|
300
324
|
)
|
|
301
325
|
}
|
|
302
326
|
|
|
327
|
+
function isMenuRendererFunction<TData, TValue>(
|
|
328
|
+
value: React.ReactNode | ColumnMenuRendererFunction<TData, TValue>,
|
|
329
|
+
): value is ColumnMenuRendererFunction<TData, TValue> {
|
|
330
|
+
return typeof value === 'function'
|
|
331
|
+
}
|
|
332
|
+
|
|
303
333
|
/**
|
|
304
334
|
* Optional header components keyed by cell variant. Components receive { header, table } and
|
|
305
335
|
* should return a React node (or null). This allows the column header to render variant-specific
|