@asteby/metacore-runtime-react 46.4.0 → 47.1.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 (123) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/action-modal-dispatcher.d.ts +10 -1
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +107 -19
  5. package/dist/audit-info.d.ts +54 -0
  6. package/dist/audit-info.d.ts.map +1 -0
  7. package/dist/audit-info.js +142 -0
  8. package/dist/business/catalog.d.ts.map +1 -1
  9. package/dist/business/catalog.js +24 -0
  10. package/dist/business/index.d.ts +3 -0
  11. package/dist/business/index.d.ts.map +1 -1
  12. package/dist/business/index.js +3 -0
  13. package/dist/business/print-send-dialog.d.ts +27 -0
  14. package/dist/business/print-send-dialog.d.ts.map +1 -0
  15. package/dist/business/print-send-dialog.js +55 -0
  16. package/dist/business/relate-documents.d.ts +41 -0
  17. package/dist/business/relate-documents.d.ts.map +1 -0
  18. package/dist/business/relate-documents.js +42 -0
  19. package/dist/business/vehicle-picker.d.ts +36 -0
  20. package/dist/business/vehicle-picker.d.ts.map +1 -0
  21. package/dist/business/vehicle-picker.js +61 -0
  22. package/dist/cfdi-stamp-panel.d.ts +29 -0
  23. package/dist/cfdi-stamp-panel.d.ts.map +1 -0
  24. package/dist/cfdi-stamp-panel.js +53 -0
  25. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  26. package/dist/dialogs/dynamic-record.js +10 -1
  27. package/dist/document-form-dialog.d.ts +23 -0
  28. package/dist/document-form-dialog.d.ts.map +1 -0
  29. package/dist/document-form-dialog.js +196 -0
  30. package/dist/dynamic-crud-page.d.ts +6 -0
  31. package/dist/dynamic-crud-page.d.ts.map +1 -1
  32. package/dist/dynamic-crud-page.js +4 -2
  33. package/dist/dynamic-form.d.ts.map +1 -1
  34. package/dist/dynamic-form.js +3 -0
  35. package/dist/dynamic-kanban.d.ts.map +1 -1
  36. package/dist/dynamic-kanban.js +11 -2
  37. package/dist/dynamic-line-items.d.ts.map +1 -1
  38. package/dist/dynamic-line-items.js +3 -0
  39. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  40. package/dist/dynamic-multi-select-field.js +2 -0
  41. package/dist/dynamic-select-field.d.ts.map +1 -1
  42. package/dist/dynamic-select-field.js +4 -0
  43. package/dist/dynamic-table.d.ts.map +1 -1
  44. package/dist/dynamic-table.js +6 -2
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -0
  48. package/dist/nav-permissions.d.ts +19 -0
  49. package/dist/nav-permissions.d.ts.map +1 -0
  50. package/dist/nav-permissions.js +51 -0
  51. package/dist/navigation-builder.d.ts +6 -0
  52. package/dist/navigation-builder.d.ts.map +1 -1
  53. package/dist/navigation-builder.js +26 -1
  54. package/dist/option-filter.d.ts +31 -0
  55. package/dist/option-filter.d.ts.map +1 -0
  56. package/dist/option-filter.js +49 -0
  57. package/dist/permissions-manager.d.ts +9 -0
  58. package/dist/permissions-manager.d.ts.map +1 -1
  59. package/dist/permissions-manager.js +50 -17
  60. package/dist/print-document-button.d.ts.map +1 -1
  61. package/dist/print-document-button.js +9 -1
  62. package/dist/record-mutation-events.d.ts +14 -0
  63. package/dist/record-mutation-events.d.ts.map +1 -0
  64. package/dist/record-mutation-events.js +37 -0
  65. package/dist/types.d.ts +86 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/use-options-resolver.d.ts +8 -0
  68. package/dist/use-options-resolver.d.ts.map +1 -1
  69. package/dist/use-options-resolver.js +8 -2
  70. package/dist/use-print-document.d.ts +15 -1
  71. package/dist/use-print-document.d.ts.map +1 -1
  72. package/dist/use-print-document.js +52 -1
  73. package/dist/use-record-mutation-tick.d.ts +6 -0
  74. package/dist/use-record-mutation-tick.d.ts.map +1 -0
  75. package/dist/use-record-mutation-tick.js +11 -0
  76. package/package.json +3 -3
  77. package/src/__tests__/action-modal-feedback.test.tsx +116 -0
  78. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  79. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  80. package/src/__tests__/audit-info.test.tsx +103 -0
  81. package/src/__tests__/business-pickers.test.tsx +157 -0
  82. package/src/__tests__/dependent-options.test.tsx +12 -5
  83. package/src/__tests__/document-form-dialog.test.tsx +190 -0
  84. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  85. package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
  86. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  87. package/src/__tests__/nav-permissions.test.tsx +76 -0
  88. package/src/__tests__/option-filter.test.tsx +115 -0
  89. package/src/__tests__/permissions-manager.test.tsx +21 -4
  90. package/src/__tests__/structured-view-value.test.tsx +5 -1
  91. package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
  92. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  93. package/src/action-modal-dispatcher.tsx +124 -6
  94. package/src/audit-info.tsx +219 -0
  95. package/src/business/catalog.ts +24 -0
  96. package/src/business/index.ts +3 -0
  97. package/src/business/print-send-dialog.tsx +175 -0
  98. package/src/business/relate-documents.tsx +182 -0
  99. package/src/business/vehicle-picker.tsx +188 -0
  100. package/src/cfdi-stamp-panel.tsx +146 -0
  101. package/src/dialogs/dynamic-record.tsx +18 -0
  102. package/src/document-form-dialog.tsx +357 -0
  103. package/src/dropdown-menu-entry.test.ts +23 -0
  104. package/src/dynamic-crud-page.tsx +21 -2
  105. package/src/dynamic-form.tsx +3 -0
  106. package/src/dynamic-kanban.tsx +10 -4
  107. package/src/dynamic-line-items.tsx +3 -0
  108. package/src/dynamic-multi-select-field.tsx +2 -0
  109. package/src/dynamic-select-field.tsx +4 -0
  110. package/src/dynamic-table.tsx +6 -2
  111. package/src/index.ts +41 -0
  112. package/src/nav-permissions.ts +58 -0
  113. package/src/navigation-builder.tsx +27 -1
  114. package/src/option-filter.ts +88 -0
  115. package/src/permissions-manager.tsx +39 -26
  116. package/src/print-document-button.tsx +9 -1
  117. package/src/record-mutation-events.ts +50 -0
  118. package/src/types.ts +90 -0
  119. package/src/use-options-resolver.ts +19 -1
  120. package/src/use-print-document.ts +66 -3
  121. package/src/use-record-mutation-tick.ts +12 -0
  122. package/vitest.config.ts +1 -0
  123. package/vitest.setup.ts +7 -0
@@ -0,0 +1,357 @@
1
+ // DocumentFormDialog — alta guiada por tipo de documento (FAC-12, #1022).
2
+ //
3
+ // "Crear documento fiscal" era el mismo formulario genérico para factura, nota
4
+ // de crédito, REP, global y traslado: los campos los dictaba el modelo, no el
5
+ // tipo. Aquí el metadata (`document_forms`) o una prop declara los tipos; el
6
+ // diálogo muestra tarjetas de tipo y, al elegir uno, SUS campos y (si lo declara)
7
+ // el paso de renglones con el editor de renglones del SDK. Un solo tipo se salta
8
+ // el selector; sin manifest el alta genérica sigue igual (DynamicCRUDPage decide).
9
+ //
10
+ // El SDK no hardcodea catálogos SAT: campos, valores fijos y el valor del tipo
11
+ // vienen del manifest que sirve el kernel o el addon.
12
+ import { useEffect, useMemo, useState } from 'react'
13
+ import { useTranslation } from 'react-i18next'
14
+ import {
15
+ Button,
16
+ Dialog,
17
+ DialogContent,
18
+ DialogDescription,
19
+ DialogFooter,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ } from '@asteby/metacore-ui/primitives'
23
+ import { Loader2 } from 'lucide-react'
24
+ import { toast } from 'sonner'
25
+ import { useApi } from './api-context'
26
+ import { DynamicIcon } from './dynamic-icon'
27
+ import { FieldCell, FieldGrid, FieldLabel } from './field-grid'
28
+ import { FormErrorBanner } from './business/feedback'
29
+ import {
30
+ DocumentLinesGrid,
31
+ type DocumentLinesGridProps,
32
+ type LineItemsColumn,
33
+ } from './business/line-items-editor'
34
+ import { serializeLineItems, validateLineItems, type LineItem } from './business/line-items'
35
+ import {
36
+ actionErrorBannerMessage,
37
+ buildFieldDefaults,
38
+ renderField,
39
+ } from './action-modal-dispatcher'
40
+ import { extractFieldErrors, localizeFieldErrorMap } from './server-error'
41
+ import { validateValues, bagHasErrors } from './validator'
42
+ import { clearFieldErrorTree } from './field-validation-ui'
43
+ import { emitRecordMutation } from './record-mutation-events'
44
+ import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
45
+ import type {
46
+ ActionFieldDef,
47
+ DocumentFormLines,
48
+ DocumentFormsManifest,
49
+ DocumentFormType,
50
+ TableMetadata,
51
+ } from './types'
52
+
53
+ export interface DocumentFormDialogProps {
54
+ open: boolean
55
+ onOpenChange: (open: boolean) => void
56
+ /** Modelo en el que se da de alta (p. ej. `fiscal_documents`). */
57
+ model: string
58
+ /** Endpoint de alta. Default `/dynamic/<model>`. */
59
+ endpoint?: string
60
+ forms: DocumentFormsManifest
61
+ /** Tipo preseleccionado (se salta el selector). */
62
+ initialType?: string
63
+ /** Se llama con el registro creado. */
64
+ onSaved?: (record?: unknown) => void
65
+ /** Buscador de productos para el editor de renglones (mismo contrato que ProductPicker). */
66
+ searchProducts?: DocumentLinesGridProps['search']
67
+ /** Moneda ISO para el editor de renglones (default: la de la org). */
68
+ currency?: string
69
+ }
70
+
71
+ /** Manifest efectivo: la prop gana sobre el metadata; sin tipos devuelve undefined. */
72
+ export function resolveDocumentForms(
73
+ metadata?: Pick<TableMetadata, 'document_forms'> | null,
74
+ override?: DocumentFormsManifest | null,
75
+ ): DocumentFormsManifest | undefined {
76
+ const m = override ?? metadata?.document_forms
77
+ return m && Array.isArray(m.types) && m.types.length > 0 ? m : undefined
78
+ }
79
+
80
+ function linesConfig(type: DocumentFormType, forms: DocumentFormsManifest): (DocumentFormLines & { field: string }) | undefined {
81
+ if (!type.lines) return undefined
82
+ const cfg: DocumentFormLines = type.lines === true ? {} : type.lines
83
+ return { ...cfg, field: cfg.field ?? forms.lines_field ?? 'lines' }
84
+ }
85
+
86
+ type Step = 'type' | 'fields' | 'lines'
87
+
88
+ export function DocumentFormDialog({
89
+ open,
90
+ onOpenChange,
91
+ model,
92
+ endpoint,
93
+ forms,
94
+ initialType,
95
+ onSaved,
96
+ searchProducts,
97
+ currency,
98
+ }: DocumentFormDialogProps) {
99
+ const { t, i18n } = useTranslation()
100
+ const api = useApi()
101
+ const types = forms.types
102
+ const single = types.length === 1 ? types[0] : undefined
103
+
104
+ const [typeKey, setTypeKey] = useState<string | null>(initialType ?? single?.key ?? null)
105
+ const [step, setStep] = useState<Step>(initialType || single ? 'fields' : 'type')
106
+ const [formData, setFormData] = useState<Record<string, any>>({})
107
+ const [lines, setLines] = useState<LineItem[]>([])
108
+ const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
109
+ const [serverLineErrors, setServerLineErrors] = useState<Record<string, string>>({})
110
+ const [formError, setFormError] = useState<string | undefined>()
111
+ const [saving, setSaving] = useState(false)
112
+
113
+ const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
114
+ const lineCfg = type ? linesConfig(type, forms) : undefined
115
+
116
+ // Reset every time the dialog (re)opens.
117
+ useEffect(() => {
118
+ if (!open) return
119
+ const start = initialType ?? single?.key ?? null
120
+ setTypeKey(start)
121
+ setStep(start ? 'fields' : 'type')
122
+ setLines([])
123
+ setFieldErrors({})
124
+ setServerLineErrors({})
125
+ setFormError(undefined)
126
+ }, [open, initialType, single?.key])
127
+
128
+ // Seed field defaults for the chosen type.
129
+ useEffect(() => {
130
+ if (!type) return
131
+ setFormData({ ...buildFieldDefaults(type.fields, undefined), ...(type.defaults ?? {}) })
132
+ setFieldErrors({})
133
+ setFormError(undefined)
134
+ }, [type])
135
+
136
+ const updateField = (key: string, value: any) => {
137
+ setFormData((prev) => ({ ...prev, [key]: value }))
138
+ setFieldErrors((prev) => clearFieldErrorTree(prev, key))
139
+ }
140
+
141
+ const tl = (s: string) => t(s, { defaultValue: s })
142
+ const lang = i18n?.language
143
+
144
+ const chooseType = (k: string) => {
145
+ setTypeKey(k)
146
+ setStep('fields')
147
+ }
148
+
149
+ /** Validate the header fields; returns true when they pass. */
150
+ const validateHeader = (): boolean => {
151
+ if (!type) return false
152
+ const bag = validateValues(type.fields, formData)
153
+ if (!bagHasErrors(bag)) {
154
+ setFieldErrors({})
155
+ return true
156
+ }
157
+ const labels: Record<string, string> = {}
158
+ for (const f of type.fields) labels[f.key] = tl(f.label)
159
+ setFieldErrors(localizeFieldErrorMap(bag, t, { labels, language: lang }))
160
+ return false
161
+ }
162
+
163
+ const goNext = () => {
164
+ if (validateHeader()) setStep('lines')
165
+ }
166
+
167
+ const submit = async () => {
168
+ if (!type) return
169
+ if (!validateHeader()) {
170
+ setStep('fields')
171
+ return
172
+ }
173
+ if (lineCfg && (lineCfg.required ?? true)) {
174
+ const v = validateLineItems(lines, { requireItems: true })
175
+ if (!v.form && Object.keys(v.errors).length === 0) {
176
+ /* ok */
177
+ } else {
178
+ setServerLineErrors(v.errors)
179
+ setFormError(v.form ?? t('documentForm.lines_invalid', { defaultValue: 'Revisa los renglones del documento.' }))
180
+ setStep('lines')
181
+ return
182
+ }
183
+ }
184
+ setSaving(true)
185
+ setFormError(undefined)
186
+ try {
187
+ const payload: Record<string, unknown> = { ...(type.defaults ?? {}), ...formData }
188
+ if (forms.type_field) payload[forms.type_field] = type.value ?? type.key
189
+ if (lineCfg) payload[lineCfg.field] = serializeLineItems(lines)
190
+ const url = type.endpoint ?? endpoint ?? `/dynamic/${model}`
191
+ const res = await api.post(url, payload)
192
+ if (res.data?.success === false) {
193
+ handleError({ response: { data: res.data } })
194
+ return
195
+ }
196
+ toast.success(t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
197
+ emitRecordMutation(model, 'create')
198
+ onSaved?.(res.data?.data ?? res.data ?? undefined)
199
+ onOpenChange(false)
200
+ } catch (err) {
201
+ handleError(err)
202
+ } finally {
203
+ setSaving(false)
204
+ }
205
+ }
206
+
207
+ /** 422 → marks the fields and shows the banner INSIDE the dialog; stays open. */
208
+ const handleError = (err: unknown) => {
209
+ setFormError(actionErrorBannerMessage(err, type?.fields, t, lang))
210
+ const map = extractFieldErrors(err)
211
+ if (map) {
212
+ const labels: Record<string, string> = {}
213
+ for (const f of type?.fields ?? []) labels[f.key] = tl(f.label)
214
+ const localized = localizeFieldErrorMap(map, t, { labels, language: lang })
215
+ const header: Record<string, string> = {}
216
+ const lineErrs: Record<string, string> = {}
217
+ const prefix = lineCfg ? `${lineCfg.field}.` : null
218
+ for (const [k, v] of Object.entries(localized)) {
219
+ if (prefix && k.startsWith(prefix)) lineErrs[k.slice(prefix.length)] = v
220
+ else header[k] = v
221
+ }
222
+ setFieldErrors(header)
223
+ setServerLineErrors(lineErrs)
224
+ }
225
+ }
226
+
227
+ const canGoBackToTypes = types.length > 1 && !initialType
228
+ const stepLabel = (s: Step) =>
229
+ s === 'type'
230
+ ? t('documentForm.step_type', { defaultValue: 'Tipo de documento' })
231
+ : s === 'fields'
232
+ ? t('documentForm.step_data', { defaultValue: 'Datos' })
233
+ : tl(lineCfg?.title ?? 'Renglones')
234
+
235
+ return (
236
+ <Dialog open={open} onOpenChange={onOpenChange}>
237
+ <DialogContent
238
+ className="flex max-h-[90dvh] flex-col overflow-hidden sm:max-w-2xl"
239
+ style={{ maxHeight: '90dvh', ...(step === 'lines' ? { maxWidth: '980px', width: '95vw' } : {}) }}
240
+ data-slot="document-form-dialog"
241
+ >
242
+ <DialogHeader className="shrink-0">
243
+ <DialogTitle>
244
+ {type ? tl(type.label) : t('documentForm.title', { defaultValue: 'Nuevo documento' })}
245
+ </DialogTitle>
246
+ <DialogDescription>{stepLabel(step)}</DialogDescription>
247
+ </DialogHeader>
248
+
249
+ <div className="-mx-1 min-h-0 flex-1 space-y-3 overflow-y-auto px-1 py-3">
250
+ <FormErrorBanner message={formError} />
251
+
252
+ {step === 'type' && (
253
+ <div role="radiogroup" className="grid gap-3 sm:grid-cols-2">
254
+ {types.map((x) => (
255
+ <button
256
+ key={x.key}
257
+ type="button"
258
+ role="radio"
259
+ aria-checked={x.key === typeKey}
260
+ data-type-card={x.key}
261
+ onClick={() => chooseType(x.key)}
262
+ className="flex items-start gap-3 rounded-lg border p-4 text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
263
+ >
264
+ {x.icon && <DynamicIcon name={x.icon} className="mt-0.5 size-5 shrink-0 text-primary" />}
265
+ <span>
266
+ <span className="block text-sm font-medium">{tl(x.label)}</span>
267
+ {x.description && (
268
+ <span className="block text-xs text-muted-foreground">{tl(x.description)}</span>
269
+ )}
270
+ </span>
271
+ </button>
272
+ ))}
273
+ </div>
274
+ )}
275
+
276
+ {step === 'fields' && type && (
277
+ <FieldGrid>
278
+ {type.fields.map((field: ActionFieldDef) => {
279
+ const fullWidth =
280
+ isLineItemsField(field) ||
281
+ resolveWidget(field) === 'textarea' ||
282
+ resolveWidget(field) === 'richtext'
283
+ return (
284
+ <FieldCell key={field.key} fullWidth={fullWidth}>
285
+ <FieldLabel htmlFor={field.key} required={field.required}>
286
+ {tl(field.label)}
287
+ </FieldLabel>
288
+ {renderField(
289
+ field,
290
+ formData[field.key],
291
+ (v: any) => updateField(field.key, v),
292
+ formData,
293
+ undefined,
294
+ fieldErrors,
295
+ )}
296
+ {fieldErrors[field.key] && (
297
+ <p className="mt-1 text-xs text-destructive">{fieldErrors[field.key]}</p>
298
+ )}
299
+ </FieldCell>
300
+ )
301
+ })}
302
+ </FieldGrid>
303
+ )}
304
+
305
+ {step === 'lines' && type && lineCfg && (
306
+ <DocumentLinesGrid
307
+ value={lines}
308
+ onChange={(next) => {
309
+ setLines(next)
310
+ setServerLineErrors({})
311
+ }}
312
+ columns={(lineCfg.columns as LineItemsColumn[] | undefined) ?? ['discount', 'tax']}
313
+ priceSource={lineCfg.price_source ?? 'sale'}
314
+ serverErrors={serverLineErrors}
315
+ search={searchProducts}
316
+ currency={currency}
317
+ />
318
+ )}
319
+ </div>
320
+
321
+ <DialogFooter className="shrink-0">
322
+ {step === 'type' ? (
323
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
324
+ {t('common.cancel', { defaultValue: 'Cancelar' })}
325
+ </Button>
326
+ ) : (
327
+ <Button
328
+ type="button"
329
+ variant="outline"
330
+ disabled={saving}
331
+ onClick={() => {
332
+ if (step === 'lines') setStep('fields')
333
+ else if (canGoBackToTypes) setStep('type')
334
+ else onOpenChange(false)
335
+ }}
336
+ >
337
+ {step === 'fields' && !canGoBackToTypes
338
+ ? t('common.cancel', { defaultValue: 'Cancelar' })
339
+ : t('common.back', { defaultValue: 'Atrás' })}
340
+ </Button>
341
+ )}
342
+ {step === 'fields' && type && lineCfg && (
343
+ <Button type="button" onClick={goNext}>
344
+ {t('common.next', { defaultValue: 'Siguiente' })}
345
+ </Button>
346
+ )}
347
+ {((step === 'fields' && type && !lineCfg) || step === 'lines') && (
348
+ <Button type="button" onClick={submit} disabled={saving}>
349
+ {saving && <Loader2 className="mr-2 size-4 animate-spin" aria-hidden />}
350
+ {type?.submit_label ? tl(type.submit_label) : t('common.create', { defaultValue: 'Crear' })}
351
+ </Button>
352
+ )}
353
+ </DialogFooter>
354
+ </DialogContent>
355
+ </Dialog>
356
+ )
357
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync, readdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ // Radix context guard: a component that renders RowActionMenuItem (whose
6
+ // DropdownMenuItem is imported from the ROOT '@asteby/metacore-ui' entry) must
7
+ // import its DropdownMenu shell from the root entry too. Hosts share the root
8
+ // entry as a Module Federation singleton but bundle '/primitives' locally, so
9
+ // mixing entries creates two Menu contexts → "`MenuItem` must be used within
10
+ // `Menu`" at runtime (Pitsline kanban card menu crash, 2026-10-05).
11
+ describe('dropdown menu entry consistency', () => {
12
+ const dir = join(__dirname)
13
+ const files = readdirSync(dir).filter((f) => f.endsWith('.tsx'))
14
+ for (const f of files) {
15
+ const src = readFileSync(join(dir, f), 'utf8')
16
+ if (!src.includes('<RowActionMenuItem')) continue
17
+ it(`${f} imports DropdownMenu from the root ui entry`, () => {
18
+ const primitivesImport = src.match(/import\s*{([^}]*)}\s*from\s*'@asteby\/metacore-ui\/primitives'/)
19
+ const fromPrimitives = primitivesImport ? primitivesImport[1] : ''
20
+ expect(fromPrimitives).not.toMatch(/\bDropdownMenu(Content|Trigger)?\b/)
21
+ })
22
+ }
23
+ })
@@ -33,13 +33,14 @@ import { useApi } from './api-context'
33
33
  import { useMetadataCache } from './metadata-cache'
34
34
  import { DynamicTable } from './dynamic-table'
35
35
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
36
+ import { DocumentFormDialog, resolveDocumentForms } from './document-form-dialog'
36
37
  import { ExportDialog } from './dialogs/export'
37
38
  import { ImportDialog } from './dialogs/import'
38
39
  import { getModelExtension } from './model-extension-registry'
39
40
  import { ModelActionToolbar } from './model-action-toolbar'
40
41
  import { resolveListPrimaryAction } from './list-primary-action'
41
42
  import { useCan, modelCapability } from './permissions-context'
42
- import type { TableMetadata } from './types'
43
+ import type { DocumentFormsManifest, TableMetadata } from './types'
43
44
 
44
45
  export interface DynamicCRUDPageStrings {
45
46
  refresh?: string
@@ -90,6 +91,11 @@ export interface DynamicCRUDPageProps {
90
91
  classes?: DynamicCRUDPageClasses
91
92
  /** Fired after a create/import/refresh successfully reloads the table. */
92
93
  onChange?: () => void
94
+ /**
95
+ * Guided create by document type (FAC-12). Wins over `metadata.document_forms`;
96
+ * with neither, the generic record dialog is used as before.
97
+ */
98
+ documentForms?: DocumentFormsManifest
93
99
  }
94
100
 
95
101
  /**
@@ -112,6 +118,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
112
118
  toolbarExtras,
113
119
  classes,
114
120
  onChange,
121
+ documentForms,
115
122
  } = props
116
123
 
117
124
  const strings = { ...defaultStrings, ...(i18n ?? {}) }
@@ -186,6 +193,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
186
193
  const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
187
194
  const showExport = !effectiveHideExport && can(modelCapability(model, 'export'))
188
195
  const showRefresh = !effectiveHideRefresh
196
+ const guidedForms = resolveDocumentForms(metadata, documentForms)
189
197
 
190
198
  const handleRefresh = useCallback(() => {
191
199
  setRefreshKey((k) => k + 1)
@@ -274,7 +282,18 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
274
282
  </div>
275
283
  </div>
276
284
 
277
- {showCreate && (
285
+ {showCreate && guidedForms && (
286
+ <DocumentFormDialog
287
+ open={openCreate}
288
+ onOpenChange={setOpenCreate}
289
+ model={model}
290
+ endpoint={dataEndpoint}
291
+ forms={guidedForms}
292
+ onSaved={handleRefresh}
293
+ />
294
+ )}
295
+
296
+ {showCreate && !guidedForms && (
278
297
  <DynamicRecordDialog
279
298
  open={openCreate}
280
299
  onOpenChange={setOpenCreate}
@@ -30,6 +30,7 @@ import {
30
30
  } from './dynamic-form-schema'
31
31
  import { ScanLine } from 'lucide-react'
32
32
  import { BarcodeScanner } from './barcode-scanner'
33
+ import { getOptionFilter } from './option-filter'
33
34
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
34
35
  import { DynamicLineItems } from './dynamic-line-items'
35
36
  import { DynamicSelectField } from './dynamic-select-field'
@@ -530,6 +531,8 @@ function RefSelect({ field, value, onChange }: FieldRendererProps) {
530
531
  modelKey: '', // unused — `ref` drives the URL
531
532
  fieldKey: 'id',
532
533
  ref: field.ref,
534
+ optionFilter: getOptionFilter(field),
535
+ keepValue: value,
533
536
  })
534
537
  return (
535
538
  <Select value={value || ''} onValueChange={onChange} disabled={loading}>
@@ -94,9 +94,6 @@ import {
94
94
  Button,
95
95
  Card,
96
96
  CardContent,
97
- DropdownMenu,
98
- DropdownMenuContent,
99
- DropdownMenuTrigger,
100
97
  Input,
101
98
  Popover,
102
99
  PopoverContent,
@@ -113,6 +110,13 @@ import {
113
110
  SheetTrigger,
114
111
  Skeleton,
115
112
  } from '@asteby/metacore-ui/primitives'
113
+ // The card "…" menu renders RowActionMenuItem (dynamic-columns), whose
114
+ // DropdownMenuItem comes from the ROOT '@asteby/metacore-ui' entry. The menu
115
+ // shell MUST come from that same entry: hosts share the root entry as a Module
116
+ // Federation singleton while '/primitives' is bundled locally, so mixing them
117
+ // yields two Radix Menu contexts and the click crashes the app with
118
+ // "`MenuItem` must be used within `Menu`" (Pitsline taller kanban, 2026-10-05).
119
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@asteby/metacore-ui'
116
120
  import { ColumnFilterControl, FilterValueCombobox, type ColumnFilterType } from '@asteby/metacore-ui/data-table'
117
121
  import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib'
118
122
  import { useApi } from './api-context'
@@ -152,6 +156,7 @@ import { useFlipAnimation } from './use-flip-animation'
152
156
  import { PortalDragOverlay } from './portal-drag-overlay'
153
157
  import { objectLabel } from './dynamic-relation-helpers'
154
158
  import { useMetadataCache } from './metadata-cache'
159
+ import { useRecordMutationTick } from './use-record-mutation-tick'
155
160
  import { useOptionsResolver } from './use-options-resolver'
156
161
  import { ActivityValueRenderer } from './activity-value-renderer'
157
162
  import { DynamicIcon } from './dynamic-icon'
@@ -923,6 +928,7 @@ function DynamicKanbanBoard({
923
928
  // Realtime refetch (opt-in) — debounced counter bumped by DATA_EVENTs for
924
929
  // this model; folded into the board refetch effect next to refreshTrigger.
925
930
  const realtimeDefault = useRealtimeDefault()
931
+ const mutationTick = useRecordMutationTick(model)
926
932
  const realtimeTick = useRealtimeTick({
927
933
  models: [model],
928
934
  enabled: realtimeProp ?? realtimeDefault,
@@ -1339,7 +1345,7 @@ function DynamicKanbanBoard({
1339
1345
  return () => clearTimeout(handle)
1340
1346
  // realtimeTick: data events for this model (see the `realtime` prop).
1341
1347
  // eslint-disable-next-line react-hooks/exhaustive-deps
1342
- }, [fetchData, metadata, refreshTrigger, realtimeTick])
1348
+ }, [fetchData, metadata, refreshTrigger, realtimeTick, mutationTick])
1343
1349
 
1344
1350
  // Filterable fields for the toolbar, in metadata order (explicit filters
1345
1351
  // first, then filterable columns), each labeled from its metadata source.
@@ -36,6 +36,7 @@ import {
36
36
  applyOptionWhen,
37
37
  } from './dynamic-form-schema'
38
38
  import { DynamicSelectField, DEFAULT_DEPENDS_HINT } from './dynamic-select-field'
39
+ import { getOptionFilter } from './option-filter'
39
40
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
40
41
 
41
42
  export interface DynamicLineItemsProps {
@@ -575,6 +576,8 @@ function RefCell({ field, value, onChange, onPick, disabled, formValues, rowValu
575
576
  ref: optSource.ref,
576
577
  endpoint: optSource.endpoint,
577
578
  filterValue: dependsOn ? scope : undefined,
579
+ optionFilter: getOptionFilter(field),
580
+ keepValue: value,
578
581
  enabled: !blockedByDependency,
579
582
  })
580
583
 
@@ -19,6 +19,7 @@
19
19
  // value instead.
20
20
  import { useMemo } from 'react'
21
21
  import { MultiSelect } from '@asteby/metacore-ui/primitives'
22
+ import { getOptionFilter } from './option-filter'
22
23
  import { useOptionsResolver } from './use-options-resolver'
23
24
  import { getFieldRef } from './dynamic-form-schema'
24
25
  import type { ActionFieldDef } from './types'
@@ -38,6 +39,7 @@ export function DynamicMultiSelectField({ field, value, onChange }: DynamicMulti
38
39
  ref,
39
40
  endpoint: !ref && field.searchEndpoint ? field.searchEndpoint : undefined,
40
41
  limit: 200,
42
+ optionFilter: getOptionFilter(field),
41
43
  })
42
44
 
43
45
  const selected = useMemo(() => (Array.isArray(value) ? value.map(String) : []), [value])
@@ -22,6 +22,7 @@
22
22
  // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
23
23
  // case — start empty and never hit this.
24
24
  import { useEffect, useRef, useState } from 'react'
25
+ import { getOptionFilter } from './option-filter'
25
26
  import { useTranslation } from 'react-i18next'
26
27
  import {
27
28
  Badge,
@@ -266,6 +267,7 @@ export function DynamicSelectField({
266
267
  const blockedByDependency = !!dependsOn && scope === ''
267
268
 
268
269
  const useStatic = Array.isArray(staticOptions)
270
+ const optionFilter = getOptionFilter(field)
269
271
 
270
272
  const { options: fetchedOptions, loading: fetchLoading } = useOptionsResolver({
271
273
  modelKey: '',
@@ -280,6 +282,8 @@ export function DynamicSelectField({
280
282
  // Cascade scope forwarded as filter_value (only when this field
281
283
  // declares a dependency). Re-fetches when the parent value changes.
282
284
  filterValue: dependsOn ? scope : undefined,
285
+ optionFilter,
286
+ keepValue: value,
283
287
  // Don't fetch until the popover opens (and keep fetching as the query
284
288
  // changes while open). A picker blocked by an unset dependency never
285
289
  // fetches. A readonly cell fetches eagerly so its value's label resolves
@@ -66,6 +66,7 @@ import { Inbox, Download, Upload, Trash2 } from 'lucide-react'
66
66
  import { toast } from 'sonner'
67
67
  import { Progress } from './dialogs/_primitives'
68
68
  import { useMetadataCache } from './metadata-cache'
69
+ import { useRecordMutationTick } from './use-record-mutation-tick'
69
70
  import { useApi, useCurrentBranch } from './api-context'
70
71
  import { useReasonPrompt } from './reason-prompt'
71
72
  import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
@@ -295,6 +296,9 @@ export function DynamicTable({
295
296
  // OrgRuntimeProvider (without it, money fell back to USD).
296
297
  const orgTimeZone = useTimeZone()
297
298
  const orgCurrency = useCurrency()
299
+ // Bumps when a dialog/modal anywhere creates, edits or deletes a record of
300
+ // this model, so the list reloads without the caller wiring refreshTrigger.
301
+ const mutationTick = useRecordMutationTick(model)
298
302
  const timeZone = timeZoneProp ?? orgTimeZone
299
303
  const currency = currencyProp ?? orgCurrency
300
304
  // The explicit `pagination` prop wins; the legacy `infiniteScroll` boolean
@@ -917,7 +921,7 @@ export function DynamicTable({
917
921
  } finally {
918
922
  setLoadingData(false)
919
923
  }
920
- }, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
924
+ }, [model, metadata, pagination, buildFilterParams, refreshTrigger, mutationTick, endpoint, currentBranch?.id, api, enableUrlSync, relationInclude])
921
925
 
922
926
  // Columns whose metadata opts into a footer total (display_config.aggregate
923
927
  // → styleConfig.aggregate). When empty, no footer row is rendered and no
@@ -1098,7 +1102,7 @@ export function DynamicTable({
1098
1102
  // deps). Without it the comment above lied: infinite lists silently
1099
1103
  // failed to reload after a create ("a veces no recarga la tabla").
1100
1104
  // eslint-disable-next-line react-hooks/exhaustive-deps
1101
- }, [infiniteScroll, metadata, filterSignature, refreshTrigger])
1105
+ }, [infiniteScroll, metadata, filterSignature, refreshTrigger, mutationTick])
1102
1106
 
1103
1107
  const handleRefresh = useCallback(() => {
1104
1108
  // Infinite mode owns its own list: refresh reloads page 1 and drops the
package/src/index.ts CHANGED
@@ -196,6 +196,34 @@ export {
196
196
  type ListActionRef,
197
197
  type CreateMode,
198
198
  } from './list-primary-action'
199
+ export {
200
+ emitRecordMutation,
201
+ subscribeRecordMutations,
202
+ RECORD_MUTATION_EVENT,
203
+ type RecordMutationKind,
204
+ type RecordMutationDetail,
205
+ } from './record-mutation-events'
206
+ export {
207
+ DocumentFormDialog,
208
+ resolveDocumentForms,
209
+ type DocumentFormDialogProps,
210
+ } from './document-form-dialog'
211
+ export {
212
+ modelFromNavUrl,
213
+ capabilityForNavItem,
214
+ isNavItemAllowed,
215
+ useNavItemVisible,
216
+ type NavLeafLike,
217
+ } from './nav-permissions'
218
+ export {
219
+ CfdiStampPanel,
220
+ CfdiStampResultDialog,
221
+ extractStampResult,
222
+ type CfdiStampResult,
223
+ type CfdiStampPanelProps,
224
+ type CfdiStampResultDialogProps,
225
+ } from './cfdi-stamp-panel'
226
+ export { useRecordMutationTick } from './use-record-mutation-tick'
199
227
  export * from './addon-loader'
200
228
  export {
201
229
  PURGE_ADDON_MESSAGE,
@@ -556,6 +584,14 @@ export {
556
584
  type ActivityEvent,
557
585
  type ActivityDiffProps,
558
586
  } from './activity-diff'
587
+ export {
588
+ AuditInfo,
589
+ readAuditMeta,
590
+ resolveAuditActor,
591
+ SYSTEM_ACTOR_ID,
592
+ type AuditInfoProps,
593
+ type AuditActor,
594
+ } from './audit-info'
559
595
  export {
560
596
  RecordHistory,
561
597
  type RecordHistoryProps,
@@ -633,3 +669,8 @@ export {
633
669
 
634
670
  // Página de documento (benchmark §5/§6): cabecera, badges, acciones por estado, botones inteligentes.
635
671
  export * from './document'
672
+
673
+ // Option filter — hide options of a relation / dynamic picker from the manifest
674
+ // (`option_filter` / `optionFilter` on the field).
675
+ export { getOptionFilter, applyOptionFilter, optionPassesRule } from './option-filter'
676
+ export type { OptionFilter, OptionFilterRule } from './option-filter'