@asteby/metacore-runtime-react 6.0.0 → 6.4.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.
@@ -0,0 +1,275 @@
1
+ // DynamicCRUDPage — drop-in route component for any model that has
2
+ // `DefineTable` metadata on the backend. Pulls the title and CRUD flag
3
+ // from `/metadata/table/<model>`, mounts <DynamicTable>, wires
4
+ // `<DynamicRecordDialog>` for create, and exposes `<ExportDialog>` /
5
+ // `<ImportDialog>` from a single toolbar.
6
+ //
7
+ // The whole thing exists so apps stop reinventing the same ~150 LOC of
8
+ // page chrome around DynamicTable. Override anything via props:
9
+ //
10
+ // <DynamicCRUDPage
11
+ // model="customers"
12
+ // endpoint="/dynamic/customers" // default: /dynamic/<model>
13
+ // title="Mis clientes" // default: metadata.title
14
+ // newLabel="Nuevo cliente" // default: "New <singular>"
15
+ // hideExport hideImport hideCreate // toolbar trimming
16
+ // headerExtras={<MyBranchSwitcher />} // injected before the title row
17
+ // toolbarExtras={<MyExtraActions />} // injected before "Nuevo X"
18
+ // i18n={{ refresh: 'Refrescar', export: 'Exportar', ... }}
19
+ // onChange={() => analytics.track('table.refresh')}
20
+ // />
21
+ //
22
+ // Apps that only need to swap the create endpoint or hide a button keep
23
+ // the boilerplate to one line. Apps that need richer per-model headers
24
+ // register a model-extension registry and feed it via `headerExtras`.
25
+ import React, {
26
+ useCallback,
27
+ useEffect,
28
+ useMemo,
29
+ useState,
30
+ } from 'react'
31
+ import { Plus, Download, Upload, RefreshCw } from 'lucide-react'
32
+ import { useApi } from './api-context'
33
+ import { useMetadataCache } from './metadata-cache'
34
+ import { DynamicTable } from './dynamic-table'
35
+ import { DynamicRecordDialog } from './dialogs/dynamic-record'
36
+ import { ExportDialog } from './dialogs/export'
37
+ import { ImportDialog } from './dialogs/import'
38
+ import { getModelExtension } from './model-extension-registry'
39
+ import type { TableMetadata } from './types'
40
+
41
+ export interface DynamicCRUDPageStrings {
42
+ refresh?: string
43
+ export?: string
44
+ import?: string
45
+ /** Used as the create button label when `newLabel` is not provided.
46
+ * Receives the singular form of the title. */
47
+ newPrefix?: string
48
+ }
49
+
50
+ const defaultStrings: Required<DynamicCRUDPageStrings> = {
51
+ refresh: 'Refresh',
52
+ export: 'Export',
53
+ import: 'Import',
54
+ newPrefix: 'New',
55
+ }
56
+
57
+ export interface DynamicCRUDPageClasses {
58
+ root?: string
59
+ container?: string
60
+ header?: string
61
+ title?: string
62
+ toolbar?: string
63
+ tableWrapper?: string
64
+ }
65
+
66
+ export interface DynamicCRUDPageProps {
67
+ /** Model key as registered on the backend (e.g. "customers"). */
68
+ model: string
69
+ /** Override the data endpoint. Defaults to `/dynamic/<model>`. */
70
+ endpoint?: string
71
+ /** Override the human title. Defaults to `metadata.title`. */
72
+ title?: string
73
+ /** Override the create button label. Defaults to `${newPrefix} ${singular}`. */
74
+ newLabel?: string
75
+ /** Strings used in default labels — pass when the host has its own i18n. */
76
+ i18n?: DynamicCRUDPageStrings
77
+ /** Hide the create button + dialog even when metadata says CRUD is enabled. */
78
+ hideCreate?: boolean
79
+ hideExport?: boolean
80
+ hideImport?: boolean
81
+ hideRefresh?: boolean
82
+ /** Slot rendered above the title row (e.g. branch switcher, kpi strip). */
83
+ headerExtras?: React.ReactNode
84
+ /** Slot rendered in the toolbar, before the create button. */
85
+ toolbarExtras?: React.ReactNode
86
+ /** Tailwind class overrides for layout primitives. */
87
+ classes?: DynamicCRUDPageClasses
88
+ /** Fired after a create/import/refresh successfully reloads the table. */
89
+ onChange?: () => void
90
+ }
91
+
92
+ /**
93
+ * Page-level CRUD shell wired around <DynamicTable>. Hosts mount a single
94
+ * `<DynamicCRUDPage model="..." />` per route and the SDK takes care of
95
+ * metadata fetch, dialogs and toolbar.
96
+ */
97
+ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
98
+ const {
99
+ model,
100
+ endpoint,
101
+ title: titleOverride,
102
+ newLabel,
103
+ i18n,
104
+ hideCreate,
105
+ hideExport,
106
+ hideImport,
107
+ hideRefresh,
108
+ headerExtras,
109
+ toolbarExtras,
110
+ classes,
111
+ onChange,
112
+ } = props
113
+
114
+ const strings = { ...defaultStrings, ...(i18n ?? {}) }
115
+ const dataEndpoint = endpoint ?? `/dynamic/${model}`
116
+ const ext = getModelExtension(model)
117
+
118
+ const api = useApi()
119
+ const cachedMeta = useMetadataCache((s) => s.getMetadata(model))
120
+
121
+ const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta ?? null)
122
+ const [refreshKey, setRefreshKey] = useState(0)
123
+ const [openCreate, setOpenCreate] = useState(false)
124
+ const [openExport, setOpenExport] = useState(false)
125
+ const [openImport, setOpenImport] = useState(false)
126
+
127
+ useEffect(() => {
128
+ if (cachedMeta) {
129
+ setMetadata(cachedMeta)
130
+ return
131
+ }
132
+ let cancelled = false
133
+ api
134
+ .get(`/metadata/table/${model}`)
135
+ .then((res) => {
136
+ if (cancelled) return
137
+ const meta = (res.data?.data ?? res.data) as TableMetadata
138
+ setMetadata(meta ?? null)
139
+ })
140
+ .catch(() => {
141
+ if (!cancelled) setMetadata(null)
142
+ })
143
+ return () => {
144
+ cancelled = true
145
+ }
146
+ }, [model, cachedMeta, api])
147
+
148
+ const title = titleOverride ?? ext?.title ?? metadata?.title ?? model
149
+ const resolvedNewLabel = newLabel ?? ext?.newLabel
150
+ const singular = useMemo(() => {
151
+ const t = title.replace(/s$/i, '')
152
+ return t.charAt(0).toUpperCase() + t.slice(1)
153
+ }, [title])
154
+
155
+ const enableCRUD = metadata?.enableCRUDActions ?? false
156
+ const effectiveHideCreate = hideCreate || ext?.hideCreate
157
+ const effectiveHideExport = hideExport || ext?.hideExport
158
+ const effectiveHideImport = hideImport || ext?.hideImport
159
+ const effectiveHideRefresh = hideRefresh || ext?.hideRefresh
160
+ const showCreate = enableCRUD && !effectiveHideCreate
161
+ const showImport = enableCRUD && !effectiveHideImport
162
+ const showExport = !effectiveHideExport
163
+ const showRefresh = !effectiveHideRefresh
164
+
165
+ const handleRefresh = useCallback(() => {
166
+ setRefreshKey((k) => k + 1)
167
+ onChange?.()
168
+ }, [onChange])
169
+
170
+ const rootCls = classes?.root ?? 'flex flex-col h-full overflow-hidden'
171
+ const containerCls = classes?.container ?? 'flex flex-col flex-1 p-6 lg:p-8 gap-4 overflow-hidden'
172
+ const headerCls = classes?.header ?? 'flex items-center justify-between shrink-0'
173
+ const titleCls = classes?.title ?? 'text-2xl font-bold tracking-tight'
174
+ const toolbarCls = classes?.toolbar ?? 'flex items-center gap-2'
175
+ const tableWrapperCls = classes?.tableWrapper ?? 'flex-1 min-h-0'
176
+
177
+ return (
178
+ <div className={rootCls}>
179
+ <div className={containerCls}>
180
+ {ext?.headerExtras && <ext.headerExtras model={model} onRefresh={handleRefresh} />}
181
+ {headerExtras}
182
+ <div className={headerCls}>
183
+ {metadata ? (
184
+ <h1 className={titleCls}>{title}</h1>
185
+ ) : (
186
+ <div className='h-8 w-48 bg-muted rounded animate-pulse' />
187
+ )}
188
+ <div className={toolbarCls}>
189
+ {showRefresh && (
190
+ <button
191
+ type='button'
192
+ onClick={handleRefresh}
193
+ aria-label={strings.refresh}
194
+ className='inline-flex items-center justify-center size-9 rounded-md border border-border bg-background hover:bg-accent text-foreground'
195
+ >
196
+ <RefreshCw className='size-4' />
197
+ </button>
198
+ )}
199
+ {metadata && showExport && (
200
+ <button
201
+ type='button'
202
+ onClick={() => setOpenExport(true)}
203
+ className='inline-flex items-center gap-2 h-9 px-3 rounded-md border border-border bg-background hover:bg-accent text-sm font-medium text-foreground'
204
+ >
205
+ <Download className='size-4' />
206
+ {strings.export}
207
+ </button>
208
+ )}
209
+ {metadata && showImport && (
210
+ <button
211
+ type='button'
212
+ onClick={() => setOpenImport(true)}
213
+ className='inline-flex items-center gap-2 h-9 px-3 rounded-md border border-border bg-background hover:bg-accent text-sm font-medium text-foreground'
214
+ >
215
+ <Upload className='size-4' />
216
+ {strings.import}
217
+ </button>
218
+ )}
219
+ {ext?.toolbarExtras && <ext.toolbarExtras model={model} onRefresh={handleRefresh} />}
220
+ {toolbarExtras}
221
+ {showCreate && (
222
+ <button
223
+ type='button'
224
+ onClick={() => setOpenCreate(true)}
225
+ className='inline-flex items-center gap-2 h-9 px-3 rounded-md bg-primary text-primary-foreground hover:opacity-90 text-sm font-medium'
226
+ >
227
+ <Plus className='size-4' />
228
+ {resolvedNewLabel ?? `${strings.newPrefix} ${singular}`}
229
+ </button>
230
+ )}
231
+ </div>
232
+ </div>
233
+
234
+ <div className={tableWrapperCls}>
235
+ <DynamicTable
236
+ key={model}
237
+ model={model}
238
+ endpoint={dataEndpoint}
239
+ refreshTrigger={refreshKey}
240
+ />
241
+ </div>
242
+ </div>
243
+
244
+ {showCreate && (
245
+ <DynamicRecordDialog
246
+ open={openCreate}
247
+ onOpenChange={setOpenCreate}
248
+ mode='create'
249
+ model={model}
250
+ endpoint={dataEndpoint}
251
+ onSaved={handleRefresh}
252
+ />
253
+ )}
254
+
255
+ {metadata && showExport && (
256
+ <ExportDialog
257
+ open={openExport}
258
+ onOpenChange={setOpenExport}
259
+ model={model}
260
+ metadata={metadata}
261
+ />
262
+ )}
263
+
264
+ {metadata && showImport && (
265
+ <ImportDialog
266
+ open={openImport}
267
+ onOpenChange={setOpenImport}
268
+ model={model}
269
+ metadata={metadata}
270
+ onImported={handleRefresh}
271
+ />
272
+ )}
273
+ </div>
274
+ )
275
+ }
@@ -1,6 +1,6 @@
1
1
  // DynamicTable — metadata-driven CRUD table used by every metacore host.
2
- // Ported from the ops starter but with the host-specific aliases swapped
3
- // for metacore packages + context-injected peer deps:
2
+ // Originally extracted from a host app and generalized so the host-specific
3
+ // aliases are swapped for metacore packages + context-injected peer deps:
4
4
  // * `@/lib/api` → <ApiProvider> (see api-context.tsx)
5
5
  // * `@/stores/branch-store` → <BranchProvider> (optional)
6
6
  // * `@/stores/metadata-cache` → internal ./metadata-cache zustand store
@@ -65,6 +65,7 @@ import { Progress } from './dialogs/_primitives'
65
65
  import { useMetadataCache } from './metadata-cache'
66
66
  import { useApi, useCurrentBranch } from './api-context'
67
67
  import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
68
+ import { defaultGetDynamicColumns } from './dynamic-columns'
68
69
  import { OptionsContext } from './options-context'
69
70
  import { ActionModalDispatcher } from './action-modal-dispatcher'
70
71
  import type { TableMetadata, ApiResponse, ActionMetadata } from './types'
@@ -477,8 +478,12 @@ export function DynamicTable({
477
478
 
478
479
  const columnFilterConfigs = useMemo(() => {
479
480
  const map = new Map<string, ColumnFilterConfig>()
480
- if (!metadata?.filters) return map
481
- for (const f of metadata.filters) {
481
+ if (!metadata) return map
482
+ // Explicit `metadata.filters` wins. When the backend does not emit
483
+ // them, derive a filter chip from every column flagged
484
+ // `filterable: true` — keeps the kernel API minimal (one flag on the
485
+ // column) while still rendering the FilterableColumnHeader.
486
+ for (const f of metadata.filters ?? []) {
482
487
  const fType = f.type as ColumnFilterConfig['filterType']
483
488
  let options: { label: string; value: string; icon?: string; color?: string }[] = []
484
489
  if (f.options && f.options.length > 0) {
@@ -498,6 +503,31 @@ export function DynamicTable({
498
503
  searchEndpoint: f.searchEndpoint,
499
504
  })
500
505
  }
506
+ for (const c of metadata.columns ?? []) {
507
+ if (!c.filterable || map.has(c.key)) continue
508
+ const hasStaticOptions = (c.options?.length ?? 0) > 0
509
+ const hasEndpoint = !!c.searchEndpoint
510
+ if (!hasStaticOptions && !hasEndpoint && c.type !== 'boolean') continue
511
+ const options = hasStaticOptions
512
+ ? c.options!.map(o => ({
513
+ label: o.label,
514
+ value: String(o.value),
515
+ icon: o.icon,
516
+ color: o.color,
517
+ }))
518
+ : hasEndpoint && filterOptionsMap.has(c.searchEndpoint!)
519
+ ? filterOptionsMap.get(c.searchEndpoint!) || []
520
+ : []
521
+ map.set(c.key, {
522
+ filterType: 'select',
523
+ filterKey: c.key,
524
+ options,
525
+ selectedValues: dynamicFilters[c.key] || [],
526
+ onFilterChange: handleDynamicFilterChange,
527
+ loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint!),
528
+ searchEndpoint: c.searchEndpoint,
529
+ })
530
+ }
501
531
  return map
502
532
  }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange])
503
533
 
@@ -757,10 +787,3 @@ export function DynamicTable({
757
787
  )
758
788
  }
759
789
 
760
- /** Sensible default when hosts don't provide their own getDynamicColumns. */
761
- const defaultGetDynamicColumns: GetDynamicColumns = (metadata, _handleAction, _t, _lang, _filters) =>
762
- (metadata.columns ?? []).map((col: any) => ({
763
- accessorKey: col.name,
764
- header: col.label ?? col.name,
765
- enableSorting: col.sortable ?? false,
766
- }))
package/src/index.ts CHANGED
@@ -25,6 +25,24 @@ export type {
25
25
  GetDynamicColumns,
26
26
  DynamicIconComponent,
27
27
  } from './dynamic-columns-shim'
28
+ export {
29
+ defaultGetDynamicColumns,
30
+ makeDefaultGetDynamicColumns,
31
+ type DynamicColumnsHelpers,
32
+ } from './dynamic-columns'
28
33
  export { DynamicRecordDialog } from './dialogs/dynamic-record'
29
34
  export { ExportDialog } from './dialogs/export'
30
35
  export { ImportDialog } from './dialogs/import'
36
+ export {
37
+ DynamicCRUDPage,
38
+ type DynamicCRUDPageProps,
39
+ type DynamicCRUDPageStrings,
40
+ type DynamicCRUDPageClasses,
41
+ } from './dynamic-crud-page'
42
+ export {
43
+ registerModelExtension,
44
+ getModelExtension,
45
+ clearModelExtensions,
46
+ type ModelExtension,
47
+ type ModelExtensionProps,
48
+ } from './model-extension-registry'
@@ -1,7 +1,7 @@
1
1
  // Metadata cache — a zustand store that memoizes table/modal metadata
2
- // responses across dynamic-table mounts. Ported from the ops starter's
3
- // `@/stores/metadata-cache` so the runtime-react package no longer depends
4
- // on a host-specific alias.
2
+ // responses across dynamic-table mounts. Generalized from a host-app
3
+ // metadata-cache store so the runtime-react package no longer depends on
4
+ // a host-specific alias.
5
5
  //
6
6
  // The prefetchAll() method needs an `api` client (axios-like); we keep that
7
7
  // as an injectable parameter so the store stays host-agnostic. If a caller
@@ -0,0 +1,46 @@
1
+ // Per-model UI extension registry. Apps register once at boot and
2
+ // `<DynamicCRUDPage>` composes the registered extension automatically — no
3
+ // per-route forking, no copy-paste of the page shell.
4
+ //
5
+ // import { registerModelExtension } from '@asteby/metacore-runtime-react'
6
+ //
7
+ // registerModelExtension('customers', {
8
+ // headerExtras: CustomersKpiStrip,
9
+ // toolbarExtras: BulkAssignButton,
10
+ // hideImport: true,
11
+ // })
12
+ //
13
+ // The registry is a module-level singleton — intentional. A React context
14
+ // would force every consumer to wrap a provider just to read a static
15
+ // configuration that is set once at app boot.
16
+ import * as React from 'react'
17
+
18
+ export interface ModelExtensionProps {
19
+ model: string
20
+ onRefresh: () => void
21
+ }
22
+
23
+ export interface ModelExtension {
24
+ headerExtras?: React.ComponentType<ModelExtensionProps>
25
+ toolbarExtras?: React.ComponentType<ModelExtensionProps>
26
+ hideCreate?: boolean
27
+ hideExport?: boolean
28
+ hideImport?: boolean
29
+ hideRefresh?: boolean
30
+ title?: string
31
+ newLabel?: string
32
+ }
33
+
34
+ const registry = new Map<string, ModelExtension>()
35
+
36
+ export function registerModelExtension(model: string, ext: ModelExtension): void {
37
+ registry.set(model, ext)
38
+ }
39
+
40
+ export function getModelExtension(model: string): ModelExtension | undefined {
41
+ return registry.get(model)
42
+ }
43
+
44
+ export function clearModelExtensions(): void {
45
+ registry.clear()
46
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,6 @@
1
- // Union of the two host copies (link + ops). Ops adds the `link` action type + `linkUrl`.
1
+ // Shared metadata shape consumed by every host. Some hosts add a `link`
2
+ // action type with a `linkUrl` template — represented here as part of the
3
+ // `type` union so the SDK can render it uniformly.
2
4
  export interface TableMetadata {
3
5
  title: string
4
6
  endpoint: string