@asteby/metacore-runtime-react 22.0.0 → 23.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.
@@ -39,6 +39,8 @@ import {
39
39
  applyOptimisticMove,
40
40
  selectCardColumns,
41
41
  cardMatchesLaneQuery,
42
+ cardMatchesLaneFunnel,
43
+ translateOptionLabels,
42
44
  summarizeFilterValues,
43
45
  UNASSIGNED_LANE,
44
46
  DynamicKanban,
@@ -232,6 +234,80 @@ describe('cardMatchesLaneQuery', () => {
232
234
  })
233
235
  })
234
236
 
237
+ describe('cardMatchesLaneFunnel', () => {
238
+ // card 4: title "Refactor api", assignee "ana", priority "mid", stage "in_progress"
239
+ const card = CARDS[3]
240
+
241
+ it('matches picked select/facet values by equality (IN), not substring', () => {
242
+ // exact value matches
243
+ expect(
244
+ cardMatchesLaneFunnel(card, { field: 'assignee', values: ['ana'] }),
245
+ ).toBe(true)
246
+ // a substring of the value must NOT match under equality
247
+ expect(
248
+ cardMatchesLaneFunnel(card, { field: 'assignee', values: ['an'] }),
249
+ ).toBe(false)
250
+ // IN semantics: any of the picked values
251
+ expect(
252
+ cardMatchesLaneFunnel(card, {
253
+ field: 'assignee',
254
+ values: ['dani', 'ana'],
255
+ }),
256
+ ).toBe(true)
257
+ expect(
258
+ cardMatchesLaneFunnel(card, {
259
+ field: 'assignee',
260
+ values: ['dani', 'eva'],
261
+ }),
262
+ ).toBe(false)
263
+ })
264
+
265
+ it('matches free text by case-insensitive substring', () => {
266
+ expect(cardMatchesLaneFunnel(card, { field: 'title', text: 'refac' })).toBe(
267
+ true,
268
+ )
269
+ expect(cardMatchesLaneFunnel(card, { field: 'title', text: 'REFAC' })).toBe(
270
+ true,
271
+ )
272
+ expect(cardMatchesLaneFunnel(card, { field: 'title', text: 'zzz' })).toBe(
273
+ false,
274
+ )
275
+ })
276
+
277
+ it('passes when there is no field or no criteria', () => {
278
+ expect(cardMatchesLaneFunnel(card, undefined)).toBe(true)
279
+ expect(cardMatchesLaneFunnel(card, { field: 'assignee' })).toBe(true)
280
+ expect(
281
+ cardMatchesLaneFunnel(card, { field: 'assignee', values: [] }),
282
+ ).toBe(true)
283
+ })
284
+ })
285
+
286
+ describe('translateOptionLabels', () => {
287
+ it('runs option labels through the translator (stage i18n keys → localized)', () => {
288
+ const opts = [
289
+ { value: 'backlog', label: 'issue.stage.backlog', color: 'slate' },
290
+ { value: 'done', label: 'issue.stage.done', color: 'green' },
291
+ ]
292
+ const dict: Record<string, string> = {
293
+ 'issue.stage.backlog': 'Pendiente',
294
+ 'issue.stage.done': 'Hecho',
295
+ }
296
+ const out = translateOptionLabels(opts, (k) => dict[k] ?? k)
297
+ expect(out.map((o) => o.label)).toEqual(['Pendiente', 'Hecho'])
298
+ // value + color preserved
299
+ expect(out[0]).toMatchObject({ value: 'backlog', color: 'slate' })
300
+ })
301
+
302
+ it('leaves raw values (no matching key) untouched', () => {
303
+ const out = translateOptionLabels(
304
+ [{ value: 'acme/repo', label: 'acme/repo' }],
305
+ (k) => k,
306
+ )
307
+ expect(out[0].label).toBe('acme/repo')
308
+ })
309
+ })
310
+
235
311
  describe('summarizeFilterValues', () => {
236
312
  const opts = [
237
313
  { value: 'backlog', label: 'Backlog' },
@@ -428,3 +504,28 @@ describe('DynamicKanban lane search', () => {
428
504
  expect(screen.getByText('Fix login bug')).toBeTruthy()
429
505
  })
430
506
  })
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // 6. Lane funnel — for a field with options (the Stage select), the value step
510
+ // renders the pro multi-select combobox, NOT a raw text input.
511
+ // ---------------------------------------------------------------------------
512
+
513
+ describe('DynamicKanban lane funnel', () => {
514
+ it('renders the value combobox for a field that has options', async () => {
515
+ useMetadataCache.getState().setMetadata('issue', meta())
516
+ render(
517
+ <ApiProvider client={fakeApi()}>
518
+ <DynamicKanban model="issue" />
519
+ </ApiProvider>,
520
+ )
521
+ await screen.findByText('Fix login bug')
522
+ // open the first lane's funnel (the only filterable field is Stage → select)
523
+ const funnelButtons = screen.getAllByLabelText('Filtrar columna')
524
+ fireEvent.click(funnelButtons[0])
525
+ // the value step is the searchable combobox, not the old raw text box
526
+ expect(
527
+ await screen.findByPlaceholderText('Buscar valores...'),
528
+ ).toBeTruthy()
529
+ expect(screen.queryByPlaceholderText('Contiene...')).toBeNull()
530
+ })
531
+ })
@@ -169,14 +169,75 @@ describe('useDynamicFilters — facet upgrade + fallback (B)', () => {
169
169
  () => useDynamicFilters(meta(), { model: 'issue' }),
170
170
  { wrapper: wrapper(api) },
171
171
  )
172
+ const titleFacetCalls = () =>
173
+ (api.get as any).mock.calls.filter(
174
+ (c: any[]) =>
175
+ String(c[0]).endsWith('/facets') &&
176
+ c[1]?.params?.field === 'title' &&
177
+ !c[1]?.params?.q,
178
+ ).length
179
+ // Let the prewarm settle first, then reopening (empty query) is cached.
180
+ await waitFor(() => expect(titleFacetCalls()).toBe(1))
172
181
  const load = result.current.columnFilterConfigs.get('title')!.loadOptions!
173
182
  await load('')
174
183
  await load('')
175
- // Second call served from cache → only one network hit for that key.
184
+ expect(titleFacetCalls()).toBe(1)
185
+ })
186
+ })
187
+
188
+ describe('useDynamicFilters — facet prefetch', () => {
189
+ it('prewarms facet options on mount so the config carries them (no lazy open)', async () => {
190
+ const api = fakeApi()
191
+ const { result } = renderHook(
192
+ () => useDynamicFilters(meta(), { model: 'issue' }),
193
+ { wrapper: wrapper(api) },
194
+ )
195
+ // The prefetch burst fires an empty-query request per facet field.
196
+ await waitFor(() =>
197
+ expect(
198
+ (api.get as any).mock.calls.some(
199
+ (c: any[]) =>
200
+ String(c[0]) === '/data/issue/facets' &&
201
+ c[1]?.params?.field === 'title' &&
202
+ c[1]?.params?.q === undefined,
203
+ ),
204
+ ).toBe(true),
205
+ )
206
+ // …and those values land directly on the config (popover opens instantly).
207
+ await waitFor(() =>
208
+ expect(
209
+ result.current.columnFilterConfigs.get('title')?.options,
210
+ ).toEqual([
211
+ { value: 'ana', label: 'Ana', count: 3 },
212
+ { value: 'beto', label: 'Beto', count: 1 },
213
+ ]),
214
+ )
215
+ })
216
+
217
+ it('opening a prewarmed facet is a cache hit (no extra request)', async () => {
218
+ const api = fakeApi()
219
+ const { result } = renderHook(
220
+ () => useDynamicFilters(meta(), { model: 'issue' }),
221
+ { wrapper: wrapper(api) },
222
+ )
223
+ // wait for prefetch to settle
176
224
  await waitFor(() =>
177
- expect((api.get as any).mock.calls.filter((c: any[]) =>
178
- String(c[0]).endsWith('/facets'),
179
- ).length).toBe(1),
225
+ expect(
226
+ result.current.columnFilterConfigs.get('title')?.options?.length,
227
+ ).toBe(2),
180
228
  )
229
+ const before = (api.get as any).mock.calls.filter(
230
+ (c: any[]) =>
231
+ String(c[0]).endsWith('/facets') &&
232
+ c[1]?.params?.field === 'title',
233
+ ).length
234
+ // the popover's empty-query load reuses the prewarmed cache
235
+ await result.current.columnFilterConfigs.get('title')!.loadOptions!('')
236
+ const after = (api.get as any).mock.calls.filter(
237
+ (c: any[]) =>
238
+ String(c[0]).endsWith('/facets') &&
239
+ c[1]?.params?.field === 'title',
240
+ ).length
241
+ expect(after).toBe(before)
181
242
  })
182
243
  })
@@ -76,7 +76,7 @@ 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'
79
+ import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
80
80
  import { generateBadgeStyles, optionColor, resolveColorCss } from '@asteby/metacore-ui/lib'
81
81
  import { useApi } from './api-context'
82
82
  import { useDynamicFilters } from './use-dynamic-filters'
@@ -298,6 +298,38 @@ function chipValueColor(config: {
298
298
  return opt?.color ? resolveColorCss(opt.color) : undefined
299
299
  }
300
300
 
301
+ /**
302
+ * Whether a card passes a lane funnel. Picked select/facet `values` match by
303
+ * equality (IN — the card's field value must be one of them); a free-text
304
+ * `text` matches by case-insensitive substring. No field / no criteria → passes.
305
+ * Pure — exported for unit tests.
306
+ */
307
+ export function cardMatchesLaneFunnel(
308
+ card: any,
309
+ filter: { field?: string; values?: string[]; text?: string } | undefined,
310
+ ): boolean {
311
+ if (!filter?.field) return true
312
+ const raw = String(card?.[filter.field] ?? '')
313
+ if (filter.values && filter.values.length > 0) {
314
+ return filter.values.includes(raw)
315
+ }
316
+ if (filter.text?.trim()) {
317
+ return raw.toLowerCase().includes(filter.text.trim().toLowerCase())
318
+ }
319
+ return true
320
+ }
321
+
322
+ /**
323
+ * Translates option labels through the app translator (manifest i18n keys →
324
+ * localized text). A raw value with no matching key falls through to itself via
325
+ * `defaultValue`. Pure — exported for unit tests.
326
+ */
327
+ export function translateOptionLabels<
328
+ T extends { label: string },
329
+ >(options: T[], translate: (key: string) => string): T[] {
330
+ return options.map((o) => ({ ...o, label: translate(o.label) }))
331
+ }
332
+
301
333
  /**
302
334
  * Whether a card matches a free-text lane search: a case-insensitive substring
303
335
  * over the card's title + every visible field value (`String(v)`). Empty query
@@ -345,10 +377,17 @@ function useIsDarkTheme(): boolean {
345
377
  // Component
346
378
  // ---------------------------------------------------------------------------
347
379
 
348
- /** Per-lane client-side filter: an optional field funnel + an optional search. */
380
+ /**
381
+ * Per-lane client-side filter. Two AND-combined dimensions:
382
+ * - The funnel: a `field` plus EITHER `values` (chosen from a select/facet —
383
+ * matched by equality/IN against the card's field value) OR `text` (a
384
+ * free-text substring for text-only fields).
385
+ * - `query`: the lane search — a substring over the card title + field values.
386
+ */
349
387
  interface LaneFilterState {
350
388
  field?: string
351
- value?: string
389
+ values?: string[]
390
+ text?: string
352
391
  query?: string
353
392
  }
354
393
 
@@ -501,11 +540,32 @@ export function DynamicKanban({
501
540
  label: string
502
541
  config: NonNullable<ReturnType<typeof columnFilterConfigs.get>>
503
542
  }[] = []
543
+ // Option labels come from the manifest as i18n keys (e.g.
544
+ // "integration_github.stage.backlog"). ColumnFilterControl lives in the
545
+ // ui package (no i18n), so translate labels HERE — on the static options
546
+ // and on whatever the facet loader resolves — before they ever reach a
547
+ // control, chip or value summary. A raw value (a repo name) has no key,
548
+ // so t() returns it verbatim via defaultValue.
549
+ const tr = (label: string) => t(label, { defaultValue: label })
504
550
  for (const [key, config] of columnFilterConfigs) {
505
551
  const f = metadata.filters?.find((x) => x.key === key)
506
552
  const c = metadata.columns.find((x) => x.key === key)
507
553
  const rawLabel = f?.label || c?.label || key
508
- out.push({ key, label: t(rawLabel, { defaultValue: rawLabel }), config })
554
+ const translatedConfig = {
555
+ ...config,
556
+ options: translateOptionLabels(config.options, tr),
557
+ loadOptions: config.loadOptions
558
+ ? (q?: string) =>
559
+ config.loadOptions!(q).then((opts) =>
560
+ translateOptionLabels(opts, tr),
561
+ )
562
+ : undefined,
563
+ }
564
+ out.push({
565
+ key,
566
+ label: tr(rawLabel),
567
+ config: translatedConfig,
568
+ })
509
569
  }
510
570
  return out
511
571
  }, [metadata, columnFilterConfigs, t])
@@ -542,7 +602,11 @@ export function DynamicKanban({
542
602
  setLaneFilters((prev) => {
543
603
  const merged: LaneFilterState = { ...prev[stageKey], ...patch }
544
604
  const next = { ...prev }
545
- const hasFunnel = !!(merged.field && merged.value?.trim())
605
+ const hasFunnel = !!(
606
+ merged.field &&
607
+ ((merged.values && merged.values.length > 0) ||
608
+ merged.text?.trim())
609
+ )
546
610
  const hasQuery = !!merged.query?.trim()
547
611
  if (hasFunnel || hasQuery) next[stageKey] = merged
548
612
  else delete next[stageKey]
@@ -882,13 +946,9 @@ export function DynamicKanban({
882
946
  // (query) are AND-combined.
883
947
  const laneFilter = laneFilters[stage.key]
884
948
  let cards = allCards
885
- if (laneFilter?.field && laneFilter.value?.trim()) {
886
- const v = laneFilter.value.trim().toLowerCase()
887
- const field = laneFilter.field
949
+ if (laneFilter?.field) {
888
950
  cards = cards.filter((c) =>
889
- String(c[field] ?? '')
890
- .toLowerCase()
891
- .includes(v),
951
+ cardMatchesLaneFunnel(c, laneFilter),
892
952
  )
893
953
  }
894
954
  if (laneFilter?.query?.trim()) {
@@ -911,7 +971,8 @@ export function DynamicKanban({
911
971
  onFunnelChange={(f) =>
912
972
  updateLaneFilter(stage.key, {
913
973
  field: f?.field,
914
- value: f?.value,
974
+ values: f?.values,
975
+ text: f?.text,
915
976
  })
916
977
  }
917
978
  onQueryChange={(q) =>
@@ -1056,7 +1117,17 @@ interface LaneFilterField {
1056
1117
  interface ColumnFilterConfigLike {
1057
1118
  filterType?: string
1058
1119
  filterKey?: string
1059
- options?: { label: string; value: string; color?: string }[]
1120
+ options?: { label: string; value: string; color?: string; count?: number }[]
1121
+ loadOptions?: (q?: string) => Promise<
1122
+ { label: string; value: string; color?: string; count?: number }[]
1123
+ >
1124
+ }
1125
+
1126
+ /** The funnel's committed value: a field + either picked `values` or free `text`. */
1127
+ interface LaneFunnelValue {
1128
+ field: string
1129
+ values?: string[]
1130
+ text?: string
1060
1131
  }
1061
1132
 
1062
1133
  interface KanbanLaneProps {
@@ -1065,7 +1136,7 @@ interface KanbanLaneProps {
1065
1136
  totalCount: number
1066
1137
  filterFields: LaneFilterField[]
1067
1138
  laneFilter: LaneFilterState | undefined
1068
- onFunnelChange: (filter: { field: string; value: string } | null) => void
1139
+ onFunnelChange: (filter: LaneFunnelValue | null) => void
1069
1140
  onQueryChange: (query: string) => void
1070
1141
  isDark: boolean
1071
1142
  dimmed: boolean
@@ -1091,12 +1162,21 @@ function KanbanLane({
1091
1162
  const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
1092
1163
  isDark,
1093
1164
  })
1094
- const funnelActive = !!(laneFilter?.field && laneFilter.value?.trim())
1165
+ const funnelField = filterFields.find((f) => f.key === laneFilter?.field)
1166
+ const funnelActive = !!(
1167
+ laneFilter?.field &&
1168
+ ((laneFilter.values && laneFilter.values.length > 0) ||
1169
+ laneFilter.text?.trim())
1170
+ )
1095
1171
  const queryActive = !!laneFilter?.query?.trim()
1096
1172
  const laneActive = funnelActive || queryActive
1097
- const activeFieldLabel =
1098
- filterFields.find((f) => f.key === laneFilter?.field)?.label ??
1099
- laneFilter?.field
1173
+ const activeFieldLabel = funnelField?.label ?? laneFilter?.field
1174
+ // Human summary of the funnel value: resolved option labels for picked
1175
+ // values, or the raw free text.
1176
+ const funnelSummary =
1177
+ laneFilter?.values && laneFilter.values.length > 0
1178
+ ? summarizeFilterValues(laneFilter.values, funnelField?.config?.options)
1179
+ : laneFilter?.text ?? ''
1100
1180
 
1101
1181
  // Inline lane search: a Search icon expands an Input; Escape or blur-while-
1102
1182
  // empty collapses it. The query itself lives in the parent's laneFilters so
@@ -1106,10 +1186,13 @@ function KanbanLane({
1106
1186
  useEffect(() => {
1107
1187
  if (searchOpen) searchRef.current?.focus()
1108
1188
  }, [searchOpen])
1109
- const funnelValue =
1110
- laneFilter?.field && laneFilter.value
1111
- ? { field: laneFilter.field, value: laneFilter.value }
1112
- : undefined
1189
+ const funnelValue: LaneFunnelValue | undefined = laneFilter?.field
1190
+ ? {
1191
+ field: laneFilter.field,
1192
+ values: laneFilter.values,
1193
+ text: laneFilter.text,
1194
+ }
1195
+ : undefined
1113
1196
 
1114
1197
  return (
1115
1198
  <div
@@ -1192,7 +1275,7 @@ function KanbanLane({
1192
1275
  <div className="flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground">
1193
1276
  <ListFilter className="h-3 w-3 shrink-0" />
1194
1277
  <span className="truncate">
1195
- {activeFieldLabel}: {laneFilter!.value}
1278
+ {activeFieldLabel}: {funnelSummary}
1196
1279
  </span>
1197
1280
  <button
1198
1281
  type="button"
@@ -1228,33 +1311,47 @@ function LaneFilterButton({
1228
1311
  onChange,
1229
1312
  }: {
1230
1313
  fields: LaneFilterField[]
1231
- value: { field: string; value: string } | undefined
1232
- onChange: (filter: { field: string; value: string } | null) => void
1314
+ value: LaneFunnelValue | undefined
1315
+ onChange: (filter: LaneFunnelValue | null) => void
1233
1316
  }) {
1234
1317
  const { t } = useTranslation()
1235
1318
  const [open, setOpen] = useState(false)
1236
1319
  const [field, setField] = useState(value?.field ?? fields[0]?.key ?? '')
1237
- const [text, setText] = useState(value?.value ?? '')
1320
+ const [values, setValues] = useState<string[]>(value?.values ?? [])
1321
+ const [text, setText] = useState(value?.text ?? '')
1238
1322
  // Re-seed the draft from the committed filter each time the popover opens.
1239
1323
  useEffect(() => {
1240
1324
  if (open) {
1241
1325
  setField(value?.field ?? fields[0]?.key ?? '')
1242
- setText(value?.value ?? '')
1326
+ setValues(value?.values ?? [])
1327
+ setText(value?.text ?? '')
1243
1328
  }
1244
1329
  }, [open, value, fields])
1245
1330
  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
1331
+ const active = !!(
1332
+ value &&
1333
+ ((value.values && value.values.length > 0) || value.text?.trim())
1334
+ )
1335
+ // The value step mirrors the sheet: when the chosen field is a select or a
1336
+ // facet (static options OR a lazy loader), render the SAME pro combobox —
1337
+ // multi-select, searchable, with counts. Only a genuinely free-text field
1338
+ // (no options, no loader) falls back to a raw "Contiene..." input.
1339
+ const cfg = fields.find((f) => f.key === field)?.config
1340
+ const hasValuePicker = (cfg?.options?.length ?? 0) > 0 || !!cfg?.loadOptions
1341
+ const toggle = (v: string) =>
1342
+ setValues((prev) =>
1343
+ prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v],
1344
+ )
1253
1345
  const apply = () => {
1254
- if (field && text.trim()) onChange({ field, value: text })
1346
+ if (field && values.length > 0) onChange({ field, values })
1347
+ else if (field && text.trim()) onChange({ field, text: text.trim() })
1255
1348
  else onChange(null)
1256
1349
  setOpen(false)
1257
1350
  }
1351
+ const clear = () => {
1352
+ onChange(null)
1353
+ setOpen(false)
1354
+ }
1258
1355
  return (
1259
1356
  <Popover open={open} onOpenChange={setOpen}>
1260
1357
  <PopoverTrigger asChild>
@@ -1270,17 +1367,21 @@ function LaneFilterButton({
1270
1367
  <ListFilter className="h-3.5 w-3.5" />
1271
1368
  </button>
1272
1369
  </PopoverTrigger>
1273
- <PopoverContent align="end" className="w-56 space-y-2 p-2">
1370
+ <PopoverContent
1371
+ align="end"
1372
+ className="w-72 space-y-2.5 rounded-xl p-2.5 shadow-lg"
1373
+ >
1274
1374
  <Select
1275
1375
  value={field}
1276
1376
  onValueChange={(f) => {
1277
1377
  setField(f)
1278
1378
  // Reset the value when switching fields — a value picked
1279
1379
  // for one field is meaningless for another.
1380
+ setValues([])
1280
1381
  setText('')
1281
1382
  }}
1282
1383
  >
1283
- <SelectTrigger className="h-8 text-xs">
1384
+ <SelectTrigger className="h-8 w-full text-xs">
1284
1385
  <SelectValue />
1285
1386
  </SelectTrigger>
1286
1387
  <SelectContent>
@@ -1291,34 +1392,18 @@ function LaneFilterButton({
1291
1392
  ))}
1292
1393
  </SelectContent>
1293
1394
  </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>
1395
+ {hasValuePicker ? (
1396
+ // `key={field}` remounts the combobox on field switch so it
1397
+ // reloads that field's values from scratch (no stale list).
1398
+ <div className="overflow-hidden rounded-lg border">
1399
+ <FilterValueCombobox
1400
+ key={field}
1401
+ staticOptions={cfg?.options}
1402
+ loadOptions={cfg?.loadOptions}
1403
+ selected={values}
1404
+ onToggle={toggle}
1405
+ />
1406
+ </div>
1322
1407
  ) : (
1323
1408
  <Input
1324
1409
  autoFocus
@@ -1328,25 +1413,29 @@ function LaneFilterButton({
1328
1413
  if (e.key === 'Enter') apply()
1329
1414
  }}
1330
1415
  placeholder={t('kanban.filterValue', {
1331
- defaultValue: 'Valor...',
1416
+ defaultValue: 'Contiene...',
1332
1417
  })}
1333
- className="h-8 text-xs"
1418
+ className="h-8 w-full text-xs"
1334
1419
  />
1335
1420
  )}
1336
- <div className="flex justify-between gap-2">
1421
+ <div className="flex gap-1.5">
1337
1422
  <Button
1338
- variant="ghost"
1423
+ variant="outline"
1339
1424
  size="sm"
1340
- className="h-7 text-xs"
1341
- onClick={() => {
1342
- onChange(null)
1343
- setOpen(false)
1344
- }}
1425
+ className="h-7 flex-1 text-xs"
1426
+ onClick={clear}
1427
+ disabled={!active && values.length === 0 && !text.trim()}
1345
1428
  >
1346
1429
  {t('kanban.clearFilters', { defaultValue: 'Limpiar' })}
1347
1430
  </Button>
1348
- <Button size="sm" className="h-7 text-xs" onClick={apply}>
1431
+ <Button
1432
+ size="sm"
1433
+ className="h-7 flex-1 text-xs"
1434
+ onClick={apply}
1435
+ disabled={values.length === 0 && !text.trim()}
1436
+ >
1349
1437
  {t('kanban.apply', { defaultValue: 'Aplicar' })}
1438
+ {values.length > 0 ? ` (${values.length})` : ''}
1350
1439
  </Button>
1351
1440
  </div>
1352
1441
  </PopoverContent>
@@ -536,7 +536,7 @@ export function DynamicTable({
536
536
  // getDynamicColumns forwards `loadOptions` into the column meta). Facets base
537
537
  // derived off the list endpoint exactly like the aggregate endpoint below.
538
538
  const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null
539
- const getFacetLoader = useFacetLoaders(facetsBase)
539
+ const { getFacetLoader } = useFacetLoaders(facetsBase)
540
540
 
541
541
  const columnFilterConfigs = useMemo(() => {
542
542
  const map = new Map<string, ColumnFilterConfig>()
@@ -96,7 +96,8 @@ export function useDynamicFilters(
96
96
  : model
97
97
  ? `/data/${model}/facets`
98
98
  : null)
99
- const getFacetLoader = useFacetLoaders(facetsBase)
99
+ const { getFacetLoader, prefetchFacets, facetOptions } =
100
+ useFacetLoaders(facetsBase)
100
101
 
101
102
  // Prefetch the option lists for relation/select filters (once per model). The
102
103
  // combobox needs these before the user opens it; mirrors DynamicTable's
@@ -227,6 +228,9 @@ export function useDynamicFilters(
227
228
  if (loader) {
228
229
  fType = 'facet'
229
230
  loadOptions = loader
231
+ // Prewarmed values (from prefetchFacets) → the popover opens with the
232
+ // list already there, no "Cargando…" flash.
233
+ options = facetOptions.get(f.column || f.key) ?? []
230
234
  }
231
235
  }
232
236
  if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
@@ -285,6 +289,8 @@ export function useDynamicFilters(
285
289
  if (loader) {
286
290
  filterType = 'facet'
287
291
  loadOptions = loader
292
+ // Prewarmed values (from prefetchFacets) → instant open.
293
+ options = facetOptions.get(c.key) ?? []
288
294
  }
289
295
  }
290
296
 
@@ -307,8 +313,27 @@ export function useDynamicFilters(
307
313
  handleDynamicFilterChange,
308
314
  facetsBase,
309
315
  getFacetLoader,
316
+ facetOptions,
310
317
  ])
311
318
 
319
+ // Prewarm every facet field in one burst once the configs settle, so the
320
+ // Sheet popover and the lane value-picker open instantly (values + counts)
321
+ // instead of flashing "Cargando…". Deduped inside prefetchFacets by field set.
322
+ const facetFieldsSig = useMemo(() => {
323
+ const keys: string[] = []
324
+ // Key on `filterKey` (the column the loader queries), not the config map key
325
+ // — they differ when an explicit filter sets a distinct `column`.
326
+ for (const config of columnFilterConfigs.values()) {
327
+ if (config.filterType === 'facet') keys.push(config.filterKey)
328
+ }
329
+ return keys.join('|')
330
+ }, [columnFilterConfigs])
331
+
332
+ useEffect(() => {
333
+ if (!facetFieldsSig) return
334
+ prefetchFacets(facetFieldsSig.split('|'))
335
+ }, [facetFieldsSig, prefetchFacets])
336
+
312
337
  const searchableKeys = useMemo(
313
338
  () => (metadata ? getSearchableColumnKeys(metadata) : null),
314
339
  [metadata],