@asteby/metacore-runtime-react 47.1.0 → 49.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 (190) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/action-modal-dispatcher.d.ts +17 -3
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +80 -13
  5. package/dist/action-priority.d.ts +34 -0
  6. package/dist/action-priority.d.ts.map +1 -0
  7. package/dist/action-priority.js +60 -0
  8. package/dist/business/catalog-product-search.d.ts +32 -0
  9. package/dist/business/catalog-product-search.d.ts.map +1 -0
  10. package/dist/business/catalog-product-search.js +71 -0
  11. package/dist/business/catalog.d.ts.map +1 -1
  12. package/dist/business/catalog.js +2 -0
  13. package/dist/business/document-editor-model.d.ts +119 -0
  14. package/dist/business/document-editor-model.d.ts.map +1 -0
  15. package/dist/business/document-editor-model.js +317 -0
  16. package/dist/business/document-editor-panels.d.ts +64 -0
  17. package/dist/business/document-editor-panels.d.ts.map +1 -0
  18. package/dist/business/document-editor-panels.js +58 -0
  19. package/dist/business/document-editor.d.ts +27 -0
  20. package/dist/business/document-editor.d.ts.map +1 -0
  21. package/dist/business/document-editor.js +493 -0
  22. package/dist/business/document-lines.d.ts.map +1 -1
  23. package/dist/business/document-lines.js +5 -1
  24. package/dist/business/index.d.ts +5 -0
  25. package/dist/business/index.d.ts.map +1 -1
  26. package/dist/business/index.js +5 -0
  27. package/dist/business/line-items.d.ts +16 -1
  28. package/dist/business/line-items.d.ts.map +1 -1
  29. package/dist/business/line-items.js +14 -2
  30. package/dist/business/payment-allocator.d.ts +19 -0
  31. package/dist/business/payment-allocator.d.ts.map +1 -0
  32. package/dist/business/payment-allocator.js +31 -0
  33. package/dist/business/product-search.d.ts +4 -0
  34. package/dist/business/product-search.d.ts.map +1 -1
  35. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  36. package/dist/dialogs/dynamic-record.js +14 -7
  37. package/dist/document/document-page.d.ts.map +1 -1
  38. package/dist/document/document-page.js +8 -1
  39. package/dist/document/evaluate.d.ts.map +1 -1
  40. package/dist/document/evaluate.js +17 -1
  41. package/dist/document/index.d.ts +1 -0
  42. package/dist/document/index.d.ts.map +1 -1
  43. package/dist/document/index.js +1 -0
  44. package/dist/document/secondary-bar.d.ts +20 -0
  45. package/dist/document/secondary-bar.d.ts.map +1 -0
  46. package/dist/document/secondary-bar.js +44 -0
  47. package/dist/document/types.d.ts +8 -0
  48. package/dist/document/types.d.ts.map +1 -1
  49. package/dist/document-form-dialog.d.ts +31 -2
  50. package/dist/document-form-dialog.d.ts.map +1 -1
  51. package/dist/document-form-dialog.js +46 -11
  52. package/dist/dynamic-columns-helpers.d.ts +9 -0
  53. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  54. package/dist/dynamic-columns-helpers.js +19 -0
  55. package/dist/dynamic-columns.d.ts +9 -13
  56. package/dist/dynamic-columns.d.ts.map +1 -1
  57. package/dist/dynamic-columns.js +27 -21
  58. package/dist/dynamic-crud-page.d.ts +18 -0
  59. package/dist/dynamic-crud-page.d.ts.map +1 -1
  60. package/dist/dynamic-crud-page.js +15 -6
  61. package/dist/dynamic-form-schema.d.ts.map +1 -1
  62. package/dist/dynamic-form-schema.js +15 -3
  63. package/dist/dynamic-form.d.ts +16 -1
  64. package/dist/dynamic-form.d.ts.map +1 -1
  65. package/dist/dynamic-form.js +78 -20
  66. package/dist/dynamic-kanban.d.ts +9 -3
  67. package/dist/dynamic-kanban.d.ts.map +1 -1
  68. package/dist/dynamic-kanban.js +31 -12
  69. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  70. package/dist/dynamic-relation-helpers.js +8 -0
  71. package/dist/dynamic-relation.js +1 -1
  72. package/dist/dynamic-row-actions.d.ts +8 -0
  73. package/dist/dynamic-row-actions.d.ts.map +1 -1
  74. package/dist/dynamic-row-actions.js +12 -2
  75. package/dist/dynamic-table.d.ts.map +1 -1
  76. package/dist/dynamic-table.js +64 -63
  77. package/dist/index.d.ts +7 -2
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +19 -2
  80. package/dist/installed-addons-context.d.ts +15 -0
  81. package/dist/installed-addons-context.d.ts.map +1 -0
  82. package/dist/installed-addons-context.js +39 -0
  83. package/dist/org-runtime-context.d.ts +8 -0
  84. package/dist/org-runtime-context.d.ts.map +1 -1
  85. package/dist/org-runtime-context.js +8 -0
  86. package/dist/org-runtime-provider.d.ts +3 -1
  87. package/dist/org-runtime-provider.d.ts.map +1 -1
  88. package/dist/org-runtime-provider.js +3 -3
  89. package/dist/primitives/aging.d.ts +34 -0
  90. package/dist/primitives/aging.d.ts.map +1 -0
  91. package/dist/primitives/aging.js +48 -0
  92. package/dist/primitives/allocation.d.ts +42 -0
  93. package/dist/primitives/allocation.d.ts.map +1 -0
  94. package/dist/primitives/allocation.js +59 -0
  95. package/dist/primitives/approvals.d.ts +71 -0
  96. package/dist/primitives/approvals.d.ts.map +1 -0
  97. package/dist/primitives/approvals.js +48 -0
  98. package/dist/primitives/contributions.d.ts +93 -0
  99. package/dist/primitives/contributions.d.ts.map +1 -0
  100. package/dist/primitives/contributions.js +163 -0
  101. package/dist/primitives/document-kinds.d.ts +25 -0
  102. package/dist/primitives/document-kinds.d.ts.map +1 -0
  103. package/dist/primitives/document-kinds.js +26 -0
  104. package/dist/primitives/index.d.ts +8 -0
  105. package/dist/primitives/index.d.ts.map +1 -0
  106. package/dist/primitives/index.js +23 -0
  107. package/dist/primitives/panels.d.ts +68 -0
  108. package/dist/primitives/panels.d.ts.map +1 -0
  109. package/dist/primitives/panels.js +3 -0
  110. package/dist/primitives/use-contributions.d.ts +15 -0
  111. package/dist/primitives/use-contributions.d.ts.map +1 -0
  112. package/dist/primitives/use-contributions.js +30 -0
  113. package/dist/record-actions.d.ts +21 -0
  114. package/dist/record-actions.d.ts.map +1 -0
  115. package/dist/record-actions.js +76 -0
  116. package/dist/record-prefill-bar.d.ts +11 -0
  117. package/dist/record-prefill-bar.d.ts.map +1 -0
  118. package/dist/record-prefill-bar.js +33 -0
  119. package/dist/row-actions-menu.d.ts +32 -0
  120. package/dist/row-actions-menu.d.ts.map +1 -0
  121. package/dist/row-actions-menu.js +59 -0
  122. package/dist/slot.d.ts +4 -21
  123. package/dist/slot.d.ts.map +1 -1
  124. package/dist/slot.js +14 -36
  125. package/dist/types.d.ts +134 -0
  126. package/dist/types.d.ts.map +1 -1
  127. package/package.json +3 -3
  128. package/src/__tests__/catalog-product-search.test.ts +61 -0
  129. package/src/__tests__/document-editor-model.test.ts +221 -0
  130. package/src/__tests__/document-editor.test.tsx +340 -0
  131. package/src/__tests__/document-form-dialog.test.tsx +158 -3
  132. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  133. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  134. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  135. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  136. package/src/__tests__/line-item-totals.test.ts +28 -0
  137. package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
  138. package/src/__tests__/primitives-federated.test.tsx +156 -0
  139. package/src/__tests__/primitives.test.ts +224 -0
  140. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  141. package/src/__tests__/row-action-link-navigation.test.ts +17 -0
  142. package/src/__tests__/secondary-actions.test.tsx +261 -0
  143. package/src/action-modal-dispatcher.tsx +97 -12
  144. package/src/action-priority.ts +82 -0
  145. package/src/business/business.test.ts +1 -1
  146. package/src/business/catalog-product-search.ts +105 -0
  147. package/src/business/catalog.ts +2 -0
  148. package/src/business/document-editor-model.ts +367 -0
  149. package/src/business/document-editor-panels.tsx +164 -0
  150. package/src/business/document-editor.tsx +781 -0
  151. package/src/business/document-lines.ts +5 -1
  152. package/src/business/index.ts +5 -0
  153. package/src/business/line-items.ts +31 -2
  154. package/src/business/payment-allocator.tsx +110 -0
  155. package/src/business/product-search.ts +4 -0
  156. package/src/dialogs/dynamic-record.tsx +29 -6
  157. package/src/document/document-page.tsx +15 -0
  158. package/src/document/evaluate.ts +17 -1
  159. package/src/document/index.ts +1 -0
  160. package/src/document/secondary-bar.tsx +92 -0
  161. package/src/document/types.ts +8 -0
  162. package/src/document-form-dialog.tsx +96 -12
  163. package/src/dynamic-columns-helpers.ts +22 -0
  164. package/src/dynamic-columns.tsx +35 -62
  165. package/src/dynamic-crud-page.tsx +33 -5
  166. package/src/dynamic-form-schema.ts +15 -2
  167. package/src/dynamic-form.tsx +117 -39
  168. package/src/dynamic-kanban.tsx +34 -7
  169. package/src/dynamic-relation-helpers.ts +9 -0
  170. package/src/dynamic-relation.tsx +7 -2
  171. package/src/dynamic-row-actions.tsx +12 -2
  172. package/src/dynamic-table.tsx +3 -0
  173. package/src/index.ts +46 -1
  174. package/src/installed-addons-context.tsx +58 -0
  175. package/src/org-runtime-context.ts +10 -0
  176. package/src/org-runtime-provider.tsx +7 -2
  177. package/src/primitives/aging.ts +76 -0
  178. package/src/primitives/allocation.ts +104 -0
  179. package/src/primitives/approvals.ts +103 -0
  180. package/src/primitives/contributions.ts +255 -0
  181. package/src/primitives/document-kinds.ts +62 -0
  182. package/src/primitives/index.ts +23 -0
  183. package/src/primitives/panels.ts +59 -0
  184. package/src/primitives/use-contributions.ts +42 -0
  185. package/src/record-actions.ts +101 -0
  186. package/src/record-prefill-bar.tsx +62 -0
  187. package/src/row-action-stage-gate.test.ts +33 -0
  188. package/src/row-actions-menu.tsx +159 -0
  189. package/src/slot.tsx +18 -53
  190. package/src/types.ts +129 -0
@@ -0,0 +1,59 @@
1
+ // Contratos de los paneles del DocumentEditor. Los componentes viven en
2
+ // business/ (PartyCard, LinesGrid = DocumentLinesGrid, TotalsPanel,
3
+ // PreviewPanel, LoadFromDocument); aquí solo los tipos para que cualquier
4
+ // addon remoto los use sin depender de la implementación.
5
+ import type { LineItemsTotals } from '../business/line-items'
6
+
7
+ /** PartyCard: lo que se muestra de la contraparte. */
8
+ export interface PartySummary {
9
+ id: string
10
+ name: string
11
+ tax_id?: string
12
+ email?: string
13
+ phone?: string
14
+ currency?: string
15
+ /** Filas extra (incluye las de extensiones `fiscal_data.*`). */
16
+ rows: Array<{ key: string; label: string; value: string; tone?: 'ok' | 'warn' | 'err' }>
17
+ credit?: { limit: number; balance: number; available: number; overdue: number; status?: 'active' | 'suspended' | 'delinquent'; hold_reason?: string }
18
+ }
19
+
20
+ /** TotalsPanel: desglose; los impuestos por tasa los aporta el addon fiscal o el core. */
21
+ export interface TotalsBreakdown extends LineItemsTotals {
22
+ taxes: Array<{ label: string; base: number; amount: number }>
23
+ /** Renglones extra (aplicaciones, saldo a favor, sin aplicar…). */
24
+ extra?: Array<{ label: string; amount: number; emphasis?: boolean }>
25
+ amount_in_words?: string
26
+ }
27
+
28
+ /** PreviewPanel: resultado de la acción de vista previa (`analyze: true`, no escribe). */
29
+ export interface PreviewResult {
30
+ pdf_base64?: string
31
+ xml?: string
32
+ html?: string
33
+ checks: ValidationCheck[]
34
+ }
35
+
36
+ /** ValidationChecklist: una revisión (local o del servidor). */
37
+ export interface ValidationCheck {
38
+ key: string
39
+ severity: 'ok' | 'warning' | 'error'
40
+ message: string
41
+ /** Campo a enfocar al hacer clic. */
42
+ field?: string
43
+ /** Si es error y `approval_policy` está, el botón ofrece «Pedir autorización». */
44
+ approval_policy?: string
45
+ }
46
+
47
+ /** LoadFromDocument: candidato a cargar (venta, cotización, ticket, OT, factura origen de NC). */
48
+ export interface LoadCandidate {
49
+ source: string // DocumentFormSource.key
50
+ id: string
51
+ label: string
52
+ party_id?: string
53
+ total?: number
54
+ state?: string
55
+ }
56
+
57
+ export function blockingChecks(checks: readonly ValidationCheck[]): ValidationCheck[] {
58
+ return checks.filter(c => c.severity === 'error' && !c.approval_policy)
59
+ }
@@ -0,0 +1,42 @@
1
+ // Hooks de React sobre el registro de contribuciones (contributions.ts).
2
+ // La lógica vive en las funciones puras; aquí solo se conecta con
3
+ // useInstalledAddons y con la suscripción para re-pintar cuando un remote
4
+ // registra o se desmonta.
5
+ import { useMemo, useSyncExternalStore } from 'react'
6
+ import { useInstalledAddons, type InstalledAddonsValue } from '../installed-addons-context'
7
+ import {
8
+ contributionsVersion,
9
+ resolveContributions,
10
+ subscribeContributions,
11
+ type DocumentEditorContext,
12
+ type DocumentRegion,
13
+ type IsAddonInstalled,
14
+ type ResolvedDocumentContribution,
15
+ } from './contributions'
16
+
17
+ /**
18
+ * Predicado de instalación a partir del InstalledAddonsProvider. Sin provider
19
+ * devuelve siempre true: el host solo carga remotes de addons instalados, así
20
+ * que si la contribución existe, su addon está (el servidor revalida igual).
21
+ */
22
+ export function installedPredicate(value: InstalledAddonsValue | null): IsAddonInstalled {
23
+ return value ? (key: string) => value.addons.has(key) : () => true
24
+ }
25
+
26
+ /** `installedPredicate(useInstalledAddons())`, estable entre renders. */
27
+ export function useIsAddonInstalled(): IsAddonInstalled {
28
+ const value = useInstalledAddons()
29
+ return useMemo(() => installedPredicate(value), [value])
30
+ }
31
+
32
+ /** Re-pinta en cada register/dispose de contribuciones o modales federados. */
33
+ export function useContributionsVersion(): number {
34
+ return useSyncExternalStore(subscribeContributions, contributionsVersion, contributionsVersion)
35
+ }
36
+
37
+ /** Contribuciones aplicables a `region` del documento `ctx` (ya filtradas y ordenadas). */
38
+ export function useDocumentContributions(ctx: DocumentEditorContext, region: DocumentRegion): ResolvedDocumentContribution<any>[] {
39
+ useContributionsVersion()
40
+ const isInstalled = useIsAddonInstalled()
41
+ return resolveContributions(ctx, region, isInstalled)
42
+ }
@@ -0,0 +1,101 @@
1
+ // Acciones primarias / secundarias de un registro.
2
+ //
3
+ // Una pantalla de registro tiene UNA acción destacada (la primaria) y el resto
4
+ // en su lugar: lo de compartir, imprimir, correo, chat, descargar PDF/XML es
5
+ // secundario — va al pie del documento (DocumentSecondaryBar) y al «Más…» del
6
+ // menú de fila, nunca compitiendo con la primaria.
7
+ //
8
+ // Dos fuentes, una misma clasificación:
9
+ // - acciones del modelo (metadata `actions[]`): manifest v3 `priority`, o la
10
+ // convención por clave si no la declara;
11
+ // - acciones aportadas por addons o por el host (registerRecordAction del
12
+ // store canónico de @asteby/metacore-sdk), filtradas por su proveedor
13
+ // instalado y activo (InstalledAddonsProvider).
14
+ import { useMemo, useSyncExternalStore } from 'react'
15
+ import {
16
+ listRecordActions,
17
+ subscribeActionComponents,
18
+ type ActionPriority,
19
+ type OwnedRecordAction,
20
+ type RecordActionContext,
21
+ type RecordActionContribution,
22
+ } from '@asteby/metacore-sdk'
23
+ import { useInstalledAddons, type InstalledAddonsValue } from './installed-addons-context'
24
+
25
+ export * from './action-priority'
26
+ export type { RecordActionContext, RecordActionContribution }
27
+
28
+ export interface ResolvedRecordAction {
29
+ /** Id de la contribución (o `<id>:<item>` si viene de `expand`). */
30
+ key: string
31
+ label: string
32
+ icon?: string
33
+ priority: ActionPriority
34
+ owner?: string
35
+ run?: () => unknown
36
+ render?: RecordActionContribution['render']
37
+ }
38
+
39
+ function modelMatches(models: RecordActionContribution['models'], model: string): boolean {
40
+ if (!models || models === '*') return true
41
+ const m = model.toLowerCase()
42
+ return models.some((x) => {
43
+ const k = x.toLowerCase()
44
+ return k === m || k.endsWith(`.${m}`) || m.endsWith(`.${k}`)
45
+ })
46
+ }
47
+
48
+ /** ¿El proveedor de la contribución está instalado y activo? Sin provider: sí. */
49
+ export function isRecordActionProviderActive(row: OwnedRecordAction, installed: InstalledAddonsValue | null): boolean {
50
+ if (!installed) return true
51
+ const req = row.contribution.requires
52
+ if (req?.addon && !installed.addons.has(req.addon)) return false
53
+ if (req?.capability && !installed.capabilities.has(req.capability)) return false
54
+ if (!req?.addon && !req?.capability && row.owner && !installed.addons.has(row.owner)) return false
55
+ return true
56
+ }
57
+
58
+ /** Contribuciones aplicables al registro, filtradas por proveedor y ordenadas. Pura. */
59
+ export function resolveRecordActions(
60
+ rows: readonly OwnedRecordAction[],
61
+ ctx: RecordActionContext,
62
+ installed: InstalledAddonsValue | null,
63
+ ): ResolvedRecordAction[] {
64
+ const out: ResolvedRecordAction[] = []
65
+ const sorted = [...rows].sort(
66
+ (a, b) => (a.contribution.order ?? 0) - (b.contribution.order ?? 0) || a.contribution.id.localeCompare(b.contribution.id),
67
+ )
68
+ for (const row of sorted) {
69
+ const c = row.contribution
70
+ if (!modelMatches(c.models, ctx.model)) continue
71
+ if (!isRecordActionProviderActive(row, installed)) continue
72
+ if (c.when && !c.when(ctx)) continue
73
+ const priority = c.priority ?? 'secondary'
74
+ if (c.expand) {
75
+ for (const item of c.expand(ctx)) {
76
+ out.push({ key: `${c.id}:${item.key}`, label: item.label, icon: item.icon ?? c.icon, priority, owner: row.owner, run: item.run })
77
+ }
78
+ continue
79
+ }
80
+ out.push({
81
+ key: c.id,
82
+ label: c.label ?? c.id,
83
+ icon: c.icon,
84
+ priority,
85
+ owner: row.owner,
86
+ run: c.run ? () => c.run!(ctx) : undefined,
87
+ render: c.render,
88
+ })
89
+ }
90
+ return out
91
+ }
92
+
93
+ /** Acciones aportadas al registro (`model`, `record`), reactivas a register/unbind. */
94
+ export function useRecordActions(model: string, record: Record<string, unknown> | null | undefined): ResolvedRecordAction[] {
95
+ const rows = useSyncExternalStore(subscribeActionComponents, listRecordActions, listRecordActions)
96
+ const installed = useInstalledAddons()
97
+ return useMemo(
98
+ () => (record ? resolveRecordActions(rows, { model, record }, installed) : []),
99
+ [rows, model, record, installed],
100
+ )
101
+ }
@@ -0,0 +1,62 @@
1
+ // RecordPrefillBar — pinta los ayudantes de captura (record-prefill-registry de
2
+ // @asteby/metacore-sdk) arriba del formulario genérico de un modelo.
3
+ //
4
+ // El formulario no conoce a los addons: sólo pregunta "¿quién aporta prefill
5
+ // para <model> en <mode>?". Hoy: fiscal_mexico → «Cargar constancia» en
6
+ // Customer/Supplier. Mañana: un vertical restaurante aporta «Importar menú» en
7
+ // Product, un vertical ferretería «Leer código de proveedor», etc.
8
+ //
9
+ // Seguridad del patch: sólo se aplican claves que el formulario declara
10
+ // (allowedKeys) — un addon no puede inyectar columnas ocultas (organization_id,
11
+ // created_by…) por esta vía; el backend sigue validando como siempre.
12
+ import { useCallback, useSyncExternalStore } from 'react'
13
+ import {
14
+ getRecordPrefills,
15
+ recordPrefillsVersion,
16
+ subscribeRecordPrefills,
17
+ type RecordFormMode,
18
+ } from '@asteby/metacore-sdk'
19
+ import { useCan } from './permissions-context'
20
+
21
+ export interface RecordPrefillBarProps {
22
+ model: string
23
+ mode: RecordFormMode
24
+ recordId?: string | null
25
+ values: Record<string, any>
26
+ allowedKeys: ReadonlySet<string>
27
+ onApply: (patch: Record<string, any>) => void
28
+ }
29
+
30
+ export function RecordPrefillBar({ model, mode, recordId, values, allowedKeys, onApply }: RecordPrefillBarProps) {
31
+ // Re-render cuando un remote registra/des-registra (carga tardía o unbind).
32
+ useSyncExternalStore(subscribeRecordPrefills, recordPrefillsVersion, recordPrefillsVersion)
33
+ const can = useCan()
34
+ const items = getRecordPrefills(model, mode).filter(c => !c.permission || can(c.permission))
35
+
36
+ const applyValues = useCallback(
37
+ (patch: Record<string, unknown>) => {
38
+ const safe: Record<string, any> = {}
39
+ for (const [k, v] of Object.entries(patch ?? {})) {
40
+ if (allowedKeys.has(k)) safe[k] = v
41
+ }
42
+ if (Object.keys(safe).length > 0) onApply(safe)
43
+ },
44
+ [allowedKeys, onApply],
45
+ )
46
+
47
+ if (items.length === 0) return null
48
+ return (
49
+ <div className="flex flex-wrap items-center gap-2" data-aby-record-prefill="">
50
+ {items.map(({ id, component: C }) => (
51
+ <C
52
+ key={id}
53
+ model={model}
54
+ mode={mode}
55
+ recordId={recordId ?? undefined}
56
+ values={values}
57
+ applyValues={applyValues}
58
+ />
59
+ ))}
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isActionAllowedForRowState, isRowActionVisible, lifecycleStageField } from './dynamic-columns'
3
+
4
+ // Pitsline taller: WorkOrder has a stage machine (stage_field "stage") and its
5
+ // row actions declare requiresState with STAGE keys. The kernel gates on the
6
+ // stage column; the UI must do the same or every action is hidden (the OT
7
+ // row/kanban menu showed only Ver/Editar/Eliminar).
8
+ describe('requiresState gate with a stage machine', () => {
9
+ const process = { key: 'process', requiresState: ['reception', 'diagnosis', 'in_process'] }
10
+ const row = { id: '1', status: 'open', stage: 'diagnosis' }
11
+
12
+ it('reads the stage field when the model declares one', () => {
13
+ expect(isActionAllowedForRowState(process, row, 'stage')).toBe(true)
14
+ expect(isRowActionVisible(process, row, 'stage')).toBe(true)
15
+ expect(isActionAllowedForRowState(process, { ...row, stage: 'delivered' }, 'stage')).toBe(false)
16
+ })
17
+
18
+ it('keeps the historical status/state lookup without a stage field', () => {
19
+ expect(isActionAllowedForRowState(process, row)).toBe(false)
20
+ expect(isActionAllowedForRowState({ requiresState: ['open'] }, row)).toBe(true)
21
+ })
22
+
23
+ it('falls back to status when the stage value is blank', () => {
24
+ expect(isActionAllowedForRowState({ requiresState: ['open'] }, { status: 'open', stage: '' }, 'stage')).toBe(true)
25
+ })
26
+
27
+ it('resolves the stage field from metadata', () => {
28
+ expect(lifecycleStageField({ stage_field: 'stage' })).toBe('stage')
29
+ expect(lifecycleStageField({ stages: [{ key: 'a' }], group_by: 'stage' })).toBe('stage')
30
+ expect(lifecycleStageField({ group_by: 'status' })).toBeUndefined()
31
+ expect(lifecycleStageField(undefined)).toBeUndefined()
32
+ })
33
+ })
@@ -0,0 +1,159 @@
1
+ // RowActionsMenu — el «…» de una fila: la acción primaria (si hay) arriba y
2
+ // destacada, las principales debajo y las secundarias (compartir, imprimir,
3
+ // correo, chat, PDF/XML y lo que aporten addons vía registerRecordAction)
4
+ // agrupadas en un «Más…» discreto. Una acción por capacidad sin proveedor
5
+ // activo, o una contribución cuyo addon no está instalado, no aparece.
6
+ //
7
+ // DynamicTable lo usa por defecto (getDynamicColumns) y los hosts que arman su
8
+ // propia columna de acciones lo reutilizan en vez de duplicar el menú.
9
+ import * as React from 'react'
10
+ import { useTranslation } from 'react-i18next'
11
+ import { MoreHorizontal } from 'lucide-react'
12
+ // Root entry on purpose (not '/primitives'): see dropdown-menu-entry.test.ts.
13
+ import {
14
+ Button,
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuSeparator,
19
+ DropdownMenuSub,
20
+ DropdownMenuSubContent,
21
+ DropdownMenuSubTrigger,
22
+ DropdownMenuTrigger,
23
+ } from '@asteby/metacore-ui'
24
+ import { DynamicIcon } from './dynamic-icon'
25
+ import { translateMetadataLabel } from './dynamic-columns-helpers'
26
+ import { RequiresAddonLock, resolveRequiresAddon, useRequiresAddonLabel } from './requires-addon'
27
+ import { useInstalledAddons } from './installed-addons-context'
28
+ import { isActionProviderActive, splitActionsByPriority, useRecordActions, type ResolvedRecordAction } from './record-actions'
29
+
30
+ /**
31
+ * One entry of the row "…" menu. An action gated by a missing optional addon
32
+ * (`requiresAddon`) stays listed with a lock + tooltip; the click still goes to
33
+ * `onAction`, whose shared handler (useDynamicRowActions) opens the
34
+ * requires-addon dialog instead of executing.
35
+ */
36
+ export function RowActionMenuItem({
37
+ action,
38
+ label,
39
+ onSelect,
40
+ className,
41
+ }: {
42
+ action: any
43
+ label: React.ReactNode
44
+ onSelect: (e: React.MouseEvent) => void
45
+ className?: string
46
+ }) {
47
+ const requirement = resolveRequiresAddon(action)
48
+ const requiresAddonLabel = useRequiresAddonLabel()
49
+ return (
50
+ <DropdownMenuItem
51
+ onClick={onSelect}
52
+ title={requirement ? requiresAddonLabel(requirement) : undefined}
53
+ data-requires-addon={requirement?.key}
54
+ data-action={action.key}
55
+ className={className}
56
+ >
57
+ <DynamicIcon name={action.icon || 'Zap'} className="mr-2 h-4 w-4" />
58
+ {label}
59
+ {requirement && <RequiresAddonLock requirement={requirement} />}
60
+ </DropdownMenuItem>
61
+ )
62
+ }
63
+
64
+ export interface RowActionsMenuProps {
65
+ /** Acciones de metadata ya filtradas por estado/condición de la fila. */
66
+ actions: any[]
67
+ row: Record<string, any>
68
+ /** Modelo de la fila: resuelve las acciones aportadas por addons. */
69
+ model?: string
70
+ onAction?: (actionKey: string, row: any) => void
71
+ /** Sustituye el ícono del disparador (p. ej. un spinner mientras imprime). */
72
+ triggerIcon?: React.ReactNode
73
+ disabled?: boolean
74
+ }
75
+
76
+ /**
77
+ * Modelo de las filas que pinta la tabla. DynamicTable lo provee: la factoría
78
+ * de columnas (getDynamicColumns) no recibe el modelo y el menú lo necesita
79
+ * para resolver las acciones aportadas por addons.
80
+ */
81
+ export const RowActionsModelContext = React.createContext<string | undefined>(undefined)
82
+
83
+ type MenuEntry =
84
+ | { kind: 'action'; action: any }
85
+ | { kind: 'contribution'; item: ResolvedRecordAction }
86
+
87
+ export function RowActionsMenu({ actions, row, model, onAction, triggerIcon, disabled }: RowActionsMenuProps) {
88
+ const { t } = useTranslation()
89
+ const installed = useInstalledAddons()
90
+ const ctxModel = React.useContext(RowActionsModelContext)
91
+ const recordModel = model ?? ctxModel
92
+ const contributed = useRecordActions(recordModel ?? '', recordModel ? row : null).filter((c) => !!c.run)
93
+
94
+ const available = actions.filter((a) => isActionProviderActive(a, installed))
95
+ const { primary, main, secondary } = splitActionsByPriority(available)
96
+ const mainEntries: MenuEntry[] = main.map((action) => ({ kind: 'action', action }))
97
+ const secondaryEntries: MenuEntry[] = secondary.map((action) => ({ kind: 'action', action }))
98
+ for (const item of contributed) {
99
+ ;(item.priority === 'primary' ? mainEntries : secondaryEntries).push({ kind: 'contribution', item })
100
+ }
101
+
102
+ if (!primary && mainEntries.length === 0 && secondaryEntries.length === 0) return null
103
+
104
+ const renderEntry = (e: MenuEntry) =>
105
+ e.kind === 'action' ? (
106
+ <RowActionMenuItem
107
+ key={e.action.key}
108
+ action={e.action}
109
+ label={translateMetadataLabel(e.action.label, t)}
110
+ onSelect={() => onAction?.(e.action.key, row)}
111
+ />
112
+ ) : (
113
+ <DropdownMenuItem key={e.item.key} data-action={e.item.key} onClick={() => void e.item.run?.()}>
114
+ <DynamicIcon name={e.item.icon || 'Share2'} className="mr-2 h-4 w-4" />
115
+ {t(e.item.label, { defaultValue: e.item.label })}
116
+ </DropdownMenuItem>
117
+ )
118
+
119
+ const hasMain = !!primary || mainEntries.length > 0
120
+ const moreLabel = t('datatable.more_actions', { defaultValue: 'Más…' })
121
+
122
+ return (
123
+ <div className="flex items-center justify-end">
124
+ <DropdownMenu>
125
+ <DropdownMenuTrigger asChild>
126
+ <Button variant="ghost" className="h-8 w-8 p-0" disabled={disabled} onClick={(e: React.MouseEvent) => e.stopPropagation()}>
127
+ <span className="sr-only">{t('datatable.open_menu', { defaultValue: 'Abrir menú' })}</span>
128
+ {triggerIcon ?? <MoreHorizontal className="h-4 w-4" />}
129
+ </Button>
130
+ </DropdownMenuTrigger>
131
+ <DropdownMenuContent align="end" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
132
+ {primary ? (
133
+ <RowActionMenuItem
134
+ action={primary}
135
+ label={translateMetadataLabel(primary.label, t)}
136
+ onSelect={() => onAction?.(primary.key, row)}
137
+ className="font-semibold"
138
+ />
139
+ ) : null}
140
+ {mainEntries.map(renderEntry)}
141
+ {secondaryEntries.length > 0 && hasMain ? (
142
+ <>
143
+ <DropdownMenuSeparator />
144
+ <DropdownMenuSub>
145
+ <DropdownMenuSubTrigger data-row-more="" className="text-muted-foreground">
146
+ {moreLabel}
147
+ </DropdownMenuSubTrigger>
148
+ <DropdownMenuSubContent data-row-secondary="">{secondaryEntries.map(renderEntry)}</DropdownMenuSubContent>
149
+ </DropdownMenuSub>
150
+ </>
151
+ ) : (
152
+ // Sin principales no hay con qué competir: lista plana.
153
+ secondaryEntries.map(renderEntry)
154
+ )}
155
+ </DropdownMenuContent>
156
+ </DropdownMenu>
157
+ </div>
158
+ )
159
+ }
package/src/slot.tsx CHANGED
@@ -1,56 +1,19 @@
1
- // Slot / SlotRegistry — named extension points the host renders and addons
2
- // contribute to at register() time. Keyed by a slot id (e.g. "dashboard.widgets",
1
+ // Slot — named extension points the host renders and addons contribute to at
2
+ // register() time. Keyed by a slot id (e.g. "dashboard.widgets",
3
3
  // "invoice.footer"). Each contribution is an arbitrary React element factory.
4
+ //
5
+ // El store es `slotStore` de @asteby/metacore-sdk: el mismo donde escribe
6
+ // `api.registry.registerSlot` (AddonAPI) y `registerDocumentContribution`, así
7
+ // que hay una sola lista por slot. Una entrada con addon dueño (`owner`) que
8
+ // consta como NO instalado (InstalledAddonsProvider) no se pinta.
4
9
  import React, { useSyncExternalStore } from 'react'
10
+ import { slotStore, type SlotComponent, type SlotEntry } from '@asteby/metacore-sdk'
11
+ import { useInstalledAddons } from './installed-addons-context'
5
12
 
6
- export type SlotComponent<P = any> = React.ComponentType<P>
13
+ export type { SlotComponent, SlotEntry }
7
14
 
8
- interface SlotEntry {
9
- id: string
10
- component: SlotComponent
11
- priority: number
12
- source?: string
13
- }
14
-
15
- type Listener = () => void
16
-
17
- class SlotRegistryImpl {
18
- private slots = new Map<string, SlotEntry[]>()
19
- private listeners = new Set<Listener>()
20
-
21
- register(slotId: string, component: SlotComponent, opts?: { priority?: number; source?: string }): () => void {
22
- const entry: SlotEntry = { id: slotId, component, priority: opts?.priority ?? 0, source: opts?.source }
23
- const list = this.slots.get(slotId) ?? []
24
- list.push(entry)
25
- // Higher priority renders first — canonical across SDK and runtime-react.
26
- // See docs/slot-priority.md.
27
- list.sort((a, b) => b.priority - a.priority)
28
- this.slots.set(slotId, list)
29
- this.emit()
30
- return () => {
31
- const arr = this.slots.get(slotId)
32
- if (!arr) return
33
- const idx = arr.indexOf(entry)
34
- if (idx >= 0) {
35
- arr.splice(idx, 1)
36
- this.emit()
37
- }
38
- }
39
- }
40
-
41
- get(slotId: string): SlotEntry[] {
42
- return this.slots.get(slotId) ?? []
43
- }
44
-
45
- subscribe(listener: Listener): () => void {
46
- this.listeners.add(listener)
47
- return () => { this.listeners.delete(listener) }
48
- }
49
-
50
- private emit() { this.listeners.forEach(l => l()) }
51
- }
52
-
53
- export const slotRegistry = new SlotRegistryImpl()
15
+ /** Alias histórico del store canónico (`slotRegistry.register/get/subscribe`). */
16
+ export const slotRegistry = slotStore
54
17
 
55
18
  export interface SlotProps {
56
19
  /** Slot id. */
@@ -62,11 +25,13 @@ export interface SlotProps {
62
25
  }
63
26
 
64
27
  export function Slot({ name, props, fallback = null }: SlotProps) {
65
- const entries = useSyncExternalStore(
66
- (cb) => slotRegistry.subscribe(cb),
67
- () => slotRegistry.get(name),
68
- () => slotRegistry.get(name),
28
+ const all = useSyncExternalStore(
29
+ (cb) => slotStore.subscribe(cb),
30
+ () => slotStore.get(name),
31
+ () => slotStore.get(name),
69
32
  )
33
+ const installed = useInstalledAddons()
34
+ const entries = installed ? all.filter((e) => !e.owner || installed.addons.has(e.owner)) : all
70
35
  if (entries.length === 0) return <>{fallback}</>
71
36
  return (
72
37
  <>