@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.
- package/CHANGELOG.md +33 -0
- package/dist/dynamic-kanban.d.ts +19 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +96 -41
- package/dist/dynamic-table.js +1 -1
- 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 +4 -4
- package/src/__tests__/dynamic-kanban.test.tsx +101 -0
- package/src/__tests__/use-dynamic-filters.test.tsx +65 -4
- package/src/dynamic-kanban.tsx +164 -75
- package/src/dynamic-table.tsx +1 -1
- package/src/use-dynamic-filters.ts +26 -1
- package/src/use-facet-loaders.ts +76 -9
|
@@ -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
|
-
|
|
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(
|
|
178
|
-
|
|
179
|
-
).
|
|
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
|
})
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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 = !!(
|
|
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
|
|
886
|
-
const v = laneFilter.value.trim().toLowerCase()
|
|
887
|
-
const field = laneFilter.field
|
|
949
|
+
if (laneFilter?.field) {
|
|
888
950
|
cards = cards.filter((c) =>
|
|
889
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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}: {
|
|
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:
|
|
1232
|
-
onChange: (filter:
|
|
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 [
|
|
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
|
-
|
|
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 = !!(
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
{
|
|
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>
|
|
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: '
|
|
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
|
|
1421
|
+
<div className="flex gap-1.5">
|
|
1337
1422
|
<Button
|
|
1338
|
-
variant="
|
|
1423
|
+
variant="outline"
|
|
1339
1424
|
size="sm"
|
|
1340
|
-
className="h-7 text-xs"
|
|
1341
|
-
onClick={
|
|
1342
|
-
|
|
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
|
|
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>
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -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 =
|
|
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],
|