@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.
Files changed (41) hide show
  1. package/dist/backend-router-trpc/generators/model-routes.generator.js +24 -3
  2. package/dist/backend-router-trpc/generators/model-routes.generator.js.map +1 -1
  3. package/dist/backend-router-trpc/router-trpc.generator.d.ts +2 -0
  4. package/dist/backend-router-trpc/router-trpc.generator.js +2 -0
  5. package/dist/backend-router-trpc/router-trpc.generator.js.map +1 -1
  6. package/dist/backend-view/model-view-service.generator.js +276 -0
  7. package/dist/backend-view/model-view-service.generator.js.map +1 -1
  8. package/dist/backend-view/template/filter.utils.test.ts +387 -0
  9. package/dist/backend-view/template/filter.utils.ts +218 -0
  10. package/dist/backend-view/view.generator.js +5 -1
  11. package/dist/backend-view/view.generator.js.map +1 -1
  12. package/dist/frontend-admin/generators/model-admin-page.generator.js +38 -8
  13. package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
  14. package/dist/frontend-core/frontend.generator.js +1 -0
  15. package/dist/frontend-core/frontend.generator.js.map +1 -1
  16. package/dist/frontend-core/template/src/components/admin/table-filter-header-icon.tsx +30 -0
  17. package/dist/frontend-core/template/src/components/admin/table-filter.tsx +537 -0
  18. package/dist/frontend-core/template/src/components/ui/checkbox/checkbox.stories.tsx +2 -1
  19. package/dist/frontend-core/template/src/components/ui/checkbox/checkbox.tsx +10 -1
  20. package/dist/frontend-core/template/src/components/ui/data-grid/cell-variants/gantt-cell.tsx +5 -3
  21. package/dist/frontend-core/template/src/components/ui/data-grid/data-grid-column-header.tsx +32 -2
  22. package/dist/frontend-core/template/src/components/ui/data-grid/data-grid-context-menu.tsx +12 -3
  23. package/dist/frontend-core/template/src/components/ui/data-grid/data-grid-types.ts +10 -2
  24. package/dist/frontend-core/template/src/components/ui/data-grid/data-grid.stories.tsx +780 -0
  25. package/dist/frontend-core/template/src/components/ui/data-grid/hooks/use-data-grid.tsx +5 -1
  26. package/dist/frontend-core/template/src/styles/styles.css +14 -2
  27. package/dist/frontend-tables/generators/model-table.generator.js +69 -46
  28. package/dist/frontend-tables/generators/model-table.generator.js.map +1 -1
  29. package/dist/frontend-trpc-client/generators/model-hook.generator.js +33 -5
  30. package/dist/frontend-trpc-client/generators/model-hook.generator.js.map +1 -1
  31. package/dist/frontend-trpc-client/trpc-client.generator.d.ts +8 -0
  32. package/dist/frontend-trpc-client/trpc-client.generator.js +2 -0
  33. package/dist/frontend-trpc-client/trpc-client.generator.js.map +1 -1
  34. package/dist/types/generators/model-type.generator.d.ts +2 -0
  35. package/dist/types/generators/model-type.generator.js +241 -0
  36. package/dist/types/generators/model-type.generator.js.map +1 -1
  37. package/dist/types/template/filter.types.ts +70 -0
  38. package/dist/types/types.generator.d.ts +25 -0
  39. package/dist/types/types.generator.js +17 -0
  40. package/dist/types/types.generator.js.map +1 -1
  41. 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
- {checkIcon === 'check' ? <CheckIcon /> : <Cross1Icon />}
79
+ {icon}
71
80
  </span>
72
81
  <label htmlFor={inputId} className="cursor-pointer select-none">
73
82
  {label}
@@ -39,9 +39,11 @@ export function GanttCell<TData>({
39
39
  }
40
40
 
41
41
  const timelineDurationMs = timelineEndMs - timelineStartMs
42
- const msUntilStart = initialValue ? initialValue.start.getTime() - timelineStartMs : 0
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 = initialValue
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
- {initialValue && (
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