@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.
- package/CHANGELOG.md +41 -0
- package/dist/action-modal-dispatcher.d.ts +33 -3
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +84 -11
- 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 +68 -19
- 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/calendar-date.d.ts +12 -0
- package/dist/calendar-date.d.ts.map +1 -1
- package/dist/calendar-date.js +36 -0
- 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 +23 -2
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +48 -9
- 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 +19 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +6 -5
- 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 +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- 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 +39 -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-create-model.test.tsx +143 -0
- package/src/__tests__/document-form-dialog.test.tsx +40 -0
- package/src/__tests__/field-default-today.test.tsx +116 -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 +97 -17
- 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 +75 -20
- package/src/business/line-items.ts +16 -1
- package/src/calendar-date.ts +36 -0
- 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 +67 -8
- package/src/dynamic-columns.tsx +13 -57
- package/src/dynamic-crud-page.tsx +31 -4
- package/src/dynamic-row-actions.tsx +12 -2
- package/src/dynamic-table.tsx +3 -0
- package/src/index.ts +31 -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 +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
|
|
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
|
-
() =>
|
|
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({
|
|
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
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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(
|
|
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
|
@@ -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
|
-
|
|
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
|
+
}
|