@asteby/metacore-runtime-react 23.0.0 → 23.2.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 +64 -0
- package/dist/dynamic-kanban.d.ts +28 -19
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +134 -82
- package/dist/dynamic-table.d.ts +9 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +189 -36
- 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-infinite-scroll.d.ts +27 -0
- package/dist/use-infinite-scroll.d.ts.map +1 -0
- package/dist/use-infinite-scroll.js +60 -0
- package/package.json +4 -4
- package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
- package/src/__tests__/dynamic-kanban.test.tsx +40 -0
- package/src/__tests__/dynamic-table-filters.test.tsx +102 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
- package/src/__tests__/filter-chips.test.tsx +119 -0
- package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
- package/src/dynamic-kanban.tsx +210 -149
- package/src/dynamic-table.tsx +216 -14
- package/src/filter-chips.tsx +175 -0
- package/src/use-infinite-scroll.ts +94 -0
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -77,9 +77,15 @@ import {
|
|
|
77
77
|
Skeleton,
|
|
78
78
|
} from '@asteby/metacore-ui/primitives'
|
|
79
79
|
import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
|
|
80
|
-
import { generateBadgeStyles, optionColor
|
|
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'
|
|
88
|
+
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
|
|
83
89
|
import { useMetadataCache } from './metadata-cache'
|
|
84
90
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
85
91
|
import { DynamicIcon } from './dynamic-icon'
|
|
@@ -96,6 +102,10 @@ import type {
|
|
|
96
102
|
StageTransition,
|
|
97
103
|
} from './types'
|
|
98
104
|
|
|
105
|
+
// Re-exported for tests + backward-compat: these live in ./filter-chips now
|
|
106
|
+
// (shared with DynamicTable) but were historically imported from here.
|
|
107
|
+
export { summarizeFilterValues, translateOptionLabels }
|
|
108
|
+
|
|
99
109
|
// ---------------------------------------------------------------------------
|
|
100
110
|
// Pure helpers (exported for unit tests — no React, no transport)
|
|
101
111
|
// ---------------------------------------------------------------------------
|
|
@@ -204,6 +214,30 @@ export function applyOptimisticMove(
|
|
|
204
214
|
return next
|
|
205
215
|
}
|
|
206
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Returns a NEW per-lane pagination map with the server totals adjusted for a
|
|
219
|
+
* card moving `fromStage` → `toStage`: the source loses one, the destination
|
|
220
|
+
* gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
|
|
221
|
+
* are left alone. Pure — backs the optimistic drag so a partial lane's
|
|
222
|
+
* `count/total` header stays truthful, and can be restored on PUT failure.
|
|
223
|
+
*/
|
|
224
|
+
export function applyLaneTotalsOnMove<T extends { total: number | null }>(
|
|
225
|
+
pagination: Record<string, T>,
|
|
226
|
+
fromStage: string,
|
|
227
|
+
toStage: string,
|
|
228
|
+
): Record<string, T> {
|
|
229
|
+
const next = { ...pagination }
|
|
230
|
+
const bump = (key: string, delta: number) => {
|
|
231
|
+
const st = next[key]
|
|
232
|
+
if (st && st.total != null) {
|
|
233
|
+
next[key] = { ...st, total: Math.max(0, st.total + delta) }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
bump(fromStage, -1)
|
|
237
|
+
bump(toStage, +1)
|
|
238
|
+
return next
|
|
239
|
+
}
|
|
240
|
+
|
|
207
241
|
/**
|
|
208
242
|
* Picks the columns shown on a card: a `title` column (first searchable column,
|
|
209
243
|
* else first text-ish column) and up to `maxFields` secondary columns. Excludes
|
|
@@ -233,71 +267,6 @@ export function selectCardColumns(
|
|
|
233
267
|
return { title, fields }
|
|
234
268
|
}
|
|
235
269
|
|
|
236
|
-
/**
|
|
237
|
-
* Human-readable summary of a field's selected filter values for the removable
|
|
238
|
-
* chip row. Resolves option labels, unwraps the wire operators
|
|
239
|
-
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
240
|
-
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
241
|
-
*/
|
|
242
|
-
export function summarizeFilterValues(
|
|
243
|
-
values: string[] | undefined,
|
|
244
|
-
options: { label: string; value: string }[] | undefined,
|
|
245
|
-
maxShown = 2,
|
|
246
|
-
): string {
|
|
247
|
-
if (!values || values.length === 0) return ''
|
|
248
|
-
const opts = options ?? []
|
|
249
|
-
const labelFor = (v: string) =>
|
|
250
|
-
opts.find((o) => o.value === v)?.label ?? v
|
|
251
|
-
const first = values[0]
|
|
252
|
-
if (values.length === 1) {
|
|
253
|
-
if (first.startsWith('ILIKE:')) return `"${first.slice(6)}"`
|
|
254
|
-
if (first.startsWith('IN:')) {
|
|
255
|
-
return summarizeList(first.slice(3).split(','), labelFor, maxShown)
|
|
256
|
-
}
|
|
257
|
-
if (first.startsWith('RANGE:')) {
|
|
258
|
-
const [min, max] = first.slice(6).split(',')
|
|
259
|
-
return `${min || '…'} – ${max || '…'}`
|
|
260
|
-
}
|
|
261
|
-
if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return first.replace('_', ' – ')
|
|
262
|
-
}
|
|
263
|
-
if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
|
|
264
|
-
const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? ''
|
|
265
|
-
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? ''
|
|
266
|
-
return `${min || '…'} – ${max || '…'}`
|
|
267
|
-
}
|
|
268
|
-
return summarizeList(values, labelFor, maxShown)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function summarizeList(
|
|
272
|
-
items: string[],
|
|
273
|
-
labelFor: (v: string) => string,
|
|
274
|
-
maxShown: number,
|
|
275
|
-
): string {
|
|
276
|
-
const labels = items.map(labelFor)
|
|
277
|
-
if (labels.length <= maxShown) return labels.join(', ')
|
|
278
|
-
return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
283
|
-
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
284
|
-
* operator/range/free-text values that carry no option color.
|
|
285
|
-
*/
|
|
286
|
-
function chipValueColor(config: {
|
|
287
|
-
selectedValues: string[]
|
|
288
|
-
options: { value: string; color?: string }[]
|
|
289
|
-
}): string | undefined {
|
|
290
|
-
const sel = config.selectedValues
|
|
291
|
-
if (!sel || sel.length === 0) return undefined
|
|
292
|
-
const first = sel[0]
|
|
293
|
-
let value = first
|
|
294
|
-
if (first.startsWith('IN:')) value = first.slice(3).split(',')[0]
|
|
295
|
-
else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first)) return undefined
|
|
296
|
-
else if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return undefined
|
|
297
|
-
const opt = config.options.find((o) => o.value === value)
|
|
298
|
-
return opt?.color ? resolveColorCss(opt.color) : undefined
|
|
299
|
-
}
|
|
300
|
-
|
|
301
270
|
/**
|
|
302
271
|
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
303
272
|
* equality (IN — the card's field value must be one of them); a free-text
|
|
@@ -320,14 +289,16 @@ export function cardMatchesLaneFunnel(
|
|
|
320
289
|
}
|
|
321
290
|
|
|
322
291
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
292
|
+
* Count of applied criteria on a lane funnel: the number of picked select/facet
|
|
293
|
+
* `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
|
|
294
|
+
* badge. Pure — exported for unit tests.
|
|
326
295
|
*/
|
|
327
|
-
export function
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
296
|
+
export function laneFunnelCount(
|
|
297
|
+
value: { values?: string[]; text?: string } | undefined,
|
|
298
|
+
): number {
|
|
299
|
+
if (value?.values?.length) return value.values.length
|
|
300
|
+
if (value?.text?.trim()) return 1
|
|
301
|
+
return 0
|
|
331
302
|
}
|
|
332
303
|
|
|
333
304
|
/**
|
|
@@ -391,6 +362,18 @@ interface LaneFilterState {
|
|
|
391
362
|
query?: string
|
|
392
363
|
}
|
|
393
364
|
|
|
365
|
+
/** Incremental pagination bookkeeping for one lane/stage. */
|
|
366
|
+
interface LanePageState {
|
|
367
|
+
/** Next stage-scoped page to request. */
|
|
368
|
+
nextPage: number
|
|
369
|
+
/** Server total for the stage (from response meta), or null if unknown. */
|
|
370
|
+
total: number | null
|
|
371
|
+
/** A top-up request is in flight. */
|
|
372
|
+
loading: boolean
|
|
373
|
+
/** No more pages for this stage. */
|
|
374
|
+
done: boolean
|
|
375
|
+
}
|
|
376
|
+
|
|
394
377
|
export interface DynamicKanbanProps {
|
|
395
378
|
/** Model key as registered on the backend (e.g. "issue"). */
|
|
396
379
|
model: string
|
|
@@ -411,10 +394,16 @@ export interface DynamicKanbanProps {
|
|
|
411
394
|
*/
|
|
412
395
|
onAction?: (action: string, row: any) => void
|
|
413
396
|
/**
|
|
414
|
-
*
|
|
415
|
-
*
|
|
397
|
+
* Size of the INITIAL board page (one request, grouped into lanes). Each
|
|
398
|
+
* lane then tops up incrementally on scroll (see `lanePageSize`). Defaults
|
|
399
|
+
* to 50 — enough to fill the visible lanes without loading the whole board.
|
|
416
400
|
*/
|
|
417
401
|
pageSize?: number
|
|
402
|
+
/**
|
|
403
|
+
* Page size for a lane's incremental top-up fetch (scoped by
|
|
404
|
+
* `f_<group_by>=<stage>`). Defaults to 25.
|
|
405
|
+
*/
|
|
406
|
+
lanePageSize?: number
|
|
418
407
|
/** IANA timezone for datetime card fields (org config). */
|
|
419
408
|
timeZone?: string
|
|
420
409
|
/** ISO 4217 currency for money card fields (org config). */
|
|
@@ -432,7 +421,8 @@ export function DynamicKanban({
|
|
|
432
421
|
refreshTrigger,
|
|
433
422
|
onCardClick,
|
|
434
423
|
onAction,
|
|
435
|
-
pageSize =
|
|
424
|
+
pageSize = 50,
|
|
425
|
+
lanePageSize = 25,
|
|
436
426
|
timeZone,
|
|
437
427
|
currency,
|
|
438
428
|
defaultFilters,
|
|
@@ -448,6 +438,14 @@ export function DynamicKanban({
|
|
|
448
438
|
const [records, setRecords] = useState<any[]>([])
|
|
449
439
|
const [loading, setLoading] = useState(!cachedMeta)
|
|
450
440
|
const [loadingData, setLoadingData] = useState(true)
|
|
441
|
+
// Per-stage incremental pagination for infinite scroll. The initial board
|
|
442
|
+
// page (grouped into lanes) is fetched once; each lane then tops up its OWN
|
|
443
|
+
// stage via `f_<group_by>=<stage>&page=n`, appended (deduped by id) into the
|
|
444
|
+
// shared `records`. `total` is the stage's server count when the response
|
|
445
|
+
// meta carries it. Reset whenever the filters/search change.
|
|
446
|
+
const [lanePagination, setLanePagination] = useState<
|
|
447
|
+
Record<string, LanePageState>
|
|
448
|
+
>({})
|
|
451
449
|
|
|
452
450
|
// Active drag card id (for the DragOverlay + drop-zone highlighting).
|
|
453
451
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
@@ -503,7 +501,10 @@ export function DynamicKanban({
|
|
|
503
501
|
clearAll,
|
|
504
502
|
} = useDynamicFilters(metadata, { defaultFilters, model, endpoint })
|
|
505
503
|
|
|
506
|
-
// ----
|
|
504
|
+
// ---- initial board page (one request, grouped into lanes) ----
|
|
505
|
+
// Resets the per-lane pagination so every lane restarts its incremental
|
|
506
|
+
// top-up from scratch — called on mount, refresh, and any filter/search
|
|
507
|
+
// change (fetchData's identity changes with filterParams).
|
|
507
508
|
const fetchData = useCallback(async () => {
|
|
508
509
|
if (!metadata) return
|
|
509
510
|
setLoadingData(true)
|
|
@@ -517,8 +518,67 @@ export function DynamicKanban({
|
|
|
517
518
|
} finally {
|
|
518
519
|
setLoadingData(false)
|
|
519
520
|
}
|
|
521
|
+
setLanePagination({})
|
|
520
522
|
}, [api, endpoint, model, metadata, pageSize, filterParams])
|
|
521
523
|
|
|
524
|
+
// Load the next page for ONE lane/stage and append it (deduped by id) into
|
|
525
|
+
// the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
|
|
526
|
+
// filterParams (the stage scope wins over any global group_by filter).
|
|
527
|
+
const groupByKey = metadata?.group_by || ''
|
|
528
|
+
const loadMoreLane = useCallback(
|
|
529
|
+
async (stageKey: string) => {
|
|
530
|
+
if (!metadata || !groupByKey) return
|
|
531
|
+
const current = lanePagination[stageKey]
|
|
532
|
+
if (current?.loading || current?.done) return
|
|
533
|
+
const nextPage = current?.nextPage ?? 1
|
|
534
|
+
setLanePagination((p) => ({
|
|
535
|
+
...p,
|
|
536
|
+
[stageKey]: {
|
|
537
|
+
nextPage,
|
|
538
|
+
total: current?.total ?? null,
|
|
539
|
+
loading: true,
|
|
540
|
+
done: false,
|
|
541
|
+
},
|
|
542
|
+
}))
|
|
543
|
+
try {
|
|
544
|
+
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
545
|
+
params: {
|
|
546
|
+
...filterParams,
|
|
547
|
+
page: nextPage,
|
|
548
|
+
per_page: lanePageSize,
|
|
549
|
+
[`f_${groupByKey}`]: stageKey,
|
|
550
|
+
},
|
|
551
|
+
})) as { data: ApiResponse<any[]> & { meta?: any } }
|
|
552
|
+
const rows = res.data.success ? res.data.data || [] : []
|
|
553
|
+
const total =
|
|
554
|
+
res.data.meta?.total ?? res.data.meta?.count ?? null
|
|
555
|
+
setRecords((prev) => dedupeById(prev, rows))
|
|
556
|
+
setLanePagination((p) => ({
|
|
557
|
+
...p,
|
|
558
|
+
[stageKey]: {
|
|
559
|
+
nextPage: nextPage + 1,
|
|
560
|
+
total,
|
|
561
|
+
loading: false,
|
|
562
|
+
// Exhausted when the server returned a short page.
|
|
563
|
+
done: rows.length < lanePageSize,
|
|
564
|
+
},
|
|
565
|
+
}))
|
|
566
|
+
} catch (err) {
|
|
567
|
+
console.error(`Error al cargar más tarjetas de ${stageKey}`, err)
|
|
568
|
+
setLanePagination((p) => ({
|
|
569
|
+
...p,
|
|
570
|
+
[stageKey]: {
|
|
571
|
+
nextPage,
|
|
572
|
+
total: current?.total ?? null,
|
|
573
|
+
loading: false,
|
|
574
|
+
done: false,
|
|
575
|
+
},
|
|
576
|
+
}))
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
[api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination],
|
|
580
|
+
)
|
|
581
|
+
|
|
522
582
|
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
523
583
|
// filters change. `fetchData` is stable while `filterParams` is unchanged
|
|
524
584
|
// (both memoized), so this only re-runs on real input changes. Debounced so
|
|
@@ -620,7 +680,6 @@ export function DynamicKanban({
|
|
|
620
680
|
() => (metadata ? deriveStages(metadata) : []),
|
|
621
681
|
[metadata],
|
|
622
682
|
)
|
|
623
|
-
const groupByKey = metadata?.group_by || ''
|
|
624
683
|
const transitions = metadata?.transitions
|
|
625
684
|
|
|
626
685
|
const grouped = useMemo(
|
|
@@ -705,11 +764,16 @@ export function DynamicKanban({
|
|
|
705
764
|
|
|
706
765
|
// OPTIMISTIC: move the card in local state immediately.
|
|
707
766
|
const prevRecords = records
|
|
767
|
+
const prevPagination = lanePagination
|
|
708
768
|
setRecords((rs) =>
|
|
709
769
|
rs.map((r) =>
|
|
710
770
|
String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r,
|
|
711
771
|
),
|
|
712
772
|
)
|
|
773
|
+
// Keep the server totals consistent with the moved card so a lane's
|
|
774
|
+
// `count/total` header stays truthful with partial lanes: one leaves
|
|
775
|
+
// the source stage, one joins the destination.
|
|
776
|
+
setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage))
|
|
713
777
|
|
|
714
778
|
try {
|
|
715
779
|
const base = endpoint || `/data/${model}`
|
|
@@ -726,6 +790,7 @@ export function DynamicKanban({
|
|
|
726
790
|
} catch (err: any) {
|
|
727
791
|
// REVERT + toast on failure.
|
|
728
792
|
setRecords(prevRecords)
|
|
793
|
+
setLanePagination(prevPagination)
|
|
729
794
|
toast.error(
|
|
730
795
|
t('kanban.moveFailed', {
|
|
731
796
|
defaultValue: 'No se pudo mover la tarjeta',
|
|
@@ -736,7 +801,7 @@ export function DynamicKanban({
|
|
|
736
801
|
)
|
|
737
802
|
}
|
|
738
803
|
},
|
|
739
|
-
[api, endpoint, groupByKey, model, records, stageOfCard, t, transitions],
|
|
804
|
+
[api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions],
|
|
740
805
|
)
|
|
741
806
|
|
|
742
807
|
if (loading) {
|
|
@@ -878,64 +943,13 @@ export function DynamicKanban({
|
|
|
878
943
|
)}
|
|
879
944
|
</div>
|
|
880
945
|
|
|
881
|
-
{/* Removable chip row —
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
>
|
|
889
|
-
{activeFields.map((field) => {
|
|
890
|
-
const summary = summarizeFilterValues(
|
|
891
|
-
field.config.selectedValues,
|
|
892
|
-
field.config.options,
|
|
893
|
-
)
|
|
894
|
-
const dot = chipValueColor(field.config)
|
|
895
|
-
return (
|
|
896
|
-
<Badge
|
|
897
|
-
key={field.key}
|
|
898
|
-
variant="secondary"
|
|
899
|
-
className="h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal"
|
|
900
|
-
>
|
|
901
|
-
{dot && (
|
|
902
|
-
<span
|
|
903
|
-
className="size-2 shrink-0 rounded-full"
|
|
904
|
-
style={{ backgroundColor: dot }}
|
|
905
|
-
/>
|
|
906
|
-
)}
|
|
907
|
-
<span className="font-medium">{field.label}:</span>
|
|
908
|
-
<span className="max-w-[180px] truncate text-muted-foreground">
|
|
909
|
-
{summary}
|
|
910
|
-
</span>
|
|
911
|
-
<button
|
|
912
|
-
type="button"
|
|
913
|
-
onClick={() =>
|
|
914
|
-
field.config.onFilterChange(
|
|
915
|
-
field.config.filterKey,
|
|
916
|
-
[],
|
|
917
|
-
)
|
|
918
|
-
}
|
|
919
|
-
className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
|
|
920
|
-
aria-label={t('kanban.removeFilter', {
|
|
921
|
-
defaultValue: 'Quitar filtro',
|
|
922
|
-
})}
|
|
923
|
-
>
|
|
924
|
-
<X className="h-3 w-3" />
|
|
925
|
-
</button>
|
|
926
|
-
</Badge>
|
|
927
|
-
)
|
|
928
|
-
})}
|
|
929
|
-
<Button
|
|
930
|
-
variant="ghost"
|
|
931
|
-
size="sm"
|
|
932
|
-
className="h-6 gap-1 px-2 text-xs text-muted-foreground"
|
|
933
|
-
onClick={clearAll}
|
|
934
|
-
>
|
|
935
|
-
{t('kanban.clearAll', { defaultValue: 'Limpiar todo' })}
|
|
936
|
-
</Button>
|
|
937
|
-
</div>
|
|
938
|
-
)}
|
|
946
|
+
{/* Removable chip row — shared with DynamicTable. Instant feedback
|
|
947
|
+
without opening the Sheet; a chip's X clears that field. */}
|
|
948
|
+
<FilterChipsRow
|
|
949
|
+
fields={activeFields}
|
|
950
|
+
onClearAll={clearAll}
|
|
951
|
+
data-testid="kanban-filter-chips"
|
|
952
|
+
/>
|
|
939
953
|
|
|
940
954
|
<DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
941
955
|
<div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
|
|
@@ -960,12 +974,21 @@ export function DynamicKanban({
|
|
|
960
974
|
!activeId ||
|
|
961
975
|
stage.key === activeStage ||
|
|
962
976
|
isTransitionAllowed(transitions, activeStage, stage.key)
|
|
977
|
+
// Infinite scroll is per declared stage; the synthetic
|
|
978
|
+
// "unassigned" lane can't be stage-scoped, so it never tops up.
|
|
979
|
+
const laneState = lanePagination[stage.key]
|
|
980
|
+
const isUnassigned = stage.key === UNASSIGNED_LANE
|
|
981
|
+
const laneHasMore = !isUnassigned && !laneState?.done
|
|
963
982
|
return (
|
|
964
983
|
<KanbanLane
|
|
965
984
|
key={stage.key}
|
|
966
985
|
stage={stage}
|
|
967
986
|
count={cards.length}
|
|
968
987
|
totalCount={allCards.length}
|
|
988
|
+
serverTotal={laneState?.total ?? null}
|
|
989
|
+
hasMore={laneHasMore}
|
|
990
|
+
loadingMore={!!laneState?.loading}
|
|
991
|
+
onLoadMore={() => loadMoreLane(stage.key)}
|
|
969
992
|
filterFields={filterFields}
|
|
970
993
|
laneFilter={laneFilter}
|
|
971
994
|
onFunnelChange={(f) =>
|
|
@@ -1134,6 +1157,14 @@ interface KanbanLaneProps {
|
|
|
1134
1157
|
stage: StageMeta
|
|
1135
1158
|
count: number
|
|
1136
1159
|
totalCount: number
|
|
1160
|
+
/** Server-reported total for the stage (from response meta), or null. */
|
|
1161
|
+
serverTotal: number | null
|
|
1162
|
+
/** More server pages available for this stage. */
|
|
1163
|
+
hasMore: boolean
|
|
1164
|
+
/** A top-up request for this stage is in flight. */
|
|
1165
|
+
loadingMore: boolean
|
|
1166
|
+
/** Request the next page for this stage. */
|
|
1167
|
+
onLoadMore: () => void
|
|
1137
1168
|
filterFields: LaneFilterField[]
|
|
1138
1169
|
laneFilter: LaneFilterState | undefined
|
|
1139
1170
|
onFunnelChange: (filter: LaneFunnelValue | null) => void
|
|
@@ -1148,6 +1179,10 @@ function KanbanLane({
|
|
|
1148
1179
|
stage,
|
|
1149
1180
|
count,
|
|
1150
1181
|
totalCount,
|
|
1182
|
+
serverTotal,
|
|
1183
|
+
hasMore,
|
|
1184
|
+
loadingMore,
|
|
1185
|
+
onLoadMore,
|
|
1151
1186
|
filterFields,
|
|
1152
1187
|
laneFilter,
|
|
1153
1188
|
onFunnelChange,
|
|
@@ -1159,6 +1194,12 @@ function KanbanLane({
|
|
|
1159
1194
|
}: KanbanLaneProps) {
|
|
1160
1195
|
const { t } = useTranslation()
|
|
1161
1196
|
const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
|
|
1197
|
+
// Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
|
|
1198
|
+
// container; a load in flight or an exhausted stage disables it.
|
|
1199
|
+
const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
|
|
1200
|
+
onLoadMore,
|
|
1201
|
+
disabled: !hasMore || loadingMore,
|
|
1202
|
+
})
|
|
1162
1203
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
1163
1204
|
isDark,
|
|
1164
1205
|
})
|
|
@@ -1216,21 +1257,21 @@ function KanbanLane({
|
|
|
1216
1257
|
{t(stage.label, { defaultValue: stage.label })}
|
|
1217
1258
|
</Badge>
|
|
1218
1259
|
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
|
1219
|
-
{laneActive
|
|
1260
|
+
{laneActive
|
|
1261
|
+
? `${count}/${totalCount}`
|
|
1262
|
+
: serverTotal != null
|
|
1263
|
+
? `${count}/${serverTotal}`
|
|
1264
|
+
: count}
|
|
1220
1265
|
</span>
|
|
1221
1266
|
</div>
|
|
1222
|
-
{/* Lane actions —
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
<div
|
|
1226
|
-
className={`flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${
|
|
1227
|
-
laneActive || searchOpen ? 'opacity-100' : 'opacity-0'
|
|
1228
|
-
}`}
|
|
1229
|
-
>
|
|
1267
|
+
{/* Lane actions — always visible in muted (a hidden hover-reveal
|
|
1268
|
+
was undiscoverable); active state is a primary tint + a count
|
|
1269
|
+
badge on the funnel. */}
|
|
1270
|
+
<div className="flex items-center gap-0.5">
|
|
1230
1271
|
<button
|
|
1231
1272
|
type="button"
|
|
1232
1273
|
onClick={() => setSearchOpen((o) => !o)}
|
|
1233
|
-
className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
1274
|
+
className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
1234
1275
|
queryActive ? 'text-primary' : 'text-muted-foreground'
|
|
1235
1276
|
}`}
|
|
1236
1277
|
aria-label={t('kanban.searchLane', {
|
|
@@ -1238,6 +1279,9 @@ function KanbanLane({
|
|
|
1238
1279
|
})}
|
|
1239
1280
|
>
|
|
1240
1281
|
<Search className="h-3.5 w-3.5" />
|
|
1282
|
+
{queryActive && (
|
|
1283
|
+
<span className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" />
|
|
1284
|
+
)}
|
|
1241
1285
|
</button>
|
|
1242
1286
|
<LaneFilterButton
|
|
1243
1287
|
fields={filterFields}
|
|
@@ -1295,8 +1339,18 @@ function KanbanLane({
|
|
|
1295
1339
|
text wraps freely (no line-clamp) the cards grew past the lane
|
|
1296
1340
|
and spilled out of the stage. A normal `overflow-y-auto` block
|
|
1297
1341
|
constrains every card to the lane width so text wraps inside it. */}
|
|
1298
|
-
<div
|
|
1342
|
+
<div
|
|
1343
|
+
ref={rootRef}
|
|
1344
|
+
className="flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3"
|
|
1345
|
+
>
|
|
1299
1346
|
{children}
|
|
1347
|
+
{loadingMore && (
|
|
1348
|
+
<Skeleton className="h-16 w-full shrink-0" data-testid="lane-loading-more" />
|
|
1349
|
+
)}
|
|
1350
|
+
{/* Sentinel: entering view triggers the next stage page. */}
|
|
1351
|
+
{hasMore && (
|
|
1352
|
+
<div ref={sentinelRef} className="h-1 w-full shrink-0" aria-hidden />
|
|
1353
|
+
)}
|
|
1300
1354
|
</div>
|
|
1301
1355
|
</div>
|
|
1302
1356
|
)
|
|
@@ -1332,6 +1386,8 @@ function LaneFilterButton({
|
|
|
1332
1386
|
value &&
|
|
1333
1387
|
((value.values && value.values.length > 0) || value.text?.trim())
|
|
1334
1388
|
)
|
|
1389
|
+
// Number of applied criteria on this lane's funnel (drives the count badge).
|
|
1390
|
+
const activeCount = laneFunnelCount(value)
|
|
1335
1391
|
// The value step mirrors the sheet: when the chosen field is a select or a
|
|
1336
1392
|
// facet (static options OR a lazy loader), render the SAME pro combobox —
|
|
1337
1393
|
// multi-select, searchable, with counts. Only a genuinely free-text field
|
|
@@ -1357,7 +1413,7 @@ function LaneFilterButton({
|
|
|
1357
1413
|
<PopoverTrigger asChild>
|
|
1358
1414
|
<button
|
|
1359
1415
|
type="button"
|
|
1360
|
-
className={`flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
1416
|
+
className={`relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${
|
|
1361
1417
|
active ? 'text-primary' : 'text-muted-foreground'
|
|
1362
1418
|
}`}
|
|
1363
1419
|
aria-label={t('kanban.filterLane', {
|
|
@@ -1365,6 +1421,11 @@ function LaneFilterButton({
|
|
|
1365
1421
|
})}
|
|
1366
1422
|
>
|
|
1367
1423
|
<ListFilter className="h-3.5 w-3.5" />
|
|
1424
|
+
{activeCount > 0 && (
|
|
1425
|
+
<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">
|
|
1426
|
+
{activeCount}
|
|
1427
|
+
</span>
|
|
1428
|
+
)}
|
|
1368
1429
|
</button>
|
|
1369
1430
|
</PopoverTrigger>
|
|
1370
1431
|
<PopoverContent
|