@asteby/metacore-runtime-react 49.3.1 → 49.5.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 +14 -0
- package/dist/api-context.d.ts +9 -1
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/business/document-editor-panels.d.ts +2 -1
- package/dist/business/document-editor-panels.d.ts.map +1 -1
- package/dist/business/document-editor-panels.js +6 -6
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +23 -9
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-items-editor.d.ts +6 -1
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +109 -83
- package/dist/business/line-product-cell.d.ts +32 -0
- package/dist/business/line-product-cell.d.ts.map +1 -0
- package/dist/business/line-product-cell.js +100 -0
- package/dist/dynamic-select-field.d.ts +2 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +49 -19
- package/dist/dynamic-table.d.ts +31 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +37 -4
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +13 -11
- package/dist/field-grid.d.ts +7 -1
- package/dist/field-grid.d.ts.map +1 -1
- package/dist/field-grid.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +17 -1
- package/dist/record-picker-actions.d.ts +27 -0
- package/dist/record-picker-actions.d.ts.map +1 -0
- package/dist/record-picker-actions.js +32 -0
- package/package.json +1 -1
- package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
- package/src/__tests__/document-editor-inline-selects.test.tsx +202 -0
- package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
- package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
- package/src/api-context.tsx +19 -4
- package/src/business/document-editor-panels.tsx +13 -10
- package/src/business/document-editor.tsx +72 -20
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +229 -174
- package/src/business/line-product-cell.tsx +219 -0
- package/src/dynamic-select-field.tsx +51 -22
- package/src/dynamic-table.tsx +91 -4
- package/src/entity-select.tsx +17 -39
- package/src/field-grid.tsx +15 -1
- package/src/index.ts +2 -0
- package/src/query-batch.ts +18 -1
- package/src/record-picker-actions.tsx +81 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DocumentEditor — selects «pro» (feedback del dueño 2026-10-06):
|
|
4
|
+
// 1. El buscador de producto vive DENTRO de la celda «Descripción» del
|
|
5
|
+
// renglón (no una barra a todo lo ancho) y siempre queda un renglón vacío
|
|
6
|
+
// al final; Enter elige y pasa el foco a «Cant.».
|
|
7
|
+
// 2. La lista de resultados se monta en un portal: fuera del contenedor con
|
|
8
|
+
// overflow de la tabla, así no la recorta ni la tapa nada.
|
|
9
|
+
// 3. Los dynamic_select con `ref` llevan crear/editar integrados al trigger:
|
|
10
|
+
// sin valor «+» abre el alta; con valor el lápiz abre la edición de ESE
|
|
11
|
+
// registro (y solo entonces). «Crear …» también al pie de la lista.
|
|
12
|
+
import { useState } from 'react'
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
14
|
+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
15
|
+
|
|
16
|
+
vi.mock('react-i18next', () => ({
|
|
17
|
+
useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import { DocumentLinesGrid } from '../business/line-items-editor'
|
|
21
|
+
import { makeLine, type LineItem } from '../business/line-items'
|
|
22
|
+
import type { ProductResult } from '../business/product-search'
|
|
23
|
+
import { DynamicSelectField } from '../dynamic-select-field'
|
|
24
|
+
import { EntitySelect } from '../entity-select'
|
|
25
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
26
|
+
import type { ActionFieldDef } from '../types'
|
|
27
|
+
|
|
28
|
+
afterEach(cleanup)
|
|
29
|
+
|
|
30
|
+
const tire: ProductResult = { id: 'p1', name: 'Llanta 205/55R16', sku: 'LL-205', price: 1500, tax_rate: 0.16 }
|
|
31
|
+
const valve: ProductResult = { id: 'p2', name: 'Válvula', sku: 'VAL', price: 40, tax_rate: 0.16 }
|
|
32
|
+
|
|
33
|
+
function Grid({ initial = [], search }: { initial?: LineItem[]; search: (q: any, s: AbortSignal) => Promise<ProductResult[]> }) {
|
|
34
|
+
const [lines, setLines] = useState(initial)
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<DocumentLinesGrid value={lines} onChange={setLines} search={search} columns={['discount', 'tax']} currency="MXN" />
|
|
38
|
+
<output data-testid="state">{JSON.stringify(lines)}</output>
|
|
39
|
+
</>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
const state = () => JSON.parse(screen.getByTestId('state').textContent ?? '[]') as LineItem[]
|
|
43
|
+
|
|
44
|
+
describe('DocumentLinesGrid — select de producto en la celda', () => {
|
|
45
|
+
it('el buscador vive en la celda «Descripción» del renglón vacío, no en una fila a todo lo ancho', () => {
|
|
46
|
+
render(<Grid search={vi.fn(async () => [tire])} />)
|
|
47
|
+
const input = screen.getByRole('combobox', { name: 'Buscar producto' })
|
|
48
|
+
const td = input.closest('td')!
|
|
49
|
+
expect(td).toBeTruthy()
|
|
50
|
+
// Ocupa solo su celda: no hay colSpan sobre toda la fila.
|
|
51
|
+
expect(td.colSpan).toBe(1)
|
|
52
|
+
expect(td.closest('tr')!.getAttribute('data-slot')).toBe('line-draft-row')
|
|
53
|
+
expect(td.closest('tr')!.children.length).toBeGreaterThan(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('la lista sale en un portal fuera del contenedor con overflow y Enter elige y pasa a «Cant.»', async () => {
|
|
57
|
+
render(<Grid search={vi.fn(async () => [tire, valve])} />)
|
|
58
|
+
const input = screen.getByRole('combobox', { name: 'Buscar producto' })
|
|
59
|
+
input.focus()
|
|
60
|
+
fireEvent.change(input, { target: { value: 'llan' } })
|
|
61
|
+
const listbox = await waitFor(() => screen.getByRole('listbox'), { timeout: 2000 })
|
|
62
|
+
await waitFor(() => expect(within(listbox).getAllByRole('option')).toHaveLength(2))
|
|
63
|
+
|
|
64
|
+
const scroller = document.querySelector('[data-slot="line-items-editor"] .overflow-x-auto')!
|
|
65
|
+
expect(scroller.contains(listbox)).toBe(false)
|
|
66
|
+
expect(document.querySelector('[data-slot="line-items-editor"]')!.contains(listbox)).toBe(false)
|
|
67
|
+
expect(listbox.closest('[data-slot="line-product-results"]')).toBeTruthy()
|
|
68
|
+
|
|
69
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
|
70
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
71
|
+
expect(state()).toHaveLength(1)
|
|
72
|
+
expect(state()[0]).toMatchObject({ product_id: 'p2', description: 'Válvula', unit_price: 40, tax_rate: 0.16, quantity: 1 })
|
|
73
|
+
await waitFor(() => expect((document.activeElement as HTMLElement | null)?.getAttribute('data-cell')).toBe('quantity'))
|
|
74
|
+
const row = (document.activeElement as HTMLElement).closest('tr')!
|
|
75
|
+
expect(row.getAttribute('data-line-key')).toBe(state()[0]!.key)
|
|
76
|
+
// Sigue habiendo un renglón vacío al final, listo para el siguiente.
|
|
77
|
+
expect((screen.getByRole('combobox', { name: 'Buscar producto' }) as HTMLInputElement).value).toBe('')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('Enter con texto sin coincidencias agrega un renglón libre con esa descripción', async () => {
|
|
81
|
+
render(<Grid search={vi.fn(async () => [])} />)
|
|
82
|
+
const input = screen.getByRole('combobox', { name: 'Buscar producto' })
|
|
83
|
+
fireEvent.change(input, { target: { value: 'Mano de obra' } })
|
|
84
|
+
await waitFor(() => screen.getByText(/Sin coincidencias/), { timeout: 2000 })
|
|
85
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
86
|
+
expect(state()).toHaveLength(1)
|
|
87
|
+
expect(state()[0]!.description).toBe('Mano de obra')
|
|
88
|
+
expect(state()[0]!.product_id).toBeUndefined()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('en un renglón libre, elegir un producto llena ESE renglón (misma clave)', async () => {
|
|
92
|
+
const free = makeLine({ description: '' })
|
|
93
|
+
render(<Grid initial={[free]} search={vi.fn(async () => [tire])} />)
|
|
94
|
+
const cell = screen.getByRole('combobox', { name: 'Descripción' })
|
|
95
|
+
expect(cell.closest('tr')!.getAttribute('data-line-key')).toBe(free.key)
|
|
96
|
+
fireEvent.change(cell, { target: { value: '205' } })
|
|
97
|
+
const option = await waitFor(() => screen.getByRole('option', { name: /Llanta 205/ }), { timeout: 2000 })
|
|
98
|
+
fireEvent.click(within(option).getByRole('button'))
|
|
99
|
+
expect(state()).toHaveLength(1)
|
|
100
|
+
expect(state()[0]).toMatchObject({ key: free.key, product_id: 'p1', unit_price: 1500, sku: 'LL-205' })
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('DynamicSelectField — crear/editar integrados', () => {
|
|
105
|
+
const field: ActionFieldDef = { key: 'customer_id', label: 'Cliente', type: 'dynamic_select', ref: 'customers.Customer' }
|
|
106
|
+
const api = () =>
|
|
107
|
+
({
|
|
108
|
+
get: vi.fn(async () => ({ data: { success: true, data: [{ id: 'c1', label: 'Transportes del Bajío' }] } })),
|
|
109
|
+
post: vi.fn(),
|
|
110
|
+
}) as unknown as ApiClient
|
|
111
|
+
|
|
112
|
+
const capture = (name: string) => {
|
|
113
|
+
const seen: any[] = []
|
|
114
|
+
const fn = (e: Event) => seen.push((e as CustomEvent).detail)
|
|
115
|
+
window.addEventListener(name, fn)
|
|
116
|
+
return { seen, off: () => window.removeEventListener(name, fn) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it('sin valor: «+» unido al trigger abre el alta del modelo ref; no hay lápiz', () => {
|
|
120
|
+
const create = capture('metacore:create-record')
|
|
121
|
+
render(
|
|
122
|
+
<ApiProvider client={api()}>
|
|
123
|
+
<DynamicSelectField field={field} value="" onChange={() => {}} />
|
|
124
|
+
</ApiProvider>,
|
|
125
|
+
)
|
|
126
|
+
expect(screen.queryByRole('button', { name: 'Editar Cliente' })).toBeNull()
|
|
127
|
+
const btn = screen.getByRole('button', { name: 'Crear Cliente' })
|
|
128
|
+
expect(btn.getAttribute('data-action')).toBe('create')
|
|
129
|
+
// Unido: comparte borde con el trigger (sin hueco entre ambos).
|
|
130
|
+
expect(screen.getByRole('combobox').className).toContain('rounded-r-none')
|
|
131
|
+
fireEvent.click(btn)
|
|
132
|
+
expect(create.seen).toHaveLength(1)
|
|
133
|
+
expect(create.seen[0].model).toBe('customers.Customer')
|
|
134
|
+
create.off()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('con valor: el lápiz abre la edición de ESE registro; no hay «+»', () => {
|
|
138
|
+
const edit = capture('metacore:edit-record')
|
|
139
|
+
const onChange = vi.fn()
|
|
140
|
+
render(
|
|
141
|
+
<ApiProvider client={api()}>
|
|
142
|
+
<DynamicSelectField field={field} value="c1" onChange={onChange} seedOption={{ id: 'c1', value: 'c1', label: 'Transportes del Bajío' }} />
|
|
143
|
+
</ApiProvider>,
|
|
144
|
+
)
|
|
145
|
+
expect(screen.queryByRole('button', { name: 'Crear Cliente' })).toBeNull()
|
|
146
|
+
fireEvent.click(screen.getByRole('button', { name: 'Editar Cliente' }))
|
|
147
|
+
expect(edit.seen).toHaveLength(1)
|
|
148
|
+
expect(edit.seen[0]).toMatchObject({ model: 'customers.Customer', recordId: 'c1' })
|
|
149
|
+
// Al guardar la edición, el trigger refresca la etiqueta.
|
|
150
|
+
act(() => edit.seen[0].onSaved({ id: 'c1', name: 'Transportes del Bajío SA' }))
|
|
151
|
+
expect(screen.getByRole('combobox').textContent).toContain('Transportes del Bajío SA')
|
|
152
|
+
edit.off()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('«Crear …» al pie de la lista, y la lista se monta en un portal', async () => {
|
|
156
|
+
const create = capture('metacore:create-record')
|
|
157
|
+
const { container } = render(
|
|
158
|
+
<ApiProvider client={api()}>
|
|
159
|
+
<DynamicSelectField field={field} value="" onChange={() => {}} />
|
|
160
|
+
</ApiProvider>,
|
|
161
|
+
)
|
|
162
|
+
await act(async () => {
|
|
163
|
+
screen.getByRole('combobox').click()
|
|
164
|
+
})
|
|
165
|
+
await waitFor(() => expect(screen.getByText('Transportes del Bajío')).toBeTruthy())
|
|
166
|
+
const footer = document.querySelector('[data-slot="picker-create"]') as HTMLElement
|
|
167
|
+
expect(footer).toBeTruthy()
|
|
168
|
+
expect(container.contains(footer)).toBe(false)
|
|
169
|
+
fireEvent.click(footer)
|
|
170
|
+
expect(create.seen).toHaveLength(1)
|
|
171
|
+
create.off()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('hideCreate quita las dos acciones', () => {
|
|
175
|
+
render(
|
|
176
|
+
<ApiProvider client={api()}>
|
|
177
|
+
<DynamicSelectField field={field} value="c1" onChange={() => {}} hideCreate />
|
|
178
|
+
</ApiProvider>,
|
|
179
|
+
)
|
|
180
|
+
expect(document.querySelector('[data-slot="record-picker-action"]')).toBeNull()
|
|
181
|
+
expect(screen.getByRole('combobox').className).not.toContain('rounded-r-none')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('EntitySelect — mismo primitivo de acciones', () => {
|
|
186
|
+
const base = { model: 'Supplier', fetcher: async () => [], onSelect: () => {}, canCreate: true, canEdit: true }
|
|
187
|
+
|
|
188
|
+
it('sin valor ofrece crear; con valor, editar', () => {
|
|
189
|
+
const { rerender } = render(
|
|
190
|
+
<ApiProvider client={{ get: vi.fn(), post: vi.fn() } as unknown as ApiClient}>
|
|
191
|
+
<EntitySelect {...base} value={null} label={null} />
|
|
192
|
+
</ApiProvider>,
|
|
193
|
+
)
|
|
194
|
+
expect(document.querySelector('[data-slot="record-picker-action"]')!.getAttribute('data-action')).toBe('create')
|
|
195
|
+
rerender(
|
|
196
|
+
<ApiProvider client={{ get: vi.fn(), post: vi.fn() } as unknown as ApiClient}>
|
|
197
|
+
<EntitySelect {...base} value="s1" label="Llantera Norte" />
|
|
198
|
+
</ApiProvider>,
|
|
199
|
+
)
|
|
200
|
+
expect(document.querySelector('[data-slot="record-picker-action"]')!.getAttribute('data-action')).toBe('edit')
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DynamicTable `extraBulkActions` / `hideBulkDelete`:
|
|
4
|
+
// 1. Host actions render in the floating bar once rows are selected and
|
|
5
|
+
// receive the selected rows / ids.
|
|
6
|
+
// 2. `clearSelection()` empties the selection.
|
|
7
|
+
// 3. Changing page drops the selection (classic pagination) so a bulk
|
|
8
|
+
// action can never target rows that are no longer visible.
|
|
9
|
+
// 4. `hideBulkDelete` removes the built-in "Eliminar".
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { cleanup, fireEvent, 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
|
+
if (o?.defaultValue && o.count != null && o.total != null) {
|
|
19
|
+
return o.defaultValue.replace('{{count}}', String(o.count)).replace('{{total}}', String(o.total))
|
|
20
|
+
}
|
|
21
|
+
return o?.defaultValue ?? _k
|
|
22
|
+
},
|
|
23
|
+
i18n: { language: 'es' },
|
|
24
|
+
}
|
|
25
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
|
|
26
|
+
|
|
27
|
+
import { DynamicTable } from '../dynamic-table'
|
|
28
|
+
import { makeDefaultGetDynamicColumns } from '../dynamic-columns'
|
|
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
|
+
const getDynamicColumns = makeDefaultGetDynamicColumns()
|
|
36
|
+
|
|
37
|
+
function meta(): TableMetadata {
|
|
38
|
+
return {
|
|
39
|
+
title: 'Issues',
|
|
40
|
+
endpoint: '/data/issue',
|
|
41
|
+
columns: [
|
|
42
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
|
|
43
|
+
],
|
|
44
|
+
actions: [],
|
|
45
|
+
perPageOptions: [10, 20, 50],
|
|
46
|
+
defaultPerPage: 10,
|
|
47
|
+
searchPlaceholder: 'Buscar...',
|
|
48
|
+
enableCRUDActions: false,
|
|
49
|
+
hasActions: false,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fakeApi(total: number): ApiClient {
|
|
54
|
+
const ok = (data: unknown) => ({ data: { success: true, data, meta: { total } } })
|
|
55
|
+
return {
|
|
56
|
+
get: vi.fn(async (url: string, cfg?: any) => {
|
|
57
|
+
if (url.startsWith('/metadata/table/')) return ok(meta())
|
|
58
|
+
if (url.endsWith('/facets')) return ok([])
|
|
59
|
+
const p = cfg?.params?.page ?? 1
|
|
60
|
+
const size = cfg?.params?.per_page ?? 10
|
|
61
|
+
const rows: Array<{ id: number; title: string }> = []
|
|
62
|
+
for (let i = (p - 1) * size; i < Math.min(p * size, total); i++) rows.push({ id: i + 1, title: `Issue ${i + 1}` })
|
|
63
|
+
return ok(rows)
|
|
64
|
+
}),
|
|
65
|
+
post: vi.fn(async () => ok(null)),
|
|
66
|
+
put: vi.fn(async () => ok(null)),
|
|
67
|
+
delete: vi.fn(async () => ok(null)),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function selectFirstRow() {
|
|
72
|
+
await screen.findAllByText('Issue 1')
|
|
73
|
+
const boxes = screen.getAllByRole('checkbox')
|
|
74
|
+
fireEvent.click(boxes[1]) // [0] is the select-all header checkbox
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('DynamicTable extraBulkActions', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
localStorage.clear()
|
|
80
|
+
sessionStorage.clear()
|
|
81
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('renders host actions with the selection and lets them clear it', async () => {
|
|
85
|
+
const seen: unknown[] = []
|
|
86
|
+
render(
|
|
87
|
+
<ApiProvider client={fakeApi(5)}>
|
|
88
|
+
<DynamicTable
|
|
89
|
+
model="issue"
|
|
90
|
+
enableUrlSync={false} getDynamicColumns={getDynamicColumns}
|
|
91
|
+
extraBulkActions={({ selectedRows, selectedIds, clearSelection }) => (
|
|
92
|
+
<button onClick={() => { seen.push({ rows: selectedRows.length, ids: selectedIds }); clearSelection() }}>
|
|
93
|
+
Aprobar seleccionados
|
|
94
|
+
</button>
|
|
95
|
+
)}
|
|
96
|
+
/>
|
|
97
|
+
</ApiProvider>,
|
|
98
|
+
)
|
|
99
|
+
expect(screen.queryByText('Aprobar seleccionados')).toBeNull()
|
|
100
|
+
await selectFirstRow()
|
|
101
|
+
fireEvent.click(await screen.findByText('Aprobar seleccionados'))
|
|
102
|
+
expect(seen).toEqual([{ rows: 1, ids: [1] }])
|
|
103
|
+
await waitFor(() => expect(screen.queryByText('Aprobar seleccionados')).toBeNull())
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('accepts a plain node and keeps the built-in delete by default', async () => {
|
|
107
|
+
render(
|
|
108
|
+
<ApiProvider client={fakeApi(5)}>
|
|
109
|
+
<DynamicTable model="issue" enableUrlSync={false} getDynamicColumns={getDynamicColumns} extraBulkActions={<button>Extra</button>} />
|
|
110
|
+
</ApiProvider>,
|
|
111
|
+
)
|
|
112
|
+
await selectFirstRow()
|
|
113
|
+
expect(await screen.findByText('Extra')).toBeTruthy()
|
|
114
|
+
expect(screen.getByText('Eliminar')).toBeTruthy()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('hideBulkDelete removes the built-in delete', async () => {
|
|
118
|
+
render(
|
|
119
|
+
<ApiProvider client={fakeApi(5)}>
|
|
120
|
+
<DynamicTable model="issue" enableUrlSync={false} getDynamicColumns={getDynamicColumns} hideBulkDelete extraBulkActions={<button>Extra</button>} />
|
|
121
|
+
</ApiProvider>,
|
|
122
|
+
)
|
|
123
|
+
await selectFirstRow()
|
|
124
|
+
expect(await screen.findByText('Extra')).toBeTruthy()
|
|
125
|
+
expect(screen.queryByText('Eliminar')).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('drops the selection when the page changes', async () => {
|
|
129
|
+
render(
|
|
130
|
+
<ApiProvider client={fakeApi(25)}>
|
|
131
|
+
<DynamicTable model="issue" enableUrlSync={false} getDynamicColumns={getDynamicColumns} extraBulkActions={<button>Extra</button>} />
|
|
132
|
+
</ApiProvider>,
|
|
133
|
+
)
|
|
134
|
+
await selectFirstRow()
|
|
135
|
+
expect(await screen.findByText('Extra')).toBeTruthy()
|
|
136
|
+
fireEvent.click(await screen.findByRole('button', { name: /siguiente|next/i }))
|
|
137
|
+
await screen.findAllByText('Issue 11')
|
|
138
|
+
await waitFor(() => expect(screen.queryByText('Extra')).toBeNull())
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Un fallo del endpoint de lista que NO es 403 (500, red, `success: false`)
|
|
4
|
+
// no debe verse como "sin resultados": se muestra "No se pudieron cargar los
|
|
5
|
+
// datos" con "Reintentar", que vuelve a pedir la lista. `emptyState` solo
|
|
6
|
+
// reemplaza el vacío cuando la lista cargó bien y no hay filtros activos.
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
9
|
+
|
|
10
|
+
vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
|
|
11
|
+
|
|
12
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
13
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
14
|
+
import { DynamicTable } from '../dynamic-table'
|
|
15
|
+
import type { TableMetadata } from '../types'
|
|
16
|
+
|
|
17
|
+
afterEach(cleanup)
|
|
18
|
+
|
|
19
|
+
const meta = {
|
|
20
|
+
title: 'Ajustes',
|
|
21
|
+
endpoint: '/data/load_err',
|
|
22
|
+
columns: [],
|
|
23
|
+
actions: [],
|
|
24
|
+
perPageOptions: [10],
|
|
25
|
+
defaultPerPage: 10,
|
|
26
|
+
searchPlaceholder: 'Buscar...',
|
|
27
|
+
enableCRUDActions: false,
|
|
28
|
+
hasActions: false,
|
|
29
|
+
} as unknown as TableMetadata
|
|
30
|
+
|
|
31
|
+
type ListResult = 'error500' | 'successFalse' | 'empty'
|
|
32
|
+
|
|
33
|
+
function mount(results: ListResult[], model: string, extra: Record<string, unknown> = {}) {
|
|
34
|
+
useMetadataCache.getState().setMetadata(model, meta)
|
|
35
|
+
let listCalls = 0
|
|
36
|
+
const client: ApiClient = {
|
|
37
|
+
get: vi.fn(async (url: string) => {
|
|
38
|
+
if (url.startsWith('/metadata/table/')) return { data: { success: true, data: meta } }
|
|
39
|
+
if (url.endsWith('/facets')) return { data: { success: true, data: [] } }
|
|
40
|
+
const r = results[Math.min(listCalls, results.length - 1)]
|
|
41
|
+
listCalls++
|
|
42
|
+
if (r === 'error500') throw Object.assign(new Error('500'), { response: { status: 500 } })
|
|
43
|
+
if (r === 'successFalse') return { data: { success: false, message: 'boom' } }
|
|
44
|
+
return { data: { success: true, data: [], meta: { total: 0 } } }
|
|
45
|
+
}),
|
|
46
|
+
post: vi.fn(async () => ({ data: { success: true, data: null } })),
|
|
47
|
+
put: vi.fn(async () => ({ data: { success: true, data: null } })),
|
|
48
|
+
delete: vi.fn(async () => ({ data: { success: true, data: null } })),
|
|
49
|
+
}
|
|
50
|
+
render(
|
|
51
|
+
<ApiProvider client={client}>
|
|
52
|
+
<DynamicTable model={model} enableUrlSync={false} {...extra} />
|
|
53
|
+
</ApiProvider>,
|
|
54
|
+
)
|
|
55
|
+
return () => listCalls
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('DynamicTable — fallo de la lista (no 403)', () => {
|
|
59
|
+
it('un 500 muestra error con Reintentar, no "No se encontraron resultados"', async () => {
|
|
60
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
61
|
+
mount(['error500'], 'lerr_a')
|
|
62
|
+
expect((await screen.findAllByText('No se pudieron cargar los datos')).length).toBeGreaterThan(0)
|
|
63
|
+
expect(screen.queryByText('No se encontraron resultados')).toBeNull()
|
|
64
|
+
expect(screen.queryByText('Sin permiso para ver este módulo')).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('`success: false` también es un error de carga', async () => {
|
|
68
|
+
mount(['successFalse'], 'lerr_b')
|
|
69
|
+
expect((await screen.findAllByText('No se pudieron cargar los datos')).length).toBeGreaterThan(0)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('Reintentar vuelve a pedir la lista y, si ahora carga, limpia el error', async () => {
|
|
73
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
74
|
+
const calls = mount(['error500', 'empty'], 'lerr_c')
|
|
75
|
+
const retry = (await screen.findAllByRole('button', { name: 'Reintentar' }))[0]
|
|
76
|
+
const before = calls()
|
|
77
|
+
fireEvent.click(retry)
|
|
78
|
+
await waitFor(() => expect(calls()).toBeGreaterThan(before))
|
|
79
|
+
await waitFor(() => expect(screen.queryByText('No se pudieron cargar los datos')).toBeNull())
|
|
80
|
+
expect((await screen.findAllByText('No se encontraron resultados')).length).toBeGreaterThan(0)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('DynamicTable — emptyState', () => {
|
|
85
|
+
it('reemplaza el vacío cuando la lista cargó bien y no hay filtros', async () => {
|
|
86
|
+
mount(['empty'], 'lerr_d', { emptyState: <p>Aún no hay registros</p> })
|
|
87
|
+
expect((await screen.findAllByText('Aún no hay registros')).length).toBeGreaterThan(0)
|
|
88
|
+
expect(screen.queryByText('No se encontraron resultados')).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('no tapa un error de carga', async () => {
|
|
92
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
93
|
+
mount(['error500'], 'lerr_e', { emptyState: <p>Aún no hay registros</p> })
|
|
94
|
+
expect((await screen.findAllByText('No se pudieron cargar los datos')).length).toBeGreaterThan(0)
|
|
95
|
+
expect(screen.queryByText('Aún no hay registros')).toBeNull()
|
|
96
|
+
})
|
|
97
|
+
})
|
package/src/api-context.tsx
CHANGED
|
@@ -15,13 +15,22 @@ export interface ApiClient {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const ApiContext = createContext<ApiClient | null>(null)
|
|
18
|
+
const BatchContext = createContext<boolean>(true)
|
|
18
19
|
|
|
19
20
|
export interface ApiProviderProps {
|
|
20
21
|
client: ApiClient
|
|
22
|
+
/**
|
|
23
|
+
* Groups list, metadata and options reads into one `POST /q`. Defaults to
|
|
24
|
+
* `true`. Pass `false` when the host backend does not implement `/q`: every
|
|
25
|
+
* read then goes straight to its own GET and no POST is attempted. When
|
|
26
|
+
* left on and `/q` answers 404/405, the batch turns itself off for that
|
|
27
|
+
* client after the first failed attempt and the read falls back to GET.
|
|
28
|
+
*/
|
|
29
|
+
batch?: boolean
|
|
21
30
|
children: React.ReactNode
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
export function ApiProvider({ client, children }: ApiProviderProps) {
|
|
33
|
+
export function ApiProvider({ client, batch = true, children }: ApiProviderProps) {
|
|
25
34
|
// Hosts also write through their own client (native dialogs, settings
|
|
26
35
|
// screens) without going through useApi(). When the client is axios,
|
|
27
36
|
// every non-GET response drops what the runtime remembered as well.
|
|
@@ -43,7 +52,11 @@ export function ApiProvider({ client, children }: ApiProviderProps) {
|
|
|
43
52
|
)
|
|
44
53
|
return () => interceptors.eject?.(id)
|
|
45
54
|
}, [client])
|
|
46
|
-
return
|
|
55
|
+
return (
|
|
56
|
+
<ApiContext.Provider value={client}>
|
|
57
|
+
<BatchContext.Provider value={batch}>{children}</BatchContext.Provider>
|
|
58
|
+
</ApiContext.Provider>
|
|
59
|
+
)
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
interface AxiosLike {
|
|
@@ -82,6 +95,7 @@ function mutating<T>(request: Promise<T>): Promise<T> {
|
|
|
82
95
|
/** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
|
|
83
96
|
export function useApi(): ApiClient {
|
|
84
97
|
const ctx = useContext(ApiContext)
|
|
98
|
+
const batch = useContext(BatchContext)
|
|
85
99
|
if (!ctx) {
|
|
86
100
|
throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.')
|
|
87
101
|
}
|
|
@@ -92,12 +106,13 @@ export function useApi(): ApiClient {
|
|
|
92
106
|
return useMemo<ApiClient>(() => ({
|
|
93
107
|
// Each call reaches the host client with the arguments it was given —
|
|
94
108
|
// never a trailing `undefined` it didn't ask for.
|
|
95
|
-
get: (...args: Parameters<ApiClient['get']>) =>
|
|
109
|
+
get: (...args: Parameters<ApiClient['get']>) =>
|
|
110
|
+
batch ? batchGet(ctx, ...args) : ctx.get(...args),
|
|
96
111
|
post: (...args: Parameters<ApiClient['post']>) =>
|
|
97
112
|
isWrite('post', args[0]) ? mutating(ctx.post(...args)) : ctx.post(...args),
|
|
98
113
|
put: (...args: Parameters<ApiClient['put']>) => mutating(ctx.put(...args)),
|
|
99
114
|
delete: (...args: Parameters<ApiClient['delete']>) => mutating(ctx.delete(...args)),
|
|
100
|
-
}), [ctx])
|
|
115
|
+
}), [ctx, batch])
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
/** Optional branch context — hosts that support tenant branches can supply
|
|
@@ -14,8 +14,8 @@ export function EditorSection({ title, hint, children, slot }: { title?: ReactNo
|
|
|
14
14
|
return (
|
|
15
15
|
<section className="space-y-3" data-slot={slot}>
|
|
16
16
|
{(title || hint) && (
|
|
17
|
-
<header className="flex items-baseline justify-between gap-3">
|
|
18
|
-
{title && <h3 className="text-sm font-semibold">{title}</h3>}
|
|
17
|
+
<header className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
|
18
|
+
{title && <h3 className="whitespace-nowrap text-sm font-semibold">{title}</h3>}
|
|
19
19
|
{hint && <span className="text-xs text-muted-foreground">{hint}</span>}
|
|
20
20
|
</header>
|
|
21
21
|
)}
|
|
@@ -49,13 +49,13 @@ export function CollapsibleSection({ title, summary, open, defaultOpen = false,
|
|
|
49
49
|
onOpenChange?.(!isOpen)
|
|
50
50
|
}
|
|
51
51
|
return (
|
|
52
|
-
<section className="rounded-
|
|
52
|
+
<section className="rounded-xl border border-dashed" data-slot={slot} data-state={isOpen ? 'open' : 'closed'}>
|
|
53
53
|
<button
|
|
54
54
|
type="button"
|
|
55
55
|
aria-expanded={isOpen}
|
|
56
56
|
aria-controls={id}
|
|
57
57
|
onClick={toggle}
|
|
58
|
-
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm"
|
|
58
|
+
className="flex w-full items-center justify-between gap-3 rounded-xl px-4 py-3 text-left text-sm transition-colors hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
59
59
|
>
|
|
60
60
|
<span className="font-medium">{title}</span>
|
|
61
61
|
<span className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
|
@@ -81,7 +81,7 @@ export interface PartyCardProps {
|
|
|
81
81
|
/** Tarjeta compacta de la contraparte: nombre, 3-6 datos clave y estado de crédito. */
|
|
82
82
|
export function PartyCard({ name, rows, credit, fmt, children }: PartyCardProps) {
|
|
83
83
|
return (
|
|
84
|
-
<div className="rounded-
|
|
84
|
+
<div className="rounded-lg bg-muted/40 px-4 py-3 text-sm" data-slot="party-card">
|
|
85
85
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
86
86
|
{name && <span className="font-medium">{name}</span>}
|
|
87
87
|
{credit && (
|
|
@@ -116,11 +116,14 @@ export interface TotalsRow {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/** Totales alineados a la derecha; la última fila con `emphasis` es el total. */
|
|
119
|
-
export function TotalsPanel({ rows }: { rows: TotalsRow[] }) {
|
|
119
|
+
export function TotalsPanel({ rows, muted = false }: { rows: TotalsRow[]; muted?: boolean }) {
|
|
120
120
|
return (
|
|
121
|
-
<dl className=
|
|
122
|
-
{rows.map((r) => (
|
|
123
|
-
<div
|
|
121
|
+
<dl className={`ml-auto w-full max-w-xs space-y-1.5 text-sm tabular-nums ${muted ? 'text-muted-foreground' : ''}`} data-slot="totals">
|
|
122
|
+
{rows.map((r, i) => (
|
|
123
|
+
<div
|
|
124
|
+
key={r.label}
|
|
125
|
+
className={`flex items-baseline justify-between gap-6 ${r.emphasis ? `${i > 0 ? 'border-t pt-2.5' : ''} text-lg font-semibold` : ''}`}
|
|
126
|
+
>
|
|
124
127
|
<dt className={r.emphasis ? '' : 'text-muted-foreground'}>{r.label}</dt>
|
|
125
128
|
<dd>{r.value}</dd>
|
|
126
129
|
</div>
|
|
@@ -137,7 +140,7 @@ export function ValidationChecklist({ issues, title = 'Revisa antes de guardar'
|
|
|
137
140
|
<p className="mb-1 text-xs font-medium text-muted-foreground">{title}</p>
|
|
138
141
|
<ul className="space-y-1 text-sm">
|
|
139
142
|
{issues.map((i, k) => (
|
|
140
|
-
<li key={`${i.field ?? ''}:${k}`} className={`flex items-start gap-2 ${i.severity === 'error' ? 'text-destructive' : 'text-amber-700'}`}>
|
|
143
|
+
<li key={`${i.field ?? ''}:${k}`} className={`flex items-start gap-2 ${i.severity === 'error' ? 'text-destructive' : 'text-amber-700 dark:text-amber-400'}`}>
|
|
141
144
|
{i.severity === 'error' ? <XCircle className="mt-0.5 size-4 shrink-0" aria-hidden /> : <AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />}
|
|
142
145
|
{i.message}
|
|
143
146
|
</li>
|