@asteby/metacore-runtime-react 23.1.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 +35 -0
- package/dist/dynamic-kanban.d.ts +19 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +114 -8
- package/dist/dynamic-table.d.ts +9 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +130 -30
- 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 +3 -3
- package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
- package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
- package/src/dynamic-kanban.tsx +168 -8
- package/src/dynamic-table.tsx +147 -8
- package/src/use-infinite-scroll.ts +94 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Shared incremental-loading primitives for DynamicTable and DynamicKanban.
|
|
2
|
+
// - `dedupeById` appends a page of rows to the accumulated set, dropping any
|
|
3
|
+
// id already present (the server may re-paginate / overlap between a global
|
|
4
|
+
// page and a scoped top-up).
|
|
5
|
+
// - `useInfiniteScrollSentinel` wires an IntersectionObserver: attach
|
|
6
|
+
// `rootRef` to the scroll container and `sentinelRef` to a sentinel at its
|
|
7
|
+
// bottom; when the sentinel enters view (and it's enabled) `onLoadMore`
|
|
8
|
+
// fires. Degrades to a no-op where IntersectionObserver is unavailable
|
|
9
|
+
// (older/SSR/happy-dom) so callers still render.
|
|
10
|
+
import { useEffect, useRef } from 'react';
|
|
11
|
+
/**
|
|
12
|
+
* Returns a NEW array = `existing` followed by every row in `incoming` whose
|
|
13
|
+
* `id` is not already present. Stable on identity/order of `existing`. Pure.
|
|
14
|
+
*/
|
|
15
|
+
export function dedupeById(existing, incoming) {
|
|
16
|
+
if (incoming.length === 0)
|
|
17
|
+
return existing;
|
|
18
|
+
const seen = new Set(existing.map((r) => String(r?.id)));
|
|
19
|
+
const additions = [];
|
|
20
|
+
for (const row of incoming) {
|
|
21
|
+
const key = String(row?.id);
|
|
22
|
+
if (seen.has(key))
|
|
23
|
+
continue;
|
|
24
|
+
seen.add(key);
|
|
25
|
+
additions.push(row);
|
|
26
|
+
}
|
|
27
|
+
return additions.length === 0 ? existing : [...existing, ...additions];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* IntersectionObserver-backed infinite scroll. Attach `rootRef` to the
|
|
31
|
+
* scrollable container and `sentinelRef` to a small element at its bottom. The
|
|
32
|
+
* latest `onLoadMore`/`disabled` are read through a ref so the observer isn't
|
|
33
|
+
* torn down and rebuilt on every render.
|
|
34
|
+
*/
|
|
35
|
+
export function useInfiniteScrollSentinel({ onLoadMore, disabled = false, rootMargin = 200, }) {
|
|
36
|
+
const rootRef = useRef(null);
|
|
37
|
+
const sentinelRef = useRef(null);
|
|
38
|
+
const cb = useRef(onLoadMore);
|
|
39
|
+
const disabledRef = useRef(disabled);
|
|
40
|
+
cb.current = onLoadMore;
|
|
41
|
+
disabledRef.current = disabled;
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const sentinel = sentinelRef.current;
|
|
44
|
+
if (!sentinel ||
|
|
45
|
+
typeof IntersectionObserver === 'undefined') {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const observer = new IntersectionObserver((entries) => {
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (entry.isIntersecting && !disabledRef.current) {
|
|
51
|
+
cb.current();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}, { root: rootRef.current ?? null, rootMargin: `0px 0px ${rootMargin}px 0px` });
|
|
55
|
+
observer.observe(sentinel);
|
|
56
|
+
return () => observer.disconnect();
|
|
57
|
+
// Re-create only when the margin changes; onLoadMore/disabled are read live.
|
|
58
|
+
}, [rootMargin]);
|
|
59
|
+
return { rootRef, sentinelRef };
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.2.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"sonner": ">=1.7",
|
|
39
39
|
"zustand": ">=5",
|
|
40
40
|
"@asteby/metacore-sdk": "^3.2.0",
|
|
41
|
-
"@asteby/metacore-ui": "^2.9.
|
|
41
|
+
"@asteby/metacore-ui": "^2.9.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
70
|
"@asteby/metacore-sdk": "3.2.0",
|
|
71
|
-
"@asteby/metacore-ui": "2.9.
|
|
71
|
+
"@asteby/metacore-ui": "2.9.1"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DynamicKanban incremental (per-lane) loading:
|
|
4
|
+
// 1. applyLaneTotalsOnMove — the pure count adjustment that keeps a partial
|
|
5
|
+
// lane's `count/total` header truthful across an optimistic drag.
|
|
6
|
+
// 2. A lane tops up its OWN stage on scroll: firing the sentinel issues
|
|
7
|
+
// `f_<group_by>=<stage>&page=1&per_page=25` (on top of the active filters)
|
|
8
|
+
// and appends the rows, with the header total taken from the response meta.
|
|
9
|
+
// 3. Changing the filters re-fetches the initial board page (resets the
|
|
10
|
+
// per-lane pagination).
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
13
|
+
|
|
14
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
15
|
+
useNavigate: () => () => {},
|
|
16
|
+
}))
|
|
17
|
+
const I18N = { t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, i18n: { language: 'es' } }
|
|
18
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
|
|
19
|
+
|
|
20
|
+
import { applyLaneTotalsOnMove, DynamicKanban } from '../dynamic-kanban'
|
|
21
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
22
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
23
|
+
import type { TableMetadata } from '../types'
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup)
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// 1. Pure count adjustment
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
describe('applyLaneTotalsOnMove', () => {
|
|
32
|
+
it('decrements the source total and increments the destination total', () => {
|
|
33
|
+
const out = applyLaneTotalsOnMove(
|
|
34
|
+
{ backlog: { total: 5 }, done: { total: 2 } },
|
|
35
|
+
'backlog',
|
|
36
|
+
'done',
|
|
37
|
+
)
|
|
38
|
+
expect(out.backlog.total).toBe(4)
|
|
39
|
+
expect(out.done.total).toBe(3)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('leaves lanes with an unknown (null) total alone', () => {
|
|
43
|
+
const out = applyLaneTotalsOnMove(
|
|
44
|
+
{ backlog: { total: null }, done: { total: 2 } },
|
|
45
|
+
'backlog',
|
|
46
|
+
'done',
|
|
47
|
+
)
|
|
48
|
+
expect(out.backlog.total).toBeNull()
|
|
49
|
+
expect(out.done.total).toBe(3)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('never drives a total below zero', () => {
|
|
53
|
+
const out = applyLaneTotalsOnMove(
|
|
54
|
+
{ backlog: { total: 0 }, done: { total: 1 } },
|
|
55
|
+
'backlog',
|
|
56
|
+
'done',
|
|
57
|
+
)
|
|
58
|
+
expect(out.backlog.total).toBe(0)
|
|
59
|
+
expect(out.done.total).toBe(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not mutate the input map', () => {
|
|
63
|
+
const input = { backlog: { total: 5 }, done: { total: 2 } }
|
|
64
|
+
applyLaneTotalsOnMove(input, 'backlog', 'done')
|
|
65
|
+
expect(input.backlog.total).toBe(5)
|
|
66
|
+
expect(input.done.total).toBe(2)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// 2 + 3. Per-lane top-up + reset
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const STAGES = [
|
|
75
|
+
{ key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
|
|
76
|
+
{ key: 'done', label: 'Done', color: 'green', order: 1 },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
function meta(over: Partial<TableMetadata> = {}): TableMetadata {
|
|
80
|
+
return {
|
|
81
|
+
title: 'Issues',
|
|
82
|
+
endpoint: '/data/issue',
|
|
83
|
+
view_type: 'kanban',
|
|
84
|
+
group_by: 'stage',
|
|
85
|
+
stages: STAGES,
|
|
86
|
+
columns: [
|
|
87
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
|
|
88
|
+
{ key: 'stage', label: 'Stage', type: 'status', sortable: false, filterable: true, options: STAGES.map((s) => ({ value: s.key, label: s.label })) },
|
|
89
|
+
],
|
|
90
|
+
actions: [],
|
|
91
|
+
perPageOptions: [50],
|
|
92
|
+
defaultPerPage: 50,
|
|
93
|
+
searchPlaceholder: 'Buscar...',
|
|
94
|
+
enableCRUDActions: false,
|
|
95
|
+
hasActions: false,
|
|
96
|
+
...over,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Initial global page: one backlog card. A backlog top-up (f_stage=backlog)
|
|
101
|
+
// returns two more backlog cards with a server total of 5.
|
|
102
|
+
function fakeApi(): ApiClient {
|
|
103
|
+
const ok = (data: unknown, meta?: Record<string, unknown>) => ({
|
|
104
|
+
data: { success: true, data, ...(meta ? { meta } : {}) },
|
|
105
|
+
})
|
|
106
|
+
return {
|
|
107
|
+
get: vi.fn(async (url: string, cfg?: any) => {
|
|
108
|
+
if (url.startsWith('/metadata/table/')) return ok(meta())
|
|
109
|
+
const stage = cfg?.params?.f_stage
|
|
110
|
+
if (stage === 'backlog') {
|
|
111
|
+
return ok(
|
|
112
|
+
[
|
|
113
|
+
{ id: 2, title: 'Backlog Two', stage: 'backlog' },
|
|
114
|
+
{ id: 3, title: 'Backlog Three', stage: 'backlog' },
|
|
115
|
+
],
|
|
116
|
+
{ total: 5 },
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if (stage === 'done') return ok([], { total: 0 })
|
|
120
|
+
// initial global board page
|
|
121
|
+
return ok([{ id: 1, title: 'Backlog One', stage: 'backlog' }])
|
|
122
|
+
}),
|
|
123
|
+
post: vi.fn(async () => ok(null)),
|
|
124
|
+
put: vi.fn(async () => ok(null)),
|
|
125
|
+
delete: vi.fn(async () => ok(null)),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stageCalls(api: ApiClient, stage: string) {
|
|
130
|
+
return (api.get as any).mock.calls.filter(
|
|
131
|
+
(c: any[]) => c[1]?.params?.f_stage === stage,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
function globalDataCalls(api: ApiClient) {
|
|
135
|
+
return (api.get as any).mock.calls.filter(
|
|
136
|
+
(c: any[]) =>
|
|
137
|
+
!String(c[0]).startsWith('/metadata/table/') &&
|
|
138
|
+
c[1]?.params?.f_stage === undefined,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Controllable IntersectionObserver.
|
|
143
|
+
type IOEntry = { isIntersecting: boolean }
|
|
144
|
+
let observers: Array<{ fire: (v: boolean) => void }> = []
|
|
145
|
+
class FakeIO {
|
|
146
|
+
cb: (e: IOEntry[]) => void
|
|
147
|
+
constructor(cb: (e: IOEntry[]) => void) {
|
|
148
|
+
this.cb = cb
|
|
149
|
+
observers.push({ fire: (v: boolean) => this.cb([{ isIntersecting: v }]) })
|
|
150
|
+
}
|
|
151
|
+
observe() {}
|
|
152
|
+
disconnect() {}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe('DynamicKanban per-lane infinite scroll', () => {
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
observers = []
|
|
158
|
+
;(globalThis as any).IntersectionObserver = FakeIO
|
|
159
|
+
})
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
delete (globalThis as any).IntersectionObserver
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('tops up ONE lane by stage on scroll and shows the server total', async () => {
|
|
165
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
166
|
+
const api = fakeApi()
|
|
167
|
+
render(
|
|
168
|
+
<ApiProvider client={api}>
|
|
169
|
+
<DynamicKanban model="issue" />
|
|
170
|
+
</ApiProvider>,
|
|
171
|
+
)
|
|
172
|
+
// Initial board page painted its single backlog card.
|
|
173
|
+
expect(await screen.findByText('Backlog One')).toBeTruthy()
|
|
174
|
+
expect(stageCalls(api, 'backlog')).toHaveLength(0)
|
|
175
|
+
|
|
176
|
+
// Sentinels enter view → each declared lane tops up its OWN stage.
|
|
177
|
+
expect(observers.length).toBeGreaterThan(0)
|
|
178
|
+
observers.forEach((o) => o.fire(true))
|
|
179
|
+
|
|
180
|
+
// 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]
|
|
183
|
+
expect(call[1].params).toMatchObject({ f_stage: 'backlog', page: 1, per_page: 25 })
|
|
184
|
+
|
|
185
|
+
// ...its rows were appended and the header shows count/serverTotal (3/5).
|
|
186
|
+
expect(await screen.findByText('Backlog Two')).toBeTruthy()
|
|
187
|
+
expect(await screen.findByText('3/5')).toBeTruthy()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('re-fetches the initial board page when the filters change (reset)', async () => {
|
|
191
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
192
|
+
const api = fakeApi()
|
|
193
|
+
const { rerender } = render(
|
|
194
|
+
<ApiProvider client={api}>
|
|
195
|
+
<DynamicKanban model="issue" />
|
|
196
|
+
</ApiProvider>,
|
|
197
|
+
)
|
|
198
|
+
await screen.findByText('Backlog One')
|
|
199
|
+
const before = globalDataCalls(api).length
|
|
200
|
+
|
|
201
|
+
rerender(
|
|
202
|
+
<ApiProvider client={api}>
|
|
203
|
+
<DynamicKanban model="issue" defaultFilters={{ priority: 'high' }} />
|
|
204
|
+
</ApiProvider>,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
// A fresh (un-scoped) board fetch fires — the per-lane pagination resets.
|
|
208
|
+
await waitFor(() => expect(globalDataCalls(api).length).toBeGreaterThan(before))
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DynamicTable infinite-scroll (opt-in) behaviour:
|
|
4
|
+
// 1. With `infiniteScroll`, the classic pager is replaced by a "N de total"
|
|
5
|
+
// indicator; the first page loads with per_page=30 and the sentinel's
|
|
6
|
+
// intersection appends the next page, deduped by id.
|
|
7
|
+
// 2. Changing the filters resets the accumulated list back to page 1.
|
|
8
|
+
// 3. WITHOUT the prop (default), the classic pager renders and no sentinel /
|
|
9
|
+
// append path is engaged — existing hosts are untouched.
|
|
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 = {
|
|
17
|
+
t: (_k: string, o?: { defaultValue?: string; count?: number; total?: number }) => {
|
|
18
|
+
// Mirror the "{{count}} de {{total}}" interpolation the footer uses.
|
|
19
|
+
if (o?.defaultValue && o.count != null && o.total != null) {
|
|
20
|
+
return o.defaultValue.replace('{{count}}', String(o.count)).replace('{{total}}', String(o.total))
|
|
21
|
+
}
|
|
22
|
+
return o?.defaultValue ?? _k
|
|
23
|
+
},
|
|
24
|
+
i18n: { language: 'es' },
|
|
25
|
+
}
|
|
26
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
|
|
27
|
+
|
|
28
|
+
import { DynamicTable } from '../dynamic-table'
|
|
29
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
30
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
31
|
+
import type { TableMetadata } from '../types'
|
|
32
|
+
|
|
33
|
+
afterEach(cleanup)
|
|
34
|
+
|
|
35
|
+
function meta(): TableMetadata {
|
|
36
|
+
return {
|
|
37
|
+
title: 'Issues',
|
|
38
|
+
endpoint: '/data/issue',
|
|
39
|
+
group_by: 'stage',
|
|
40
|
+
columns: [
|
|
41
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
|
|
42
|
+
],
|
|
43
|
+
actions: [],
|
|
44
|
+
perPageOptions: [50],
|
|
45
|
+
defaultPerPage: 50,
|
|
46
|
+
searchPlaceholder: 'Buscar...',
|
|
47
|
+
enableCRUDActions: false,
|
|
48
|
+
hasActions: false,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A model with `total` rows. The data endpoint returns a page of {id,title};
|
|
53
|
+
// page 2 deliberately re-includes the last id of page 1 to exercise dedup.
|
|
54
|
+
function fakeApi(total: number, pageSize = 30): ApiClient {
|
|
55
|
+
const ok = (data: unknown, extra: Record<string, unknown> = {}) => ({
|
|
56
|
+
data: { success: true, data, meta: { total, ...extra } },
|
|
57
|
+
})
|
|
58
|
+
const page = (n: number) => {
|
|
59
|
+
const start = (n - 1) * pageSize
|
|
60
|
+
const rows: Array<{ id: number; title: string }> = []
|
|
61
|
+
// Overlap: page n>1 repeats the previous page's last row (a server that
|
|
62
|
+
// re-paginated) — dedupeById must drop it.
|
|
63
|
+
const from = n > 1 ? start - 1 : start
|
|
64
|
+
for (let i = from; i < Math.min(start + pageSize, total); i++) {
|
|
65
|
+
rows.push({ id: i + 1, title: `Issue ${i + 1}` })
|
|
66
|
+
}
|
|
67
|
+
return rows
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
get: vi.fn(async (url: string, cfg?: any) => {
|
|
71
|
+
if (url.startsWith('/metadata/table/')) return ok(meta())
|
|
72
|
+
if (url.endsWith('/facets')) return ok([])
|
|
73
|
+
// data endpoint
|
|
74
|
+
const p = cfg?.params?.page ?? 1
|
|
75
|
+
return ok(page(p))
|
|
76
|
+
}),
|
|
77
|
+
post: vi.fn(async () => ok(null)),
|
|
78
|
+
put: vi.fn(async () => ok(null)),
|
|
79
|
+
delete: vi.fn(async () => ok(null)),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function dataCalls(api: ApiClient) {
|
|
84
|
+
return (api.get as any).mock.calls.filter(
|
|
85
|
+
(c: any[]) => c[0] === '/data/issue' || (c[0] === undefined),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Controllable IntersectionObserver — captures the observers so the test can
|
|
90
|
+
// fire the sentinel's intersection deterministically.
|
|
91
|
+
type IOEntry = { isIntersecting: boolean }
|
|
92
|
+
let observers: Array<{ fire: (v: boolean) => void }> = []
|
|
93
|
+
class FakeIO {
|
|
94
|
+
cb: (e: IOEntry[]) => void
|
|
95
|
+
constructor(cb: (e: IOEntry[]) => void) {
|
|
96
|
+
this.cb = cb
|
|
97
|
+
observers.push({ fire: (v: boolean) => this.cb([{ isIntersecting: v }]) })
|
|
98
|
+
}
|
|
99
|
+
observe() {}
|
|
100
|
+
disconnect() {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('DynamicTable infinite scroll', () => {
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
observers = []
|
|
106
|
+
;(globalThis as any).IntersectionObserver = FakeIO
|
|
107
|
+
})
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
delete (globalThis as any).IntersectionObserver
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('loads page 1 (per_page 30) then appends page 2 on intersect, deduped', async () => {
|
|
113
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
114
|
+
const api = fakeApi(45) // 45 rows → page1: 30, page2: 15 (+1 overlap dropped)
|
|
115
|
+
render(
|
|
116
|
+
<ApiProvider client={api}>
|
|
117
|
+
<DynamicTable model="issue" infiniteScroll enableUrlSync={false} />
|
|
118
|
+
</ApiProvider>,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
// Page 1 request: page=1, per_page=30.
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
const first = dataCalls(api)[0]
|
|
124
|
+
expect(first?.[1]?.params).toMatchObject({ page: 1, per_page: 30 })
|
|
125
|
+
})
|
|
126
|
+
// Footer indicator reflects the loaded/total counts.
|
|
127
|
+
await waitFor(() => expect(screen.getByText('30 de 45')).toBeTruthy())
|
|
128
|
+
|
|
129
|
+
// Sentinel enters view → page 2 fetched and APPENDED.
|
|
130
|
+
expect(observers.length).toBeGreaterThan(0)
|
|
131
|
+
observers.forEach((o) => o.fire(true))
|
|
132
|
+
|
|
133
|
+
await waitFor(() => {
|
|
134
|
+
const pages = dataCalls(api).map((c: any[]) => c[1]?.params?.page)
|
|
135
|
+
expect(pages).toContain(2)
|
|
136
|
+
})
|
|
137
|
+
// 30 + 15 unique = 45 (NOT 46 — the overlap row was deduped).
|
|
138
|
+
await waitFor(() => expect(screen.getByText('45 de 45')).toBeTruthy())
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('resets to page 1 (replacing the accumulated rows) when filters change', async () => {
|
|
142
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
143
|
+
const api = fakeApi(45)
|
|
144
|
+
const { rerender } = render(
|
|
145
|
+
<ApiProvider client={api}>
|
|
146
|
+
<DynamicTable model="issue" infiniteScroll enableUrlSync={false} />
|
|
147
|
+
</ApiProvider>,
|
|
148
|
+
)
|
|
149
|
+
await waitFor(() => expect(screen.getByText('30 de 45')).toBeTruthy())
|
|
150
|
+
observers.forEach((o) => o.fire(true))
|
|
151
|
+
await waitFor(() => expect(screen.getByText('45 de 45')).toBeTruthy())
|
|
152
|
+
|
|
153
|
+
// Change the active filters via defaultFilters (part of buildFilterParams).
|
|
154
|
+
rerender(
|
|
155
|
+
<ApiProvider client={api}>
|
|
156
|
+
<DynamicTable
|
|
157
|
+
model="issue"
|
|
158
|
+
infiniteScroll
|
|
159
|
+
enableUrlSync={false}
|
|
160
|
+
defaultFilters={{ stage: 'done' }}
|
|
161
|
+
/>
|
|
162
|
+
</ApiProvider>,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
// A fresh page-1 request carrying the new filter, and the list collapses
|
|
166
|
+
// back to a single page (30 of 45) — the accumulated 45 were dropped.
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
const withFilter = dataCalls(api).filter(
|
|
169
|
+
(c: any[]) => c[1]?.params?.f_stage === 'done',
|
|
170
|
+
)
|
|
171
|
+
expect(withFilter.some((c: any[]) => c[1]?.params?.page === 1)).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
await waitFor(() => expect(screen.getByText('30 de 45')).toBeTruthy())
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('default (no prop) keeps the classic pager and never appends', async () => {
|
|
177
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
178
|
+
const api = fakeApi(45)
|
|
179
|
+
render(
|
|
180
|
+
<ApiProvider client={api}>
|
|
181
|
+
<DynamicTable model="issue" enableUrlSync={false} />
|
|
182
|
+
</ApiProvider>,
|
|
183
|
+
)
|
|
184
|
+
// Classic pager renders its rows-per-page control; the infinite footer
|
|
185
|
+
// indicator ("N de N") is absent.
|
|
186
|
+
await waitFor(() => expect(dataCalls(api).length).toBeGreaterThan(0))
|
|
187
|
+
expect(screen.queryByText('30 de 45')).toBeNull()
|
|
188
|
+
expect(screen.queryByText('45 de 45')).toBeNull()
|
|
189
|
+
|
|
190
|
+
// Firing any (stray) sentinel must NOT trigger a page-2 append.
|
|
191
|
+
observers.forEach((o) => o.fire(true))
|
|
192
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
193
|
+
const pages = dataCalls(api).map((c: any[]) => c[1]?.params?.page)
|
|
194
|
+
expect(pages).not.toContain(2)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Shared infinite-scroll primitives (used by both DynamicTable and
|
|
4
|
+
// DynamicKanban):
|
|
5
|
+
// 1. dedupeById — pure append that drops ids already present, preserves order,
|
|
6
|
+
// and is identity-stable when there is nothing new to add.
|
|
7
|
+
// 2. useInfiniteScrollSentinel — an IntersectionObserver wrapper. happy-dom
|
|
8
|
+
// has no IntersectionObserver, so we install a controllable fake and drive
|
|
9
|
+
// it: entering view fires onLoadMore, `disabled` suppresses it, and the
|
|
10
|
+
// absence of IntersectionObserver degrades to a no-op instead of throwing.
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { cleanup, render } from '@testing-library/react'
|
|
13
|
+
import { useEffect, useState } from 'react'
|
|
14
|
+
import { dedupeById, useInfiniteScrollSentinel } from '../use-infinite-scroll'
|
|
15
|
+
|
|
16
|
+
afterEach(cleanup)
|
|
17
|
+
|
|
18
|
+
describe('dedupeById', () => {
|
|
19
|
+
it('appends only rows whose id is not already present', () => {
|
|
20
|
+
const existing = [{ id: 1, n: 'a' }, { id: 2, n: 'b' }]
|
|
21
|
+
const incoming = [{ id: 2, n: 'b2' }, { id: 3, n: 'c' }]
|
|
22
|
+
const out = dedupeById(existing, incoming)
|
|
23
|
+
expect(out.map((r) => r.id)).toEqual([1, 2, 3])
|
|
24
|
+
// The already-present row keeps the ORIGINAL (existing wins over incoming).
|
|
25
|
+
expect(out[1].n).toBe('b')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('preserves the order of existing then the new arrivals', () => {
|
|
29
|
+
const out = dedupeById(
|
|
30
|
+
[{ id: 'x' }, { id: 'y' }],
|
|
31
|
+
[{ id: 'z' }, { id: 'w' }],
|
|
32
|
+
)
|
|
33
|
+
expect(out.map((r) => r.id)).toEqual(['x', 'y', 'z', 'w'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns the SAME array reference when nothing is added', () => {
|
|
37
|
+
const existing = [{ id: 1 }, { id: 2 }]
|
|
38
|
+
expect(dedupeById(existing, [])).toBe(existing)
|
|
39
|
+
expect(dedupeById(existing, [{ id: 1 }, { id: 2 }])).toBe(existing)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('treats numeric and string ids as the same key', () => {
|
|
43
|
+
const out = dedupeById([{ id: 1 }], [{ id: '1' }, { id: 2 }])
|
|
44
|
+
expect(out.map((r) => r.id)).toEqual([1, 2])
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Controllable IntersectionObserver fake
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
type IOEntry = { isIntersecting: boolean }
|
|
53
|
+
let observers: FakeIO[] = []
|
|
54
|
+
|
|
55
|
+
class FakeIO {
|
|
56
|
+
cb: (entries: IOEntry[]) => void
|
|
57
|
+
observed: Element[] = []
|
|
58
|
+
disconnected = false
|
|
59
|
+
constructor(cb: (entries: IOEntry[]) => void) {
|
|
60
|
+
this.cb = cb
|
|
61
|
+
observers.push(this)
|
|
62
|
+
}
|
|
63
|
+
observe(el: Element) {
|
|
64
|
+
this.observed.push(el)
|
|
65
|
+
}
|
|
66
|
+
disconnect() {
|
|
67
|
+
this.disconnected = true
|
|
68
|
+
}
|
|
69
|
+
// Test helper: simulate the sentinel scrolling into / out of view.
|
|
70
|
+
fire(isIntersecting: boolean) {
|
|
71
|
+
this.cb([{ isIntersecting }])
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function Harness({
|
|
76
|
+
onLoadMore,
|
|
77
|
+
disabled,
|
|
78
|
+
}: {
|
|
79
|
+
onLoadMore: () => void
|
|
80
|
+
disabled?: boolean
|
|
81
|
+
}) {
|
|
82
|
+
const { rootRef, sentinelRef } = useInfiniteScrollSentinel({ onLoadMore, disabled })
|
|
83
|
+
return (
|
|
84
|
+
<div ref={rootRef}>
|
|
85
|
+
<div ref={sentinelRef} data-testid="sentinel" />
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('useInfiniteScrollSentinel', () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
observers = []
|
|
93
|
+
;(globalThis as any).IntersectionObserver = FakeIO
|
|
94
|
+
})
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
delete (globalThis as any).IntersectionObserver
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('fires onLoadMore when the sentinel intersects', () => {
|
|
100
|
+
const onLoadMore = vi.fn()
|
|
101
|
+
render(<Harness onLoadMore={onLoadMore} />)
|
|
102
|
+
expect(observers).toHaveLength(1)
|
|
103
|
+
observers[0].fire(true)
|
|
104
|
+
expect(onLoadMore).toHaveBeenCalledTimes(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does NOT fire when disabled (load in flight / no more pages)', () => {
|
|
108
|
+
const onLoadMore = vi.fn()
|
|
109
|
+
render(<Harness onLoadMore={onLoadMore} disabled />)
|
|
110
|
+
observers[0].fire(true)
|
|
111
|
+
expect(onLoadMore).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('ignores non-intersecting callbacks (scrolling away)', () => {
|
|
115
|
+
const onLoadMore = vi.fn()
|
|
116
|
+
render(<Harness onLoadMore={onLoadMore} />)
|
|
117
|
+
observers[0].fire(false)
|
|
118
|
+
expect(onLoadMore).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('reads the LATEST onLoadMore/disabled through a ref (no observer churn)', () => {
|
|
122
|
+
// A parent that flips `disabled` from true -> false without remounting;
|
|
123
|
+
// the observer must not be torn down, and the newest callback wins.
|
|
124
|
+
const calls: string[] = []
|
|
125
|
+
function Parent() {
|
|
126
|
+
const [enabled, setEnabled] = useState(false)
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
setEnabled(true)
|
|
129
|
+
}, [])
|
|
130
|
+
return (
|
|
131
|
+
<Harness onLoadMore={() => calls.push('load')} disabled={!enabled} />
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
render(<Parent />)
|
|
135
|
+
// Only ONE observer instance across the disabled->enabled flip.
|
|
136
|
+
expect(observers).toHaveLength(1)
|
|
137
|
+
observers[0].fire(true)
|
|
138
|
+
expect(calls).toEqual(['load'])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('degrades to a no-op when IntersectionObserver is unavailable', () => {
|
|
142
|
+
delete (globalThis as any).IntersectionObserver
|
|
143
|
+
const onLoadMore = vi.fn()
|
|
144
|
+
// Must render without throwing even though there is no IO.
|
|
145
|
+
expect(() => render(<Harness onLoadMore={onLoadMore} />)).not.toThrow()
|
|
146
|
+
})
|
|
147
|
+
})
|