@asteby/metacore-runtime-react 22.0.0 → 23.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/CHANGELOG.md +62 -0
- package/dist/dynamic-kanban.d.ts +20 -8
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +103 -102
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +60 -7
- package/dist/filter-chips.d.ts +60 -0
- package/dist/filter-chips.d.ts.map +1 -0
- package/dist/filter-chips.js +93 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +25 -1
- package/dist/use-facet-loaders.d.ts +23 -7
- package/dist/use-facet-loaders.d.ts.map +1 -1
- package/dist/use-facet-loaders.js +49 -8
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban.test.tsx +141 -0
- package/src/__tests__/dynamic-table-filters.test.tsx +102 -0
- package/src/__tests__/filter-chips.test.tsx +119 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +65 -4
- package/src/dynamic-kanban.tsx +189 -199
- package/src/dynamic-table.tsx +69 -6
- package/src/filter-chips.tsx +175 -0
- package/src/use-dynamic-filters.ts +26 -1
- package/src/use-facet-loaders.ts +76 -9
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -76,10 +76,15 @@ import {
|
|
|
76
76
|
SheetTrigger,
|
|
77
77
|
Skeleton,
|
|
78
78
|
} from '@asteby/metacore-ui/primitives'
|
|
79
|
-
import { ColumnFilterControl, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
|
|
80
|
-
import { generateBadgeStyles, optionColor
|
|
79
|
+
import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
|
|
80
|
+
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
|
|
81
81
|
import { useApi } from './api-context'
|
|
82
82
|
import { useDynamicFilters } from './use-dynamic-filters'
|
|
83
|
+
import {
|
|
84
|
+
FilterChipsRow,
|
|
85
|
+
summarizeFilterValues,
|
|
86
|
+
translateOptionLabels,
|
|
87
|
+
} from './filter-chips'
|
|
83
88
|
import { useMetadataCache } from './metadata-cache'
|
|
84
89
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
85
90
|
import { DynamicIcon } from './dynamic-icon'
|
|
@@ -96,6 +101,10 @@ import type {
|
|
|
96
101
|
StageTransition,
|
|
97
102
|
} from './types'
|
|
98
103
|
|
|
104
|
+
// Re-exported for tests + backward-compat: these live in ./filter-chips now
|
|
105
|
+
// (shared with DynamicTable) but were historically imported from here.
|
|
106
|
+
export { summarizeFilterValues, translateOptionLabels }
|
|
107
|
+
|
|
99
108
|
// ---------------------------------------------------------------------------
|
|
100
109
|
// Pure helpers (exported for unit tests — no React, no transport)
|
|
101
110
|
// ---------------------------------------------------------------------------
|
|
@@ -234,68 +243,37 @@ export function selectCardColumns(
|
|
|
234
243
|
}
|
|
235
244
|
|
|
236
245
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
246
|
+
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
247
|
+
* equality (IN — the card's field value must be one of them); a free-text
|
|
248
|
+
* `text` matches by case-insensitive substring. No field / no criteria → passes.
|
|
249
|
+
* Pure — exported for unit tests.
|
|
241
250
|
*/
|
|
242
|
-
export function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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('_', ' – ')
|
|
251
|
+
export function cardMatchesLaneFunnel(
|
|
252
|
+
card: any,
|
|
253
|
+
filter: { field?: string; values?: string[]; text?: string } | undefined,
|
|
254
|
+
): boolean {
|
|
255
|
+
if (!filter?.field) return true
|
|
256
|
+
const raw = String(card?.[filter.field] ?? '')
|
|
257
|
+
if (filter.values && filter.values.length > 0) {
|
|
258
|
+
return filter.values.includes(raw)
|
|
262
259
|
}
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? ''
|
|
266
|
-
return `${min || '…'} – ${max || '…'}`
|
|
260
|
+
if (filter.text?.trim()) {
|
|
261
|
+
return raw.toLowerCase().includes(filter.text.trim().toLowerCase())
|
|
267
262
|
}
|
|
268
|
-
return
|
|
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}`
|
|
263
|
+
return true
|
|
279
264
|
}
|
|
280
265
|
|
|
281
266
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
267
|
+
* Count of applied criteria on a lane funnel: the number of picked select/facet
|
|
268
|
+
* `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
|
|
269
|
+
* badge. Pure — exported for unit tests.
|
|
285
270
|
*/
|
|
286
|
-
function
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
271
|
+
export function laneFunnelCount(
|
|
272
|
+
value: { values?: string[]; text?: string } | undefined,
|
|
273
|
+
): number {
|
|
274
|
+
if (value?.values?.length) return value.values.length
|
|
275
|
+
if (value?.text?.trim()) return 1
|
|
276
|
+
return 0
|
|
299
277
|
}
|
|
300
278
|
|
|
301
279
|
/**
|
|
@@ -345,10 +323,17 @@ function useIsDarkTheme(): boolean {
|
|
|
345
323
|
// Component
|
|
346
324
|
// ---------------------------------------------------------------------------
|
|
347
325
|
|
|
348
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* Per-lane client-side filter. Two AND-combined dimensions:
|
|
328
|
+
* - The funnel: a `field` plus EITHER `values` (chosen from a select/facet —
|
|
329
|
+
* matched by equality/IN against the card's field value) OR `text` (a
|
|
330
|
+
* free-text substring for text-only fields).
|
|
331
|
+
* - `query`: the lane search — a substring over the card title + field values.
|
|
332
|
+
*/
|
|
349
333
|
interface LaneFilterState {
|
|
350
334
|
field?: string
|
|
351
|
-
|
|
335
|
+
values?: string[]
|
|
336
|
+
text?: string
|
|
352
337
|
query?: string
|
|
353
338
|
}
|
|
354
339
|
|
|
@@ -501,11 +486,32 @@ export function DynamicKanban({
|
|
|
501
486
|
label: string
|
|
502
487
|
config: NonNullable<ReturnType<typeof columnFilterConfigs.get>>
|
|
503
488
|
}[] = []
|
|
489
|
+
// Option labels come from the manifest as i18n keys (e.g.
|
|
490
|
+
// "integration_github.stage.backlog"). ColumnFilterControl lives in the
|
|
491
|
+
// ui package (no i18n), so translate labels HERE — on the static options
|
|
492
|
+
// and on whatever the facet loader resolves — before they ever reach a
|
|
493
|
+
// control, chip or value summary. A raw value (a repo name) has no key,
|
|
494
|
+
// so t() returns it verbatim via defaultValue.
|
|
495
|
+
const tr = (label: string) => t(label, { defaultValue: label })
|
|
504
496
|
for (const [key, config] of columnFilterConfigs) {
|
|
505
497
|
const f = metadata.filters?.find((x) => x.key === key)
|
|
506
498
|
const c = metadata.columns.find((x) => x.key === key)
|
|
507
499
|
const rawLabel = f?.label || c?.label || key
|
|
508
|
-
|
|
500
|
+
const translatedConfig = {
|
|
501
|
+
...config,
|
|
502
|
+
options: translateOptionLabels(config.options, tr),
|
|
503
|
+
loadOptions: config.loadOptions
|
|
504
|
+
? (q?: string) =>
|
|
505
|
+
config.loadOptions!(q).then((opts) =>
|
|
506
|
+
translateOptionLabels(opts, tr),
|
|
507
|
+
)
|
|
508
|
+
: undefined,
|
|
509
|
+
}
|
|
510
|
+
out.push({
|
|
511
|
+
key,
|
|
512
|
+
label: tr(rawLabel),
|
|
513
|
+
config: translatedConfig,
|
|
514
|
+
})
|
|
509
515
|
}
|
|
510
516
|
return out
|
|
511
517
|
}, [metadata, columnFilterConfigs, t])
|
|
@@ -542,7 +548,11 @@ export function DynamicKanban({
|
|
|
542
548
|
setLaneFilters((prev) => {
|
|
543
549
|
const merged: LaneFilterState = { ...prev[stageKey], ...patch }
|
|
544
550
|
const next = { ...prev }
|
|
545
|
-
const hasFunnel = !!(
|
|
551
|
+
const hasFunnel = !!(
|
|
552
|
+
merged.field &&
|
|
553
|
+
((merged.values && merged.values.length > 0) ||
|
|
554
|
+
merged.text?.trim())
|
|
555
|
+
)
|
|
546
556
|
const hasQuery = !!merged.query?.trim()
|
|
547
557
|
if (hasFunnel || hasQuery) next[stageKey] = merged
|
|
548
558
|
else delete next[stageKey]
|
|
@@ -814,64 +824,13 @@ export function DynamicKanban({
|
|
|
814
824
|
)}
|
|
815
825
|
</div>
|
|
816
826
|
|
|
817
|
-
{/* Removable chip row —
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
)}
|
|
827
|
+
{/* Removable chip row — shared with DynamicTable. Instant feedback
|
|
828
|
+
without opening the Sheet; a chip's X clears that field. */}
|
|
829
|
+
<FilterChipsRow
|
|
830
|
+
fields={activeFields}
|
|
831
|
+
onClearAll={clearAll}
|
|
832
|
+
data-testid="kanban-filter-chips"
|
|
833
|
+
/>
|
|
875
834
|
|
|
876
835
|
<DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
877
836
|
<div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
|
|
@@ -882,13 +841,9 @@ export function DynamicKanban({
|
|
|
882
841
|
// (query) are AND-combined.
|
|
883
842
|
const laneFilter = laneFilters[stage.key]
|
|
884
843
|
let cards = allCards
|
|
885
|
-
if (laneFilter?.field
|
|
886
|
-
const v = laneFilter.value.trim().toLowerCase()
|
|
887
|
-
const field = laneFilter.field
|
|
844
|
+
if (laneFilter?.field) {
|
|
888
845
|
cards = cards.filter((c) =>
|
|
889
|
-
|
|
890
|
-
.toLowerCase()
|
|
891
|
-
.includes(v),
|
|
846
|
+
cardMatchesLaneFunnel(c, laneFilter),
|
|
892
847
|
)
|
|
893
848
|
}
|
|
894
849
|
if (laneFilter?.query?.trim()) {
|
|
@@ -911,7 +866,8 @@ export function DynamicKanban({
|
|
|
911
866
|
onFunnelChange={(f) =>
|
|
912
867
|
updateLaneFilter(stage.key, {
|
|
913
868
|
field: f?.field,
|
|
914
|
-
|
|
869
|
+
values: f?.values,
|
|
870
|
+
text: f?.text,
|
|
915
871
|
})
|
|
916
872
|
}
|
|
917
873
|
onQueryChange={(q) =>
|
|
@@ -1056,7 +1012,17 @@ interface LaneFilterField {
|
|
|
1056
1012
|
interface ColumnFilterConfigLike {
|
|
1057
1013
|
filterType?: string
|
|
1058
1014
|
filterKey?: string
|
|
1059
|
-
options?: { label: string; value: string; color?: string }[]
|
|
1015
|
+
options?: { label: string; value: string; color?: string; count?: number }[]
|
|
1016
|
+
loadOptions?: (q?: string) => Promise<
|
|
1017
|
+
{ label: string; value: string; color?: string; count?: number }[]
|
|
1018
|
+
>
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** The funnel's committed value: a field + either picked `values` or free `text`. */
|
|
1022
|
+
interface LaneFunnelValue {
|
|
1023
|
+
field: string
|
|
1024
|
+
values?: string[]
|
|
1025
|
+
text?: string
|
|
1060
1026
|
}
|
|
1061
1027
|
|
|
1062
1028
|
interface KanbanLaneProps {
|
|
@@ -1065,7 +1031,7 @@ interface KanbanLaneProps {
|
|
|
1065
1031
|
totalCount: number
|
|
1066
1032
|
filterFields: LaneFilterField[]
|
|
1067
1033
|
laneFilter: LaneFilterState | undefined
|
|
1068
|
-
onFunnelChange: (filter:
|
|
1034
|
+
onFunnelChange: (filter: LaneFunnelValue | null) => void
|
|
1069
1035
|
onQueryChange: (query: string) => void
|
|
1070
1036
|
isDark: boolean
|
|
1071
1037
|
dimmed: boolean
|
|
@@ -1091,12 +1057,21 @@ function KanbanLane({
|
|
|
1091
1057
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
1092
1058
|
isDark,
|
|
1093
1059
|
})
|
|
1094
|
-
const
|
|
1060
|
+
const funnelField = filterFields.find((f) => f.key === laneFilter?.field)
|
|
1061
|
+
const funnelActive = !!(
|
|
1062
|
+
laneFilter?.field &&
|
|
1063
|
+
((laneFilter.values && laneFilter.values.length > 0) ||
|
|
1064
|
+
laneFilter.text?.trim())
|
|
1065
|
+
)
|
|
1095
1066
|
const queryActive = !!laneFilter?.query?.trim()
|
|
1096
1067
|
const laneActive = funnelActive || queryActive
|
|
1097
|
-
const activeFieldLabel =
|
|
1098
|
-
|
|
1099
|
-
|
|
1068
|
+
const activeFieldLabel = funnelField?.label ?? laneFilter?.field
|
|
1069
|
+
// Human summary of the funnel value: resolved option labels for picked
|
|
1070
|
+
// values, or the raw free text.
|
|
1071
|
+
const funnelSummary =
|
|
1072
|
+
laneFilter?.values && laneFilter.values.length > 0
|
|
1073
|
+
? summarizeFilterValues(laneFilter.values, funnelField?.config?.options)
|
|
1074
|
+
: laneFilter?.text ?? ''
|
|
1100
1075
|
|
|
1101
1076
|
// Inline lane search: a Search icon expands an Input; Escape or blur-while-
|
|
1102
1077
|
// empty collapses it. The query itself lives in the parent's laneFilters so
|
|
@@ -1106,10 +1081,13 @@ function KanbanLane({
|
|
|
1106
1081
|
useEffect(() => {
|
|
1107
1082
|
if (searchOpen) searchRef.current?.focus()
|
|
1108
1083
|
}, [searchOpen])
|
|
1109
|
-
const funnelValue =
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1084
|
+
const funnelValue: LaneFunnelValue | undefined = laneFilter?.field
|
|
1085
|
+
? {
|
|
1086
|
+
field: laneFilter.field,
|
|
1087
|
+
values: laneFilter.values,
|
|
1088
|
+
text: laneFilter.text,
|
|
1089
|
+
}
|
|
1090
|
+
: undefined
|
|
1113
1091
|
|
|
1114
1092
|
return (
|
|
1115
1093
|
<div
|
|
@@ -1136,18 +1114,14 @@ function KanbanLane({
|
|
|
1136
1114
|
{laneActive ? `${count}/${totalCount}` : count}
|
|
1137
1115
|
</span>
|
|
1138
1116
|
</div>
|
|
1139
|
-
{/* Lane actions —
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
>
|
|
1117
|
+
{/* Lane actions — always visible in muted (a hidden hover-reveal
|
|
1118
|
+
was undiscoverable); active state is a primary tint + a count
|
|
1119
|
+
badge on the funnel. */}
|
|
1120
|
+
<div className="flex items-center gap-0.5">
|
|
1147
1121
|
<button
|
|
1148
1122
|
type="button"
|
|
1149
1123
|
onClick={() => setSearchOpen((o) => !o)}
|
|
1150
|
-
className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
1124
|
+
className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
1151
1125
|
queryActive ? 'text-primary' : 'text-muted-foreground'
|
|
1152
1126
|
}`}
|
|
1153
1127
|
aria-label={t('kanban.searchLane', {
|
|
@@ -1155,6 +1129,9 @@ function KanbanLane({
|
|
|
1155
1129
|
})}
|
|
1156
1130
|
>
|
|
1157
1131
|
<Search className="h-3.5 w-3.5" />
|
|
1132
|
+
{queryActive && (
|
|
1133
|
+
<span className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" />
|
|
1134
|
+
)}
|
|
1158
1135
|
</button>
|
|
1159
1136
|
<LaneFilterButton
|
|
1160
1137
|
fields={filterFields}
|
|
@@ -1192,7 +1169,7 @@ function KanbanLane({
|
|
|
1192
1169
|
<div className="flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground">
|
|
1193
1170
|
<ListFilter className="h-3 w-3 shrink-0" />
|
|
1194
1171
|
<span className="truncate">
|
|
1195
|
-
{activeFieldLabel}: {
|
|
1172
|
+
{activeFieldLabel}: {funnelSummary}
|
|
1196
1173
|
</span>
|
|
1197
1174
|
<button
|
|
1198
1175
|
type="button"
|
|
@@ -1228,39 +1205,55 @@ function LaneFilterButton({
|
|
|
1228
1205
|
onChange,
|
|
1229
1206
|
}: {
|
|
1230
1207
|
fields: LaneFilterField[]
|
|
1231
|
-
value:
|
|
1232
|
-
onChange: (filter:
|
|
1208
|
+
value: LaneFunnelValue | undefined
|
|
1209
|
+
onChange: (filter: LaneFunnelValue | null) => void
|
|
1233
1210
|
}) {
|
|
1234
1211
|
const { t } = useTranslation()
|
|
1235
1212
|
const [open, setOpen] = useState(false)
|
|
1236
1213
|
const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '')
|
|
1237
|
-
const [
|
|
1214
|
+
const [values, setValues] = useState<string[]>(value?.values ?? [])
|
|
1215
|
+
const [text, setText] = useState(value?.text ?? '')
|
|
1238
1216
|
// Re-seed the draft from the committed filter each time the popover opens.
|
|
1239
1217
|
useEffect(() => {
|
|
1240
1218
|
if (open) {
|
|
1241
1219
|
setField(value?.field ?? fields[0]?.key ?? '')
|
|
1242
|
-
|
|
1220
|
+
setValues(value?.values ?? [])
|
|
1221
|
+
setText(value?.text ?? '')
|
|
1243
1222
|
}
|
|
1244
1223
|
}, [open, value, fields])
|
|
1245
1224
|
if (fields.length === 0) return null
|
|
1246
|
-
const active = !!(
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1225
|
+
const active = !!(
|
|
1226
|
+
value &&
|
|
1227
|
+
((value.values && value.values.length > 0) || value.text?.trim())
|
|
1228
|
+
)
|
|
1229
|
+
// Number of applied criteria on this lane's funnel (drives the count badge).
|
|
1230
|
+
const activeCount = laneFunnelCount(value)
|
|
1231
|
+
// The value step mirrors the sheet: when the chosen field is a select or a
|
|
1232
|
+
// facet (static options OR a lazy loader), render the SAME pro combobox —
|
|
1233
|
+
// multi-select, searchable, with counts. Only a genuinely free-text field
|
|
1234
|
+
// (no options, no loader) falls back to a raw "Contiene..." input.
|
|
1235
|
+
const cfg = fields.find((f) => f.key === field)?.config
|
|
1236
|
+
const hasValuePicker = (cfg?.options?.length ?? 0) > 0 || !!cfg?.loadOptions
|
|
1237
|
+
const toggle = (v: string) =>
|
|
1238
|
+
setValues((prev) =>
|
|
1239
|
+
prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v],
|
|
1240
|
+
)
|
|
1253
1241
|
const apply = () => {
|
|
1254
|
-
if (field &&
|
|
1242
|
+
if (field && values.length > 0) onChange({ field, values })
|
|
1243
|
+
else if (field && text.trim()) onChange({ field, text: text.trim() })
|
|
1255
1244
|
else onChange(null)
|
|
1256
1245
|
setOpen(false)
|
|
1257
1246
|
}
|
|
1247
|
+
const clear = () => {
|
|
1248
|
+
onChange(null)
|
|
1249
|
+
setOpen(false)
|
|
1250
|
+
}
|
|
1258
1251
|
return (
|
|
1259
1252
|
<Popover open={open} onOpenChange={setOpen}>
|
|
1260
1253
|
<PopoverTrigger asChild>
|
|
1261
1254
|
<button
|
|
1262
1255
|
type="button"
|
|
1263
|
-
className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
1256
|
+
className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
1264
1257
|
active ? 'text-primary' : 'text-muted-foreground'
|
|
1265
1258
|
}`}
|
|
1266
1259
|
aria-label={t('kanban.filterLane', {
|
|
@@ -1268,19 +1261,28 @@ function LaneFilterButton({
|
|
|
1268
1261
|
})}
|
|
1269
1262
|
>
|
|
1270
1263
|
<ListFilter className="h-3.5 w-3.5" />
|
|
1264
|
+
{activeCount > 0 && (
|
|
1265
|
+
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[9px] font-bold leading-none text-primary-foreground tabular-nums">
|
|
1266
|
+
{activeCount}
|
|
1267
|
+
</span>
|
|
1268
|
+
)}
|
|
1271
1269
|
</button>
|
|
1272
1270
|
</PopoverTrigger>
|
|
1273
|
-
<PopoverContent
|
|
1271
|
+
<PopoverContent
|
|
1272
|
+
align="end"
|
|
1273
|
+
className="w-72 space-y-2.5 rounded-xl p-2.5 shadow-lg"
|
|
1274
|
+
>
|
|
1274
1275
|
<Select
|
|
1275
1276
|
value={field}
|
|
1276
1277
|
onValueChange={(f) => {
|
|
1277
1278
|
setField(f)
|
|
1278
1279
|
// Reset the value when switching fields — a value picked
|
|
1279
1280
|
// for one field is meaningless for another.
|
|
1281
|
+
setValues([])
|
|
1280
1282
|
setText('')
|
|
1281
1283
|
}}
|
|
1282
1284
|
>
|
|
1283
|
-
<SelectTrigger className="h-8 text-xs">
|
|
1285
|
+
<SelectTrigger className="h-8 w-full text-xs">
|
|
1284
1286
|
<SelectValue />
|
|
1285
1287
|
</SelectTrigger>
|
|
1286
1288
|
<SelectContent>
|
|
@@ -1291,34 +1293,18 @@ function LaneFilterButton({
|
|
|
1291
1293
|
))}
|
|
1292
1294
|
</SelectContent>
|
|
1293
1295
|
</Select>
|
|
1294
|
-
{
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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>
|
|
1296
|
+
{hasValuePicker ? (
|
|
1297
|
+
// `key={field}` remounts the combobox on field switch so it
|
|
1298
|
+
// reloads that field's values from scratch (no stale list).
|
|
1299
|
+
<div className="overflow-hidden rounded-lg border">
|
|
1300
|
+
<FilterValueCombobox
|
|
1301
|
+
key={field}
|
|
1302
|
+
staticOptions={cfg?.options}
|
|
1303
|
+
loadOptions={cfg?.loadOptions}
|
|
1304
|
+
selected={values}
|
|
1305
|
+
onToggle={toggle}
|
|
1306
|
+
/>
|
|
1307
|
+
</div>
|
|
1322
1308
|
) : (
|
|
1323
1309
|
<Input
|
|
1324
1310
|
autoFocus
|
|
@@ -1328,25 +1314,29 @@ function LaneFilterButton({
|
|
|
1328
1314
|
if (e.key === 'Enter') apply()
|
|
1329
1315
|
}}
|
|
1330
1316
|
placeholder={t('kanban.filterValue', {
|
|
1331
|
-
defaultValue: '
|
|
1317
|
+
defaultValue: 'Contiene...',
|
|
1332
1318
|
})}
|
|
1333
|
-
className="h-8 text-xs"
|
|
1319
|
+
className="h-8 w-full text-xs"
|
|
1334
1320
|
/>
|
|
1335
1321
|
)}
|
|
1336
|
-
<div className="flex
|
|
1322
|
+
<div className="flex gap-1.5">
|
|
1337
1323
|
<Button
|
|
1338
|
-
variant="
|
|
1324
|
+
variant="outline"
|
|
1339
1325
|
size="sm"
|
|
1340
|
-
className="h-7 text-xs"
|
|
1341
|
-
onClick={
|
|
1342
|
-
|
|
1343
|
-
setOpen(false)
|
|
1344
|
-
}}
|
|
1326
|
+
className="h-7 flex-1 text-xs"
|
|
1327
|
+
onClick={clear}
|
|
1328
|
+
disabled={!active && values.length === 0 && !text.trim()}
|
|
1345
1329
|
>
|
|
1346
1330
|
{t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
|
|
1347
1331
|
</Button>
|
|
1348
|
-
<Button
|
|
1332
|
+
<Button
|
|
1333
|
+
size="sm"
|
|
1334
|
+
className="h-7 flex-1 text-xs"
|
|
1335
|
+
onClick={apply}
|
|
1336
|
+
disabled={values.length === 0 && !text.trim()}
|
|
1337
|
+
>
|
|
1349
1338
|
{t('kanban.apply', { defaultValue: 'Aplicar' })}
|
|
1339
|
+
{values.length > 0 ? ` (${values.length})` : ''}
|
|
1350
1340
|
</Button>
|
|
1351
1341
|
</div>
|
|
1352
1342
|
</PopoverContent>
|