@asteby/metacore-runtime-react 23.1.0 → 23.3.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/action-modal-dispatcher.js +4 -4
  3. package/dist/dialogs/dynamic-record.d.ts +5 -0
  4. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  5. package/dist/dialogs/dynamic-record.js +146 -31
  6. package/dist/display-value.d.ts +56 -0
  7. package/dist/display-value.d.ts.map +1 -0
  8. package/dist/display-value.js +74 -0
  9. package/dist/dynamic-columns.d.ts.map +1 -1
  10. package/dist/dynamic-columns.js +2 -53
  11. package/dist/dynamic-kanban.d.ts +19 -3
  12. package/dist/dynamic-kanban.d.ts.map +1 -1
  13. package/dist/dynamic-kanban.js +114 -8
  14. package/dist/dynamic-row-actions.d.ts.map +1 -1
  15. package/dist/dynamic-row-actions.js +5 -3
  16. package/dist/dynamic-table.d.ts +9 -1
  17. package/dist/dynamic-table.d.ts.map +1 -1
  18. package/dist/dynamic-table.js +132 -32
  19. package/dist/use-infinite-scroll.d.ts +27 -0
  20. package/dist/use-infinite-scroll.d.ts.map +1 -0
  21. package/dist/use-infinite-scroll.js +60 -0
  22. package/package.json +3 -3
  23. package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
  24. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
  25. package/src/__tests__/readonly-fields.test.tsx +97 -0
  26. package/src/__tests__/record-detail-display.test.tsx +113 -0
  27. package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
  28. package/src/action-modal-dispatcher.tsx +4 -4
  29. package/src/dialogs/dynamic-record.tsx +217 -40
  30. package/src/display-value.tsx +134 -0
  31. package/src/dynamic-columns.tsx +6 -102
  32. package/src/dynamic-kanban.tsx +168 -8
  33. package/src/dynamic-row-actions.tsx +5 -3
  34. package/src/dynamic-table.tsx +149 -10
  35. package/src/use-infinite-scroll.ts +94 -0
@@ -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,97 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // `readonly` field handling in the generic record dialog + action modal.
4
+ //
5
+ // A `readonly` field is server/system-generated (e.g. the GitHub addon's
6
+ // `number`/`github_url`, filled by the API after the outbound create). Kernel
7
+ // v0.64.0 stamps `FieldDef.readonly` on such fields served by DeriveFormFields.
8
+ // UI semantics:
9
+ // - CREATE: the field is excluded from the form entirely (`filterVisibleFields`)
10
+ // — the user can't set a value the server will overwrite.
11
+ // - EDIT: the field is shown, but as a disabled/muted input
12
+ // (`ReadonlyEditField`) — the value is visible, not editable.
13
+ // - VIEW: unchanged (rich read-only renderer).
14
+ //
15
+ // The last block covers action fields with a materialized `options_source`: ops
16
+ // turns them into `type:'select'` with server-populated `options`, and the modal
17
+ // must render a <Select> for them. That decision lives in `resolveWidget`.
18
+ import { afterEach, describe, expect, it, vi } from 'vitest'
19
+ import { cleanup, render, screen } from '@testing-library/react'
20
+
21
+ vi.mock('react-i18next', () => ({
22
+ useTranslation: () => ({
23
+ t: (k: string, o?: any) => o?.defaultValue ?? k,
24
+ i18n: { language: 'es' },
25
+ }),
26
+ }))
27
+
28
+ import { filterVisibleFields, ReadonlyEditField } from '../dialogs/dynamic-record'
29
+ import { resolveWidget } from '../dynamic-form-schema'
30
+
31
+ afterEach(cleanup)
32
+
33
+ const fields = [
34
+ { key: 'name', label: 'Nombre', type: 'text' },
35
+ // System-generated: filled by the API after the outbound create.
36
+ { key: 'number', label: 'Número', type: 'text', readonly: true },
37
+ { key: 'secret', label: 'Secret', type: 'text', hidden: true },
38
+ ]
39
+
40
+ describe('filterVisibleFields — readonly exclusion per mode', () => {
41
+ it('excludes a readonly field on CREATE (and always excludes hidden)', () => {
42
+ const keys = filterVisibleFields(fields as any, 'create').map(f => f.key)
43
+ expect(keys).toEqual(['name'])
44
+ })
45
+
46
+ it('keeps a readonly field visible on EDIT and VIEW (still drops hidden)', () => {
47
+ expect(filterVisibleFields(fields as any, 'edit').map(f => f.key)).toEqual(['name', 'number'])
48
+ expect(filterVisibleFields(fields as any, 'view').map(f => f.key)).toEqual(['name', 'number'])
49
+ })
50
+ })
51
+
52
+ describe('ReadonlyEditField — edit-mode rendering of a readonly field', () => {
53
+ it('renders a disabled, muted input with the value visible', () => {
54
+ render(
55
+ <ReadonlyEditField
56
+ field={{ key: 'number', label: 'Número', type: 'text', readonly: true }}
57
+ value="GH-42"
58
+ />,
59
+ )
60
+ const input = screen.getByDisplayValue('GH-42') as HTMLInputElement
61
+ expect(input).toBeTruthy()
62
+ expect(input.disabled).toBe(true)
63
+ expect(input.className).toContain('text-muted-foreground')
64
+ })
65
+
66
+ it('renders a boolean readonly field as a disabled switch', () => {
67
+ const { container } = render(
68
+ <ReadonlyEditField
69
+ field={{ key: 'active', label: 'Activo', type: 'boolean', readonly: true }}
70
+ value={true}
71
+ />,
72
+ )
73
+ const sw = container.querySelector('[role="switch"]') as HTMLElement
74
+ expect(sw).toBeTruthy()
75
+ expect(sw.getAttribute('data-disabled') != null || sw.hasAttribute('disabled')).toBe(true)
76
+ expect(screen.getByText('Sí')).toBeTruthy()
77
+ })
78
+ })
79
+
80
+ describe('action fields — options_source materialized to a select', () => {
81
+ it('resolves a select-typed field (server-populated options) to the select widget', () => {
82
+ // ops materializes an `options_source` action field into `type:'select'`
83
+ // with the options populated server-side; the modal's renderField keys off
84
+ // resolveWidget to render a <Select> for it.
85
+ expect(
86
+ resolveWidget({
87
+ key: 'status',
88
+ label: 'Estado',
89
+ type: 'select',
90
+ options: [
91
+ { value: 'open', label: 'Abierto' },
92
+ { value: 'closed', label: 'Cerrado' },
93
+ ],
94
+ } as any),
95
+ ).toBe('select')
96
+ })
97
+ })
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Detail-dialog display mapping: the read-only `ViewValue` must render values
4
+ // with the SAME "pro" display logic as the table (shared `display-value.tsx`
5
+ // primitives), keyed off the stamped display type (`cellStyle ?? type`):
6
+ // - served option → colored badge with the localized label,
7
+ // - `cellStyle:'url'` on a text column → clickable external link (new tab),
8
+ // - `cellStyle:'status'` bare token (kanban stage) → colored, translated pill,
9
+ // - a `labels`/`tags` array → a row of badges (never a raw mini-table / JSON).
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+
13
+ // Identity-ish translator with a tiny stage dictionary, matching how the host
14
+ // resolves an addon's i18n keys (t(token, { defaultValue })).
15
+ vi.mock('react-i18next', () => ({
16
+ useTranslation: () => ({
17
+ t: (k: string, opts?: any) =>
18
+ k === 'backlog' ? 'Pendiente' : opts?.defaultValue ?? k,
19
+ i18n: { language: 'es' },
20
+ }),
21
+ }))
22
+
23
+ import { ViewValue } from '../dialogs/dynamic-record'
24
+
25
+ afterEach(cleanup)
26
+
27
+ describe('ViewValue — detail dialog display mapping', () => {
28
+ it('renders a served option as a colored badge with the localized label', () => {
29
+ render(
30
+ <ViewValue
31
+ field={{
32
+ key: 'product_type',
33
+ label: 'Tipo',
34
+ type: 'select',
35
+ options: [
36
+ { value: 'storable', label: 'Almacenable', color: '#22c55e' },
37
+ ],
38
+ }}
39
+ value="storable"
40
+ record={{}}
41
+ />
42
+ )
43
+ const badge = screen.getByText('Almacenable')
44
+ expect(badge).toBeTruthy()
45
+ // Never the raw enum token.
46
+ expect(screen.queryByText('storable')).toBeNull()
47
+ })
48
+
49
+ it('renders a cellStyle:url text column as a clickable external link', () => {
50
+ const { container } = render(
51
+ <ViewValue
52
+ field={{ key: 'github_url', label: 'GitHub', type: 'text', cellStyle: 'url' }}
53
+ value="https://github.com/asteby/x"
54
+ record={{}}
55
+ />
56
+ )
57
+ const link = container.querySelector('a') as HTMLAnchorElement
58
+ expect(link).toBeTruthy()
59
+ expect(link.getAttribute('href')).toBe('https://github.com/asteby/x')
60
+ expect(link.getAttribute('target')).toBe('_blank')
61
+ })
62
+
63
+ it('renders a bare stage token (cellStyle:status) as a translated colored pill', () => {
64
+ const { container } = render(
65
+ <ViewValue
66
+ field={{ key: 'stage', label: 'Etapa', type: 'text', cellStyle: 'status' }}
67
+ value="backlog"
68
+ record={{}}
69
+ />
70
+ )
71
+ // Localized via the manifest i18n key, not the raw "backlog".
72
+ expect(screen.getByText('Pendiente')).toBeTruthy()
73
+ expect(container.textContent).not.toContain('backlog')
74
+ // Colored: the badge carries an inline background style.
75
+ const styled = container.querySelector('[style*="background"]')
76
+ expect(styled).toBeTruthy()
77
+ })
78
+
79
+ it('renders a labels array (cellStyle:tags) as a row of badges', () => {
80
+ render(
81
+ <ViewValue
82
+ field={{ key: 'labels', label: 'Labels', type: 'json', cellStyle: 'tags' }}
83
+ value={['bug', 'urgent']}
84
+ record={{}}
85
+ />
86
+ )
87
+ expect(screen.getByText('bug')).toBeTruthy()
88
+ expect(screen.getByText('urgent')).toBeTruthy()
89
+ })
90
+
91
+ it('renders label objects with color as colored badges', () => {
92
+ render(
93
+ <ViewValue
94
+ field={{ key: 'labels', label: 'Labels', type: 'json', cellStyle: 'relation-badge-list' }}
95
+ value={[{ label: 'Bug', color: '#ef4444' }, { name: 'P1' }]}
96
+ record={{}}
97
+ />
98
+ )
99
+ expect(screen.getByText('Bug')).toBeTruthy()
100
+ expect(screen.getByText('P1')).toBeTruthy()
101
+ })
102
+
103
+ it('keeps the "—" empty marker for an empty labels array', () => {
104
+ const { container } = render(
105
+ <ViewValue
106
+ field={{ key: 'labels', label: 'Labels', type: 'json', cellStyle: 'tags' }}
107
+ value={[]}
108
+ record={{}}
109
+ />
110
+ )
111
+ expect(container.textContent).toContain('—')
112
+ })
113
+ })
@@ -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
+ })
@@ -225,11 +225,11 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
225
225
  const url = buildActionUrl(endpoint, model, record.id, action.key)
226
226
  const res = await api.post(url, {})
227
227
  if (res.data.success) {
228
- toast.success(res.data.message || t('common.success'))
228
+ toast.success(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.success'))
229
229
  onOpenChange(false)
230
230
  onSuccess()
231
231
  } else {
232
- toast.error(res.data.message || t('common.error'))
232
+ toast.error(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.error'))
233
233
  }
234
234
  } catch (err: any) {
235
235
  toast.error(err?.response?.data?.message || t('common.error'))
@@ -342,11 +342,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
342
342
  const url = buildActionUrl(endpoint, model, record.id, action.key)
343
343
  const res = await api.post(url, formData)
344
344
  if (res.data.success) {
345
- toast.success(res.data.message || t('common.success'))
345
+ toast.success(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.success'))
346
346
  onOpenChange(false)
347
347
  onSuccess()
348
348
  } else {
349
- toast.error(res.data.message || t('common.error'))
349
+ toast.error(res.data.message ? t(res.data.message, { defaultValue: res.data.message }) : t('common.error'))
350
350
  }
351
351
  } catch (err: any) {
352
352
  toast.error(err?.response?.data?.message || t('common.error'))