@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.
@@ -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, resolveColorCss } from '@asteby/metacore-ui/lib'
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
- * 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.
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 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('_', ' – ')
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 (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 || '…'}`
260
+ if (filter.text?.trim()) {
261
+ return raw.toLowerCase().includes(filter.text.trim().toLowerCase())
267
262
  }
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}`
263
+ return true
279
264
  }
280
265
 
281
266
  /**
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.
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 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
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
- /** Per-lane client-side filter: an optional field funnel + an optional search. */
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
- value?: string
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
- out.push({ key, label: t(rawLabel, { defaultValue: rawLabel }), config })
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 = !!(merged.field && merged.value?.trim())
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 — 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
- )}
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 && laneFilter.value?.trim()) {
886
- const v = laneFilter.value.trim().toLowerCase()
887
- const field = laneFilter.field
844
+ if (laneFilter?.field) {
888
845
  cards = cards.filter((c) =>
889
- String(c[field] ?? '')
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
- value: f?.value,
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: { field: string; value: string } | null) => void
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 funnelActive = !!(laneFilter?.field && laneFilter.value?.trim())
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
- filterFields.find((f) => f.key === laneFilter?.field)?.label ??
1099
- laneFilter?.field
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
- laneFilter?.field && laneFilter.value
1111
- ? { field: laneFilter.field, value: laneFilter.value }
1112
- : undefined
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 — 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
- >
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}: {laneFilter!.value}
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: { field: string; value: string } | undefined
1232
- onChange: (filter: { field: string; value: string } | null) => void
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 [text, setText] = useState(value?.value ?? '')
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
- setText(value?.value ?? '')
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 = !!(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
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 && text.trim()) onChange({ field, value: text })
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 align="end" className="w-56 space-y-2 p-2">
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
- {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>
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: 'Valor...',
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 justify-between gap-2">
1322
+ <div className="flex gap-1.5">
1337
1323
  <Button
1338
- variant="ghost"
1324
+ variant="outline"
1339
1325
  size="sm"
1340
- className="h-7 text-xs"
1341
- onClick={() => {
1342
- onChange(null)
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 size="sm" className="h-7 text-xs" onClick={apply}>
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>