@asteby/metacore-runtime-react 47.0.0 → 48.0.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 (178) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +24 -4
  4. package/dist/audit-info.d.ts +54 -0
  5. package/dist/audit-info.d.ts.map +1 -0
  6. package/dist/audit-info.js +142 -0
  7. package/dist/business/catalog-product-search.d.ts +25 -0
  8. package/dist/business/catalog-product-search.d.ts.map +1 -0
  9. package/dist/business/catalog-product-search.js +57 -0
  10. package/dist/business/catalog.d.ts.map +1 -1
  11. package/dist/business/catalog.js +2 -0
  12. package/dist/business/document-editor-model.d.ts +107 -0
  13. package/dist/business/document-editor-model.d.ts.map +1 -0
  14. package/dist/business/document-editor-model.js +279 -0
  15. package/dist/business/document-editor-panels.d.ts +64 -0
  16. package/dist/business/document-editor-panels.d.ts.map +1 -0
  17. package/dist/business/document-editor-panels.js +58 -0
  18. package/dist/business/document-editor.d.ts +27 -0
  19. package/dist/business/document-editor.d.ts.map +1 -0
  20. package/dist/business/document-editor.js +445 -0
  21. package/dist/business/document-lines.d.ts.map +1 -1
  22. package/dist/business/document-lines.js +5 -1
  23. package/dist/business/index.d.ts +5 -0
  24. package/dist/business/index.d.ts.map +1 -1
  25. package/dist/business/index.js +5 -0
  26. package/dist/business/line-items.d.ts +7 -0
  27. package/dist/business/line-items.d.ts.map +1 -1
  28. package/dist/business/line-items.js +6 -1
  29. package/dist/business/payment-allocator.d.ts +19 -0
  30. package/dist/business/payment-allocator.d.ts.map +1 -0
  31. package/dist/business/payment-allocator.js +31 -0
  32. package/dist/business/product-search.d.ts +4 -0
  33. package/dist/business/product-search.d.ts.map +1 -1
  34. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  35. package/dist/dialogs/dynamic-record.js +19 -8
  36. package/dist/document-form-dialog.d.ts +31 -2
  37. package/dist/document-form-dialog.d.ts.map +1 -1
  38. package/dist/document-form-dialog.js +43 -11
  39. package/dist/dynamic-columns-helpers.d.ts +9 -0
  40. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  41. package/dist/dynamic-columns-helpers.js +19 -0
  42. package/dist/dynamic-columns.d.ts +8 -2
  43. package/dist/dynamic-columns.d.ts.map +1 -1
  44. package/dist/dynamic-columns.js +21 -5
  45. package/dist/dynamic-crud-page.d.ts +6 -0
  46. package/dist/dynamic-crud-page.d.ts.map +1 -1
  47. package/dist/dynamic-crud-page.js +14 -5
  48. package/dist/dynamic-form-schema.d.ts.map +1 -1
  49. package/dist/dynamic-form-schema.js +15 -3
  50. package/dist/dynamic-form.d.ts +16 -1
  51. package/dist/dynamic-form.d.ts.map +1 -1
  52. package/dist/dynamic-form.js +81 -20
  53. package/dist/dynamic-kanban.d.ts +9 -3
  54. package/dist/dynamic-kanban.d.ts.map +1 -1
  55. package/dist/dynamic-kanban.js +39 -13
  56. package/dist/dynamic-line-items.d.ts.map +1 -1
  57. package/dist/dynamic-line-items.js +3 -0
  58. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  59. package/dist/dynamic-multi-select-field.js +2 -0
  60. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  61. package/dist/dynamic-relation-helpers.js +8 -0
  62. package/dist/dynamic-relation.js +1 -1
  63. package/dist/dynamic-select-field.d.ts.map +1 -1
  64. package/dist/dynamic-select-field.js +4 -0
  65. package/dist/index.d.ts +8 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +17 -2
  68. package/dist/installed-addons-context.d.ts +15 -0
  69. package/dist/installed-addons-context.d.ts.map +1 -0
  70. package/dist/installed-addons-context.js +39 -0
  71. package/dist/option-filter.d.ts +31 -0
  72. package/dist/option-filter.d.ts.map +1 -0
  73. package/dist/option-filter.js +49 -0
  74. package/dist/org-runtime-context.d.ts +8 -0
  75. package/dist/org-runtime-context.d.ts.map +1 -1
  76. package/dist/org-runtime-context.js +8 -0
  77. package/dist/org-runtime-provider.d.ts +3 -1
  78. package/dist/org-runtime-provider.d.ts.map +1 -1
  79. package/dist/org-runtime-provider.js +3 -3
  80. package/dist/primitives/aging.d.ts +34 -0
  81. package/dist/primitives/aging.d.ts.map +1 -0
  82. package/dist/primitives/aging.js +48 -0
  83. package/dist/primitives/allocation.d.ts +42 -0
  84. package/dist/primitives/allocation.d.ts.map +1 -0
  85. package/dist/primitives/allocation.js +59 -0
  86. package/dist/primitives/approvals.d.ts +71 -0
  87. package/dist/primitives/approvals.d.ts.map +1 -0
  88. package/dist/primitives/approvals.js +48 -0
  89. package/dist/primitives/contributions.d.ts +82 -0
  90. package/dist/primitives/contributions.d.ts.map +1 -0
  91. package/dist/primitives/contributions.js +126 -0
  92. package/dist/primitives/document-kinds.d.ts +25 -0
  93. package/dist/primitives/document-kinds.d.ts.map +1 -0
  94. package/dist/primitives/document-kinds.js +26 -0
  95. package/dist/primitives/index.d.ts +8 -0
  96. package/dist/primitives/index.d.ts.map +1 -0
  97. package/dist/primitives/index.js +23 -0
  98. package/dist/primitives/panels.d.ts +68 -0
  99. package/dist/primitives/panels.d.ts.map +1 -0
  100. package/dist/primitives/panels.js +3 -0
  101. package/dist/primitives/use-contributions.d.ts +15 -0
  102. package/dist/primitives/use-contributions.d.ts.map +1 -0
  103. package/dist/primitives/use-contributions.js +30 -0
  104. package/dist/record-prefill-bar.d.ts +11 -0
  105. package/dist/record-prefill-bar.d.ts.map +1 -0
  106. package/dist/record-prefill-bar.js +33 -0
  107. package/dist/types.d.ts +142 -0
  108. package/dist/types.d.ts.map +1 -1
  109. package/dist/use-options-resolver.d.ts +8 -0
  110. package/dist/use-options-resolver.d.ts.map +1 -1
  111. package/dist/use-options-resolver.js +8 -2
  112. package/package.json +5 -5
  113. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  114. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  115. package/src/__tests__/audit-info.test.tsx +103 -0
  116. package/src/__tests__/catalog-product-search.test.ts +61 -0
  117. package/src/__tests__/dependent-options.test.tsx +12 -5
  118. package/src/__tests__/document-editor-model.test.ts +167 -0
  119. package/src/__tests__/document-editor.test.tsx +261 -0
  120. package/src/__tests__/document-form-dialog.test.tsx +118 -3
  121. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  122. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  123. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  124. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  125. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  126. package/src/__tests__/line-item-totals.test.ts +28 -0
  127. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  128. package/src/__tests__/option-filter.test.tsx +115 -0
  129. package/src/__tests__/primitives-federated.test.tsx +156 -0
  130. package/src/__tests__/primitives.test.ts +224 -0
  131. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  132. package/src/__tests__/structured-view-value.test.tsx +5 -1
  133. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  134. package/src/action-modal-dispatcher.tsx +47 -4
  135. package/src/audit-info.tsx +219 -0
  136. package/src/business/catalog-product-search.ts +87 -0
  137. package/src/business/catalog.ts +2 -0
  138. package/src/business/document-editor-model.ts +325 -0
  139. package/src/business/document-editor-panels.tsx +164 -0
  140. package/src/business/document-editor.tsx +727 -0
  141. package/src/business/document-lines.ts +5 -1
  142. package/src/business/index.ts +5 -0
  143. package/src/business/line-items.ts +15 -1
  144. package/src/business/payment-allocator.tsx +110 -0
  145. package/src/business/product-search.ts +4 -0
  146. package/src/dialogs/dynamic-record.tsx +42 -6
  147. package/src/document-form-dialog.tsx +92 -12
  148. package/src/dropdown-menu-entry.test.ts +23 -0
  149. package/src/dynamic-columns-helpers.ts +22 -0
  150. package/src/dynamic-columns.tsx +23 -6
  151. package/src/dynamic-crud-page.tsx +22 -3
  152. package/src/dynamic-form-schema.ts +15 -2
  153. package/src/dynamic-form.tsx +120 -39
  154. package/src/dynamic-kanban.tsx +41 -10
  155. package/src/dynamic-line-items.tsx +3 -0
  156. package/src/dynamic-multi-select-field.tsx +2 -0
  157. package/src/dynamic-relation-helpers.ts +9 -0
  158. package/src/dynamic-relation.tsx +7 -2
  159. package/src/dynamic-select-field.tsx +4 -0
  160. package/src/index.ts +34 -1
  161. package/src/installed-addons-context.tsx +58 -0
  162. package/src/option-filter.ts +88 -0
  163. package/src/org-runtime-context.ts +10 -0
  164. package/src/org-runtime-provider.tsx +7 -2
  165. package/src/primitives/aging.ts +76 -0
  166. package/src/primitives/allocation.ts +104 -0
  167. package/src/primitives/approvals.ts +103 -0
  168. package/src/primitives/contributions.ts +208 -0
  169. package/src/primitives/document-kinds.ts +62 -0
  170. package/src/primitives/index.ts +23 -0
  171. package/src/primitives/panels.ts +59 -0
  172. package/src/primitives/use-contributions.ts +42 -0
  173. package/src/record-prefill-bar.tsx +62 -0
  174. package/src/row-action-stage-gate.test.ts +33 -0
  175. package/src/types.ts +142 -0
  176. package/src/use-options-resolver.ts +19 -1
  177. package/vitest.config.ts +1 -0
  178. package/vitest.setup.ts +7 -0
@@ -0,0 +1,155 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Editar un registro con un select que YA tiene valor y guardar sin tocarlo debe
4
+ // conservar ese valor en el payload. Antes los `values` del DynamicForm se
5
+ // sembraban en un efecto (primer render con '') y el Select de Radix emitía
6
+ // `onValueChange('')`, que pisaba el valor real y viajaba en el PATCH (p.ej. un
7
+ // renglón «Refacción» quedaba en «-»). Cubre también: `readonly` de columna en
8
+ // `deriveRelationFormFields`, selects vacíos no tocados fuera del PATCH, y el
9
+ // modal de renglón con cuerpo con scroll y footer fijo.
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
12
+
13
+ vi.mock('react-i18next', () => ({
14
+ useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
15
+ }))
16
+
17
+ import { DynamicForm, guardEmptySelect } from '../dynamic-form'
18
+ import { DynamicRelation } from '../dynamic-relation'
19
+ import { deriveRelationFormFields } from '../dynamic-relation-helpers'
20
+ import { ApiProvider, type ApiClient } from '../api-context'
21
+ import type { ActionFieldDef } from '../types'
22
+
23
+ afterEach(cleanup)
24
+
25
+ const kindField: ActionFieldDef = {
26
+ key: 'kind',
27
+ label: 'Tipo',
28
+ type: 'select',
29
+ options: [
30
+ { value: 'service', label: 'Servicio' },
31
+ { value: 'part', label: 'Refacción' },
32
+ ],
33
+ }
34
+
35
+ describe('DynamicForm — edición con select', () => {
36
+ it('guardar sin tocar el select conserva su valor en el payload', async () => {
37
+ const onSubmit = vi.fn()
38
+ render(
39
+ <DynamicForm
40
+ fields={[{ key: 'description', label: 'Descripción', type: 'text' }, kindField]}
41
+ initialValues={{ id: 'r1', description: 'Balata', kind: 'part' }}
42
+ onSubmit={onSubmit}
43
+ />,
44
+ )
45
+ await act(async () => {
46
+ screen.getByText('Guardar').click()
47
+ })
48
+ expect(onSubmit).toHaveBeenCalledTimes(1)
49
+ expect(onSubmit.mock.calls[0][0]).toMatchObject({ description: 'Balata', kind: 'part' })
50
+ })
51
+
52
+ it('en edición no manda un select vacío que el usuario no tocó', async () => {
53
+ const onSubmit = vi.fn()
54
+ render(
55
+ <DynamicForm
56
+ fields={[{ key: 'description', label: 'Descripción', type: 'text' }, kindField]}
57
+ initialValues={{ id: 'r1', description: 'Balata' }}
58
+ onSubmit={onSubmit}
59
+ />,
60
+ )
61
+ await act(async () => {
62
+ screen.getByText('Guardar').click()
63
+ })
64
+ expect(onSubmit).toHaveBeenCalledTimes(1)
65
+ expect(onSubmit.mock.calls[0][0]).not.toHaveProperty('kind')
66
+ })
67
+ })
68
+
69
+ describe('guardEmptySelect', () => {
70
+ it("ignora '' cuando ya hay valor y '' no es opción", () => {
71
+ const onChange = vi.fn()
72
+ guardEmptySelect('part', onChange, ['service', 'part'])('')
73
+ expect(onChange).not.toHaveBeenCalled()
74
+ })
75
+ it("deja pasar '' si no había valor o si '' es opción válida, y cualquier otro valor", () => {
76
+ const onChange = vi.fn()
77
+ guardEmptySelect('', onChange, ['service', 'part'])('')
78
+ guardEmptySelect('part', onChange, ['', 'part'])('')
79
+ guardEmptySelect('part', onChange, ['service', 'part'])('service')
80
+ expect(onChange.mock.calls).toEqual([[''], [''], ['service']])
81
+ })
82
+ })
83
+
84
+ const META = {
85
+ name: 'work_order_item',
86
+ columns: [
87
+ { key: 'id', label: 'ID', type: 'text', sortable: true, filterable: false, hidden: true },
88
+ { key: 'work_order_id', label: 'OT', type: 'text', sortable: false, filterable: false },
89
+ { key: 'description', label: 'Descripción', type: 'text', sortable: true, filterable: true },
90
+ {
91
+ key: 'kind',
92
+ label: 'Tipo',
93
+ type: 'select',
94
+ sortable: true,
95
+ filterable: true,
96
+ options: [
97
+ { value: 'service', label: 'Servicio' },
98
+ { value: 'part', label: 'Refacción' },
99
+ ],
100
+ },
101
+ { key: 'line_total', label: 'Importe', type: 'number', sortable: false, filterable: false, readonly: true },
102
+ ],
103
+ actions: [],
104
+ hasActions: false,
105
+ enableCRUDActions: false,
106
+ }
107
+
108
+ const DATA = [{ id: 'woi_1', work_order_id: 'wo_1', description: 'Balata', kind: 'part', line_total: 450 }]
109
+
110
+ function mockApi(): ApiClient {
111
+ return {
112
+ get: vi.fn((url: string) => {
113
+ if (url.startsWith('/metadata/table/')) {
114
+ return Promise.resolve({ data: { success: true, data: META } })
115
+ }
116
+ return Promise.resolve({ data: { success: true, data: DATA } })
117
+ }),
118
+ post: vi.fn(() => Promise.resolve({ data: { success: true, data: {} } })),
119
+ put: vi.fn(() => Promise.resolve({ data: { success: true, data: {} } })),
120
+ delete: vi.fn(() => Promise.resolve({ data: { success: true, data: {} } })),
121
+ }
122
+ }
123
+
124
+ describe('DynamicRelation — editar renglón con select', () => {
125
+ it('deriveRelationFormFields copia readonly de la columna', () => {
126
+ const fields = deriveRelationFormFields(META as any, 'work_order_id')
127
+ expect(fields.find((f) => f.key === 'line_total')?.readonly).toBe(true)
128
+ expect(fields.find((f) => f.key === 'kind')?.readonly).toBeUndefined()
129
+ })
130
+
131
+ it('el PATCH conserva el tipo y no manda columnas readonly; el modal hace scroll con footer fijo', async () => {
132
+ const api = mockApi()
133
+ render(
134
+ <ApiProvider client={api}>
135
+ <DynamicRelation kind="one_to_many" model="work_order_item" foreignKey="work_order_id" parentId="wo_1" />
136
+ </ApiProvider>,
137
+ )
138
+ await waitFor(() => expect(screen.getByText('Balata')).toBeTruthy())
139
+ await act(async () => {
140
+ screen.getByLabelText('Editar').click()
141
+ })
142
+ const dialog = await screen.findByRole('dialog')
143
+ expect(dialog.className).toContain('max-h-[90dvh]')
144
+ expect(dialog.querySelector('.overflow-y-auto')).toBeTruthy()
145
+
146
+ await act(async () => {
147
+ screen.getByText('Guardar').click()
148
+ })
149
+ await waitFor(() => expect(api.put).toHaveBeenCalledTimes(1))
150
+ const [url, payload] = (api.put as any).mock.calls[0]
151
+ expect(url).toContain('woi_1')
152
+ expect(payload).toMatchObject({ description: 'Balata', kind: 'part' })
153
+ expect(payload).not.toHaveProperty('line_total')
154
+ })
155
+ })
@@ -293,11 +293,32 @@ describe('withGroupBy (one model, several boards)', () => {
293
293
  ...over,
294
294
  }) as any
295
295
 
296
- it('is the same object when there is no override or it matches the served group_by', () => {
297
- const m = boardMeta()
296
+ it('is the same object when the served group_by is already the stage column', () => {
297
+ const m = boardMeta({ group_by: 'stage' })
298
298
  expect(withGroupBy(m, undefined)).toBe(m)
299
- expect(withGroupBy(m, 'assigned_mechanic_id')).toBe(m)
299
+ expect(withGroupBy(m, 'stage')).toBe(m)
300
300
  expect(withGroupBy(null, 'stage')).toBeNull()
301
+ const flat = boardMeta({ stages: undefined, transitions: undefined, smart_lanes: undefined })
302
+ expect(withGroupBy(flat, 'assigned_mechanic_id')).toBe(flat)
303
+ expect(withGroupBy(flat, undefined)).toBe(flat)
304
+ })
305
+ // Pitsline r3 (b): WorkOrder serves group_by=service_bay_id (the last kanban
306
+ // nav entry) with the stage lanes. Without ?group_by= the board grouped the
307
+ // stage lanes by the bay: every card in «Sin etapa», each lane count sent
308
+ // f_service_bay_id=diagnosis and a drop PUT service_bay_id='diagnosis' (422).
309
+ it('groups a board without ?group_by= by the stage column, not the served one', () => {
310
+ const out = withGroupBy(boardMeta(), undefined)!
311
+ expect(out.group_by).toBe('stage')
312
+ expect(out.stages).toHaveLength(1)
313
+ expect(out.transitions).toHaveLength(1)
314
+ })
315
+ it('drops the stage machine on the board of the served (non-stage) column', () => {
316
+ const out = withGroupBy(boardMeta(), 'assigned_mechanic_id')!
317
+ expect(out.group_by).toBe('assigned_mechanic_id')
318
+ expect(out.stages).toBeUndefined()
319
+ expect(out.transitions).toBeUndefined()
320
+ expect(out.smart_lanes).toBeUndefined()
321
+ expect(refLaneSource(out)).toEqual({ ref: 'users' })
301
322
  })
302
323
  it('ignores a column the model does not have', () => {
303
324
  const m = boardMeta()
@@ -0,0 +1,88 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Retest Pitsline 05/10: the customer and product forms printed the fields that
4
+ // fiscal_mexico adds by extension as `FISCAL_MEXICO.EXT.CUSTOMER.RFC_RECEPTOR`.
5
+ // The server served the label as the raw catalog key and the dialog rendered it
6
+ // verbatim (uppercased by CSS). The dialog now resolves such a key with the
7
+ // client catalog, and humanizes it when the catalog does not have it either.
8
+ import { afterEach, describe, expect, it, vi } from 'vitest'
9
+ import { cleanup, render, waitFor } from '@testing-library/react'
10
+ import { I18nextProvider } from 'react-i18next'
11
+ import i18next from 'i18next'
12
+
13
+ import { DynamicRecordDialog } from '../dialogs/dynamic-record'
14
+ import { ApiProvider } from '../api-context'
15
+ import { localizeFieldLabel } from '../dynamic-columns-helpers'
16
+
17
+ afterEach(cleanup)
18
+
19
+ function makeI18n() {
20
+ const inst = i18next.createInstance()
21
+ void inst.init({
22
+ lng: 'es',
23
+ fallbackLng: 'es',
24
+ react: { useSuspense: false },
25
+ resources: {
26
+ es: {
27
+ translation: {
28
+ fiscal_mexico: { ext: { customer: { rfc_receptor: 'RFC' } } },
29
+ },
30
+ },
31
+ },
32
+ })
33
+ return inst
34
+ }
35
+
36
+ const api = {
37
+ get: vi.fn(async () => ({ data: { data: {} } })),
38
+ post: async () => ({ data: { success: true } }),
39
+ put: async () => ({ data: { success: true } }),
40
+ delete: async () => ({ data: { success: true } }),
41
+ } as never
42
+
43
+ describe('DynamicRecordDialog — extension field labels', () => {
44
+ it('never prints a raw catalog key as the field label', async () => {
45
+ render(
46
+ <I18nextProvider i18n={makeI18n()}>
47
+ <ApiProvider client={api}>
48
+ <DynamicRecordDialog
49
+ open
50
+ onOpenChange={() => {}}
51
+ mode="create"
52
+ model="customers"
53
+ schema={{
54
+ title: 'Nuevo cliente',
55
+ fields: [
56
+ { key: 'name', label: 'Nombre', type: 'text' },
57
+ { key: 'fiscal_data.rfc_receptor', label: 'fiscal_mexico.ext.customer.rfc_receptor', type: 'text' },
58
+ { key: 'fiscal_data.codigo_postal', label: 'fiscal_mexico.ext.customer.codigo_postal', type: 'text' },
59
+ ],
60
+ } as never}
61
+ />
62
+ </ApiProvider>
63
+ </I18nextProvider>,
64
+ )
65
+
66
+ const label = (key: string) =>
67
+ document.querySelector(`[data-aby-field="${key}"] label`)?.textContent ?? ''
68
+ await waitFor(() => expect(label('fiscal_data.rfc_receptor')).toBe('RFC'))
69
+ expect(label('fiscal_data.codigo_postal')).toBe('Codigo Postal')
70
+ expect(label('name')).toBe('Nombre')
71
+ expect(document.body.textContent).not.toContain('fiscal_mexico.ext')
72
+ })
73
+ })
74
+
75
+ describe('localizeFieldLabel', () => {
76
+ it('leaves human labels alone, even single words or acronyms', () => {
77
+ const t = vi.fn((k: string) => `T:${k}`)
78
+ expect(localizeFieldLabel('Nombre', t)).toBe('Nombre')
79
+ expect(localizeFieldLabel('RFC', t)).toBe('RFC')
80
+ expect(localizeFieldLabel('Código postal', t)).toBe('Código postal')
81
+ expect(t).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('resolves catalog keys and humanizes the ones the catalog lacks', () => {
85
+ expect(localizeFieldLabel('a.b', (k: string, o?: { defaultValue?: string }) => (k === 'a.b' ? 'Hola' : o?.defaultValue ?? k))).toBe('Hola')
86
+ expect(localizeFieldLabel('fiscal_mexico.ext.product.mx_clave_unidad', (k: string) => k)).toBe('Mx Clave Unidad')
87
+ })
88
+ })
@@ -149,6 +149,10 @@ describe('DynamicTable infinite scroll', () => {
149
149
  await waitFor(() => expect(screen.getByText('30 de 45')).toBeTruthy())
150
150
  observers.forEach((o) => o.fire(true))
151
151
  await waitFor(() => expect(screen.getByText('45 de 45')).toBeTruthy())
152
+ // The sentinel leaves the viewport once everything is loaded. While it
153
+ // stays intersecting the hook deliberately chains the next page after a
154
+ // reset (viewport-fill), which would mask the collapse asserted below.
155
+ observers.forEach((o) => o.fire(false))
152
156
 
153
157
  // Change the active filters via defaultFilters (part of buildFilterParams).
154
158
  rerender(
@@ -0,0 +1,50 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // InstalledAddonsProvider: sin provider = desconocido (undefined), con provider
4
+ // responde por clave de addon y por capacidad.
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import { cleanup, render, screen } from '@testing-library/react'
7
+ import {
8
+ InstalledAddonsProvider,
9
+ useAddonInstalled,
10
+ useCapabilityProvided,
11
+ useInstalledAddons,
12
+ } from '../installed-addons-context'
13
+
14
+ afterEach(() => cleanup())
15
+
16
+ function Probe() {
17
+ const fiscal = useAddonInstalled('fiscal_mexico')
18
+ const issuer = useCapabilityProvided('fiscal.invoice_issuer')
19
+ const all = useInstalledAddons()
20
+ return (
21
+ <span data-testid="probe">
22
+ {String(fiscal)}|{String(issuer)}|{all ? [...all.addons].join(',') : 'null'}
23
+ </span>
24
+ )
25
+ }
26
+
27
+ describe('InstalledAddonsProvider', () => {
28
+ it('sin provider devuelve undefined/null (nunca asume ausente)', () => {
29
+ render(<Probe />)
30
+ expect(screen.getByTestId('probe').textContent).toBe('undefined|undefined|null')
31
+ })
32
+
33
+ it('responde por addon y por capacidad', () => {
34
+ render(
35
+ <InstalledAddonsProvider addons={['customers', 'fiscal_mexico']} capabilities={['fiscal.invoice_issuer']}>
36
+ <Probe />
37
+ </InstalledAddonsProvider>,
38
+ )
39
+ expect(screen.getByTestId('probe').textContent).toBe('true|true|customers,fiscal_mexico')
40
+ })
41
+
42
+ it('addon no instalado = false', () => {
43
+ render(
44
+ <InstalledAddonsProvider addons={['customers']}>
45
+ <Probe />
46
+ </InstalledAddonsProvider>,
47
+ )
48
+ expect(screen.getByTestId('probe').textContent).toBe('false|false|customers')
49
+ })
50
+ })
@@ -25,6 +25,34 @@ const journalField = (overrides: Partial<ActionFieldDef> = {}): ActionFieldDef =
25
25
  })
26
26
 
27
27
  describe('computeLineItemTotals', () => {
28
+ // Pitsline r3 (g): the work order lines carry qty + a `total` price column
29
+ // and no amount column. qty=2 × 750 footed 750; the saved order was 1500.
30
+ it('suma qty × precio cuando la columna total es el precio unitario', () => {
31
+ const field = {
32
+ key: 'lines',
33
+ type: 'array',
34
+ itemFields: [
35
+ { key: 'description', type: 'string' },
36
+ { key: 'qty', type: 'number' },
37
+ { key: 'price', type: 'number', total: true },
38
+ ],
39
+ } as any
40
+ const rows = [{ qty: '2', price: '750' }, { qty: 1, price: 99.9 }]
41
+ expect(computeLineItemTotals(field, rows)).toEqual({ price: 1599.9 })
42
+ })
43
+ it('con columna de importe, el precio unitario se suma tal cual', () => {
44
+ const field = {
45
+ key: 'lines',
46
+ type: 'array',
47
+ itemFields: [
48
+ { key: 'qty', type: 'number' },
49
+ { key: 'unit_price', type: 'number', total: true },
50
+ { key: 'subtotal', type: 'number', total: true },
51
+ ],
52
+ } as any
53
+ const rows = [{ qty: 2, unit_price: 10, subtotal: 20 }]
54
+ expect(computeLineItemTotals(field, rows)).toEqual({ unit_price: 10, subtotal: 20 })
55
+ })
28
56
  it('suma solo las columnas marcadas con total', () => {
29
57
  const rows = [
30
58
  { account_id: 'a', debit: '100', credit: '' },
@@ -65,6 +65,7 @@ describe('DynamicLineItems — lock_rows', () => {
65
65
  it('control: sin lock_rows muestra "Agregar renglón" y un botón de borrar por fila', () => {
66
66
  render(<DynamicLineItems field={lineField()} value={rows} onChange={vi.fn()} />)
67
67
  expect(screen.getByText('Agregar renglón')).toBeTruthy()
68
- expect(screen.getAllByLabelText('Eliminar renglón')).toHaveLength(2)
68
+ // Mobile cards + desktop table both render a delete control per row.
69
+ expect(screen.getAllByLabelText('Eliminar renglón')).toHaveLength(rows.length * 2)
69
70
  })
70
71
  })
@@ -0,0 +1,115 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // `option_filter` / `optionFilter` on a relation picker hides options client-side
4
+ // (PIT-059: cancelled invoices must not show up in the payments selector).
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'
7
+ import { applyOptionFilter, getOptionFilter, optionPassesRule } from '../option-filter'
8
+ import { projectOption } from '../use-options-resolver'
9
+ import { DynamicSelectField } from '../dynamic-select-field'
10
+ import { ApiProvider, type ApiClient } from '../api-context'
11
+ import type { ActionFieldDef } from '../types'
12
+
13
+ vi.mock('react-i18next', () => ({
14
+ useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
15
+ }))
16
+
17
+ afterEach(cleanup)
18
+
19
+ const invoices = [
20
+ { id: 'i1', label: 'FAC-1', status: 'timbrada', total: 100 },
21
+ { id: 'i2', label: 'FAC-2', status: 'Cancelada', total: 50 },
22
+ { id: 'i3', label: 'FAC-3', status: 'pendiente', total: 70 },
23
+ { id: 'i4', label: 'FAC-4' },
24
+ ].map(projectOption)
25
+
26
+ describe('getOptionFilter', () => {
27
+ it('is empty when the field declares none (retrocompat)', () => {
28
+ expect(getOptionFilter({ key: 'x' })).toEqual([])
29
+ expect(getOptionFilter(undefined)).toEqual([])
30
+ })
31
+ it('reads camelCase and snake_case, one rule or a list', () => {
32
+ const rule = { field: 'status', not_in: ['cancelada'] }
33
+ expect(getOptionFilter({ optionFilter: rule })).toEqual([rule])
34
+ expect(getOptionFilter({ option_filter: [rule] })).toEqual([rule])
35
+ })
36
+ it('ignores malformed rules', () => {
37
+ expect(getOptionFilter({ option_filter: [{ nope: 1 }, null, { field: '' }] })).toEqual([])
38
+ })
39
+ })
40
+
41
+ describe('applyOptionFilter', () => {
42
+ it('returns the same array with no rules', () => {
43
+ expect(applyOptionFilter(invoices, [])).toBe(invoices)
44
+ })
45
+ it('not_in hides cancelled invoices (case-insensitive) and keeps options without the column', () => {
46
+ const out = applyOptionFilter(invoices, [{ field: 'status', not_in: ['cancelada'] }])
47
+ expect(out.map((o) => o.id)).toEqual(['i1', 'i3', 'i4'])
48
+ })
49
+ it('notEquals alias and equals / in need the column to exist', () => {
50
+ expect(applyOptionFilter(invoices, [{ field: 'status', notEquals: 'cancelada' }]).map((o) => o.id)).toEqual(['i1', 'i3', 'i4'])
51
+ expect(applyOptionFilter(invoices, [{ field: 'status', equals: 'timbrada' }]).map((o) => o.id)).toEqual(['i1'])
52
+ expect(applyOptionFilter(invoices, [{ field: 'status', in: ['timbrada', 'pendiente'] }]).map((o) => o.id)).toEqual(['i1', 'i3'])
53
+ })
54
+ it('combines rules with AND', () => {
55
+ const out = applyOptionFilter(invoices, [
56
+ { field: 'status', not_in: ['cancelada'] },
57
+ { field: 'status', not_equals: 'pendiente' },
58
+ ])
59
+ expect(out.map((o) => o.id)).toEqual(['i1', 'i4'])
60
+ })
61
+ it('never hides the current selection', () => {
62
+ const out = applyOptionFilter(invoices, [{ field: 'status', not_in: ['cancelada'] }], 'i2')
63
+ expect(out.map((o) => o.id)).toEqual(['i1', 'i2', 'i3', 'i4'])
64
+ })
65
+ it('tests standard keys too (label)', () => {
66
+ expect(optionPassesRule(invoices[0], { field: 'label', equals: 'fac-1' })).toBe(true)
67
+ })
68
+ })
69
+
70
+ describe('DynamicSelectField with option_filter', () => {
71
+ const field = (extra: Partial<ActionFieldDef> = {}): ActionFieldDef => ({
72
+ key: 'invoice_id',
73
+ label: 'Factura',
74
+ type: 'dynamic_select',
75
+ ref: 'invoices.Invoice',
76
+ ...extra,
77
+ })
78
+ const api = () => {
79
+ const get = vi.fn(async () => ({
80
+ data: {
81
+ success: true,
82
+ data: [
83
+ { id: 'i1', label: 'FAC-1', status: 'timbrada' },
84
+ { id: 'i2', label: 'FAC-2', status: 'cancelada' },
85
+ ],
86
+ meta: { type: 'dynamic', count: 2 },
87
+ },
88
+ }))
89
+ const post = vi.fn(async () => ({ data: { success: false } }))
90
+ return { get, post } as unknown as ApiClient
91
+ }
92
+ const open = async (f: ActionFieldDef) => {
93
+ render(
94
+ <ApiProvider client={api()}>
95
+ <DynamicSelectField field={f} value="" onChange={() => {}} />
96
+ </ApiProvider>,
97
+ )
98
+ await act(async () => {
99
+ screen.getByRole('combobox').click()
100
+ })
101
+ }
102
+
103
+ it('lists every option without a filter', async () => {
104
+ await open(field())
105
+ await waitFor(() => expect(screen.getByText('FAC-2')).toBeTruthy())
106
+ expect(screen.getByText('FAC-1')).toBeTruthy()
107
+ })
108
+
109
+ it('hides the cancelled invoice when the manifest declares option_filter', async () => {
110
+ await open(field({ option_filter: { field: 'status', not_in: ['cancelada'] } }))
111
+ await waitFor(() => expect(screen.getByText('FAC-1')).toBeTruthy())
112
+ expect(screen.queryByText('FAC-2')).toBeNull()
113
+ expect(within(document.body).queryAllByText('FAC-2')).toHaveLength(0)
114
+ })
115
+ })
@@ -0,0 +1,156 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Primitivos federados en React: el dispatcher pinta un modal de
4
+ // registerFederatedModal sin esperar al host, usa el formulario genérico de
5
+ // inmediato si el addon del slug no está instalado, y useDocumentContributions
6
+ // re-pinta al registrar/desmontar un remote.
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { act, cleanup, render, screen } from '@testing-library/react'
9
+
10
+ const USE_TRANSLATION = {
11
+ t: (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k,
12
+ i18n: { language: 'es' },
13
+ }
14
+ vi.mock('react-i18next', () => ({ useTranslation: () => USE_TRANSLATION }))
15
+ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
16
+
17
+ import { ActionModalDispatcher } from '../action-modal-dispatcher'
18
+ import { setFederatedActionLoader } from '../federated-action-loader'
19
+ import { ApiProvider, type ApiClient } from '../api-context'
20
+ import { InstalledAddonsProvider } from '../installed-addons-context'
21
+ import {
22
+ __resetContributions,
23
+ registerDocumentContribution,
24
+ registerFederatedModal,
25
+ type DocumentEditorContext,
26
+ } from '../primitives/contributions'
27
+ import { useDocumentContributions } from '../primitives/use-contributions'
28
+ import type { ActionMetadata, ActionModalProps } from '@asteby/metacore-sdk'
29
+
30
+ afterEach(() => {
31
+ cleanup()
32
+ vi.restoreAllMocks()
33
+ setFederatedActionLoader(null)
34
+ __resetContributions()
35
+ })
36
+
37
+ const noopApi = {
38
+ get: vi.fn().mockResolvedValue({ data: {} }),
39
+ post: vi.fn().mockResolvedValue({ data: { success: true } }),
40
+ delete: vi.fn().mockResolvedValue({ data: { success: true } }),
41
+ } as unknown as ApiClient
42
+
43
+ const importAction: ActionMetadata = {
44
+ key: 'import_cfdi',
45
+ label: 'Importar CFDI',
46
+ modal: 'fiscal_mexico.import_cfdi',
47
+ confirm: true,
48
+ confirmMessage: '¿Importar?',
49
+ executable: true,
50
+ }
51
+
52
+ function ImportModal({ action }: ActionModalProps) {
53
+ return <div data-testid="import-modal">{action.modal}</div>
54
+ }
55
+
56
+ function renderDispatcher(addons?: string[]) {
57
+ const tree = (
58
+ <ActionModalDispatcher
59
+ open
60
+ onOpenChange={() => {}}
61
+ action={importAction}
62
+ model="Invoice"
63
+ record={{ id: '1' } as any}
64
+ endpoint="/data/Invoice"
65
+ onSuccess={() => {}}
66
+ />
67
+ )
68
+ return render(
69
+ <ApiProvider client={noopApi}>
70
+ {addons ? <InstalledAddonsProvider addons={addons}>{tree}</InstalledAddonsProvider> : tree}
71
+ </ApiProvider>,
72
+ )
73
+ }
74
+
75
+ describe('ActionModalDispatcher + registerFederatedModal', () => {
76
+ it('pinta el modal registrado (lazy) sin pedir el remote al host', async () => {
77
+ const loader = vi.fn()
78
+ setFederatedActionLoader(loader)
79
+ registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: ImportModal }) })
80
+ renderDispatcher(['fiscal_mexico'])
81
+ expect((await screen.findByTestId('import-modal')).textContent).toBe('fiscal_mexico.import_cfdi')
82
+ expect(loader).not.toHaveBeenCalled()
83
+ expect(screen.queryByText('¿Importar?')).toBeNull()
84
+ })
85
+
86
+ it('addon del slug no instalado → formulario genérico de inmediato, sin esperar ni pedir el remote', () => {
87
+ const loader = vi.fn()
88
+ setFederatedActionLoader(loader)
89
+ registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: ImportModal }) })
90
+ renderDispatcher(['customers'])
91
+ expect(screen.getByText('¿Importar?')).toBeTruthy()
92
+ expect(screen.queryByTestId('federated-action-loading')).toBeNull()
93
+ expect(loader).not.toHaveBeenCalled()
94
+ })
95
+
96
+ it('addon instalado pero modal aún sin registrar → espera al remote (no cae al genérico)', async () => {
97
+ renderDispatcher(['fiscal_mexico'])
98
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
99
+ expect(screen.queryByText('¿Importar?')).toBeNull()
100
+ act(() => {
101
+ registerFederatedModal({ key: 'fiscal_mexico.import_cfdi', addon: 'fiscal_mexico', load: async () => ({ default: ImportModal }) })
102
+ })
103
+ expect(await screen.findByTestId('import-modal')).toBeTruthy()
104
+ })
105
+
106
+ it('sin InstalledAddonsProvider se conserva la espera de siempre', () => {
107
+ renderDispatcher()
108
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
109
+ expect(screen.queryByText('¿Importar?')).toBeNull()
110
+ })
111
+ })
112
+
113
+ describe('useDocumentContributions', () => {
114
+ const ctx: DocumentEditorContext = { kind: 'invoice', model: 'customers.Invoice', values: {}, mode: 'create' }
115
+
116
+ function Region() {
117
+ const items = useDocumentContributions(ctx, 'header.fields')
118
+ return (
119
+ <div data-testid="region">
120
+ {items.map(({ id, component: C }) => (
121
+ <C key={id} ctx={ctx} setValue={() => {}} issues={{ report: () => {} }} />
122
+ ))}
123
+ </div>
124
+ )
125
+ }
126
+
127
+ it('re-pinta al registrar y al desmontar el remote; filtra por addon instalado', async () => {
128
+ render(
129
+ <InstalledAddonsProvider addons={['fiscal_mexico']}>
130
+ <Region />
131
+ </InstalledAddonsProvider>,
132
+ )
133
+ expect(screen.getByTestId('region').textContent).toBe('')
134
+ let off = () => {}
135
+ act(() => {
136
+ off = registerDocumentContribution({
137
+ id: 'fiscal_mexico.header.cfdi',
138
+ kinds: ['invoice'],
139
+ region: 'header.fields',
140
+ requiresAddon: 'fiscal_mexico',
141
+ load: async () => ({ default: () => <span>Uso del CFDI</span> }),
142
+ })
143
+ registerDocumentContribution({
144
+ id: 'inventory.header.warehouse',
145
+ kinds: '*',
146
+ region: 'header.fields',
147
+ requiresAddon: 'inventory',
148
+ component: () => <span>Almacén</span>,
149
+ })
150
+ })
151
+ expect(await screen.findByText('Uso del CFDI')).toBeTruthy()
152
+ expect(screen.queryByText('Almacén')).toBeNull()
153
+ act(() => off())
154
+ expect(screen.queryByText('Uso del CFDI')).toBeNull()
155
+ })
156
+ })