@asteby/metacore-runtime-react 23.4.0 → 23.5.1
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 +12 -0
- package/dist/dynamic-kanban.d.ts +10 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +75 -8
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +17 -3
- package/src/__tests__/dynamic-kanban-lane-totals.test.tsx +161 -0
- package/src/__tests__/dynamic-kanban.test.tsx +23 -0
- package/src/dynamic-kanban.tsx +80 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 23.5.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 36e7cb8: DynamicKanban: las lanes ahora aprovechan el ancho del viewport. Crecen (flex-1) para llenar el contenedor cuando todas caben, con un ancho mínimo legible (280px) y un máximo razonable (420px); el scroll horizontal solo aparece cuando ya no caben. Elimina el espacio muerto a la derecha y las columnas angostas al reducir la ventana.
|
|
8
|
+
|
|
9
|
+
## 23.5.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- beb7af1: DynamicKanban: los contadores de cada columna muestran el total real de la etapa desde el primer render, sin necesidad de scrollear. Tras la carga inicial del tablero se dispara en paralelo una consulta liviana (`per_page=1`) por etapa, respetando la búsqueda y los filtros activos, para leer su total. El header ahora se lee `N` cuando ya están cargadas todas las tarjetas (antes `N/N`) y `N/M` cuando la etapa está parcialmente cargada. Los totales se refrescan al cambiar filtros/búsqueda y mantienen el ajuste optimista tras arrastrar una tarjeta.
|
|
14
|
+
|
|
3
15
|
## 23.4.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/dist/dynamic-kanban.d.ts
CHANGED
|
@@ -42,6 +42,16 @@ export declare function applyOptimisticMove(grouped: Map<string, any[]>, cardId:
|
|
|
42
42
|
export declare function applyLaneTotalsOnMove<T extends {
|
|
43
43
|
total: number | null;
|
|
44
44
|
}>(pagination: Record<string, T>, fromStage: string, toStage: string): Record<string, T>;
|
|
45
|
+
/**
|
|
46
|
+
* Formats a lane header's count badge. Three cases:
|
|
47
|
+
* - A lane filter/search is active → `shown/loaded` (the client-side narrowed
|
|
48
|
+
* count over what this lane has loaded).
|
|
49
|
+
* - Otherwise, when the stage's server total is known → `shown` alone once
|
|
50
|
+
* everything is loaded (`shown >= total`), else `shown/total` (partial).
|
|
51
|
+
* - Total still unknown → just `shown`.
|
|
52
|
+
* Pure — exported for unit tests.
|
|
53
|
+
*/
|
|
54
|
+
export declare function formatLaneCount(shown: number, loaded: number, serverTotal: number | null, laneActive: boolean): string;
|
|
45
55
|
/**
|
|
46
56
|
* Picks the columns shown on a card: a `title` column (first searchable column,
|
|
47
57
|
* else first text-ish column) and up to `maxFields` secondary columns. Excludes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
|
package/dist/dynamic-kanban.js
CHANGED
|
@@ -132,6 +132,23 @@ export function applyLaneTotalsOnMove(pagination, fromStage, toStage) {
|
|
|
132
132
|
bump(toStage, +1);
|
|
133
133
|
return next;
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Formats a lane header's count badge. Three cases:
|
|
137
|
+
* - A lane filter/search is active → `shown/loaded` (the client-side narrowed
|
|
138
|
+
* count over what this lane has loaded).
|
|
139
|
+
* - Otherwise, when the stage's server total is known → `shown` alone once
|
|
140
|
+
* everything is loaded (`shown >= total`), else `shown/total` (partial).
|
|
141
|
+
* - Total still unknown → just `shown`.
|
|
142
|
+
* Pure — exported for unit tests.
|
|
143
|
+
*/
|
|
144
|
+
export function formatLaneCount(shown, loaded, serverTotal, laneActive) {
|
|
145
|
+
if (laneActive)
|
|
146
|
+
return `${shown}/${loaded}`;
|
|
147
|
+
if (serverTotal != null) {
|
|
148
|
+
return shown >= serverTotal ? String(serverTotal) : `${shown}/${serverTotal}`;
|
|
149
|
+
}
|
|
150
|
+
return String(shown);
|
|
151
|
+
}
|
|
135
152
|
/**
|
|
136
153
|
* Picks the columns shown on a card: a `title` column (first searchable column,
|
|
137
154
|
* else first text-ish column) and up to `maxFields` secondary columns. Excludes
|
|
@@ -233,6 +250,10 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
233
250
|
const [lanePagination, setLanePagination] = useState({});
|
|
234
251
|
// Active drag card id (for the DragOverlay + drop-zone highlighting).
|
|
235
252
|
const [activeId, setActiveId] = useState(null);
|
|
253
|
+
// Monotonic token for the current board load. Bumped on every fetchData so a
|
|
254
|
+
// slow eager-totals response from a superseded filter set can't inject stale
|
|
255
|
+
// stage totals into the fresh board.
|
|
256
|
+
const fetchGenRef = useRef(0);
|
|
236
257
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
237
258
|
// ---- metadata fetch (same path as DynamicTable) ----
|
|
238
259
|
useEffect(() => {
|
|
@@ -280,6 +301,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
280
301
|
const fetchData = useCallback(async () => {
|
|
281
302
|
if (!metadata)
|
|
282
303
|
return;
|
|
304
|
+
const gen = ++fetchGenRef.current;
|
|
283
305
|
setLoadingData(true);
|
|
284
306
|
try {
|
|
285
307
|
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
@@ -295,6 +317,50 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
295
317
|
setLoadingData(false);
|
|
296
318
|
}
|
|
297
319
|
setLanePagination({});
|
|
320
|
+
// Eager per-lane totals: so every lane header shows the REAL stage count
|
|
321
|
+
// on first render (not just what the global page happened to load), fire
|
|
322
|
+
// one lightweight `per_page=1` request per declared stage — in parallel,
|
|
323
|
+
// scoped by the SAME active filters/search (`f_<group_by>=<stage>` on top
|
|
324
|
+
// of filterParams). We read only `meta.total`; the row itself is ignored
|
|
325
|
+
// (the lane loads its real cards via loadMoreLane on scroll). The
|
|
326
|
+
// "unassigned" lane can't be stage-scoped, so it keeps its loaded count.
|
|
327
|
+
const gb = metadata.group_by;
|
|
328
|
+
const declaredStages = deriveStages(metadata);
|
|
329
|
+
if (gb && declaredStages.length > 0) {
|
|
330
|
+
void Promise.all(declaredStages.map(async (stage) => {
|
|
331
|
+
try {
|
|
332
|
+
const r = (await api.get(endpoint || `/data/${model}`, {
|
|
333
|
+
params: {
|
|
334
|
+
...filterParams,
|
|
335
|
+
page: 1,
|
|
336
|
+
per_page: 1,
|
|
337
|
+
[`f_${gb}`]: stage.key,
|
|
338
|
+
},
|
|
339
|
+
}));
|
|
340
|
+
const total = r.data.meta?.total ?? r.data.meta?.count ?? null;
|
|
341
|
+
if (total == null || gen !== fetchGenRef.current)
|
|
342
|
+
return;
|
|
343
|
+
setLanePagination((p) => {
|
|
344
|
+
const existing = p[stage.key];
|
|
345
|
+
// A real page fetch already learned this lane's total.
|
|
346
|
+
if (existing?.total != null)
|
|
347
|
+
return p;
|
|
348
|
+
return {
|
|
349
|
+
...p,
|
|
350
|
+
[stage.key]: {
|
|
351
|
+
nextPage: existing?.nextPage ?? 1,
|
|
352
|
+
total,
|
|
353
|
+
loading: existing?.loading ?? false,
|
|
354
|
+
done: existing?.done ?? total === 0,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
// A failed total just leaves the header on the loaded count.
|
|
361
|
+
}
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
298
364
|
}, [api, endpoint, model, metadata, pageSize, filterParams]);
|
|
299
365
|
// Load the next page for ONE lane/stage and append it (deduped by id) into
|
|
300
366
|
// the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
|
|
@@ -522,7 +588,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
522
588
|
}
|
|
523
589
|
}, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions]);
|
|
524
590
|
if (loading) {
|
|
525
|
-
return (_jsx("div", { className: "flex gap-4 overflow-x-auto p-1", children: [0, 1, 2, 3].map((i) => (_jsxs("div", { className: "w-[
|
|
591
|
+
return (_jsx("div", { className: "flex w-full gap-4 overflow-x-auto p-1", children: [0, 1, 2, 3].map((i) => (_jsxs("div", { className: "min-w-[280px] max-w-[420px] flex-1 shrink-0 space-y-3", children: [_jsx(Skeleton, { className: "h-8 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" })] }, i))) }));
|
|
526
592
|
}
|
|
527
593
|
if (!metadata || !groupByKey || stages.length === 0) {
|
|
528
594
|
return (_jsx("div", { className: "rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground", children: t('kanban.noStages', {
|
|
@@ -549,7 +615,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
549
615
|
})
|
|
550
616
|
: t('kanban.noActiveFilters', {
|
|
551
617
|
defaultValue: 'Sin filtros',
|
|
552
|
-
}) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), _jsx(FilterChipsRow, { fields: activeFields, onClearAll: clearAll, "data-testid": "kanban-filter-chips" }), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
|
|
618
|
+
}) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), _jsx(FilterChipsRow, { fields: activeFields, onClearAll: clearAll, "data-testid": "kanban-filter-chips" }), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex w-full min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
|
|
553
619
|
const allCards = grouped.get(stage.key) ?? [];
|
|
554
620
|
// Per-lane client-side narrowing (instant, scoped to this
|
|
555
621
|
// stage). The funnel (field/value) and the lane search
|
|
@@ -644,15 +710,16 @@ function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMor
|
|
|
644
710
|
text: laneFilter.text,
|
|
645
711
|
}
|
|
646
712
|
: undefined;
|
|
647
|
-
return (_jsxs("div", { ref: setNodeRef,
|
|
713
|
+
return (_jsxs("div", { ref: setNodeRef,
|
|
714
|
+
// Fluid width: lanes grow (flex-1) to fill the board when they all
|
|
715
|
+
// fit, capped at max-w so a couple of lanes don't stretch absurdly
|
|
716
|
+
// wide. Below min-w the board's overflow-x-auto takes over and the
|
|
717
|
+
// lanes scroll horizontally at their minimum width.
|
|
718
|
+
className: "group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
|
|
648
719
|
opacity: dimmed ? 0.45 : 1,
|
|
649
720
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
650
721
|
outlineOffset: 2,
|
|
651
|
-
}, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive
|
|
652
|
-
? `${count}/${totalCount}`
|
|
653
|
-
: serverTotal != null
|
|
654
|
-
? `${count}/${serverTotal}`
|
|
655
|
-
: count })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
|
|
722
|
+
}, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: formatLaneCount(count, totalCount, serverTotal, laneActive) })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
|
|
656
723
|
defaultValue: 'Buscar en la columna',
|
|
657
724
|
}), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
|
|
658
725
|
if (e.key === 'Escape') {
|
package/package.json
CHANGED
|
@@ -131,6 +131,15 @@ function stageCalls(api: ApiClient, stage: string) {
|
|
|
131
131
|
(c: any[]) => c[1]?.params?.f_stage === stage,
|
|
132
132
|
)
|
|
133
133
|
}
|
|
134
|
+
// A lane's real page-load (per_page 25), as opposed to the eager per_page=1
|
|
135
|
+
// total probe fired on the initial board load.
|
|
136
|
+
function stagePageCalls(api: ApiClient, stage: string) {
|
|
137
|
+
return stageCalls(api, stage).filter((c: any[]) => c[1]?.params?.per_page === 25)
|
|
138
|
+
}
|
|
139
|
+
// The eager total probe (per_page 1) for a stage.
|
|
140
|
+
function stageTotalCalls(api: ApiClient, stage: string) {
|
|
141
|
+
return stageCalls(api, stage).filter((c: any[]) => c[1]?.params?.per_page === 1)
|
|
142
|
+
}
|
|
134
143
|
function globalDataCalls(api: ApiClient) {
|
|
135
144
|
return (api.get as any).mock.calls.filter(
|
|
136
145
|
(c: any[]) =>
|
|
@@ -171,15 +180,20 @@ describe('DynamicKanban per-lane infinite scroll', () => {
|
|
|
171
180
|
)
|
|
172
181
|
// Initial board page painted its single backlog card.
|
|
173
182
|
expect(await screen.findByText('Backlog One')).toBeTruthy()
|
|
174
|
-
|
|
183
|
+
// No real page-load fired yet — only the eager per_page=1 total probe.
|
|
184
|
+
expect(stagePageCalls(api, 'backlog')).toHaveLength(0)
|
|
185
|
+
// The eager total lands, so the header shows the real stage count (1/5)
|
|
186
|
+
// BEFORE the user scrolls.
|
|
187
|
+
await waitFor(() => expect(stageTotalCalls(api, 'backlog').length).toBeGreaterThan(0))
|
|
188
|
+
expect(await screen.findByText('1/5')).toBeTruthy()
|
|
175
189
|
|
|
176
190
|
// Sentinels enter view → each declared lane tops up its OWN stage.
|
|
177
191
|
expect(observers.length).toBeGreaterThan(0)
|
|
178
192
|
observers.forEach((o) => o.fire(true))
|
|
179
193
|
|
|
180
194
|
// The backlog top-up ran with the right scoped params...
|
|
181
|
-
await waitFor(() => expect(
|
|
182
|
-
const call =
|
|
195
|
+
await waitFor(() => expect(stagePageCalls(api, 'backlog').length).toBeGreaterThan(0))
|
|
196
|
+
const call = stagePageCalls(api, 'backlog')[0]
|
|
183
197
|
expect(call[1].params).toMatchObject({ f_stage: 'backlog', page: 1, per_page: 25 })
|
|
184
198
|
|
|
185
199
|
// ...its rows were appended and the header shows count/serverTotal (3/5).
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DynamicKanban eager lane totals (#521 follow-up): every lane header shows the
|
|
4
|
+
// REAL stage count on first render — before the user scrolls — by firing one
|
|
5
|
+
// lightweight `per_page=1` probe per declared stage after the initial board
|
|
6
|
+
// page. Covers:
|
|
7
|
+
// 1. formatLaneCount — the pure `N` vs `N/M` header formatter.
|
|
8
|
+
// 2. Totals visible without scrolling (partial `N/M` and complete `N`).
|
|
9
|
+
// 3. Totals refetch (scoped by the new filters) when the filters change.
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
12
|
+
|
|
13
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
14
|
+
useNavigate: () => () => {},
|
|
15
|
+
}))
|
|
16
|
+
const I18N = { t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, i18n: { language: 'es' } }
|
|
17
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
|
|
18
|
+
|
|
19
|
+
import { formatLaneCount, DynamicKanban } from '../dynamic-kanban'
|
|
20
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
21
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
22
|
+
import type { TableMetadata } from '../types'
|
|
23
|
+
|
|
24
|
+
afterEach(cleanup)
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// 1. Pure formatter
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
describe('formatLaneCount', () => {
|
|
31
|
+
it('shows just the total when everything is loaded (shown === total)', () => {
|
|
32
|
+
expect(formatLaneCount(41, 41, 41, false)).toBe('41')
|
|
33
|
+
})
|
|
34
|
+
it('shows shown/total when the lane is partial', () => {
|
|
35
|
+
expect(formatLaneCount(22, 22, 41, false)).toBe('22/41')
|
|
36
|
+
})
|
|
37
|
+
it('shows just the loaded count when the total is unknown', () => {
|
|
38
|
+
expect(formatLaneCount(7, 7, null, false)).toBe('7')
|
|
39
|
+
})
|
|
40
|
+
it('shows shown/loaded when a lane filter is active', () => {
|
|
41
|
+
expect(formatLaneCount(3, 22, 41, true)).toBe('3/22')
|
|
42
|
+
})
|
|
43
|
+
it('collapses to the total once shown catches up (never N/N)', () => {
|
|
44
|
+
expect(formatLaneCount(5, 5, 5, false)).toBe('5')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// 2 + 3. Component: eager totals on load and on filter change
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const STAGES = [
|
|
53
|
+
{ key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
|
|
54
|
+
{ key: 'done', label: 'Done', color: 'green', order: 1 },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
function meta(over: Partial<TableMetadata> = {}): TableMetadata {
|
|
58
|
+
return {
|
|
59
|
+
title: 'Issues',
|
|
60
|
+
endpoint: '/data/issue',
|
|
61
|
+
view_type: 'kanban',
|
|
62
|
+
group_by: 'stage',
|
|
63
|
+
stages: STAGES,
|
|
64
|
+
columns: [
|
|
65
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
|
|
66
|
+
{ key: 'stage', label: 'Stage', type: 'status', sortable: false, filterable: true, options: STAGES.map((s) => ({ value: s.key, label: s.label })) },
|
|
67
|
+
],
|
|
68
|
+
actions: [],
|
|
69
|
+
perPageOptions: [50],
|
|
70
|
+
defaultPerPage: 50,
|
|
71
|
+
searchPlaceholder: 'Buscar...',
|
|
72
|
+
enableCRUDActions: false,
|
|
73
|
+
hasActions: false,
|
|
74
|
+
...over,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Initial global page: one backlog card + two done cards. The eager per_page=1
|
|
79
|
+
// probes report a backlog total of 5 (partial) and a done total of 2 (complete
|
|
80
|
+
// — the two done cards are already loaded).
|
|
81
|
+
function fakeApi(): ApiClient {
|
|
82
|
+
const ok = (data: unknown, m?: Record<string, unknown>) => ({
|
|
83
|
+
data: { success: true, data, ...(m ? { meta: m } : {}) },
|
|
84
|
+
})
|
|
85
|
+
return {
|
|
86
|
+
get: vi.fn(async (url: string, cfg?: any) => {
|
|
87
|
+
if (url.startsWith('/metadata/table/')) return ok(meta())
|
|
88
|
+
const stage = cfg?.params?.f_stage
|
|
89
|
+
if (stage === 'backlog') return ok([{ id: 1, title: 'Backlog One', stage: 'backlog' }], { total: 5 })
|
|
90
|
+
if (stage === 'done') return ok([{ id: 2, title: 'Done Two', stage: 'done' }], { total: 2 })
|
|
91
|
+
// initial global board page
|
|
92
|
+
return ok([
|
|
93
|
+
{ id: 1, title: 'Backlog One', stage: 'backlog' },
|
|
94
|
+
{ id: 2, title: 'Done Two', stage: 'done' },
|
|
95
|
+
{ id: 3, title: 'Done Three', stage: 'done' },
|
|
96
|
+
])
|
|
97
|
+
}),
|
|
98
|
+
post: vi.fn(async () => ok(null)),
|
|
99
|
+
put: vi.fn(async () => ok(null)),
|
|
100
|
+
delete: vi.fn(async () => ok(null)),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function totalProbeCalls(api: ApiClient) {
|
|
105
|
+
return (api.get as any).mock.calls.filter(
|
|
106
|
+
(c: any[]) => c[1]?.params?.f_stage !== undefined && c[1]?.params?.per_page === 1,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('DynamicKanban eager lane totals', () => {
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
// No IntersectionObserver: nothing scrolls, so any total shown is proof
|
|
113
|
+
// it arrived from the eager probe, not from a lane top-up.
|
|
114
|
+
;(globalThis as any).IntersectionObserver = class {
|
|
115
|
+
observe() {}
|
|
116
|
+
disconnect() {}
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
delete (globalThis as any).IntersectionObserver
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('shows the real stage total on first render without scrolling', async () => {
|
|
124
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
125
|
+
const api = fakeApi()
|
|
126
|
+
render(
|
|
127
|
+
<ApiProvider client={api}>
|
|
128
|
+
<DynamicKanban model="issue" />
|
|
129
|
+
</ApiProvider>,
|
|
130
|
+
)
|
|
131
|
+
await screen.findByText('Backlog One')
|
|
132
|
+
// Backlog: 1 loaded of 5 → partial. Done: 2 loaded of 2 → collapses to "2".
|
|
133
|
+
expect(await screen.findByText('1/5')).toBeTruthy()
|
|
134
|
+
expect(await screen.findByText('2')).toBeTruthy()
|
|
135
|
+
// One probe per declared stage.
|
|
136
|
+
await waitFor(() => expect(totalProbeCalls(api)).toHaveLength(2))
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('refetches the totals (scoped by the new filters) when the filters change', async () => {
|
|
140
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
141
|
+
const api = fakeApi()
|
|
142
|
+
const { rerender } = render(
|
|
143
|
+
<ApiProvider client={api}>
|
|
144
|
+
<DynamicKanban model="issue" />
|
|
145
|
+
</ApiProvider>,
|
|
146
|
+
)
|
|
147
|
+
await screen.findByText('1/5')
|
|
148
|
+
await waitFor(() => expect(totalProbeCalls(api)).toHaveLength(2))
|
|
149
|
+
|
|
150
|
+
rerender(
|
|
151
|
+
<ApiProvider client={api}>
|
|
152
|
+
<DynamicKanban model="issue" defaultFilters={{ priority: 'high' }} />
|
|
153
|
+
</ApiProvider>,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// The filter change re-fires the eager probes (now carrying the filter).
|
|
157
|
+
await waitFor(() => expect(totalProbeCalls(api).length).toBeGreaterThan(2))
|
|
158
|
+
const probe = totalProbeCalls(api).at(-1)!
|
|
159
|
+
expect(probe[1].params).toMatchObject({ f_priority: 'high', per_page: 1 })
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -383,6 +383,29 @@ describe('DynamicKanban render', () => {
|
|
|
383
383
|
// a secondary field label is present
|
|
384
384
|
expect(screen.getAllByText('Assignee:').length).toBeGreaterThan(0)
|
|
385
385
|
})
|
|
386
|
+
|
|
387
|
+
it('lays out fluid lanes: the board fills the width and lanes grow (flex-1) with a min/max width', async () => {
|
|
388
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
389
|
+
const { container } = render(
|
|
390
|
+
<ApiProvider client={fakeApi()}>
|
|
391
|
+
<DynamicKanban model="issue" />
|
|
392
|
+
</ApiProvider>,
|
|
393
|
+
)
|
|
394
|
+
await screen.findByText('Backlog')
|
|
395
|
+
// The board fills the container and scrolls horizontally only on overflow.
|
|
396
|
+
const board = container.querySelector('[data-testid="kanban-board"]')!
|
|
397
|
+
expect(board.className).toContain('w-full')
|
|
398
|
+
expect(board.className).toContain('overflow-x-auto')
|
|
399
|
+
// Each lane grows to fill the available width but never shrinks past a
|
|
400
|
+
// readable min-width nor stretches beyond a sane max-width.
|
|
401
|
+
const lanes = container.querySelectorAll('[data-stage]')
|
|
402
|
+
expect(lanes.length).toBe(4)
|
|
403
|
+
lanes.forEach((lane) => {
|
|
404
|
+
expect(lane.className).toContain('flex-1')
|
|
405
|
+
expect(lane.className).toContain('min-w-[280px]')
|
|
406
|
+
expect(lane.className).toContain('max-w-[420px]')
|
|
407
|
+
})
|
|
408
|
+
})
|
|
386
409
|
})
|
|
387
410
|
|
|
388
411
|
// The drag itself is wired by dnd-kit (pointer + layout), which happy-dom can't
|
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -238,6 +238,28 @@ export function applyLaneTotalsOnMove<T extends { total: number | null }>(
|
|
|
238
238
|
return next
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Formats a lane header's count badge. Three cases:
|
|
243
|
+
* - A lane filter/search is active → `shown/loaded` (the client-side narrowed
|
|
244
|
+
* count over what this lane has loaded).
|
|
245
|
+
* - Otherwise, when the stage's server total is known → `shown` alone once
|
|
246
|
+
* everything is loaded (`shown >= total`), else `shown/total` (partial).
|
|
247
|
+
* - Total still unknown → just `shown`.
|
|
248
|
+
* Pure — exported for unit tests.
|
|
249
|
+
*/
|
|
250
|
+
export function formatLaneCount(
|
|
251
|
+
shown: number,
|
|
252
|
+
loaded: number,
|
|
253
|
+
serverTotal: number | null,
|
|
254
|
+
laneActive: boolean,
|
|
255
|
+
): string {
|
|
256
|
+
if (laneActive) return `${shown}/${loaded}`
|
|
257
|
+
if (serverTotal != null) {
|
|
258
|
+
return shown >= serverTotal ? String(serverTotal) : `${shown}/${serverTotal}`
|
|
259
|
+
}
|
|
260
|
+
return String(shown)
|
|
261
|
+
}
|
|
262
|
+
|
|
241
263
|
/**
|
|
242
264
|
* Picks the columns shown on a card: a `title` column (first searchable column,
|
|
243
265
|
* else first text-ish column) and up to `maxFields` secondary columns. Excludes
|
|
@@ -450,6 +472,11 @@ export function DynamicKanban({
|
|
|
450
472
|
// Active drag card id (for the DragOverlay + drop-zone highlighting).
|
|
451
473
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
452
474
|
|
|
475
|
+
// Monotonic token for the current board load. Bumped on every fetchData so a
|
|
476
|
+
// slow eager-totals response from a superseded filter set can't inject stale
|
|
477
|
+
// stage totals into the fresh board.
|
|
478
|
+
const fetchGenRef = useRef(0)
|
|
479
|
+
|
|
453
480
|
const sensors = useSensors(
|
|
454
481
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
455
482
|
)
|
|
@@ -507,6 +534,7 @@ export function DynamicKanban({
|
|
|
507
534
|
// change (fetchData's identity changes with filterParams).
|
|
508
535
|
const fetchData = useCallback(async () => {
|
|
509
536
|
if (!metadata) return
|
|
537
|
+
const gen = ++fetchGenRef.current
|
|
510
538
|
setLoadingData(true)
|
|
511
539
|
try {
|
|
512
540
|
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
@@ -519,6 +547,49 @@ export function DynamicKanban({
|
|
|
519
547
|
setLoadingData(false)
|
|
520
548
|
}
|
|
521
549
|
setLanePagination({})
|
|
550
|
+
// Eager per-lane totals: so every lane header shows the REAL stage count
|
|
551
|
+
// on first render (not just what the global page happened to load), fire
|
|
552
|
+
// one lightweight `per_page=1` request per declared stage — in parallel,
|
|
553
|
+
// scoped by the SAME active filters/search (`f_<group_by>=<stage>` on top
|
|
554
|
+
// of filterParams). We read only `meta.total`; the row itself is ignored
|
|
555
|
+
// (the lane loads its real cards via loadMoreLane on scroll). The
|
|
556
|
+
// "unassigned" lane can't be stage-scoped, so it keeps its loaded count.
|
|
557
|
+
const gb = metadata.group_by
|
|
558
|
+
const declaredStages = deriveStages(metadata)
|
|
559
|
+
if (gb && declaredStages.length > 0) {
|
|
560
|
+
void Promise.all(
|
|
561
|
+
declaredStages.map(async (stage) => {
|
|
562
|
+
try {
|
|
563
|
+
const r = (await api.get(endpoint || `/data/${model}`, {
|
|
564
|
+
params: {
|
|
565
|
+
...filterParams,
|
|
566
|
+
page: 1,
|
|
567
|
+
per_page: 1,
|
|
568
|
+
[`f_${gb}`]: stage.key,
|
|
569
|
+
},
|
|
570
|
+
})) as { data: ApiResponse<any[]> & { meta?: any } }
|
|
571
|
+
const total = r.data.meta?.total ?? r.data.meta?.count ?? null
|
|
572
|
+
if (total == null || gen !== fetchGenRef.current) return
|
|
573
|
+
setLanePagination((p) => {
|
|
574
|
+
const existing = p[stage.key]
|
|
575
|
+
// A real page fetch already learned this lane's total.
|
|
576
|
+
if (existing?.total != null) return p
|
|
577
|
+
return {
|
|
578
|
+
...p,
|
|
579
|
+
[stage.key]: {
|
|
580
|
+
nextPage: existing?.nextPage ?? 1,
|
|
581
|
+
total,
|
|
582
|
+
loading: existing?.loading ?? false,
|
|
583
|
+
done: existing?.done ?? total === 0,
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
} catch (err) {
|
|
588
|
+
// A failed total just leaves the header on the loaded count.
|
|
589
|
+
}
|
|
590
|
+
}),
|
|
591
|
+
)
|
|
592
|
+
}
|
|
522
593
|
}, [api, endpoint, model, metadata, pageSize, filterParams])
|
|
523
594
|
|
|
524
595
|
// Load the next page for ONE lane/stage and append it (deduped by id) into
|
|
@@ -806,9 +877,9 @@ export function DynamicKanban({
|
|
|
806
877
|
|
|
807
878
|
if (loading) {
|
|
808
879
|
return (
|
|
809
|
-
<div className="flex gap-4 overflow-x-auto p-1">
|
|
880
|
+
<div className="flex w-full gap-4 overflow-x-auto p-1">
|
|
810
881
|
{[0, 1, 2, 3].map((i) => (
|
|
811
|
-
<div key={i} className="w-[
|
|
882
|
+
<div key={i} className="min-w-[280px] max-w-[420px] flex-1 shrink-0 space-y-3">
|
|
812
883
|
<Skeleton className="h-8 w-full" />
|
|
813
884
|
<Skeleton className="h-24 w-full" />
|
|
814
885
|
<Skeleton className="h-24 w-full" />
|
|
@@ -952,7 +1023,7 @@ export function DynamicKanban({
|
|
|
952
1023
|
/>
|
|
953
1024
|
|
|
954
1025
|
<DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
955
|
-
<div className="flex min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
|
|
1026
|
+
<div className="flex w-full min-w-0 gap-4 overflow-x-auto p-1" data-testid="kanban-board">
|
|
956
1027
|
{lanes.map((stage) => {
|
|
957
1028
|
const allCards = grouped.get(stage.key) ?? []
|
|
958
1029
|
// Per-lane client-side narrowing (instant, scoped to this
|
|
@@ -1238,7 +1309,11 @@ function KanbanLane({
|
|
|
1238
1309
|
return (
|
|
1239
1310
|
<div
|
|
1240
1311
|
ref={setNodeRef}
|
|
1241
|
-
|
|
1312
|
+
// Fluid width: lanes grow (flex-1) to fill the board when they all
|
|
1313
|
+
// fit, capped at max-w so a couple of lanes don't stretch absurdly
|
|
1314
|
+
// wide. Below min-w the board's overflow-x-auto takes over and the
|
|
1315
|
+
// lanes scroll horizontally at their minimum width.
|
|
1316
|
+
className="group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity"
|
|
1242
1317
|
style={{
|
|
1243
1318
|
opacity: dimmed ? 0.45 : 1,
|
|
1244
1319
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
@@ -1257,11 +1332,7 @@ function KanbanLane({
|
|
|
1257
1332
|
{t(stage.label, { defaultValue: stage.label })}
|
|
1258
1333
|
</Badge>
|
|
1259
1334
|
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
|
1260
|
-
{laneActive
|
|
1261
|
-
? `${count}/${totalCount}`
|
|
1262
|
-
: serverTotal != null
|
|
1263
|
-
? `${count}/${serverTotal}`
|
|
1264
|
-
: count}
|
|
1335
|
+
{formatLaneCount(count, totalCount, serverTotal, laneActive)}
|
|
1265
1336
|
</span>
|
|
1266
1337
|
</div>
|
|
1267
1338
|
{/* Lane actions — always visible in muted (a hidden hover-reveal
|