@asteby/metacore-runtime-react 11.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +21 -1
  4. package/dist/dialogs/create-record-dialog.d.ts +3 -0
  5. package/dist/dialogs/create-record-dialog.d.ts.map +1 -0
  6. package/dist/dialogs/create-record-dialog.js +20 -0
  7. package/dist/dialogs/dynamic-record.d.ts +38 -1
  8. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  9. package/dist/dialogs/dynamic-record.js +50 -12
  10. package/dist/dialogs/types.d.ts +115 -0
  11. package/dist/dialogs/types.d.ts.map +1 -0
  12. package/dist/dialogs/types.js +15 -0
  13. package/dist/dialogs/view-record-dialog.d.ts +3 -0
  14. package/dist/dialogs/view-record-dialog.d.ts.map +1 -0
  15. package/dist/dialogs/view-record-dialog.js +15 -0
  16. package/dist/dynamic-crud-page.d.ts.map +1 -1
  17. package/dist/dynamic-crud-page.js +6 -2
  18. package/dist/dynamic-form-schema.d.ts +9 -0
  19. package/dist/dynamic-form-schema.d.ts.map +1 -1
  20. package/dist/dynamic-form-schema.js +22 -0
  21. package/dist/dynamic-form.d.ts +1 -0
  22. package/dist/dynamic-form.d.ts.map +1 -1
  23. package/dist/dynamic-form.js +12 -1
  24. package/dist/dynamic-line-items.d.ts +9 -0
  25. package/dist/dynamic-line-items.d.ts.map +1 -0
  26. package/dist/dynamic-line-items.js +64 -0
  27. package/dist/dynamic-table.d.ts.map +1 -1
  28. package/dist/dynamic-table.js +7 -1
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -0
  32. package/dist/model-action-toolbar.d.ts +27 -0
  33. package/dist/model-action-toolbar.d.ts.map +1 -0
  34. package/dist/model-action-toolbar.js +88 -0
  35. package/dist/types.d.ts +18 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +6 -6
  38. package/src/__tests__/dynamic-form.test.ts +56 -1
  39. package/src/action-modal-dispatcher.tsx +22 -2
  40. package/src/dialogs/create-record-dialog.tsx +46 -0
  41. package/src/dialogs/dynamic-record.tsx +111 -15
  42. package/src/dialogs/types.ts +119 -0
  43. package/src/dialogs/view-record-dialog.tsx +37 -0
  44. package/src/dynamic-crud-page.tsx +11 -1
  45. package/src/dynamic-form-schema.ts +25 -0
  46. package/src/dynamic-form.tsx +12 -1
  47. package/src/dynamic-line-items.tsx +221 -0
  48. package/src/dynamic-table.tsx +7 -1
  49. package/src/index.ts +16 -0
  50. package/src/model-action-toolbar.tsx +154 -0
  51. package/src/types.ts +18 -0
@@ -0,0 +1,221 @@
1
+ // DynamicLineItems — renders a repeatable line-items group: a table/grid of
2
+ // rows where each column is one of the field's `itemFields` (the v3
3
+ // `item_fields`). Powers declarative multi-line action modals (e.g. the item
4
+ // rows of a "Recibir mercancía" modal, or the debit/credit lines of a journal
5
+ // entry) without needing a custom federated modal.
6
+ //
7
+ // The value is an array of row objects keyed by the item field keys. Add/remove
8
+ // row controls mutate the array; each cell is a widget resolved via
9
+ // `resolveWidget`, matching the flat-field renderer in dynamic-form.tsx.
10
+ import {
11
+ Input,
12
+ Textarea,
13
+ Switch,
14
+ Button,
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from '@asteby/metacore-ui/primitives'
21
+ import { Plus, Trash2 } from 'lucide-react'
22
+ import type { ActionFieldDef } from './types'
23
+ import { resolveWidget, getItemFields } from './dynamic-form-schema'
24
+ import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
25
+
26
+ export interface DynamicLineItemsProps {
27
+ field: ActionFieldDef
28
+ value: any[] | undefined
29
+ onChange: (rows: any[]) => void
30
+ disabled?: boolean
31
+ }
32
+
33
+ function emptyRow(itemFields: ActionFieldDef[]): Record<string, any> {
34
+ const row: Record<string, any> = {}
35
+ for (const f of itemFields) {
36
+ row[f.key] = f.defaultValue ?? (f.type === 'boolean' ? false : '')
37
+ }
38
+ return row
39
+ }
40
+
41
+ export function DynamicLineItems({ field, value, onChange, disabled = false }: DynamicLineItemsProps) {
42
+ const itemFields = getItemFields(field)
43
+ const rows: any[] = Array.isArray(value) ? value : []
44
+
45
+ const addRow = () => onChange([...rows, emptyRow(itemFields)])
46
+ const removeRow = (idx: number) => onChange(rows.filter((_, i) => i !== idx))
47
+ const updateCell = (idx: number, key: string, cellValue: any) =>
48
+ onChange(rows.map((r, i) => (i === idx ? { ...r, [key]: cellValue } : r)))
49
+
50
+ return (
51
+ <div className="grid gap-2" data-widget="line_items">
52
+ <div className="overflow-x-auto rounded-md border">
53
+ <table className="w-full text-sm">
54
+ <thead className="bg-muted/50">
55
+ <tr>
56
+ {itemFields.map((col) => (
57
+ <th key={col.key} className="px-3 py-2 text-left font-medium">
58
+ {col.label}
59
+ {col.required && <span className="text-red-500 ml-1">*</span>}
60
+ </th>
61
+ ))}
62
+ <th className="w-12 px-3 py-2" aria-label="acciones" />
63
+ </tr>
64
+ </thead>
65
+ <tbody>
66
+ {rows.length === 0 && (
67
+ <tr>
68
+ <td
69
+ colSpan={itemFields.length + 1}
70
+ className="px-3 py-4 text-center text-muted-foreground"
71
+ >
72
+ Sin renglones
73
+ </td>
74
+ </tr>
75
+ )}
76
+ {rows.map((row, idx) => (
77
+ <tr key={idx} className="border-t align-top">
78
+ {itemFields.map((col) => (
79
+ <td key={col.key} className="px-2 py-1.5">
80
+ <CellRenderer
81
+ field={col}
82
+ value={row?.[col.key]}
83
+ onChange={(v: any) => updateCell(idx, col.key, v)}
84
+ disabled={disabled}
85
+ />
86
+ </td>
87
+ ))}
88
+ <td className="px-2 py-1.5 text-center">
89
+ <Button
90
+ type="button"
91
+ variant="ghost"
92
+ size="icon"
93
+ onClick={() => removeRow(idx)}
94
+ disabled={disabled}
95
+ aria-label="Eliminar renglón"
96
+ >
97
+ <Trash2 className="h-4 w-4 text-red-500" />
98
+ </Button>
99
+ </td>
100
+ </tr>
101
+ ))}
102
+ </tbody>
103
+ </table>
104
+ </div>
105
+ <div>
106
+ <Button type="button" variant="outline" size="sm" onClick={addRow} disabled={disabled}>
107
+ <Plus className="mr-1 h-4 w-4" />
108
+ Agregar renglón
109
+ </Button>
110
+ </div>
111
+ </div>
112
+ )
113
+ }
114
+
115
+ interface CellRendererProps {
116
+ field: ActionFieldDef
117
+ value: any
118
+ onChange: (v: any) => void
119
+ disabled?: boolean
120
+ }
121
+
122
+ // Per-cell widget. Mirrors the flat FieldRenderer in dynamic-form.tsx but
123
+ // without the per-field Label (the column header is the label) and sized for a
124
+ // table cell. Nested line-items inside a row are not supported (a row column is
125
+ // a scalar widget).
126
+ function CellRenderer({ field, value, onChange, disabled }: CellRendererProps) {
127
+ const widget = resolveWidget(field)
128
+ if (widget === 'select' && field.ref) {
129
+ return <RefCell field={field} value={value} onChange={onChange} disabled={disabled} />
130
+ }
131
+ switch (widget) {
132
+ case 'textarea':
133
+ case 'richtext':
134
+ return (
135
+ <Textarea
136
+ value={value || ''}
137
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
138
+ placeholder={field.placeholder}
139
+ disabled={disabled}
140
+ rows={2}
141
+ />
142
+ )
143
+ case 'color':
144
+ return (
145
+ <Input
146
+ type="color"
147
+ value={value || '#000000'}
148
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
149
+ disabled={disabled}
150
+ />
151
+ )
152
+ case 'select':
153
+ return (
154
+ <Select value={value || ''} onValueChange={onChange} disabled={disabled}>
155
+ <SelectTrigger>
156
+ <SelectValue placeholder={field.placeholder || 'Seleccionar...'} />
157
+ </SelectTrigger>
158
+ <SelectContent>
159
+ {field.options?.map((opt) => (
160
+ <SelectItem key={opt.value} value={opt.value}>
161
+ {opt.label}
162
+ </SelectItem>
163
+ ))}
164
+ </SelectContent>
165
+ </Select>
166
+ )
167
+ case 'switch':
168
+ return <Switch checked={!!value} onCheckedChange={onChange} disabled={disabled} />
169
+ case 'number':
170
+ return (
171
+ <Input
172
+ type="number"
173
+ value={value ?? ''}
174
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')}
175
+ placeholder={field.placeholder}
176
+ disabled={disabled}
177
+ />
178
+ )
179
+ case 'date':
180
+ return (
181
+ <Input
182
+ type="date"
183
+ value={value || ''}
184
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
185
+ disabled={disabled}
186
+ />
187
+ )
188
+ default:
189
+ return (
190
+ <Input
191
+ type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'}
192
+ value={value || ''}
193
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
194
+ placeholder={field.placeholder}
195
+ disabled={disabled}
196
+ />
197
+ )
198
+ }
199
+ }
200
+
201
+ function RefCell({ field, value, onChange, disabled }: CellRendererProps) {
202
+ const { options, loading } = useOptionsResolver({
203
+ modelKey: '',
204
+ fieldKey: 'id',
205
+ ref: field.ref,
206
+ })
207
+ return (
208
+ <Select value={value || ''} onValueChange={onChange} disabled={disabled || loading}>
209
+ <SelectTrigger>
210
+ <SelectValue placeholder={loading ? 'Cargando…' : field.placeholder || 'Seleccionar...'} />
211
+ </SelectTrigger>
212
+ <SelectContent>
213
+ {options.map((opt: ResolvedOption) => (
214
+ <SelectItem key={String(opt.id)} value={String(opt.id)}>
215
+ {opt.label}
216
+ </SelectItem>
217
+ ))}
218
+ </SelectContent>
219
+ </Select>
220
+ )
221
+ }
@@ -569,7 +569,13 @@ export function DynamicTable({
569
569
 
570
570
  const columns = useMemo(() => {
571
571
  if (!metadata) return []
572
- const baseColumns = getDynamicColumns(metadata, handleInternalAction, t, i18n.language, columnFilterConfigs)
572
+ // Row-action column only renders per-row actions. Table-level placements
573
+ // ("table"/"create") are surfaced by <ModelActionToolbar> at the page
574
+ // level, so strip them here to avoid a meaningless per-row button.
575
+ const rowMetadata = metadata.actions?.some((a) => a.placement === 'table' || a.placement === 'create')
576
+ ? { ...metadata, actions: metadata.actions.filter((a) => !a.placement || a.placement === 'row') }
577
+ : metadata
578
+ const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs)
573
579
  const filteredBase = baseColumns.filter((col: ColumnDef<any>) => !hiddenColumns.includes(col.id as string))
574
580
  const actionsCol = filteredBase.find((c: ColumnDef<any>) => c.id === 'actions')
575
581
  const otherCols = filteredBase.filter((c: ColumnDef<any>) => c.id !== 'actions')
package/src/index.ts CHANGED
@@ -11,6 +11,12 @@ export {
11
11
  ActionModalDispatcher,
12
12
  type ActionModalProps,
13
13
  } from './action-modal-dispatcher'
14
+ export {
15
+ ModelActionToolbar,
16
+ useModelActions,
17
+ type ModelActionToolbarProps,
18
+ type ActionPlacement,
19
+ } from './model-action-toolbar'
14
20
  export * from './addon-loader'
15
21
  export {
16
22
  AddonLayoutProvider,
@@ -59,6 +65,16 @@ export {
59
65
  type DynamicColumnsHelpers,
60
66
  } from './dynamic-columns'
61
67
  export { DynamicRecordDialog } from './dialogs/dynamic-record'
68
+ export { CreateRecordDialog } from './dialogs/create-record-dialog'
69
+ export { ViewRecordDialog } from './dialogs/view-record-dialog'
70
+ export type {
71
+ ModelKey,
72
+ ModelSchema,
73
+ CreateResult,
74
+ RecordDialogProps,
75
+ CreateRecordDialogProps,
76
+ ViewRecordDialogProps,
77
+ } from './dialogs/types'
62
78
  export { ExportDialog } from './dialogs/export'
63
79
  export { ImportDialog } from './dialogs/import'
64
80
  export {
@@ -0,0 +1,154 @@
1
+ // ModelActionToolbar — renders page-level (toolbar) triggers for a model's
2
+ // declarative actions and owns the modal dispatch for them.
3
+ //
4
+ // A model's actions carry a `placement` hint (see manifest/v3 Action.placement):
5
+ // "row" (default) — rendered per-row inside <DynamicTable>'s action column.
6
+ // "table" — a plain toolbar button (no record context).
7
+ // "create" — a primary toolbar button that replaces the generic
8
+ // "create" button, for addons shipping a custom create
9
+ // experience (e.g. a journal entry with debit/credit lines).
10
+ //
11
+ // This component renders the buttons for the placements it's asked to surface
12
+ // (default: table + create) and mounts the <ActionModalDispatcher>, which
13
+ // resolves either a custom federated modal (registered via the action registry)
14
+ // or the generic declarative form. For create-style actions there is no record
15
+ // yet, so the dispatcher receives an empty record `{}`.
16
+ //
17
+ // It is the single generic primitive every host consumes — DynamicCRUDPage uses
18
+ // it internally, and bespoke host pages (e.g. ops `/m/$model`) mount it directly
19
+ // next to their own toolbar. Hosts never reimplement action-button plumbing.
20
+ import { useEffect, useMemo, useState } from 'react'
21
+ import { Button } from '@asteby/metacore-ui/primitives'
22
+ import { useApi } from './api-context'
23
+ import { useMetadataCache } from './metadata-cache'
24
+ import { DynamicIcon } from './dynamic-icon'
25
+ import { ActionModalDispatcher } from './action-modal-dispatcher'
26
+ import type { ActionDefinition, ActionMetadata, TableMetadata } from './types'
27
+
28
+ export type ActionPlacement = 'row' | 'table' | 'create'
29
+
30
+ export interface ModelActionToolbarProps {
31
+ /** Model key as registered on the backend (e.g. "JournalEntry"). */
32
+ model: string
33
+ /** Data endpoint passed to the dispatcher. Defaults to `/data/<model>/me`. */
34
+ endpoint?: string
35
+ /**
36
+ * Pre-fetched action definitions. When omitted the toolbar reads them from
37
+ * the metadata cache, falling back to `/metadata/table/<model>`. Pass this
38
+ * when the host page already holds the metadata to avoid a second fetch.
39
+ */
40
+ actions?: ActionDefinition[]
41
+ /** Which placements to render. Defaults to `['table', 'create']`. */
42
+ placements?: ActionPlacement[]
43
+ /** Fired after an action's modal reports success. */
44
+ onChange?: () => void
45
+ /** Extra classes on the button row container. */
46
+ className?: string
47
+ }
48
+
49
+ const DEFAULT_PLACEMENTS: ActionPlacement[] = ['table', 'create']
50
+
51
+ function toActionMetadata(a: ActionDefinition): ActionMetadata {
52
+ return {
53
+ key: a.key,
54
+ label: a.label,
55
+ icon: a.icon || 'Zap',
56
+ color: a.color,
57
+ confirm: a.confirm,
58
+ confirmMessage: a.confirmMessage,
59
+ fields: a.fields,
60
+ requiresState: a.requiresState,
61
+ executable: a.executable,
62
+ placement: a.placement,
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Returns the model's actions matching the requested placements. Reads from the
68
+ * `actions` prop when provided, else the metadata cache, else fetches once.
69
+ */
70
+ export function useModelActions(
71
+ model: string,
72
+ placements: ActionPlacement[] = DEFAULT_PLACEMENTS,
73
+ provided?: ActionDefinition[],
74
+ ): ActionDefinition[] {
75
+ const api = useApi()
76
+ const cached = useMetadataCache((s) => s.getMetadata(model))
77
+ const [fetched, setFetched] = useState<TableMetadata | null>(null)
78
+
79
+ const haveSource = provided != null || cached != null
80
+ useEffect(() => {
81
+ if (haveSource) return
82
+ let cancelled = false
83
+ api
84
+ .get(`/metadata/table/${model}`)
85
+ .then((res) => {
86
+ if (!cancelled) setFetched((res.data?.data ?? res.data) as TableMetadata)
87
+ })
88
+ .catch(() => {
89
+ if (!cancelled) setFetched(null)
90
+ })
91
+ return () => {
92
+ cancelled = true
93
+ }
94
+ }, [model, haveSource, api])
95
+
96
+ const all = provided ?? cached?.actions ?? fetched?.actions ?? []
97
+ return useMemo(
98
+ () => all.filter((a) => placements.includes((a.placement ?? 'row') as ActionPlacement)),
99
+ [all, placements],
100
+ )
101
+ }
102
+
103
+ export function ModelActionToolbar({
104
+ model,
105
+ endpoint,
106
+ actions,
107
+ placements = DEFAULT_PLACEMENTS,
108
+ onChange,
109
+ className,
110
+ }: ModelActionToolbarProps) {
111
+ const surfaced = useModelActions(model, placements, actions)
112
+ const [active, setActive] = useState<ActionMetadata | null>(null)
113
+ const dataEndpoint = endpoint ?? `/data/${model}/me`
114
+
115
+ if (surfaced.length === 0) return null
116
+
117
+ return (
118
+ <>
119
+ <div className={className ?? 'flex items-center gap-2'}>
120
+ {surfaced.map((a) => {
121
+ const isCreate = (a.placement ?? 'row') === 'create'
122
+ return (
123
+ <Button
124
+ key={a.key}
125
+ variant={isCreate ? 'default' : 'outline'}
126
+ onClick={() => setActive(toActionMetadata(a))}
127
+ style={a.color && !isCreate ? { borderColor: a.color, color: a.color } : undefined}
128
+ >
129
+ <DynamicIcon name={a.icon || (isCreate ? 'Plus' : 'Zap')} className="mr-2 h-4 w-4" />
130
+ {a.label}
131
+ </Button>
132
+ )
133
+ })}
134
+ </div>
135
+
136
+ {active && (
137
+ <ActionModalDispatcher
138
+ open={!!active}
139
+ onOpenChange={(open) => {
140
+ if (!open) setActive(null)
141
+ }}
142
+ action={active}
143
+ model={model}
144
+ record={{}}
145
+ endpoint={dataEndpoint}
146
+ onSuccess={() => {
147
+ setActive(null)
148
+ onChange?.()
149
+ }}
150
+ />
151
+ )}
152
+ </>
153
+ )
154
+ }
package/src/types.ts CHANGED
@@ -135,6 +135,16 @@ export interface ActionFieldDef {
135
135
  * `useOptionsResolver` against `/api/options/<ref>?field=id`.
136
136
  */
137
137
  ref?: string
138
+ /**
139
+ * Columns of a repeatable line-items group. Mirrors the kernel v3
140
+ * `ActionField.item_fields` (json `item_fields`). Present on a field
141
+ * with `type: "array"` — the multi-row container (e.g. the item rows
142
+ * of a "Recibir mercancía" modal, or the debit/credit lines of a
143
+ * journal entry). Each entry is itself an ActionFieldDef describing
144
+ * one column's cell widget. The field value is an array of objects
145
+ * keyed by these item field keys. Rendered by `DynamicLineItems`.
146
+ */
147
+ itemFields?: ActionFieldDef[]
138
148
  }
139
149
 
140
150
  export interface ActionDefinition {
@@ -152,6 +162,13 @@ export interface ActionDefinition {
152
162
  fields?: ActionFieldDef[]
153
163
  requiresState?: string[]
154
164
  executable?: boolean
165
+ /**
166
+ * Where the host surfaces the trigger. Mirrors manifest/v3 Action.placement.
167
+ * "row" (default) — per-row table action.
168
+ * "table" — page toolbar button (no record context).
169
+ * "create" — toolbar button that replaces the generic create button.
170
+ */
171
+ placement?: 'row' | 'table' | 'create'
155
172
  }
156
173
 
157
174
  export interface ApiResponse<T> {
@@ -184,4 +201,5 @@ export interface ActionMetadata {
184
201
  fields?: ActionFieldDef[]
185
202
  requiresState?: string[]
186
203
  executable?: boolean
204
+ placement?: 'row' | 'table' | 'create'
187
205
  }