@asteby/metacore-runtime-react 48.0.0 → 49.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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/action-modal-dispatcher.d.ts +33 -3
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +84 -11
  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 +68 -19
  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/calendar-date.d.ts +12 -0
  20. package/dist/calendar-date.d.ts.map +1 -1
  21. package/dist/calendar-date.js +36 -0
  22. package/dist/document/document-page.d.ts.map +1 -1
  23. package/dist/document/document-page.js +8 -1
  24. package/dist/document/evaluate.d.ts.map +1 -1
  25. package/dist/document/evaluate.js +17 -1
  26. package/dist/document/index.d.ts +1 -0
  27. package/dist/document/index.d.ts.map +1 -1
  28. package/dist/document/index.js +1 -0
  29. package/dist/document/secondary-bar.d.ts +20 -0
  30. package/dist/document/secondary-bar.d.ts.map +1 -0
  31. package/dist/document/secondary-bar.js +44 -0
  32. package/dist/document/types.d.ts +8 -0
  33. package/dist/document/types.d.ts.map +1 -1
  34. package/dist/document-form-dialog.d.ts +23 -2
  35. package/dist/document-form-dialog.d.ts.map +1 -1
  36. package/dist/document-form-dialog.js +48 -9
  37. package/dist/dynamic-columns.d.ts +1 -11
  38. package/dist/dynamic-columns.d.ts.map +1 -1
  39. package/dist/dynamic-columns.js +7 -17
  40. package/dist/dynamic-crud-page.d.ts +19 -0
  41. package/dist/dynamic-crud-page.d.ts.map +1 -1
  42. package/dist/dynamic-crud-page.js +6 -5
  43. package/dist/dynamic-row-actions.d.ts +8 -0
  44. package/dist/dynamic-row-actions.d.ts.map +1 -1
  45. package/dist/dynamic-row-actions.js +12 -2
  46. package/dist/dynamic-table.d.ts.map +1 -1
  47. package/dist/dynamic-table.js +64 -63
  48. package/dist/index.d.ts +5 -2
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +9 -2
  51. package/dist/primitives/contributions.d.ts +14 -3
  52. package/dist/primitives/contributions.d.ts.map +1 -1
  53. package/dist/primitives/contributions.js +50 -13
  54. package/dist/record-actions.d.ts +21 -0
  55. package/dist/record-actions.d.ts.map +1 -0
  56. package/dist/record-actions.js +76 -0
  57. package/dist/row-actions-menu.d.ts +32 -0
  58. package/dist/row-actions-menu.d.ts.map +1 -0
  59. package/dist/row-actions-menu.js +59 -0
  60. package/dist/slot.d.ts +4 -21
  61. package/dist/slot.d.ts.map +1 -1
  62. package/dist/slot.js +14 -36
  63. package/dist/types.d.ts +39 -0
  64. package/dist/types.d.ts.map +1 -1
  65. package/package.json +3 -3
  66. package/src/__tests__/document-editor-model.test.ts +55 -1
  67. package/src/__tests__/document-editor.test.tsx +80 -1
  68. package/src/__tests__/document-form-create-model.test.tsx +143 -0
  69. package/src/__tests__/document-form-dialog.test.tsx +40 -0
  70. package/src/__tests__/field-default-today.test.tsx +116 -0
  71. package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
  72. package/src/__tests__/row-action-link-navigation.test.ts +17 -0
  73. package/src/__tests__/secondary-actions.test.tsx +261 -0
  74. package/src/action-modal-dispatcher.tsx +97 -17
  75. package/src/action-priority.ts +82 -0
  76. package/src/business/business.test.ts +1 -1
  77. package/src/business/catalog-product-search.ts +18 -0
  78. package/src/business/document-editor-model.ts +48 -6
  79. package/src/business/document-editor.tsx +75 -20
  80. package/src/business/line-items.ts +16 -1
  81. package/src/calendar-date.ts +36 -0
  82. package/src/document/document-page.tsx +15 -0
  83. package/src/document/evaluate.ts +17 -1
  84. package/src/document/index.ts +1 -0
  85. package/src/document/secondary-bar.tsx +92 -0
  86. package/src/document/types.ts +8 -0
  87. package/src/document-form-dialog.tsx +67 -8
  88. package/src/dynamic-columns.tsx +13 -57
  89. package/src/dynamic-crud-page.tsx +31 -4
  90. package/src/dynamic-row-actions.tsx +12 -2
  91. package/src/dynamic-table.tsx +3 -0
  92. package/src/index.ts +31 -0
  93. package/src/primitives/contributions.ts +59 -12
  94. package/src/record-actions.ts +101 -0
  95. package/src/row-actions-menu.tsx +159 -0
  96. package/src/slot.tsx +18 -53
  97. package/src/types.ts +35 -0
@@ -23,7 +23,7 @@ import {
23
23
  import { Loader2 } from 'lucide-react'
24
24
  import { toast } from 'sonner'
25
25
  import { useApi } from './api-context'
26
- import { useOrgTaxRate } from './org-runtime-context'
26
+ import { useOrgTaxRate, useTimeZone } from './org-runtime-context'
27
27
  import { DynamicIcon } from './dynamic-icon'
28
28
  import { FieldCell, FieldGrid, FieldLabel } from './field-grid'
29
29
  import { FormErrorBanner } from './business/feedback'
@@ -43,11 +43,12 @@ import { validateValues, bagHasErrors } from './validator'
43
43
  import { clearFieldErrorTree } from './field-validation-ui'
44
44
  import { emitRecordMutation } from './record-mutation-events'
45
45
  import { isLineItemsField, resolveWidget, scopeValueFromFilterToken } from './dynamic-form-schema'
46
- import { createCatalogProductSearch } from './business/catalog-product-search'
46
+ import { createCatalogProductSearch, withDefaultTaxRate } from './business/catalog-product-search'
47
47
  import { DocumentEditor } from './business/document-editor'
48
48
  import { editorLinesConfig, isEditorLayout } from './business/document-editor-model'
49
49
  import type {
50
50
  ActionFieldDef,
51
+ DocumentFormType,
51
52
  DocumentFormsManifest,
52
53
  TableMetadata,
53
54
  } from './types'
@@ -82,6 +83,39 @@ export interface DocumentFormDialogProps {
82
83
  record?: Record<string, any>
83
84
  /** Documento origen a precargar en el DocumentEditor (`sources[].key` + id). */
84
85
  initialSource?: { key: string; id: string }
86
+ /**
87
+ * Tipo con `create_model`: el alta vive en otro modelo. El host navega a la
88
+ * página de ese modelo con su alta abierta; el diálogo se cierra sin pintar
89
+ * formulario. Sin este callback un tipo delegado no se ofrece.
90
+ */
91
+ onDelegateCreate?: (target: DelegatedCreate) => void
92
+ }
93
+
94
+ /** Alta delegada a otro modelo (`document_forms.types[].create_model`). */
95
+ export interface DelegatedCreate {
96
+ /** Clave del modelo destino tal como la declara el manifest (`customers.Invoice`). */
97
+ model: string
98
+ /** Tipo que la declaró. */
99
+ type: DocumentFormType
100
+ }
101
+
102
+ /**
103
+ * Si «Crear» de estos formularios es un alta delegada: un único tipo (p. ej.
104
+ * ya acotado por el filtro de la vista) con `create_model`. Con varios tipos el
105
+ * selector la ofrece como una tarjeta más.
106
+ */
107
+ export function delegatedCreate(forms: DocumentFormsManifest | undefined): DelegatedCreate | undefined {
108
+ const only = forms?.types.length === 1 ? forms.types[0] : undefined
109
+ const model = only?.create_model?.trim()
110
+ return only && model ? { model, type: only } : undefined
111
+ }
112
+
113
+ /** Quita los tipos delegados cuando el host no sabe navegar a otro modelo. */
114
+ export function withoutDelegatedTypes(forms: DocumentFormsManifest | undefined): DocumentFormsManifest | undefined {
115
+ if (!forms) return forms
116
+ const types = forms.types.filter((x) => !x.create_model?.trim())
117
+ if (types.length === forms.types.length) return forms
118
+ return types.length > 0 ? { ...forms, types } : undefined
85
119
  }
86
120
 
87
121
  /** Manifest efectivo: la prop gana sobre el metadata; sin tipos devuelve undefined. */
@@ -130,11 +164,17 @@ export function DocumentFormDialog({
130
164
  currency,
131
165
  record,
132
166
  initialSource,
167
+ onDelegateCreate,
133
168
  }: DocumentFormDialogProps) {
134
169
  const { t, i18n } = useTranslation()
135
170
  const api = useApi()
136
171
  const orgTaxRate = useOrgTaxRate()
137
- const types = forms.types
172
+ const orgTimeZone = useTimeZone()
173
+ // Sin onDelegateCreate el host no sabe abrir otro modelo: sus tipos no se ofrecen.
174
+ const types = useMemo(
175
+ () => (onDelegateCreate ? forms.types : forms.types.filter((x) => !x.create_model?.trim())),
176
+ [forms.types, onDelegateCreate],
177
+ )
138
178
  const single = types.length === 1 ? types[0] : undefined
139
179
 
140
180
  const [typeKey, setTypeKey] = useState<string | null>(initialType ?? single?.key ?? null)
@@ -147,12 +187,26 @@ export function DocumentFormDialog({
147
187
  const [saving, setSaving] = useState(false)
148
188
 
149
189
  const type = useMemo(() => types.find((x) => x.key === typeKey) ?? null, [types, typeKey])
190
+ const delegatedModel = type?.create_model?.trim() || undefined
191
+
192
+ // Tipo con alta en otro modelo: no hay formulario aquí; el host navega.
193
+ useEffect(() => {
194
+ if (!open || !type || !delegatedModel) return
195
+ onDelegateCreate?.({ model: delegatedModel, type })
196
+ onOpenChange(false)
197
+ // onDelegateCreate/onOpenChange: callbacks del host, no disparan de nuevo.
198
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
+ }, [open, type, delegatedModel])
150
200
  // Sin buscador inyectado, el paso de renglones solo ofrecía «Renglón libre»
151
201
  // y la línea entraba sin precio (DynamicCRUDPage no pasa searchProducts).
152
- // Default: el catálogo de productos de la org.
202
+ // Default: el catálogo de productos de la org. El buscador del host también
203
+ // recibe el IVA de la org para los productos que no traen su tasa.
153
204
  const taxRate = defaultTaxRate ?? orgTaxRate
154
205
  const lineSearch = useMemo(
155
- () => searchProducts ?? createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
206
+ () =>
207
+ searchProducts
208
+ ? withDefaultTaxRate(searchProducts, taxRate)
209
+ : createCatalogProductSearch(api, { model: productModel, defaultTaxRate: taxRate }),
156
210
  [searchProducts, api, productModel, taxRate],
157
211
  )
158
212
  const lineCfg = type ? editorLinesConfig(type, forms) : undefined
@@ -172,10 +226,13 @@ export function DocumentFormDialog({
172
226
  // Seed field defaults for the chosen type.
173
227
  useEffect(() => {
174
228
  if (!type) return
175
- setFormData({ ...buildFieldDefaults(type.fields, record), ...(type.defaults ?? {}) })
229
+ setFormData({
230
+ ...buildFieldDefaults(type.fields, record, undefined, { timeZone: orgTimeZone }),
231
+ ...(type.defaults ?? {}),
232
+ })
176
233
  setFieldErrors({})
177
234
  setFormError(undefined)
178
- }, [type, record])
235
+ }, [type, record, orgTimeZone])
179
236
 
180
237
  const updateField = (key: string, value: any) => {
181
238
  setFormData((prev) => ({ ...prev, [key]: value }))
@@ -276,9 +333,11 @@ export function DocumentFormDialog({
276
333
  ? t('documentForm.step_data', { defaultValue: 'Datos' })
277
334
  : tl(lineCfg?.title ?? 'Renglones')
278
335
 
336
+ if (open && type && delegatedModel) return null
337
+
279
338
  // layout "editor": una sola pantalla por secciones (DocumentEditor). Sin él
280
339
  // sigue el wizard de siempre (retrocompatible).
281
- if (open && type && step !== 'type' && isEditorLayout(type)) {
340
+ if (open && type && step !== 'type' && (isEditorLayout(type) || !!initialSource)) {
282
341
  return (
283
342
  <Dialog open={open} onOpenChange={onOpenChange}>
284
343
  <DialogContent
@@ -17,7 +17,7 @@ import { ColumnDef } from '@tanstack/react-table'
17
17
  import { format, type Locale } from 'date-fns'
18
18
  import { isCalendarDayValue, parseCalendarDate } from './calendar-date'
19
19
  import { es, enUS } from 'date-fns/locale'
20
- import { Calendar, Check, Copy, Mail, Minus, MoreHorizontal } from 'lucide-react'
20
+ import { Calendar, Check, Copy, Mail, Minus } from 'lucide-react'
21
21
  import {
22
22
  Avatar,
23
23
  AvatarFallback,
@@ -25,10 +25,6 @@ import {
25
25
  Badge,
26
26
  Button,
27
27
  Checkbox,
28
- DropdownMenu,
29
- DropdownMenuContent,
30
- DropdownMenuItem,
31
- DropdownMenuTrigger,
32
28
  InitialsAvatar,
33
29
  } from '@asteby/metacore-ui'
34
30
  import {
@@ -57,7 +53,7 @@ import { DynamicIcon, isLucideIconName } from './dynamic-icon'
57
53
  import { CollectionCell } from './collection-cell'
58
54
  import { isNilUuid, normalizeNilUuid } from './nil-uuid'
59
55
  import { useOptionsResolver } from './use-options-resolver'
60
- import { RequiresAddonLock, resolveRequiresAddon, useRequiresAddonLabel } from './requires-addon'
56
+ import { RowActionsMenu } from './row-actions-menu'
61
57
  import type { TableMetadata, ColumnDefinition } from './types'
62
58
  import { isColumnVisibleInTable } from './column-visibility'
63
59
  import type {
@@ -208,35 +204,9 @@ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLen
208
204
  )
209
205
  }
210
206
 
211
- /**
212
- * One entry of the row "…" menu. An action gated by a missing optional addon
213
- * (`requiresAddon`) stays listed with a lock + tooltip; the click still goes to
214
- * `onAction`, whose shared handler (useDynamicRowActions) opens the
215
- * requires-addon dialog instead of executing.
216
- */
217
- export function RowActionMenuItem({
218
- action,
219
- label,
220
- onSelect,
221
- }: {
222
- action: any
223
- label: React.ReactNode
224
- onSelect: (e: React.MouseEvent) => void
225
- }) {
226
- const requirement = resolveRequiresAddon(action)
227
- const requiresAddonLabel = useRequiresAddonLabel()
228
- return (
229
- <DropdownMenuItem
230
- onClick={onSelect}
231
- title={requirement ? requiresAddonLabel(requirement) : undefined}
232
- data-requires-addon={requirement?.key}
233
- >
234
- <DynamicIcon name={action.icon || 'Zap'} className="mr-2 h-4 w-4" />
235
- {label}
236
- {requirement && <RequiresAddonLock requirement={requirement} />}
237
- </DropdownMenuItem>
238
- )
239
- }
207
+ // RowActionMenuItem vive en row-actions-menu.tsx (junto al menú que lo usa);
208
+ // se re-exporta aquí por compatibilidad (dynamic-kanban, hosts).
209
+ export { RowActionMenuItem } from './row-actions-menu'
240
210
 
241
211
  /**
242
212
  * Lifecycle column used by `requiresState`: prefer `status` (workshop, vehicles,
@@ -1571,28 +1541,14 @@ export function makeDefaultGetDynamicColumns(
1571
1541
  maxSize: 80,
1572
1542
  meta: {},
1573
1543
  cell: ({ row }) => (
1574
- <div className="flex items-center justify-end">
1575
- <DropdownMenu>
1576
- <DropdownMenuTrigger asChild>
1577
- <Button variant="ghost" className="h-8 w-8 p-0">
1578
- <span className="sr-only">Abrir menú</span>
1579
- <MoreHorizontal className="h-4 w-4" />
1580
- </Button>
1581
- </DropdownMenuTrigger>
1582
- <DropdownMenuContent align="end">
1583
- {resolvedActions
1584
- .filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))
1585
- .map((action) => (
1586
- <RowActionMenuItem
1587
- key={action.key}
1588
- action={action}
1589
- label={translateMetadataLabel(action.label, t)}
1590
- onSelect={() => onAction && onAction(action.key, row.original)}
1591
- />
1592
- ))}
1593
- </DropdownMenuContent>
1594
- </DropdownMenu>
1595
- </div>
1544
+ // Primaria destacada, principales y un «Más…» con las
1545
+ // secundarias (compartir/imprimir/correo/chat + las que
1546
+ // aporten addons instalados). Ver row-actions-menu.tsx.
1547
+ <RowActionsMenu
1548
+ actions={resolvedActions.filter((action) => isRowActionVisible(action, row.original, lifecycleStageField(metadata)))}
1549
+ row={row.original}
1550
+ onAction={onAction}
1551
+ />
1596
1552
  ),
1597
1553
  })
1598
1554
  }
@@ -33,7 +33,13 @@ 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, scopeDocumentFormsToFilter } from './document-form-dialog'
36
+ import {
37
+ DocumentFormDialog,
38
+ resolveDocumentForms,
39
+ scopeDocumentFormsToFilter,
40
+ withoutDelegatedTypes,
41
+ type DelegatedCreate,
42
+ } from './document-form-dialog'
37
43
  import { ExportDialog } from './dialogs/export'
38
44
  import { ImportDialog } from './dialogs/import'
39
45
  import { getModelExtension } from './model-extension-registry'
@@ -102,6 +108,18 @@ export interface DynamicCRUDPageProps {
102
108
  * formulario para él.
103
109
  */
104
110
  filter?: Record<string, string>
111
+ /**
112
+ * Abre el alta guiada al montar («crear desde»): tipo de documento y origen
113
+ * a precargar (`sources[].key` + id), p. ej. leídos de la URL
114
+ * `?create=<tipo>&from=<fuente>&from_id=<id>`. Solo con document_forms.
115
+ */
116
+ initialCreate?: { type?: string; source?: { key: string; id: string } }
117
+ /**
118
+ * Alta delegada (`document_forms.types[].create_model`): el host abre la
119
+ * página del modelo destino con su alta abierta. Sin él, esos tipos no se
120
+ * ofrecen (y si la vista queda acotada a uno, no hay «Crear»).
121
+ */
122
+ onDelegateCreate?: (target: DelegatedCreate) => void
105
123
  }
106
124
 
107
125
  /**
@@ -126,6 +144,8 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
126
144
  onChange,
127
145
  documentForms,
128
146
  filter,
147
+ initialCreate,
148
+ onDelegateCreate,
129
149
  } = props
130
150
 
131
151
  const strings = { ...defaultStrings, ...(i18n ?? {}) }
@@ -137,7 +157,7 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
137
157
 
138
158
  const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta ?? null)
139
159
  const [refreshKey, setRefreshKey] = useState(0)
140
- const [openCreate, setOpenCreate] = useState(false)
160
+ const [openCreate, setOpenCreate] = useState(!!initialCreate)
141
161
  const [openExport, setOpenExport] = useState(false)
142
162
  const [openImport, setOpenImport] = useState(false)
143
163
 
@@ -196,7 +216,11 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
196
216
  // (useCan defaults to always-true), in which case create/export/import
197
217
  // require `lowercase(model).create|export|import`.
198
218
  const can = useCan()
199
- const scoped = scopeDocumentFormsToFilter(resolveDocumentForms(metadata, documentForms), filter)
219
+ const declaredForms = resolveDocumentForms(metadata, documentForms)
220
+ const scoped = scopeDocumentFormsToFilter(
221
+ onDelegateCreate ? declaredForms : withoutDelegatedTypes(declaredForms),
222
+ filter,
223
+ )
200
224
  const guidedForms = scoped.forms
201
225
  const showCreate = primary.showGenericCreate && !scoped.hideCreate && can(modelCapability(model, 'create'))
202
226
  const showImport = enableCRUD && !effectiveHideImport && can(modelCapability(model, 'import'))
@@ -308,13 +332,16 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
308
332
  model={model}
309
333
  endpoint={dataEndpoint}
310
334
  forms={guidedForms}
335
+ initialType={initialCreate?.type}
336
+ initialSource={initialCreate?.source}
311
337
  onSaved={handleRefresh}
338
+ onDelegateCreate={onDelegateCreate}
312
339
  />
313
340
  )}
314
341
 
315
342
  {showCreate && !guidedForms && (
316
343
  <DynamicRecordDialog
317
- open={openCreate}
344
+ open={openCreate && !initialCreate?.source}
318
345
  onOpenChange={setOpenCreate}
319
346
  mode='create'
320
347
  model={model}
@@ -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
@@ -179,8 +179,11 @@ export { FieldGrid, FieldCell, FieldLabel } from './field-grid'
179
179
  export {
180
180
  ActionModalDispatcher,
181
181
  FEDERATED_ACTION_MODAL_TIMEOUT_MS,
182
+ resolveFieldDefault,
182
183
  type ActionModalProps,
184
+ type FieldDefaultContext,
183
185
  } from './action-modal-dispatcher'
186
+ export { todayInZone, isTodayToken } from './calendar-date'
184
187
  export { setFederatedActionLoader, type FederatedActionLoader } from './federated-action-loader'
185
188
  export {
186
189
  ModelActionToolbar,
@@ -207,6 +210,9 @@ export {
207
210
  DocumentFormDialog,
208
211
  resolveDocumentForms,
209
212
  scopeDocumentFormsToFilter,
213
+ delegatedCreate,
214
+ withoutDelegatedTypes,
215
+ type DelegatedCreate,
210
216
  type DocumentFormDialogProps,
211
217
  } from './document-form-dialog'
212
218
  export {
@@ -694,3 +700,28 @@ export {
694
700
  // Ayudantes de captura (registerRecordPrefill de @asteby/metacore-sdk) arriba
695
701
  // del formulario genérico de un modelo; DynamicRecordDialog ya lo monta.
696
702
  export { RecordPrefillBar, type RecordPrefillBarProps } from './record-prefill-bar'
703
+
704
+ // Acciones primarias / secundarias de un registro: una sola primaria
705
+ // destacada; compartir/imprimir/correo/chat al «Más…» de la fila y al pie del
706
+ // documento. Las aportadas por addons (registerRecordAction del SDK) sólo con
707
+ // su proveedor instalado y activo.
708
+ export {
709
+ RowActionsMenu,
710
+ RowActionMenuItem,
711
+ RowActionsModelContext,
712
+ type RowActionsMenuProps,
713
+ } from './row-actions-menu'
714
+ export {
715
+ classifyActionPriority,
716
+ isActionProviderActive,
717
+ isRecordActionProviderActive,
718
+ resolveRecordActions,
719
+ splitActionsByPriority,
720
+ useRecordActions,
721
+ type ActionPriority,
722
+ type PrioritizableAction,
723
+ type RecordActionContext,
724
+ type RecordActionContribution,
725
+ type ResolvedRecordAction,
726
+ type SplitActions,
727
+ } 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
+ }