@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.
- package/CHANGELOG.md +22 -0
- package/dist/action-modal-dispatcher.d.ts +17 -3
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +57 -10
- package/dist/action-priority.d.ts +34 -0
- package/dist/action-priority.d.ts.map +1 -0
- package/dist/action-priority.js +60 -0
- package/dist/business/catalog-product-search.d.ts +7 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -1
- package/dist/business/catalog-product-search.js +14 -0
- package/dist/business/document-editor-model.d.ts +14 -2
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +42 -4
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +65 -17
- package/dist/business/line-items.d.ts +9 -1
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +8 -1
- package/dist/document/document-page.d.ts.map +1 -1
- package/dist/document/document-page.js +8 -1
- package/dist/document/evaluate.d.ts.map +1 -1
- package/dist/document/evaluate.js +17 -1
- package/dist/document/index.d.ts +1 -0
- package/dist/document/index.d.ts.map +1 -1
- package/dist/document/index.js +1 -0
- package/dist/document/secondary-bar.d.ts +20 -0
- package/dist/document/secondary-bar.d.ts.map +1 -0
- package/dist/document/secondary-bar.js +44 -0
- package/dist/document/types.d.ts +8 -0
- package/dist/document/types.d.ts.map +1 -1
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +7 -4
- package/dist/dynamic-columns.d.ts +1 -11
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +7 -17
- package/dist/dynamic-crud-page.d.ts +12 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +3 -3
- package/dist/dynamic-row-actions.d.ts +8 -0
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +12 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +64 -63
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/primitives/contributions.d.ts +14 -3
- package/dist/primitives/contributions.d.ts.map +1 -1
- package/dist/primitives/contributions.js +50 -13
- package/dist/record-actions.d.ts +21 -0
- package/dist/record-actions.d.ts.map +1 -0
- package/dist/record-actions.js +76 -0
- package/dist/row-actions-menu.d.ts +32 -0
- package/dist/row-actions-menu.d.ts.map +1 -0
- package/dist/row-actions-menu.js +59 -0
- package/dist/slot.d.ts +4 -21
- package/dist/slot.d.ts.map +1 -1
- package/dist/slot.js +14 -36
- package/dist/types.d.ts +32 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/document-editor-model.test.ts +55 -1
- package/src/__tests__/document-editor.test.tsx +80 -1
- package/src/__tests__/document-form-dialog.test.tsx +40 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
- package/src/__tests__/row-action-link-navigation.test.ts +17 -0
- package/src/__tests__/secondary-actions.test.tsx +261 -0
- package/src/action-modal-dispatcher.tsx +58 -16
- package/src/action-priority.ts +82 -0
- package/src/business/business.test.ts +1 -1
- package/src/business/catalog-product-search.ts +18 -0
- package/src/business/document-editor-model.ts +48 -6
- package/src/business/document-editor.tsx +72 -18
- package/src/business/line-items.ts +16 -1
- package/src/document/document-page.tsx +15 -0
- package/src/document/evaluate.ts +17 -1
- package/src/document/index.ts +1 -0
- package/src/document/secondary-bar.tsx +92 -0
- package/src/document/types.ts +8 -0
- package/src/document-form-dialog.tsx +8 -4
- package/src/dynamic-columns.tsx +13 -57
- package/src/dynamic-crud-page.tsx +11 -2
- package/src/dynamic-row-actions.tsx +12 -2
- package/src/dynamic-table.tsx +3 -0
- package/src/index.ts +25 -0
- package/src/primitives/contributions.ts +59 -12
- package/src/record-actions.ts +101 -0
- package/src/row-actions-menu.tsx +159 -0
- package/src/slot.tsx +18 -53
- 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(
|
|
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)
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
/**
|
|
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 =
|
|
186
|
-
return m && isInstalled(m.
|
|
229
|
+
const m = getModalComponent(key)
|
|
230
|
+
return m && (!m.owner || isInstalled(m.owner)) ? viewOf(m) : null
|
|
187
231
|
}
|
|
188
232
|
|
|
189
233
|
/**
|
|
190
|
-
* Componente
|
|
191
|
-
* re-crea en cada render
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
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
|
|
13
|
+
export type { SlotComponent, SlotEntry }
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
66
|
-
(cb) =>
|
|
67
|
-
() =>
|
|
68
|
-
() =>
|
|
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
|