@asteby/metacore-runtime-react 48.0.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 (90) hide show
  1. package/CHANGELOG.md +22 -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 +57 -10
  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 +7 -0
  9. package/dist/business/catalog-product-search.d.ts.map +1 -1
  10. package/dist/business/catalog-product-search.js +14 -0
  11. package/dist/business/document-editor-model.d.ts +14 -2
  12. package/dist/business/document-editor-model.d.ts.map +1 -1
  13. package/dist/business/document-editor-model.js +42 -4
  14. package/dist/business/document-editor.d.ts.map +1 -1
  15. package/dist/business/document-editor.js +65 -17
  16. package/dist/business/line-items.d.ts +9 -1
  17. package/dist/business/line-items.d.ts.map +1 -1
  18. package/dist/business/line-items.js +8 -1
  19. package/dist/document/document-page.d.ts.map +1 -1
  20. package/dist/document/document-page.js +8 -1
  21. package/dist/document/evaluate.d.ts.map +1 -1
  22. package/dist/document/evaluate.js +17 -1
  23. package/dist/document/index.d.ts +1 -0
  24. package/dist/document/index.d.ts.map +1 -1
  25. package/dist/document/index.js +1 -0
  26. package/dist/document/secondary-bar.d.ts +20 -0
  27. package/dist/document/secondary-bar.d.ts.map +1 -0
  28. package/dist/document/secondary-bar.js +44 -0
  29. package/dist/document/types.d.ts +8 -0
  30. package/dist/document/types.d.ts.map +1 -1
  31. package/dist/document-form-dialog.d.ts.map +1 -1
  32. package/dist/document-form-dialog.js +7 -4
  33. package/dist/dynamic-columns.d.ts +1 -11
  34. package/dist/dynamic-columns.d.ts.map +1 -1
  35. package/dist/dynamic-columns.js +7 -17
  36. package/dist/dynamic-crud-page.d.ts +12 -0
  37. package/dist/dynamic-crud-page.d.ts.map +1 -1
  38. package/dist/dynamic-crud-page.js +3 -3
  39. package/dist/dynamic-row-actions.d.ts +8 -0
  40. package/dist/dynamic-row-actions.d.ts.map +1 -1
  41. package/dist/dynamic-row-actions.js +12 -2
  42. package/dist/dynamic-table.d.ts.map +1 -1
  43. package/dist/dynamic-table.js +64 -63
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +6 -0
  47. package/dist/primitives/contributions.d.ts +14 -3
  48. package/dist/primitives/contributions.d.ts.map +1 -1
  49. package/dist/primitives/contributions.js +50 -13
  50. package/dist/record-actions.d.ts +21 -0
  51. package/dist/record-actions.d.ts.map +1 -0
  52. package/dist/record-actions.js +76 -0
  53. package/dist/row-actions-menu.d.ts +32 -0
  54. package/dist/row-actions-menu.d.ts.map +1 -0
  55. package/dist/row-actions-menu.js +59 -0
  56. package/dist/slot.d.ts +4 -21
  57. package/dist/slot.d.ts.map +1 -1
  58. package/dist/slot.js +14 -36
  59. package/dist/types.d.ts +32 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +3 -3
  62. package/src/__tests__/document-editor-model.test.ts +55 -1
  63. package/src/__tests__/document-editor.test.tsx +80 -1
  64. package/src/__tests__/document-form-dialog.test.tsx +40 -0
  65. package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
  66. package/src/__tests__/row-action-link-navigation.test.ts +17 -0
  67. package/src/__tests__/secondary-actions.test.tsx +261 -0
  68. package/src/action-modal-dispatcher.tsx +58 -16
  69. package/src/action-priority.ts +82 -0
  70. package/src/business/business.test.ts +1 -1
  71. package/src/business/catalog-product-search.ts +18 -0
  72. package/src/business/document-editor-model.ts +48 -6
  73. package/src/business/document-editor.tsx +72 -18
  74. package/src/business/line-items.ts +16 -1
  75. package/src/document/document-page.tsx +15 -0
  76. package/src/document/evaluate.ts +17 -1
  77. package/src/document/index.ts +1 -0
  78. package/src/document/secondary-bar.tsx +92 -0
  79. package/src/document/types.ts +8 -0
  80. package/src/document-form-dialog.tsx +8 -4
  81. package/src/dynamic-columns.tsx +13 -57
  82. package/src/dynamic-crud-page.tsx +11 -2
  83. package/src/dynamic-row-actions.tsx +12 -2
  84. package/src/dynamic-table.tsx +3 -0
  85. package/src/index.ts +25 -0
  86. package/src/primitives/contributions.ts +59 -12
  87. package/src/record-actions.ts +101 -0
  88. package/src/row-actions-menu.tsx +159 -0
  89. package/src/slot.tsx +18 -53
  90. package/src/types.ts +28 -0
@@ -40,6 +40,16 @@ import { DynamicRecordDialog } from './dialogs/dynamic-record'
40
40
  import { RequiresAddonDialog, resolveRequiresAddon } from './requires-addon'
41
41
  import type { TableMetadata, ActionMetadata, RequiresAddon } from './types'
42
42
 
43
+ /**
44
+ * Destino de una acción link. La query (`?create=…&from_id=…`) va como `search`:
45
+ * metida en `to` el router la tomaba como parte del path y la ruta no casaba.
46
+ */
47
+ export function linkNavigation(url: string): { to: string; search?: Record<string, string> } {
48
+ const i = url.indexOf('?')
49
+ if (i < 0) return { to: url }
50
+ return { to: url.slice(0, i), search: Object.fromEntries(new URLSearchParams(url.slice(i + 1))) }
51
+ }
52
+
43
53
  export interface UseDynamicRowActionsParams {
44
54
  /** Model key as registered on the backend (e.g. "issue"). */
45
55
  model: string
@@ -128,8 +138,8 @@ export function useDynamicRowActions({
128
138
  }
129
139
  const linkDef = metadata?.actions?.find((a) => a.key === action && a.type === 'link')
130
140
  if (linkDef?.linkUrl) {
131
- const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_: string, field: string) => String(row[field] ?? ''))
132
- navigate({ to: url })
141
+ const url = linkDef.linkUrl.replace(/\{(\w+)\}/g, (_: string, field: string) => encodeURIComponent(String(row[field] ?? '')))
142
+ navigate(linkNavigation(url) as any)
133
143
  return
134
144
  }
135
145
  const actionDef = metadata?.actions?.find((a) => a.key === action)
@@ -80,6 +80,7 @@ import {
80
80
  resolveVirtualizeThreshold,
81
81
  } from './table-virtualization'
82
82
  import { OptionsContext } from './options-context'
83
+ import { RowActionsModelContext } from './row-actions-menu'
83
84
  import type { TableMetadata, ApiResponse, ColumnDefinition } from './types'
84
85
  import { getSearchableColumnKeys } from './column-visibility'
85
86
  import { visibleRelationInclude } from './list-include'
@@ -1455,6 +1456,7 @@ export function DynamicTable({
1455
1456
  }
1456
1457
 
1457
1458
  return (
1459
+ <RowActionsModelContext.Provider value={model}>
1458
1460
  <OptionsContext.Provider value={{ optionsMap }}>
1459
1461
  <div className='flex flex-col h-full min-h-0 w-full'>
1460
1462
  <div className='pb-4 shrink-0'>
@@ -1899,6 +1901,7 @@ export function DynamicTable({
1899
1901
  </Button>
1900
1902
  </DataTableBulkActions>
1901
1903
  </OptionsContext.Provider>
1904
+ </RowActionsModelContext.Provider>
1902
1905
  )
1903
1906
  }
1904
1907
 
package/src/index.ts CHANGED
@@ -694,3 +694,28 @@ export {
694
694
  // Ayudantes de captura (registerRecordPrefill de @asteby/metacore-sdk) arriba
695
695
  // del formulario genérico de un modelo; DynamicRecordDialog ya lo monta.
696
696
  export { RecordPrefillBar, type RecordPrefillBarProps } from './record-prefill-bar'
697
+
698
+ // Acciones primarias / secundarias de un registro: una sola primaria
699
+ // destacada; compartir/imprimir/correo/chat al «Más…» de la fila y al pie del
700
+ // documento. Las aportadas por addons (registerRecordAction del SDK) sólo con
701
+ // su proveedor instalado y activo.
702
+ export {
703
+ RowActionsMenu,
704
+ RowActionMenuItem,
705
+ RowActionsModelContext,
706
+ type RowActionsMenuProps,
707
+ } from './row-actions-menu'
708
+ export {
709
+ classifyActionPriority,
710
+ isActionProviderActive,
711
+ isRecordActionProviderActive,
712
+ resolveRecordActions,
713
+ splitActionsByPriority,
714
+ useRecordActions,
715
+ type ActionPriority,
716
+ type PrioritizableAction,
717
+ type RecordActionContext,
718
+ type RecordActionContribution,
719
+ type ResolvedRecordAction,
720
+ type SplitActions,
721
+ } from './record-actions'
@@ -12,6 +12,12 @@
12
12
  // `register*` devuelve un disposer; el AddonLoader lo llama al desmontar o
13
13
  // recargar el remote y la contribución desaparece sin recargar la página.
14
14
  import { createElement, lazy, Suspense, type ComponentType } from 'react'
15
+ import {
16
+ getModalComponent,
17
+ registerModalComponent,
18
+ subscribeActionComponents,
19
+ type ModalComponentEntry,
20
+ } from '@asteby/metacore-sdk'
15
21
  import { slotRegistry } from '../slot'
16
22
 
17
23
  /** Regiones del DocumentEditor/DocumentPage donde se puede contribuir. */
@@ -76,6 +82,11 @@ function notify() {
76
82
  for (const l of [...listeners]) l()
77
83
  }
78
84
 
85
+ // Los modales viven en el store canónico del SDK (action-registry): cualquier
86
+ // cambio ahí (Registry.registerModal, registerModalComponent, unbind) también
87
+ // re-pinta a quien escucha contribuciones.
88
+ subscribeActionComponents(notify)
89
+
79
90
  /** Suscripción para useSyncExternalStore (contribuciones y modales). */
80
91
  export function subscribeContributions(listener: () => void): () => void {
81
92
  listeners.add(listener)
@@ -114,6 +125,7 @@ export function registerDocumentContribution<P = DocumentContributionProps>(c: D
114
125
  slotRegistry.register(slotIdFor(k, c.region), resolved.component as ComponentType, {
115
126
  priority: c.priority ?? 0,
116
127
  source: source ?? c.requiresAddon ?? c.id,
128
+ owner: c.requiresAddon,
117
129
  }),
118
130
  )
119
131
  let disposed = false
@@ -157,6 +169,10 @@ export function resolveContributions(
157
169
  // declarativa: el addon registra la clave al cargar. Si el addon dueño no está
158
170
  // instalado, resolveFederatedModal devuelve null de inmediato y el dispatcher
159
171
  // usa el formulario genérico de la acción en vez de colgarse esperando.
172
+ //
173
+ // No hay mapa propio: es un alias del store canónico de modales por slug de
174
+ // @asteby/metacore-sdk (registerModalComponent), el mismo donde escribe
175
+ // `api.registry.registerModal` de AddonAPI.
160
176
 
161
177
  export interface FederatedModal<P = any> {
162
178
  /** Clave `<addon>.<nombre>`, la misma que `modal` en el manifest. */
@@ -164,33 +180,63 @@ export interface FederatedModal<P = any> {
164
180
  addon: string
165
181
  /** El dispatcher le pasa ActionModalProps (open, onOpenChange, action, model, record…). */
166
182
  load: () => Promise<{ default: ComponentType<P> }>
183
+ /** Presente cuando el modal se registró ya cargado (`api.registry.registerModal`). */
184
+ component?: ComponentType<P>
167
185
  }
168
186
 
169
- const modals = new Map<string, FederatedModal>()
187
+ /** Vista FederatedModal de cada entrada del store (identidad estable por entrada). */
188
+ const views = new WeakMap<ModalComponentEntry, FederatedModal>()
170
189
  const modalComponents = new WeakMap<FederatedModal, ComponentType<any>>()
190
+ /** Disposers de lo registrado por este alias (sólo __resetContributions los usa). */
191
+ const aliasDisposers = new Set<() => void>()
171
192
 
193
+ /**
194
+ * @deprecated Usa `registerModalComponent({ slug, load, owner })` de
195
+ * `@asteby/metacore-sdk` o `api.registry.registerModal` desde el plugin. Se
196
+ * mantiene como alias compatible: escribe en el mismo store.
197
+ */
172
198
  export function registerFederatedModal(m: FederatedModal): () => void {
173
- modals.set(m.key, m)
174
- notify()
199
+ const dispose = registerModalComponent({ slug: m.key, owner: m.addon, load: m.load, component: m.component })
200
+ const stored = getModalComponent(m.key)
201
+ if (stored) views.set(stored, m)
202
+ aliasDisposers.add(dispose)
175
203
  return () => {
176
- if (modals.get(m.key) === m) {
177
- modals.delete(m.key)
178
- notify()
204
+ aliasDisposers.delete(dispose)
205
+ dispose()
206
+ }
207
+ }
208
+
209
+ function viewOf(e: ModalComponentEntry): FederatedModal {
210
+ let v = views.get(e)
211
+ if (!v) {
212
+ const component = e.component
213
+ v = {
214
+ key: e.slug,
215
+ addon: e.owner ?? '',
216
+ load: e.load ?? (async () => ({ default: component! })),
217
+ component,
179
218
  }
219
+ views.set(e, v)
180
220
  }
221
+ return v
181
222
  }
182
223
 
183
- /** null si el modal no está registrado o su addon no está instalado. Nunca espera. */
224
+ /**
225
+ * Modal del slug en el store canónico, o null si no está registrado o su addon
226
+ * dueño no está instalado. Nunca espera.
227
+ */
184
228
  export function resolveFederatedModal(key: string, isInstalled: IsAddonInstalled): FederatedModal | null {
185
- const m = modals.get(key)
186
- return m && isInstalled(m.addon) ? m : null
229
+ const m = getModalComponent(key)
230
+ return m && (!m.owner || isInstalled(m.owner)) ? viewOf(m) : null
187
231
  }
188
232
 
189
233
  /**
190
- * Componente `React.lazy` del modal resuelto, memoizado por registro (no se
191
- * re-crea en cada render). Quien lo pinta lo envuelve en `<Suspense>`.
234
+ * Componente del modal resuelto: el ya cargado si lo hay; si no, un
235
+ * `React.lazy` memoizado por registro (no se re-crea en cada render; quien lo
236
+ * pinta lo envuelve en `<Suspense>`).
192
237
  */
193
238
  export function federatedModalComponent<P = any>(m: FederatedModal<P>): ComponentType<P> {
239
+ if (m.component) return m.component
194
240
  let C = modalComponents.get(m)
195
241
  if (!C) {
196
242
  C = lazy(m.load) as unknown as ComponentType<any>
@@ -203,6 +249,7 @@ export function federatedModalComponent<P = any>(m: FederatedModal<P>): Componen
203
249
  export function __resetContributions(): void {
204
250
  for (const e of [...contribs.values()]) e.dispose()
205
251
  contribs.clear()
206
- modals.clear()
252
+ for (const d of [...aliasDisposers]) d()
253
+ aliasDisposers.clear()
207
254
  notify()
208
255
  }
@@ -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,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
  <>
package/src/types.ts CHANGED
@@ -141,6 +141,20 @@ export interface DocumentFormSource {
141
141
  option_filter?: unknown
142
142
  /** Addon requerido (gating de UI; el servidor revalida). */
143
143
  requires_addon?: string
144
+ /** Columna de cantidad del renglón origen (default `quantity`). */
145
+ qty_field?: string
146
+ /**
147
+ * Columna del renglón de ESTE documento que guarda el id del renglón origen
148
+ * (p. ej. `sales_order_item_id`). Con ella el host sirve lo pendiente por
149
+ * renglón (`<endpoint>/source-lines`) y rechaza al guardar si se excede.
150
+ */
151
+ line_link_field?: string
152
+ /** Columna del renglón origen que ya trae lo pendiente (la mantiene su addon). */
153
+ remaining_qty_field?: string
154
+ /** Endpoint del addon que sirve los renglones con `remaining_quantity` (`?id=`). */
155
+ remaining_endpoint?: string
156
+ /** Estados de este documento que no consumen el origen (cancelada…). */
157
+ exclude_states?: string[]
144
158
  }
145
159
 
146
160
  /** Vista previa: acción de fila sobre el borrador, llamada con `analyze: true`. */
@@ -907,6 +921,18 @@ export interface ActionDefinition {
907
921
  */
908
922
  replaces_create?: boolean
909
923
  replacesCreate?: boolean
924
+ /**
925
+ * Manifest v3 `priority`. "primary": la única acción destacada del menú de
926
+ * fila / cabecera. "secondary": compartir, imprimir, correo, chat → pie del
927
+ * documento y «Más…» del menú de fila. Sin valor: convención por clave
928
+ * (ver classifyActionPriority).
929
+ */
930
+ priority?: 'primary' | 'secondary'
931
+ /**
932
+ * Cómo despacha (served `trigger`). `type: "capability"` necesita un addon
933
+ * que provea `capability`; sin proveedor activo la acción no se ofrece.
934
+ */
935
+ trigger?: { type: string; capability?: string; connector?: string }
910
936
  /**
911
937
  * Optional addon this action depends on, stamped by the host only while it
912
938
  * is NOT installed (served as `requires_addon`; read through
@@ -958,6 +984,8 @@ export interface ActionMetadata {
958
984
  /** Federated modal slug "<addon_key>.<action_key>"; see ActionModalDispatcher. */
959
985
  modal?: string
960
986
  placement?: 'row' | 'table' | 'create'
987
+ /** Manifest v3 `priority`; ver ActionDefinition.priority. */
988
+ priority?: 'primary' | 'secondary'
961
989
  /**
962
990
  * Optional addon this action depends on, stamped by the host only while it
963
991
  * is NOT installed (served as `requires_addon`; read through