@asteby/metacore-runtime-react 21.1.0 → 22.0.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/CHANGELOG.md +46 -0
- package/dist/dynamic-columns-shim.d.ts +9 -1
- package/dist/dynamic-columns-shim.d.ts.map +1 -1
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +1 -0
- package/dist/dynamic-kanban.d.ts +16 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +211 -23
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +51 -4
- package/dist/use-dynamic-filters.d.ts +19 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +75 -5
- package/dist/use-facet-loaders.d.ts +22 -0
- package/dist/use-facet-loaders.d.ts.map +1 -0
- package/dist/use-facet-loaders.js +67 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +96 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +182 -0
- package/src/dynamic-columns-shim.ts +9 -1
- package/src/dynamic-columns.tsx +1 -0
- package/src/dynamic-kanban.tsx +496 -78
- package/src/dynamic-table.tsx +52 -4
- package/src/use-dynamic-filters.ts +100 -5
- package/src/use-facet-loaders.ts +79 -0
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -38,7 +38,18 @@ import {
|
|
|
38
38
|
type DragStartEvent,
|
|
39
39
|
type DragEndEvent,
|
|
40
40
|
} from '@dnd-kit/core'
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
Calendar,
|
|
43
|
+
CircleDot,
|
|
44
|
+
Hash,
|
|
45
|
+
ListFilter,
|
|
46
|
+
MoreHorizontal,
|
|
47
|
+
Search,
|
|
48
|
+
Tag,
|
|
49
|
+
ToggleLeft,
|
|
50
|
+
Type,
|
|
51
|
+
X,
|
|
52
|
+
} from 'lucide-react'
|
|
42
53
|
import { toast } from 'sonner'
|
|
43
54
|
import {
|
|
44
55
|
Badge,
|
|
@@ -66,7 +77,7 @@ import {
|
|
|
66
77
|
Skeleton,
|
|
67
78
|
} from '@asteby/metacore-ui/primitives'
|
|
68
79
|
import { ColumnFilterControl, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
|
|
69
|
-
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
|
|
80
|
+
import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib'
|
|
70
81
|
import { useApi } from './api-context'
|
|
71
82
|
import { useDynamicFilters } from './use-dynamic-filters'
|
|
72
83
|
import { useMetadataCache } from './metadata-cache'
|
|
@@ -222,6 +233,90 @@ export function selectCardColumns(
|
|
|
222
233
|
return { title, fields }
|
|
223
234
|
}
|
|
224
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Human-readable summary of a field's selected filter values for the removable
|
|
238
|
+
* chip row. Resolves option labels, unwraps the wire operators
|
|
239
|
+
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
240
|
+
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
241
|
+
*/
|
|
242
|
+
export function summarizeFilterValues(
|
|
243
|
+
values: string[] | undefined,
|
|
244
|
+
options: { label: string; value: string }[] | undefined,
|
|
245
|
+
maxShown = 2,
|
|
246
|
+
): string {
|
|
247
|
+
if (!values || values.length === 0) return ''
|
|
248
|
+
const opts = options ?? []
|
|
249
|
+
const labelFor = (v: string) =>
|
|
250
|
+
opts.find((o) => o.value === v)?.label ?? v
|
|
251
|
+
const first = values[0]
|
|
252
|
+
if (values.length === 1) {
|
|
253
|
+
if (first.startsWith('ILIKE:')) return `"${first.slice(6)}"`
|
|
254
|
+
if (first.startsWith('IN:')) {
|
|
255
|
+
return summarizeList(first.slice(3).split(','), labelFor, maxShown)
|
|
256
|
+
}
|
|
257
|
+
if (first.startsWith('RANGE:')) {
|
|
258
|
+
const [min, max] = first.slice(6).split(',')
|
|
259
|
+
return `${min || '…'} – ${max || '…'}`
|
|
260
|
+
}
|
|
261
|
+
if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return first.replace('_', ' – ')
|
|
262
|
+
}
|
|
263
|
+
if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
|
|
264
|
+
const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? ''
|
|
265
|
+
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? ''
|
|
266
|
+
return `${min || '…'} – ${max || '…'}`
|
|
267
|
+
}
|
|
268
|
+
return summarizeList(values, labelFor, maxShown)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function summarizeList(
|
|
272
|
+
items: string[],
|
|
273
|
+
labelFor: (v: string) => string,
|
|
274
|
+
maxShown: number,
|
|
275
|
+
): string {
|
|
276
|
+
const labels = items.map(labelFor)
|
|
277
|
+
if (labels.length <= maxShown) return labels.join(', ')
|
|
278
|
+
return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
283
|
+
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
284
|
+
* operator/range/free-text values that carry no option color.
|
|
285
|
+
*/
|
|
286
|
+
function chipValueColor(config: {
|
|
287
|
+
selectedValues: string[]
|
|
288
|
+
options: { value: string; color?: string }[]
|
|
289
|
+
}): string | undefined {
|
|
290
|
+
const sel = config.selectedValues
|
|
291
|
+
if (!sel || sel.length === 0) return undefined
|
|
292
|
+
const first = sel[0]
|
|
293
|
+
let value = first
|
|
294
|
+
if (first.startsWith('IN:')) value = first.slice(3).split(',')[0]
|
|
295
|
+
else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first)) return undefined
|
|
296
|
+
else if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return undefined
|
|
297
|
+
const opt = config.options.find((o) => o.value === value)
|
|
298
|
+
return opt?.color ? resolveColorCss(opt.color) : undefined
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
303
|
+
* over the card's title + every visible field value (`String(v)`). Empty query
|
|
304
|
+
* matches everything. Pure — exported for unit tests.
|
|
305
|
+
*/
|
|
306
|
+
export function cardMatchesLaneQuery(
|
|
307
|
+
card: any,
|
|
308
|
+
cols: ColumnDefinition[],
|
|
309
|
+
query: string,
|
|
310
|
+
): boolean {
|
|
311
|
+
const q = query.trim().toLowerCase()
|
|
312
|
+
if (!q) return true
|
|
313
|
+
return cols.some((c) =>
|
|
314
|
+
String(card?.[c.key] ?? '')
|
|
315
|
+
.toLowerCase()
|
|
316
|
+
.includes(q),
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
225
320
|
// ---------------------------------------------------------------------------
|
|
226
321
|
// Theme hook (mirrors the private one in dynamic-columns / activity-renderer)
|
|
227
322
|
// ---------------------------------------------------------------------------
|
|
@@ -250,6 +345,13 @@ function useIsDarkTheme(): boolean {
|
|
|
250
345
|
// Component
|
|
251
346
|
// ---------------------------------------------------------------------------
|
|
252
347
|
|
|
348
|
+
/** Per-lane client-side filter: an optional field funnel + an optional search. */
|
|
349
|
+
interface LaneFilterState {
|
|
350
|
+
field?: string
|
|
351
|
+
value?: string
|
|
352
|
+
query?: string
|
|
353
|
+
}
|
|
354
|
+
|
|
253
355
|
export interface DynamicKanbanProps {
|
|
254
356
|
/** Model key as registered on the backend (e.g. "issue"). */
|
|
255
357
|
model: string
|
|
@@ -352,13 +454,15 @@ export function DynamicKanban({
|
|
|
352
454
|
// and `f_<key>` serialization DynamicTable uses, so the board filters
|
|
353
455
|
// identically to its table sibling.
|
|
354
456
|
const {
|
|
457
|
+
dynamicFilters,
|
|
355
458
|
globalFilter,
|
|
356
459
|
setGlobalFilter,
|
|
357
460
|
columnFilterConfigs,
|
|
358
461
|
filterParams,
|
|
359
462
|
activeFilterCount,
|
|
463
|
+
handleDynamicFilterChange,
|
|
360
464
|
clearAll,
|
|
361
|
-
} = useDynamicFilters(metadata, { defaultFilters })
|
|
465
|
+
} = useDynamicFilters(metadata, { defaultFilters, model, endpoint })
|
|
362
466
|
|
|
363
467
|
// ---- records fetch (same path as DynamicTable, single large page) ----
|
|
364
468
|
const fetchData = useCallback(async () => {
|
|
@@ -406,20 +510,41 @@ export function DynamicKanban({
|
|
|
406
510
|
return out
|
|
407
511
|
}, [metadata, columnFilterConfigs, t])
|
|
408
512
|
|
|
513
|
+
// Split filters into active (with a selection) and the rest — the Sheet
|
|
514
|
+
// groups the active ones on top, the rest alphabetically. Also drives the
|
|
515
|
+
// removable chip row below the toolbar.
|
|
516
|
+
const { activeFields, inactiveFields } = useMemo(() => {
|
|
517
|
+
const active = filterFields.filter(
|
|
518
|
+
(f) => (f.config.selectedValues?.length ?? 0) > 0,
|
|
519
|
+
)
|
|
520
|
+
const inactive = filterFields
|
|
521
|
+
.filter((f) => (f.config.selectedValues?.length ?? 0) === 0)
|
|
522
|
+
.sort((a, b) => a.label.localeCompare(b.label))
|
|
523
|
+
return { activeFields: active, inactiveFields: inactive }
|
|
524
|
+
}, [filterFields])
|
|
525
|
+
|
|
409
526
|
// Sheet (grouped global filters) open state + per-lane client-side filters.
|
|
410
527
|
// A lane filter narrows ONLY that stage's already-fetched cards by a field
|
|
411
528
|
// value — instant, no refetch — so a user can drill into one column without
|
|
412
529
|
// touching the rest of the board (the global filters, by contrast, refetch
|
|
413
530
|
// the whole board server-side).
|
|
414
531
|
const [filtersOpen, setFiltersOpen] = useState(false)
|
|
532
|
+
// Per-lane client-side narrowing. Two independent, AND-combined dimensions:
|
|
533
|
+
// - `field`/`value`: the funnel — a field-scoped substring match.
|
|
534
|
+
// - `query`: the lane search — a substring over the card title + every
|
|
535
|
+
// visible field value.
|
|
536
|
+
// A lane with neither is dropped from the map (so it reads as "unfiltered").
|
|
415
537
|
const [laneFilters, setLaneFilters] = useState<
|
|
416
|
-
Record<string,
|
|
538
|
+
Record<string, LaneFilterState>
|
|
417
539
|
>({})
|
|
418
|
-
const
|
|
419
|
-
(stageKey: string,
|
|
540
|
+
const updateLaneFilter = useCallback(
|
|
541
|
+
(stageKey: string, patch: Partial<LaneFilterState>) => {
|
|
420
542
|
setLaneFilters((prev) => {
|
|
543
|
+
const merged: LaneFilterState = { ...prev[stageKey], ...patch }
|
|
421
544
|
const next = { ...prev }
|
|
422
|
-
|
|
545
|
+
const hasFunnel = !!(merged.field && merged.value?.trim())
|
|
546
|
+
const hasQuery = !!merged.query?.trim()
|
|
547
|
+
if (hasFunnel || hasQuery) next[stageKey] = merged
|
|
423
548
|
else delete next[stageKey]
|
|
424
549
|
return next
|
|
425
550
|
})
|
|
@@ -444,6 +569,12 @@ export function DynamicKanban({
|
|
|
444
569
|
[metadata],
|
|
445
570
|
)
|
|
446
571
|
|
|
572
|
+
// Columns the lane search scans: the card title + its visible field cells.
|
|
573
|
+
const searchCols = useMemo(
|
|
574
|
+
() => [titleCol, ...fieldCols].filter(Boolean) as ColumnDefinition[],
|
|
575
|
+
[titleCol, fieldCols],
|
|
576
|
+
)
|
|
577
|
+
|
|
447
578
|
// Row-placement actions resolved EXACTLY like DynamicTable's action column:
|
|
448
579
|
// capability-gated (when a <PermissionsProvider> is mounted) and with the
|
|
449
580
|
// implicit View/Edit/Delete trio materialized for CRUD models. An action the
|
|
@@ -616,69 +747,155 @@ export function DynamicKanban({
|
|
|
616
747
|
</SheetTrigger>
|
|
617
748
|
<SheetContent
|
|
618
749
|
side="right"
|
|
619
|
-
className="flex w-80 flex-col gap-
|
|
750
|
+
className="flex w-80 flex-col gap-0 p-0 sm:max-w-sm"
|
|
620
751
|
>
|
|
621
|
-
<SheetHeader className="space-y-0">
|
|
622
|
-
<SheetTitle>
|
|
752
|
+
<SheetHeader className="space-y-0 border-b px-4 py-3">
|
|
753
|
+
<SheetTitle className="text-sm">
|
|
623
754
|
{t('kanban.filters', { defaultValue: 'Filtros' })}
|
|
624
755
|
</SheetTitle>
|
|
625
756
|
</SheetHeader>
|
|
626
|
-
{/*
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
757
|
+
{/* Active filters grouped on top, the rest alphabetical
|
|
758
|
+
below a separator. Each row: label + its control; the
|
|
759
|
+
active field is highlighted by ColumnFilterControl's
|
|
760
|
+
own active styling. Same server-side engine as before. */}
|
|
761
|
+
<div className="flex flex-1 flex-col gap-1 overflow-y-auto px-4 py-3">
|
|
762
|
+
{activeFields.length > 0 && (
|
|
763
|
+
<>
|
|
764
|
+
<p className="px-0.5 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
765
|
+
{t('kanban.activeFilters', {
|
|
766
|
+
defaultValue: 'Con filtros activos',
|
|
767
|
+
})}
|
|
768
|
+
</p>
|
|
769
|
+
{activeFields.map((field) => (
|
|
770
|
+
<SheetFilterRow
|
|
771
|
+
key={field.key}
|
|
772
|
+
field={field}
|
|
773
|
+
isStage={field.key === groupByKey}
|
|
774
|
+
/>
|
|
775
|
+
))}
|
|
776
|
+
{inactiveFields.length > 0 && (
|
|
777
|
+
<div className="my-2 border-t" />
|
|
778
|
+
)}
|
|
779
|
+
</>
|
|
780
|
+
)}
|
|
781
|
+
{inactiveFields.map((field) => (
|
|
782
|
+
<SheetFilterRow
|
|
632
783
|
key={field.key}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
filterKey={field.config.filterKey}
|
|
636
|
-
filterType={
|
|
637
|
-
field.config.filterType as ColumnFilterType
|
|
638
|
-
}
|
|
639
|
-
filterOptions={field.config.options}
|
|
640
|
-
filterLoading={field.config.loading}
|
|
641
|
-
filterSearchEndpoint={field.config.searchEndpoint}
|
|
642
|
-
selectedValues={field.config.selectedValues}
|
|
643
|
-
onFilterChange={field.config.onFilterChange}
|
|
784
|
+
field={field}
|
|
785
|
+
isStage={field.key === groupByKey}
|
|
644
786
|
/>
|
|
645
787
|
))}
|
|
646
788
|
</div>
|
|
647
|
-
|
|
789
|
+
<div className="sticky bottom-0 flex items-center justify-between gap-2 border-t bg-background px-4 py-3">
|
|
790
|
+
<span className="text-xs text-muted-foreground tabular-nums">
|
|
791
|
+
{activeFilterCount > 0
|
|
792
|
+
? t('kanban.activeCount', {
|
|
793
|
+
defaultValue: '{{count}} activos',
|
|
794
|
+
count: activeFilterCount,
|
|
795
|
+
})
|
|
796
|
+
: t('kanban.noActiveFilters', {
|
|
797
|
+
defaultValue: 'Sin filtros',
|
|
798
|
+
})}
|
|
799
|
+
</span>
|
|
648
800
|
<Button
|
|
649
801
|
variant="ghost"
|
|
650
802
|
size="sm"
|
|
651
|
-
className="gap-1 text-xs
|
|
803
|
+
className="h-7 gap-1 text-xs"
|
|
652
804
|
onClick={clearAll}
|
|
805
|
+
disabled={activeFilterCount === 0}
|
|
653
806
|
>
|
|
654
807
|
<X className="h-3.5 w-3.5" />
|
|
655
|
-
{t('kanban.
|
|
656
|
-
{` (${activeFilterCount})`}
|
|
808
|
+
{t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
|
|
809
|
+
{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}
|
|
657
810
|
</Button>
|
|
658
|
-
|
|
811
|
+
</div>
|
|
659
812
|
</SheetContent>
|
|
660
813
|
</Sheet>
|
|
661
814
|
)}
|
|
662
815
|
</div>
|
|
663
816
|
|
|
817
|
+
{/* Removable chip row — one chip per active field filter, plus a
|
|
818
|
+
"Limpiar todo" at the end. Instant feedback without opening the
|
|
819
|
+
Sheet; clicking a chip's X clears that field server-side. */}
|
|
820
|
+
{activeFields.length > 0 && (
|
|
821
|
+
<div
|
|
822
|
+
className="flex flex-wrap items-center gap-1.5"
|
|
823
|
+
data-testid="kanban-filter-chips"
|
|
824
|
+
>
|
|
825
|
+
{activeFields.map((field) => {
|
|
826
|
+
const summary = summarizeFilterValues(
|
|
827
|
+
field.config.selectedValues,
|
|
828
|
+
field.config.options,
|
|
829
|
+
)
|
|
830
|
+
const dot = chipValueColor(field.config)
|
|
831
|
+
return (
|
|
832
|
+
<Badge
|
|
833
|
+
key={field.key}
|
|
834
|
+
variant="secondary"
|
|
835
|
+
className="h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal"
|
|
836
|
+
>
|
|
837
|
+
{dot && (
|
|
838
|
+
<span
|
|
839
|
+
className="size-2 shrink-0 rounded-full"
|
|
840
|
+
style={{ backgroundColor: dot }}
|
|
841
|
+
/>
|
|
842
|
+
)}
|
|
843
|
+
<span className="font-medium">{field.label}:</span>
|
|
844
|
+
<span className="max-w-[180px] truncate text-muted-foreground">
|
|
845
|
+
{summary}
|
|
846
|
+
</span>
|
|
847
|
+
<button
|
|
848
|
+
type="button"
|
|
849
|
+
onClick={() =>
|
|
850
|
+
field.config.onFilterChange(
|
|
851
|
+
field.config.filterKey,
|
|
852
|
+
[],
|
|
853
|
+
)
|
|
854
|
+
}
|
|
855
|
+
className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
|
|
856
|
+
aria-label={t('kanban.removeFilter', {
|
|
857
|
+
defaultValue: 'Quitar filtro',
|
|
858
|
+
})}
|
|
859
|
+
>
|
|
860
|
+
<X className="h-3 w-3" />
|
|
861
|
+
</button>
|
|
862
|
+
</Badge>
|
|
863
|
+
)
|
|
864
|
+
})}
|
|
865
|
+
<Button
|
|
866
|
+
variant="ghost"
|
|
867
|
+
size="sm"
|
|
868
|
+
className="h-6 gap-1 px-2 text-xs text-muted-foreground"
|
|
869
|
+
onClick={clearAll}
|
|
870
|
+
>
|
|
871
|
+
{t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
|
|
872
|
+
</Button>
|
|
873
|
+
</div>
|
|
874
|
+
)}
|
|
875
|
+
|
|
664
876
|
<DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
665
877
|
<div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
|
|
666
878
|
{lanes.map((stage) => {
|
|
667
879
|
const allCards = grouped.get(stage.key) ?? []
|
|
668
|
-
// Per-lane client-side narrowing
|
|
669
|
-
//
|
|
670
|
-
//
|
|
880
|
+
// Per-lane client-side narrowing (instant, scoped to this
|
|
881
|
+
// stage). The funnel (field/value) and the lane search
|
|
882
|
+
// (query) are AND-combined.
|
|
671
883
|
const laneFilter = laneFilters[stage.key]
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
884
|
+
let cards = allCards
|
|
885
|
+
if (laneFilter?.field && laneFilter.value?.trim()) {
|
|
886
|
+
const v = laneFilter.value.trim().toLowerCase()
|
|
887
|
+
const field = laneFilter.field
|
|
888
|
+
cards = cards.filter((c) =>
|
|
889
|
+
String(c[field] ?? '')
|
|
890
|
+
.toLowerCase()
|
|
891
|
+
.includes(v),
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
if (laneFilter?.query?.trim()) {
|
|
895
|
+
cards = cards.filter((c) =>
|
|
896
|
+
cardMatchesLaneQuery(c, searchCols, laneFilter.query!),
|
|
897
|
+
)
|
|
898
|
+
}
|
|
682
899
|
const droppableAllowed =
|
|
683
900
|
!activeId ||
|
|
684
901
|
stage.key === activeStage ||
|
|
@@ -691,8 +908,14 @@ export function DynamicKanban({
|
|
|
691
908
|
totalCount={allCards.length}
|
|
692
909
|
filterFields={filterFields}
|
|
693
910
|
laneFilter={laneFilter}
|
|
694
|
-
|
|
695
|
-
|
|
911
|
+
onFunnelChange={(f) =>
|
|
912
|
+
updateLaneFilter(stage.key, {
|
|
913
|
+
field: f?.field,
|
|
914
|
+
value: f?.value,
|
|
915
|
+
})
|
|
916
|
+
}
|
|
917
|
+
onQueryChange={(q) =>
|
|
918
|
+
updateLaneFilter(stage.key, { query: q })
|
|
696
919
|
}
|
|
697
920
|
isDark={isDark}
|
|
698
921
|
dimmed={!!activeId && !droppableAllowed}
|
|
@@ -747,17 +970,103 @@ export function DynamicKanban({
|
|
|
747
970
|
)
|
|
748
971
|
}
|
|
749
972
|
|
|
973
|
+
// ---------------------------------------------------------------------------
|
|
974
|
+
// Sheet filter row — a labeled ColumnFilterControl for the Filtros panel.
|
|
975
|
+
// ---------------------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
interface SheetFilterField {
|
|
978
|
+
key: string
|
|
979
|
+
label: string
|
|
980
|
+
config: {
|
|
981
|
+
filterType: string
|
|
982
|
+
filterKey: string
|
|
983
|
+
options: { label: string; value: string; icon?: string; color?: string }[]
|
|
984
|
+
selectedValues: string[]
|
|
985
|
+
onFilterChange: (filterKey: string, values: string[]) => void
|
|
986
|
+
loading?: boolean
|
|
987
|
+
searchEndpoint?: string
|
|
988
|
+
loadOptions?: (q?: string) => Promise<any[]>
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* A per-data-type glyph for the Filtros panel rows (and their popover header):
|
|
994
|
+
* Hash for numbers, Calendar for dates, CircleDot for the pipeline stage, Tag
|
|
995
|
+
* for value pickers, ToggleLeft for booleans, Type for free text.
|
|
996
|
+
*/
|
|
997
|
+
function filterTypeIcon(filterType: string, isStage: boolean): React.ReactNode {
|
|
998
|
+
if (isStage) return <CircleDot className="h-3.5 w-3.5" />
|
|
999
|
+
switch (filterType) {
|
|
1000
|
+
case 'number_range':
|
|
1001
|
+
return <Hash className="h-3.5 w-3.5" />
|
|
1002
|
+
case 'date_range':
|
|
1003
|
+
return <Calendar className="h-3.5 w-3.5" />
|
|
1004
|
+
case 'boolean':
|
|
1005
|
+
return <ToggleLeft className="h-3.5 w-3.5" />
|
|
1006
|
+
case 'select':
|
|
1007
|
+
case 'dynamic_select':
|
|
1008
|
+
case 'facet':
|
|
1009
|
+
return <Tag className="h-3.5 w-3.5" />
|
|
1010
|
+
default:
|
|
1011
|
+
return <Type className="h-3.5 w-3.5" />
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function SheetFilterRow({
|
|
1016
|
+
field,
|
|
1017
|
+
isStage,
|
|
1018
|
+
}: {
|
|
1019
|
+
field: SheetFilterField
|
|
1020
|
+
isStage: boolean
|
|
1021
|
+
}) {
|
|
1022
|
+
const summary = summarizeFilterValues(
|
|
1023
|
+
field.config.selectedValues,
|
|
1024
|
+
field.config.options,
|
|
1025
|
+
)
|
|
1026
|
+
return (
|
|
1027
|
+
<ColumnFilterControl
|
|
1028
|
+
variant="row"
|
|
1029
|
+
align="end"
|
|
1030
|
+
icon={filterTypeIcon(field.config.filterType, isStage)}
|
|
1031
|
+
label={field.label}
|
|
1032
|
+
valueSummary={summary}
|
|
1033
|
+
filterKey={field.config.filterKey}
|
|
1034
|
+
filterType={field.config.filterType as ColumnFilterType}
|
|
1035
|
+
filterOptions={field.config.options}
|
|
1036
|
+
filterLoading={field.config.loading}
|
|
1037
|
+
filterSearchEndpoint={field.config.searchEndpoint}
|
|
1038
|
+
selectedValues={field.config.selectedValues}
|
|
1039
|
+
onFilterChange={field.config.onFilterChange}
|
|
1040
|
+
loadOptions={field.config.loadOptions}
|
|
1041
|
+
/>
|
|
1042
|
+
)
|
|
1043
|
+
}
|
|
1044
|
+
|
|
750
1045
|
// ---------------------------------------------------------------------------
|
|
751
1046
|
// Lane (droppable column)
|
|
752
1047
|
// ---------------------------------------------------------------------------
|
|
753
1048
|
|
|
1049
|
+
interface LaneFilterField {
|
|
1050
|
+
key: string
|
|
1051
|
+
label: string
|
|
1052
|
+
config?: ColumnFilterConfigLike
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** Minimal shape the lane funnel reads off a shared filter config. */
|
|
1056
|
+
interface ColumnFilterConfigLike {
|
|
1057
|
+
filterType?: string
|
|
1058
|
+
filterKey?: string
|
|
1059
|
+
options?: { label: string; value: string; color?: string }[]
|
|
1060
|
+
}
|
|
1061
|
+
|
|
754
1062
|
interface KanbanLaneProps {
|
|
755
1063
|
stage: StageMeta
|
|
756
1064
|
count: number
|
|
757
1065
|
totalCount: number
|
|
758
|
-
filterFields:
|
|
759
|
-
laneFilter:
|
|
760
|
-
|
|
1066
|
+
filterFields: LaneFilterField[]
|
|
1067
|
+
laneFilter: LaneFilterState | undefined
|
|
1068
|
+
onFunnelChange: (filter: { field: string; value: string } | null) => void
|
|
1069
|
+
onQueryChange: (query: string) => void
|
|
761
1070
|
isDark: boolean
|
|
762
1071
|
dimmed: boolean
|
|
763
1072
|
disabled: boolean
|
|
@@ -770,7 +1079,8 @@ function KanbanLane({
|
|
|
770
1079
|
totalCount,
|
|
771
1080
|
filterFields,
|
|
772
1081
|
laneFilter,
|
|
773
|
-
|
|
1082
|
+
onFunnelChange,
|
|
1083
|
+
onQueryChange,
|
|
774
1084
|
isDark,
|
|
775
1085
|
dimmed,
|
|
776
1086
|
disabled,
|
|
@@ -781,14 +1091,30 @@ function KanbanLane({
|
|
|
781
1091
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
782
1092
|
isDark,
|
|
783
1093
|
})
|
|
784
|
-
const
|
|
1094
|
+
const funnelActive = !!(laneFilter?.field && laneFilter.value?.trim())
|
|
1095
|
+
const queryActive = !!laneFilter?.query?.trim()
|
|
1096
|
+
const laneActive = funnelActive || queryActive
|
|
785
1097
|
const activeFieldLabel =
|
|
786
1098
|
filterFields.find((f) => f.key === laneFilter?.field)?.label ??
|
|
787
1099
|
laneFilter?.field
|
|
1100
|
+
|
|
1101
|
+
// Inline lane search: a Search icon expands an Input; Escape or blur-while-
|
|
1102
|
+
// empty collapses it. The query itself lives in the parent's laneFilters so
|
|
1103
|
+
// it survives collapse and combines with the funnel.
|
|
1104
|
+
const [searchOpen, setSearchOpen] = useState(queryActive)
|
|
1105
|
+
const searchRef = useRef<HTMLInputElement | null>(null)
|
|
1106
|
+
useEffect(() => {
|
|
1107
|
+
if (searchOpen) searchRef.current?.focus()
|
|
1108
|
+
}, [searchOpen])
|
|
1109
|
+
const funnelValue =
|
|
1110
|
+
laneFilter?.field && laneFilter.value
|
|
1111
|
+
? { field: laneFilter.field, value: laneFilter.value }
|
|
1112
|
+
: undefined
|
|
1113
|
+
|
|
788
1114
|
return (
|
|
789
1115
|
<div
|
|
790
1116
|
ref={setNodeRef}
|
|
791
|
-
className="flex w-[300px] shrink-0 flex-col rounded-
|
|
1117
|
+
className="group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity"
|
|
792
1118
|
style={{
|
|
793
1119
|
opacity: dimmed ? 0.45 : 1,
|
|
794
1120
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
@@ -798,25 +1124,71 @@ function KanbanLane({
|
|
|
798
1124
|
data-disabled={disabled || undefined}
|
|
799
1125
|
>
|
|
800
1126
|
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
|
|
801
|
-
<
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1127
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
1128
|
+
<Badge
|
|
1129
|
+
variant="outline"
|
|
1130
|
+
className="border-0 text-xs font-semibold"
|
|
1131
|
+
style={headerStyle}
|
|
1132
|
+
>
|
|
1133
|
+
{t(stage.label, { defaultValue: stage.label })}
|
|
1134
|
+
</Badge>
|
|
809
1135
|
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
|
810
1136
|
{laneActive ? `${count}/${totalCount}` : count}
|
|
811
1137
|
</span>
|
|
1138
|
+
</div>
|
|
1139
|
+
{/* Lane actions — dimmed until the lane (or the board row) is
|
|
1140
|
+
hovered/focused, so an idle board stays clean. Stay lit when a
|
|
1141
|
+
lane filter is active. */}
|
|
1142
|
+
<div
|
|
1143
|
+
className={`flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${
|
|
1144
|
+
laneActive || searchOpen ? 'opacity-100' : 'opacity-0'
|
|
1145
|
+
}`}
|
|
1146
|
+
>
|
|
1147
|
+
<button
|
|
1148
|
+
type="button"
|
|
1149
|
+
onClick={() => setSearchOpen((o) => !o)}
|
|
1150
|
+
className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
1151
|
+
queryActive ? 'text-primary' : 'text-muted-foreground'
|
|
1152
|
+
}`}
|
|
1153
|
+
aria-label={t('kanban.searchLane', {
|
|
1154
|
+
defaultValue: 'Buscar en la columna',
|
|
1155
|
+
})}
|
|
1156
|
+
>
|
|
1157
|
+
<Search className="h-3.5 w-3.5" />
|
|
1158
|
+
</button>
|
|
812
1159
|
<LaneFilterButton
|
|
813
1160
|
fields={filterFields}
|
|
814
|
-
value={
|
|
815
|
-
onChange={
|
|
1161
|
+
value={funnelValue}
|
|
1162
|
+
onChange={onFunnelChange}
|
|
816
1163
|
/>
|
|
817
1164
|
</div>
|
|
818
1165
|
</div>
|
|
819
|
-
{
|
|
1166
|
+
{searchOpen && (
|
|
1167
|
+
<div className="px-3 pb-1.5">
|
|
1168
|
+
<div className="relative">
|
|
1169
|
+
<Search className="pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
|
1170
|
+
<Input
|
|
1171
|
+
ref={searchRef}
|
|
1172
|
+
value={laneFilter?.query ?? ''}
|
|
1173
|
+
onChange={(e) => onQueryChange(e.target.value)}
|
|
1174
|
+
onKeyDown={(e) => {
|
|
1175
|
+
if (e.key === 'Escape') {
|
|
1176
|
+
onQueryChange('')
|
|
1177
|
+
setSearchOpen(false)
|
|
1178
|
+
}
|
|
1179
|
+
}}
|
|
1180
|
+
onBlur={() => {
|
|
1181
|
+
if (!laneFilter?.query?.trim()) setSearchOpen(false)
|
|
1182
|
+
}}
|
|
1183
|
+
placeholder={t('kanban.searchLanePlaceholder', {
|
|
1184
|
+
defaultValue: 'Buscar tarjetas...',
|
|
1185
|
+
})}
|
|
1186
|
+
className="h-7 pl-7 text-xs"
|
|
1187
|
+
/>
|
|
1188
|
+
</div>
|
|
1189
|
+
</div>
|
|
1190
|
+
)}
|
|
1191
|
+
{funnelActive && (
|
|
820
1192
|
<div className="flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground">
|
|
821
1193
|
<ListFilter className="h-3 w-3 shrink-0" />
|
|
822
1194
|
<span className="truncate">
|
|
@@ -824,7 +1196,7 @@ function KanbanLane({
|
|
|
824
1196
|
</span>
|
|
825
1197
|
<button
|
|
826
1198
|
type="button"
|
|
827
|
-
onClick={() =>
|
|
1199
|
+
onClick={() => onFunnelChange(null)}
|
|
828
1200
|
className="ml-auto rounded p-0.5 hover:bg-muted"
|
|
829
1201
|
aria-label={t('kanban.clearFilters', {
|
|
830
1202
|
defaultValue: 'Limpiar',
|
|
@@ -855,7 +1227,7 @@ function LaneFilterButton({
|
|
|
855
1227
|
value,
|
|
856
1228
|
onChange,
|
|
857
1229
|
}: {
|
|
858
|
-
fields:
|
|
1230
|
+
fields: LaneFilterField[]
|
|
859
1231
|
value: { field: string; value: string } | undefined
|
|
860
1232
|
onChange: (filter: { field: string; value: string } | null) => void
|
|
861
1233
|
}) {
|
|
@@ -872,6 +1244,12 @@ function LaneFilterButton({
|
|
|
872
1244
|
}, [open, value, fields])
|
|
873
1245
|
if (fields.length === 0) return null
|
|
874
1246
|
const active = !!(value && value.value.trim())
|
|
1247
|
+
// When the chosen field carries a known option set (select/facet with
|
|
1248
|
+
// pre-loaded values), offer those values as a Select instead of a raw text
|
|
1249
|
+
// box — so the funnel picks real values, not free strings.
|
|
1250
|
+
const selectedFieldCfg = fields.find((f) => f.key === field)?.config
|
|
1251
|
+
const valueOptions = selectedFieldCfg?.options ?? []
|
|
1252
|
+
const hasValueOptions = valueOptions.length > 0
|
|
875
1253
|
const apply = () => {
|
|
876
1254
|
if (field && text.trim()) onChange({ field, value: text })
|
|
877
1255
|
else onChange(null)
|
|
@@ -882,7 +1260,7 @@ function LaneFilterButton({
|
|
|
882
1260
|
<PopoverTrigger asChild>
|
|
883
1261
|
<button
|
|
884
1262
|
type="button"
|
|
885
|
-
className={`
|
|
1263
|
+
className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
886
1264
|
active ? 'text-primary' : 'text-muted-foreground'
|
|
887
1265
|
}`}
|
|
888
1266
|
aria-label={t('kanban.filterLane', {
|
|
@@ -893,7 +1271,15 @@ function LaneFilterButton({
|
|
|
893
1271
|
</button>
|
|
894
1272
|
</PopoverTrigger>
|
|
895
1273
|
<PopoverContent align="end" className="w-56 space-y-2 p-2">
|
|
896
|
-
<Select
|
|
1274
|
+
<Select
|
|
1275
|
+
value={field}
|
|
1276
|
+
onValueChange={(f) => {
|
|
1277
|
+
setField(f)
|
|
1278
|
+
// Reset the value when switching fields — a value picked
|
|
1279
|
+
// for one field is meaningless for another.
|
|
1280
|
+
setText('')
|
|
1281
|
+
}}
|
|
1282
|
+
>
|
|
897
1283
|
<SelectTrigger className="h-8 text-xs">
|
|
898
1284
|
<SelectValue />
|
|
899
1285
|
</SelectTrigger>
|
|
@@ -905,16 +1291,48 @@ function LaneFilterButton({
|
|
|
905
1291
|
))}
|
|
906
1292
|
</SelectContent>
|
|
907
1293
|
</Select>
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1294
|
+
{hasValueOptions ? (
|
|
1295
|
+
<Select
|
|
1296
|
+
value={text || undefined}
|
|
1297
|
+
onValueChange={(v) => {
|
|
1298
|
+
setText(v)
|
|
1299
|
+
if (field && v) onChange({ field, value: v })
|
|
1300
|
+
setOpen(false)
|
|
1301
|
+
}}
|
|
1302
|
+
>
|
|
1303
|
+
<SelectTrigger className="h-8 text-xs">
|
|
1304
|
+
<SelectValue
|
|
1305
|
+
placeholder={t('kanban.filterValue', {
|
|
1306
|
+
defaultValue: 'Valor...',
|
|
1307
|
+
})}
|
|
1308
|
+
/>
|
|
1309
|
+
</SelectTrigger>
|
|
1310
|
+
<SelectContent>
|
|
1311
|
+
{valueOptions.map((o) => (
|
|
1312
|
+
<SelectItem
|
|
1313
|
+
key={o.value}
|
|
1314
|
+
value={o.value}
|
|
1315
|
+
className="text-xs"
|
|
1316
|
+
>
|
|
1317
|
+
{o.label}
|
|
1318
|
+
</SelectItem>
|
|
1319
|
+
))}
|
|
1320
|
+
</SelectContent>
|
|
1321
|
+
</Select>
|
|
1322
|
+
) : (
|
|
1323
|
+
<Input
|
|
1324
|
+
autoFocus
|
|
1325
|
+
value={text}
|
|
1326
|
+
onChange={(e) => setText(e.target.value)}
|
|
1327
|
+
onKeyDown={(e) => {
|
|
1328
|
+
if (e.key === 'Enter') apply()
|
|
1329
|
+
}}
|
|
1330
|
+
placeholder={t('kanban.filterValue', {
|
|
1331
|
+
defaultValue: 'Valor...',
|
|
1332
|
+
})}
|
|
1333
|
+
className="h-8 text-xs"
|
|
1334
|
+
/>
|
|
1335
|
+
)}
|
|
918
1336
|
<div className="flex justify-between gap-2">
|
|
919
1337
|
<Button
|
|
920
1338
|
variant="ghost"
|