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