@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 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
@@ -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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"}
@@ -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-[300px] 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))) }));
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, className: "group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity", style: {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.4.0",
3
+ "version": "23.5.1",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- expect(stageCalls(api, 'backlog')).toHaveLength(0)
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(stageCalls(api, 'backlog').length).toBeGreaterThan(0))
182
- const call = stageCalls(api, 'backlog')[0]
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
@@ -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-[300px] shrink-0 space-y-3">
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
- className="group/lane flex w-[300px] shrink-0 flex-col rounded-xl border bg-muted/30 transition-opacity"
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