@asteby/metacore-runtime-react 6.1.0 → 7.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 CHANGED
@@ -1,5 +1,28 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 7.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 3450876: Add `getInitials(name)` helper to `@asteby/metacore-ui/lib`.
8
+
9
+ Pulls a duplicated 6-line snippet (`name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()`) out of every avatar across the platform — chat headers, profile dropdowns, dynamic-table avatar cells, sidebar nav. Trims whitespace, caps token count, and falls back to a single character when the input is empty.
10
+
11
+ `runtime-react`'s avatar cell renderer now uses it; visually identical, one less inline lambda.
12
+
13
+ - Updated dependencies [3450876]
14
+ - @asteby/metacore-ui@0.7.0
15
+
16
+ ## 6.4.0
17
+
18
+ ### Minor Changes
19
+
20
+ - d7f1e55: Per-model extension registry, badge cell normalization, and auto-derived filter chips.
21
+ - `registerModelExtension(model, ext)` lets apps layer per-model UI on top of `<DynamicCRUDPage>` (header KPI strip, custom toolbar buttons, hidden create flow, title overrides) without forking the page or copy-pasting it.
22
+ - `defaultGetDynamicColumns` now accepts `type === 'badge'` (what the kernel emits) in addition to `cellStyle === 'badge'`. Columns marked `type: badge` previously rendered as plain text.
23
+ - `<DynamicTable>` derives a filter chip from every column flagged `filterable: true` plus either static options, a `searchEndpoint`, or boolean type, so apps no longer need to mirror the same options into a separate `filters` array on the metadata. Explicit `metadata.filters` still wins when present.
24
+ - Fixes the default `getDynamicColumns` fallback that previously read `col.name` instead of `col.key`, leaving cells blank for hosts that did not pass a custom factory.
25
+
3
26
  ## 6.0.0
4
27
 
5
28
  ### Patch Changes
@@ -20,7 +43,7 @@
20
43
 
21
44
  - e23eede: Publicación inicial a npm del ecosistema metacore.
22
45
 
23
- Propaga los 13 paquetes del SDK al registry público para que apps consumidoras (ops, link) migren de `file:` a semver y Renovate pueda propagar updates.
46
+ Propaga los 13 paquetes del SDK al registry público para que las host applications consumidoras migren de `file:` a semver y Renovate pueda propagar updates.
24
47
 
25
48
  ### Patch Changes
26
49
 
@@ -34,7 +57,7 @@
34
57
 
35
58
  - 6d243b0: Initial release of the metacore frontend ecosystem.
36
59
 
37
- 11 packages extracted from ops/link frontends into a publishable monorepo with auto-propagation via Changesets + Renovate.
60
+ 11 packages extracted from host application frontends into a publishable monorepo with auto-propagation via Changesets + Renovate.
38
61
 
39
62
  ### Patch Changes
40
63
 
package/README.md CHANGED
@@ -1,59 +1,82 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
- React runtime for [metacore](https://github.com/asteby/metacore-sdk) hosts. This
4
- package bundles the generic components a host (`ops`, `link`, `hub`) renders
5
- when showing addon contributionsdynamic tables, forms, action dispatchers,
6
- slot extension points and the federated addon loader.
7
-
8
- It is a *runtime*, not a UI kit: the actual visual primitives (buttons,
9
- dialogs, tables…) are resolved through the host's bundler aliases. The host
10
- must provide the following modules at build time:
11
-
12
- | Alias | Purpose |
13
- | ---------------------------------- | ------------------------------------- |
14
- | `@/components/ui/*` | shadcn primitives |
15
- | `@/components/data-table` | DataTableToolbar / Pagination / etc. |
16
- | `@/components/dynamic/dynamic-columns` | column renderers (`DynamicIcon`, etc) |
17
- | `@/components/dynamic/dynamic-record-dialog` | CRUD dialog (still host-owned) |
18
- | `@/components/dynamic/export-dialog` | Export dialog |
19
- | `@/components/dynamic/import-dialog` | Import dialog |
20
- | `@/lib/api` | axios instance |
21
- | `@/lib/utils` | `cn()` helper |
22
- | `@/stores/metadata-cache` | zustand store |
23
- | `@/stores/branch-store` | zustand store (optional) |
3
+ React runtime for [Metacore](https://github.com/asteby/metacore-sdk) hosts. The metadata-driven CRUD layer that turns a manifest declaration into a working UI: dynamic tables, forms, action dispatchers, slot extension points, capability gates, and the federated addon loader.
4
+
5
+ This is a *runtime*, not a UI kit visual primitives come from [`@asteby/metacore-ui`](../ui). Hosts inject their HTTP client and (optionally) tenant-branch context via React providers; no bundler aliases are required.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @asteby/metacore-runtime-react @asteby/metacore-sdk @asteby/metacore-ui
11
+ ```
12
+
13
+ Peers: `react`, `react-dom`, `react-i18next`, `i18next`, `@tanstack/react-router`, `@tanstack/react-table`, `date-fns`, `lucide-react`, `sonner`, `zustand`. They're declared as peers so React stays single-instance.
24
14
 
25
15
  ## Exports
26
16
 
27
- - `DynamicTable` CRUD-capable table driven by `manifest.model_definition`.
28
- - `DynamicForm` – renders a form from `fields[]`.
29
- - `ActionModalDispatcher` routes a custom action to its registered component
30
- (falls back to confirm dialog / generic form).
31
- - `AddonLoader` injects a federated `remoteEntry.js` and calls `register(api)`.
32
- - `Slot` / `slotRegistry` named extension points (`dashboard.widgets`, …).
33
- - `CapabilityGate` / `CapabilityProvider` conditional UI by capability.
34
- - `NavigationBuilder` / `mergeNavigation` merges host sidebar with addon nav.
35
- - `I18nProvider` injects `manifest.i18n` namespaces into the host's i18next.
17
+ | Export | What it does |
18
+ |---|---|
19
+ | `<DynamicTable model="…" />` | Metadata-driven CRUD table. Sortable, paginated, filterable, URL-syncable, with built-in dialogs. |
20
+ | `<DynamicForm fields={…} onSubmit={…} />` | Standalone form renderer over `ActionFieldDef[]`. |
21
+ | `<DynamicRecordDialog />` | Create / edit / view modal driven by `/metadata/modal/<model>`. |
22
+ | `<ActionModalDispatcher />` | Routes a custom action to its registered component, generic form, or confirm dialog. |
23
+ | `<AddonLoader />` | Injects a federated `remoteEntry.js` and calls the addon's `register(api)`. |
24
+ | `<Slot name="…" />` / `slotRegistry` | Named extension points contributed by addons. |
25
+ | `<CapabilityGate require="…" />` / `<CapabilityProvider />` | Conditional UI by capability. |
26
+ | `<NavigationBuilder />` / `useNavigation()` / `mergeNavigation()` | Merges host sidebar with addon `manifest.navigation`. |
27
+ | `<I18nProvider />` | Folds `manifest.i18n` namespaces into the host's i18next instance. |
28
+ | `<ApiProvider client={axios} />` / `useApi()` | Inject the host's HTTP client. Required by every dynamic component. |
29
+ | `<BranchProvider branch={…} />` / `useCurrentBranch()` | Optional tenant-branch context. |
30
+ | `useMetadataCache()` | Zustand store for table/modal metadata, persisted to LocalStorage. `prefetchAll(api)` warms it from `/metadata/all`. |
31
+ | `defaultGetDynamicColumns` / `makeDefaultGetDynamicColumns(helpers)` | The factory `<DynamicTable>` uses to convert metadata into TanStack column defs. The default reads from `col.key` (matching the kernel contract). |
32
+ | `DynamicIcon` | Lucide icon resolver by name. |
36
33
 
37
34
  ## Minimal usage
38
35
 
39
36
  ```tsx
40
- import { DynamicTable, Slot, CapabilityGate, AddonLoader } from '@asteby/metacore-runtime-react'
41
-
42
- <AddonLoader scope="billing" url="/addons/billing/remoteEntry.js" api={api}>
43
- <CapabilityGate require="invoice.read">
44
- <DynamicTable model="invoice" />
45
- <Slot name="invoice.footer" />
46
- </CapabilityGate>
47
- </AddonLoader>
37
+ import {
38
+ ApiProvider,
39
+ CapabilityProvider,
40
+ DynamicTable,
41
+ } from '@asteby/metacore-runtime-react'
42
+ import { api } from './lib/api'
43
+
44
+ export function App() {
45
+ return (
46
+ <ApiProvider client={api}>
47
+ <CapabilityProvider capabilities={session.capabilities}>
48
+ <DynamicTable model="tickets" />
49
+ </CapabilityProvider>
50
+ </ApiProvider>
51
+ )
52
+ }
48
53
  ```
49
54
 
50
- ## Installation
55
+ For props, response shapes, customization patterns and the full surface, see [`docs/dynamic-ui.md`](https://github.com/asteby/metacore-sdk/blob/main/docs/dynamic-ui.md).
51
56
 
52
- `@asteby/metacore-runtime-react` depends on `@asteby/metacore-sdk` for the
53
- canonical action registry and `AddonAPI` contract. Build order:
57
+ ## How it talks to the kernel
54
58
 
55
- ```
56
- pnpm --filter @asteby/metacore-sdk build
59
+ | Endpoint | Used by |
60
+ |---|---|
61
+ | `GET /metadata/table/<model>` | `<DynamicTable>` (cached). |
62
+ | `GET /metadata/modal/<model>` | `<DynamicRecordDialog>` (cached). |
63
+ | `GET /metadata/all` | `useMetadataCache().prefetchAll()`. |
64
+ | `GET /data/<model>` | `<DynamicTable>` list. |
65
+ | `GET /data/<model>/<id>` | `<DynamicRecordDialog>` view/edit. |
66
+ | `POST /data/<model>` | Create. |
67
+ | `PUT /data/<model>/<id>` | Update. |
68
+ | `DELETE /data/<model>/<id>` | Delete (single + bulk). |
69
+ | `POST /data/<model>/<id>/action/<key>` | `<ActionModalDispatcher>`. |
70
+ | `GET /options/<endpoint>` | Relation pickers + select prefetch. |
71
+
72
+ All endpoints can be overridden per-component via the `endpoint` prop.
73
+
74
+ ## Build
75
+
76
+ ```bash
57
77
  pnpm --filter @asteby/metacore-runtime-react build
58
- pnpm --filter <host> install
59
78
  ```
79
+
80
+ ## License
81
+
82
+ Apache-2.0
@@ -1,7 +1,7 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // ExportDialog — lets users pick format (csv/json) + columns and kicks off
3
3
  // either a sync download or an async export job (polled via /exports/:id/status).
4
- // Ported from the ops starter. Axios-like client is provided by <ApiProvider>.
4
+ // Axios-like client is provided by <ApiProvider>.
5
5
  import { useState, useEffect, useCallback } from 'react';
6
6
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Button, Label, Checkbox, Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@asteby/metacore-ui/primitives';
7
7
  import { Progress, RadioGroup, RadioGroupItem } from './_primitives';
@@ -1 +1 @@
1
- {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../src/dialogs/import.tsx"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAG7C,UAAU,iBAAiB;IACvB,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,aAAa,CAAA;IACvB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;CAC1B;AAoBD,wBAAgB,YAAY,CAAC,EACzB,IAAI,EACJ,YAAY,EACZ,KAAK,EACL,QAAQ,EACR,UAAU,GACb,EAAE,iBAAiB,2CAwVnB"}
1
+ {"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../src/dialogs/import.tsx"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAG7C,UAAU,iBAAiB;IACvB,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,aAAa,CAAA;IACvB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;CAC1B;AAoBD,wBAAgB,YAAY,CAAC,EACzB,IAAI,EACJ,YAAY,EACZ,KAAK,EACL,QAAQ,EACR,UAAU,GACb,EAAE,iBAAiB,2CAwVnB"}
@@ -1,7 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // ImportDialog — three-step CSV/JSON import flow (upload → validate → import
3
- // with per-row error report). Ported from the ops starter. Axios-like client
4
- // is provided by <ApiProvider>.
3
+ // with per-row error report). Axios-like client is provided by <ApiProvider>.
5
4
  import { useState, useEffect, useRef } from 'react';
6
5
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Button, Input, Label, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@asteby/metacore-ui/primitives';
7
6
  import { Progress } from './_primitives';
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAwHD;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CA+UnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
1
+ {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAwHD;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CA8UnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // badge (static + endpoint-loaded options), avatar, phone, date, boolean,
5
5
  // relation-badge-list, media-gallery, image, plus a generic text fallback.
6
6
  //
7
- // The implementation was previously duplicated across `link` and `ops`
7
+ // The implementation was previously duplicated across multiple host apps
8
8
  // (~550 LOC each, drifting). It now lives here so a single fix propagates
9
9
  // to every host. Hosts inject app-specific URL helpers via the `helpers`
10
10
  // argument so the SDK stays free of environment-bound code.
@@ -15,7 +15,7 @@ import * as icons from 'lucide-react';
15
15
  import { MoreHorizontal } from 'lucide-react';
16
16
  import { Avatar, AvatarFallback, AvatarImage, Badge, Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@asteby/metacore-ui';
17
17
  import { DataTableColumnHeader, FilterableColumnHeader, } from '@asteby/metacore-ui/data-table';
18
- import { generateBadgeStyles } from '@asteby/metacore-ui/lib';
18
+ import { generateBadgeStyles, getInitials } from '@asteby/metacore-ui/lib';
19
19
  import { OptionsContext } from './options-context';
20
20
  import { DynamicIcon } from './dynamic-icon';
21
21
  const defaultGetImageUrl = (path) => path;
@@ -150,14 +150,18 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
150
150
  header: ({ column }) => filterConfig ? (_jsx(FilterableColumnHeader, { column: column, title: translatedLabel })) : (_jsx(DataTableColumnHeader, { column: column, title: translatedLabel })),
151
151
  cell: ({ row }) => {
152
152
  const value = getNestedValue(row.original, col.key);
153
+ // Kernel emits the renderer flag as `type`; older hosts used
154
+ // `cellStyle`. Accept both so a single backend works across
155
+ // SDK versions.
156
+ const renderAs = col.cellStyle ?? col.type;
153
157
  // Endpoint-loaded badge options (preloaded into OptionsContext)
154
- if (col.cellStyle === 'badge' && col.useOptions && col.searchEndpoint) {
158
+ if (renderAs === 'badge' && col.useOptions && col.searchEndpoint) {
155
159
  if (!value)
156
160
  return _jsx("span", { className: "text-muted-foreground", children: "-" });
157
161
  return _jsx(BadgeWithEndpointOptions, { endpoint: col.searchEndpoint, value: value });
158
162
  }
159
163
  // Static badge options — map value → label/icon/color
160
- if (col.cellStyle === 'badge' && col.options && col.options.length > 0) {
164
+ if (renderAs === 'badge' && col.options && col.options.length > 0) {
161
165
  if (!value && value !== 0)
162
166
  return _jsx("span", { className: "text-muted-foreground", children: "-" });
163
167
  const option = col.options.find((o) => o.value === String(value));
@@ -165,7 +169,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
165
169
  return _jsx(OptionBadge, { option: option, fallback: String(value) });
166
170
  return _jsx(Badge, { variant: "outline", children: String(value) });
167
171
  }
168
- if (col.cellStyle === 'relation-badge-list') {
172
+ if (renderAs === 'relation-badge-list') {
169
173
  return renderRelationBadges(value, col);
170
174
  }
171
175
  switch (col.type) {
@@ -203,12 +207,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
203
207
  else if (value) {
204
208
  avatarSrc = `${apiBaseUrl}${col.basePath || ''}${value}`;
205
209
  }
206
- return (_jsxs("div", { className: "flex items-center gap-3 min-w-0", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg ring-1 ring-border/50", children: [_jsx(AvatarImage, { src: getImageUrl(avatarSrc || ''), alt: String(name), className: "object-cover" }), _jsx(AvatarFallback, { className: "text-[10px] font-bold bg-primary/5 text-primary rounded-lg", children: String(name)
207
- .split(' ')
208
- .map((n) => n[0])
209
- .slice(0, 2)
210
- .join('')
211
- .toUpperCase() })] }), _jsxs("div", { className: "flex flex-col min-w-0 overflow-hidden", children: [_jsx("span", { className: "font-medium text-sm truncate leading-none mb-0.5 text-foreground/90", children: String(name) }), desc && (_jsx("span", { className: "text-[11px] text-muted-foreground truncate leading-none", children: String(desc) }))] })] }));
210
+ return (_jsxs("div", { className: "flex items-center gap-3 min-w-0", children: [_jsxs(Avatar, { className: "h-8 w-8 rounded-lg ring-1 ring-border/50", children: [_jsx(AvatarImage, { src: getImageUrl(avatarSrc || ''), alt: String(name), className: "object-cover" }), _jsx(AvatarFallback, { className: "text-[10px] font-bold bg-primary/5 text-primary rounded-lg", children: getInitials(String(name)) })] }), _jsxs("div", { className: "flex flex-col min-w-0 overflow-hidden", children: [_jsx("span", { className: "font-medium text-sm truncate leading-none mb-0.5 text-foreground/90", children: String(name) }), desc && (_jsx("span", { className: "text-[11px] text-muted-foreground truncate leading-none", children: String(desc) }))] })] }));
212
211
  }
213
212
  case 'relation-badge-list':
214
213
  return renderRelationBadges(value, col);
@@ -0,0 +1,49 @@
1
+ import React from 'react';
2
+ export interface DynamicCRUDPageStrings {
3
+ refresh?: string;
4
+ export?: string;
5
+ import?: string;
6
+ /** Used as the create button label when `newLabel` is not provided.
7
+ * Receives the singular form of the title. */
8
+ newPrefix?: string;
9
+ }
10
+ export interface DynamicCRUDPageClasses {
11
+ root?: string;
12
+ container?: string;
13
+ header?: string;
14
+ title?: string;
15
+ toolbar?: string;
16
+ tableWrapper?: string;
17
+ }
18
+ export interface DynamicCRUDPageProps {
19
+ /** Model key as registered on the backend (e.g. "customers"). */
20
+ model: string;
21
+ /** Override the data endpoint. Defaults to `/dynamic/<model>`. */
22
+ endpoint?: string;
23
+ /** Override the human title. Defaults to `metadata.title`. */
24
+ title?: string;
25
+ /** Override the create button label. Defaults to `${newPrefix} ${singular}`. */
26
+ newLabel?: string;
27
+ /** Strings used in default labels — pass when the host has its own i18n. */
28
+ i18n?: DynamicCRUDPageStrings;
29
+ /** Hide the create button + dialog even when metadata says CRUD is enabled. */
30
+ hideCreate?: boolean;
31
+ hideExport?: boolean;
32
+ hideImport?: boolean;
33
+ hideRefresh?: boolean;
34
+ /** Slot rendered above the title row (e.g. branch switcher, kpi strip). */
35
+ headerExtras?: React.ReactNode;
36
+ /** Slot rendered in the toolbar, before the create button. */
37
+ toolbarExtras?: React.ReactNode;
38
+ /** Tailwind class overrides for layout primitives. */
39
+ classes?: DynamicCRUDPageClasses;
40
+ /** Fired after a create/import/refresh successfully reloads the table. */
41
+ onChange?: () => void;
42
+ }
43
+ /**
44
+ * Page-level CRUD shell wired around <DynamicTable>. Hosts mount a single
45
+ * `<DynamicCRUDPage model="..." />` per route and the SDK takes care of
46
+ * metadata fetch, dialogs and toolbar.
47
+ */
48
+ export declare function DynamicCRUDPage(props: DynamicCRUDPageProps): import("react/jsx-runtime").JSX.Element;
49
+ //# sourceMappingURL=dynamic-crud-page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dynamic-crud-page.d.ts","sourceRoot":"","sources":["../src/dynamic-crud-page.tsx"],"names":[],"mappings":"AAwBA,OAAO,KAKN,MAAM,OAAO,CAAA;AAWd,MAAM,WAAW,sBAAsB;IACnC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;mDAC+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAA;CACrB;AASD,MAAM,WAAW,sBAAsB;IACnC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,YAAY,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,oBAAoB;IACjC,iEAAiE;IACjE,KAAK,EAAE,MAAM,CAAA;IACb,kEAAkE;IAClE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,sBAAsB,CAAA;IAC7B,+EAA+E;IAC/E,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC9B,8DAA8D;IAC9D,aAAa,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC/B,sDAAsD;IACtD,OAAO,CAAC,EAAE,sBAAsB,CAAA;IAChC,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACxB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,2CAkL1D"}
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // DynamicCRUDPage — drop-in route component for any model that has
3
+ // `DefineTable` metadata on the backend. Pulls the title and CRUD flag
4
+ // from `/metadata/table/<model>`, mounts <DynamicTable>, wires
5
+ // `<DynamicRecordDialog>` for create, and exposes `<ExportDialog>` /
6
+ // `<ImportDialog>` from a single toolbar.
7
+ //
8
+ // The whole thing exists so apps stop reinventing the same ~150 LOC of
9
+ // page chrome around DynamicTable. Override anything via props:
10
+ //
11
+ // <DynamicCRUDPage
12
+ // model="customers"
13
+ // endpoint="/dynamic/customers" // default: /dynamic/<model>
14
+ // title="Mis clientes" // default: metadata.title
15
+ // newLabel="Nuevo cliente" // default: "New <singular>"
16
+ // hideExport hideImport hideCreate // toolbar trimming
17
+ // headerExtras={<MyBranchSwitcher />} // injected before the title row
18
+ // toolbarExtras={<MyExtraActions />} // injected before "Nuevo X"
19
+ // i18n={{ refresh: 'Refrescar', export: 'Exportar', ... }}
20
+ // onChange={() => analytics.track('table.refresh')}
21
+ // />
22
+ //
23
+ // Apps that only need to swap the create endpoint or hide a button keep
24
+ // the boilerplate to one line. Apps that need richer per-model headers
25
+ // register a model-extension registry and feed it via `headerExtras`.
26
+ import { useCallback, useEffect, useMemo, useState, } from 'react';
27
+ import { Plus, Download, Upload, RefreshCw } from 'lucide-react';
28
+ import { useApi } from './api-context';
29
+ import { useMetadataCache } from './metadata-cache';
30
+ import { DynamicTable } from './dynamic-table';
31
+ import { DynamicRecordDialog } from './dialogs/dynamic-record';
32
+ import { ExportDialog } from './dialogs/export';
33
+ import { ImportDialog } from './dialogs/import';
34
+ import { getModelExtension } from './model-extension-registry';
35
+ const defaultStrings = {
36
+ refresh: 'Refresh',
37
+ export: 'Export',
38
+ import: 'Import',
39
+ newPrefix: 'New',
40
+ };
41
+ /**
42
+ * Page-level CRUD shell wired around <DynamicTable>. Hosts mount a single
43
+ * `<DynamicCRUDPage model="..." />` per route and the SDK takes care of
44
+ * metadata fetch, dialogs and toolbar.
45
+ */
46
+ export function DynamicCRUDPage(props) {
47
+ const { model, endpoint, title: titleOverride, newLabel, i18n, hideCreate, hideExport, hideImport, hideRefresh, headerExtras, toolbarExtras, classes, onChange, } = props;
48
+ const strings = { ...defaultStrings, ...(i18n ?? {}) };
49
+ const dataEndpoint = endpoint ?? `/dynamic/${model}`;
50
+ const ext = getModelExtension(model);
51
+ const api = useApi();
52
+ const cachedMeta = useMetadataCache((s) => s.getMetadata(model));
53
+ const [metadata, setMetadata] = useState(cachedMeta ?? null);
54
+ const [refreshKey, setRefreshKey] = useState(0);
55
+ const [openCreate, setOpenCreate] = useState(false);
56
+ const [openExport, setOpenExport] = useState(false);
57
+ const [openImport, setOpenImport] = useState(false);
58
+ useEffect(() => {
59
+ if (cachedMeta) {
60
+ setMetadata(cachedMeta);
61
+ return;
62
+ }
63
+ let cancelled = false;
64
+ api
65
+ .get(`/metadata/table/${model}`)
66
+ .then((res) => {
67
+ if (cancelled)
68
+ return;
69
+ const meta = (res.data?.data ?? res.data);
70
+ setMetadata(meta ?? null);
71
+ })
72
+ .catch(() => {
73
+ if (!cancelled)
74
+ setMetadata(null);
75
+ });
76
+ return () => {
77
+ cancelled = true;
78
+ };
79
+ }, [model, cachedMeta, api]);
80
+ const title = titleOverride ?? ext?.title ?? metadata?.title ?? model;
81
+ const resolvedNewLabel = newLabel ?? ext?.newLabel;
82
+ const singular = useMemo(() => {
83
+ const t = title.replace(/s$/i, '');
84
+ return t.charAt(0).toUpperCase() + t.slice(1);
85
+ }, [title]);
86
+ const enableCRUD = metadata?.enableCRUDActions ?? false;
87
+ const effectiveHideCreate = hideCreate || ext?.hideCreate;
88
+ const effectiveHideExport = hideExport || ext?.hideExport;
89
+ const effectiveHideImport = hideImport || ext?.hideImport;
90
+ const effectiveHideRefresh = hideRefresh || ext?.hideRefresh;
91
+ const showCreate = enableCRUD && !effectiveHideCreate;
92
+ const showImport = enableCRUD && !effectiveHideImport;
93
+ const showExport = !effectiveHideExport;
94
+ const showRefresh = !effectiveHideRefresh;
95
+ const handleRefresh = useCallback(() => {
96
+ setRefreshKey((k) => k + 1);
97
+ onChange?.();
98
+ }, [onChange]);
99
+ const rootCls = classes?.root ?? 'flex flex-col h-full overflow-hidden';
100
+ const containerCls = classes?.container ?? 'flex flex-col flex-1 p-6 lg:p-8 gap-4 overflow-hidden';
101
+ const headerCls = classes?.header ?? 'flex items-center justify-between shrink-0';
102
+ const titleCls = classes?.title ?? 'text-2xl font-bold tracking-tight';
103
+ const toolbarCls = classes?.toolbar ?? 'flex items-center gap-2';
104
+ const tableWrapperCls = classes?.tableWrapper ?? 'flex-1 min-h-0';
105
+ return (_jsxs("div", { className: rootCls, children: [_jsxs("div", { className: containerCls, children: [ext?.headerExtras && _jsx(ext.headerExtras, { model: model, onRefresh: handleRefresh }), headerExtras, _jsxs("div", { className: headerCls, children: [metadata ? (_jsx("h1", { className: titleCls, children: title })) : (_jsx("div", { className: 'h-8 w-48 bg-muted rounded animate-pulse' })), _jsxs("div", { className: toolbarCls, children: [showRefresh && (_jsx("button", { type: 'button', onClick: handleRefresh, "aria-label": strings.refresh, className: 'inline-flex items-center justify-center size-9 rounded-md border border-border bg-background hover:bg-accent text-foreground', children: _jsx(RefreshCw, { className: 'size-4' }) })), metadata && showExport && (_jsxs("button", { type: 'button', onClick: () => setOpenExport(true), 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', children: [_jsx(Download, { className: 'size-4' }), strings.export] })), metadata && showImport && (_jsxs("button", { type: 'button', onClick: () => setOpenImport(true), 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', children: [_jsx(Upload, { className: 'size-4' }), strings.import] })), ext?.toolbarExtras && _jsx(ext.toolbarExtras, { model: model, onRefresh: handleRefresh }), toolbarExtras, showCreate && (_jsxs("button", { type: 'button', onClick: () => setOpenCreate(true), 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', children: [_jsx(Plus, { className: 'size-4' }), resolvedNewLabel ?? `${strings.newPrefix} ${singular}`] }))] })] }), _jsx("div", { className: tableWrapperCls, children: _jsx(DynamicTable, { model: model, endpoint: dataEndpoint, refreshTrigger: refreshKey }, model) })] }), showCreate && (_jsx(DynamicRecordDialog, { open: openCreate, onOpenChange: setOpenCreate, mode: 'create', model: model, endpoint: dataEndpoint, onSaved: handleRefresh })), metadata && showExport && (_jsx(ExportDialog, { open: openExport, onOpenChange: setOpenExport, model: model, metadata: metadata })), metadata && showImport && (_jsx(ImportDialog, { open: openImport, onOpenChange: setOpenImport, model: model, metadata: metadata, onImported: handleRefresh }))] }));
106
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAiBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AA+B9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAQnF,UAAU,iBAAiB;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;CACxC;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,GAC/C,EAAE,iBAAiB,2CA+oBnB"}
1
+ {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAiBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AA+B9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AASnF,UAAU,iBAAiB;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;CACxC;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,GAC/C,EAAE,iBAAiB,2CA4qBnB"}
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // DynamicTable — metadata-driven CRUD table used by every metacore host.
3
- // Ported from the ops starter but with the host-specific aliases swapped
4
- // for metacore packages + context-injected peer deps:
3
+ // Originally extracted from a host app and generalized so the host-specific
4
+ // aliases are swapped for metacore packages + context-injected peer deps:
5
5
  // * `@/lib/api` → <ApiProvider> (see api-context.tsx)
6
6
  // * `@/stores/branch-store` → <BranchProvider> (optional)
7
7
  // * `@/stores/metadata-cache` → internal ./metadata-cache zustand store
@@ -24,6 +24,7 @@ import { toast } from 'sonner';
24
24
  import { Progress } from './dialogs/_primitives';
25
25
  import { useMetadataCache } from './metadata-cache';
26
26
  import { useApi, useCurrentBranch } from './api-context';
27
+ import { defaultGetDynamicColumns } from './dynamic-columns';
27
28
  import { OptionsContext } from './options-context';
28
29
  import { ActionModalDispatcher } from './action-modal-dispatcher';
29
30
  import { DynamicRecordDialog } from './dialogs/dynamic-record';
@@ -436,9 +437,13 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
436
437
  }, []);
437
438
  const columnFilterConfigs = useMemo(() => {
438
439
  const map = new Map();
439
- if (!metadata?.filters)
440
+ if (!metadata)
440
441
  return map;
441
- for (const f of metadata.filters) {
442
+ // Explicit `metadata.filters` wins. When the backend does not emit
443
+ // them, derive a filter chip from every column flagged
444
+ // `filterable: true` — keeps the kernel API minimal (one flag on the
445
+ // column) while still rendering the FilterableColumnHeader.
446
+ for (const f of metadata.filters ?? []) {
442
447
  const fType = f.type;
443
448
  let options = [];
444
449
  if (f.options && f.options.length > 0) {
@@ -459,6 +464,33 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
459
464
  searchEndpoint: f.searchEndpoint,
460
465
  });
461
466
  }
467
+ for (const c of metadata.columns ?? []) {
468
+ if (!c.filterable || map.has(c.key))
469
+ continue;
470
+ const hasStaticOptions = (c.options?.length ?? 0) > 0;
471
+ const hasEndpoint = !!c.searchEndpoint;
472
+ if (!hasStaticOptions && !hasEndpoint && c.type !== 'boolean')
473
+ continue;
474
+ const options = hasStaticOptions
475
+ ? c.options.map(o => ({
476
+ label: o.label,
477
+ value: String(o.value),
478
+ icon: o.icon,
479
+ color: o.color,
480
+ }))
481
+ : hasEndpoint && filterOptionsMap.has(c.searchEndpoint)
482
+ ? filterOptionsMap.get(c.searchEndpoint) || []
483
+ : [];
484
+ map.set(c.key, {
485
+ filterType: 'select',
486
+ filterKey: c.key,
487
+ options,
488
+ selectedValues: dynamicFilters[c.key] || [],
489
+ onFilterChange: handleDynamicFilterChange,
490
+ loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
491
+ searchEndpoint: c.searchEndpoint,
492
+ });
493
+ }
462
494
  return map;
463
495
  }, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange]);
464
496
  const columns = useMemo(() => {
@@ -508,9 +540,3 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
508
540
  return (_jsx(TableCell, { style: cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined, className: cn('py-2', isActionsColumn && 'sticky right-0 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
509
541
  }) }, row.id)))) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })] }) }) })) })] }) }), _jsx("div", { className: 'shrink-0 pt-4', children: _jsx(DataTablePagination, { table: table, pageSizeOptions: metadata.perPageOptions }) })] }), _jsx(AlertDialog, { open: !!rowToDelete, onOpenChange: (open) => !open && setRowToDelete(null), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: "\u00BFEst\u00E1 absolutamente seguro?" }), _jsx(AlertDialogDescription, { children: "Esta acci\u00F3n no se puede deshacer. Esto eliminar\u00E1 permanentemente el registro seleccionado de nuestros servidores." })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: isDeleting, children: t('common.cancel') }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); confirmDelete(); }, className: "bg-red-600 hover:bg-red-700", disabled: isDeleting, children: isDeleting ? 'Eliminando...' : 'Eliminar' })] })] }) }), _jsx(AlertDialog, { open: showBulkDeleteConfirm, onOpenChange: (open) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?' }), _jsx(AlertDialogDescription, { children: isBulkDeleting ? (_jsxs("div", { className: "space-y-4 mt-4", children: [_jsx(Progress, { value: (bulkDeleteProgress / bulkDeleteTotal) * 100 }), _jsxs("p", { className: "text-center text-sm", children: ["Procesando ", bulkDeleteProgress, " de ", bulkDeleteTotal, " registros..."] })] })) : (_jsxs(_Fragment, { children: ["Esta acci\u00F3n no se puede deshacer. Se eliminar\u00E1n permanentemente ", _jsx("strong", { children: Object.keys(rowSelection).length }), " registro(s) de nuestros servidores."] })) })] }), !isBulkDeleting && (_jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: t('common.cancel') }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); confirmBulkDelete(); }, className: "bg-red-600 hover:bg-red-700", children: "Eliminar todos" })] }))] }) }), _jsx(DynamicRecordDialog, { open: recordDialog.open, onOpenChange: (open) => setRecordDialog((prev) => ({ ...prev, open })), mode: recordDialog.mode, model: model, recordId: recordDialog.recordId, endpoint: endpoint, onSaved: handleRefresh }), metadata.canExport && (_jsx(ExportDialog, { open: exportOpen, onOpenChange: setExportOpen, model: model, metadata: metadata, currentFilters: buildFilterParams(), hasActiveFilters: hasActiveFilters })), metadata.canImport && (_jsx(ImportDialog, { open: importOpen, onOpenChange: setImportOpen, model: model, metadata: metadata, onImported: handleRefresh })), actionModal.action && (_jsx(ActionModalDispatcher, { open: actionModal.open, onOpenChange: (open) => setActionModal((prev) => ({ ...prev, open })), action: actionModal.action, model: model, record: actionModal.record, endpoint: endpoint, onSuccess: handleRefresh })), _jsx(DataTableBulkActions, { table: table, entityName: "registro", children: _jsxs(Button, { variant: "destructive", size: "sm", className: "h-8", onClick: () => setShowBulkDeleteConfirm(true), children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5" }), " Eliminar"] }) })] }));
510
542
  }
511
- /** Sensible default when hosts don't provide their own getDynamicColumns. */
512
- const defaultGetDynamicColumns = (metadata, _handleAction, _t, _lang, _filters) => (metadata.columns ?? []).map((col) => ({
513
- accessorKey: col.name,
514
- header: col.label ?? col.name,
515
- enableSorting: col.sortable ?? false,
516
- }));
package/dist/index.d.ts CHANGED
@@ -16,4 +16,6 @@ export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, type DynamicCol
16
16
  export { DynamicRecordDialog } from './dialogs/dynamic-record';
17
17
  export { ExportDialog } from './dialogs/export';
18
18
  export { ImportDialog } from './dialogs/import';
19
+ export { DynamicCRUDPage, type DynamicCRUDPageProps, type DynamicCRUDPageStrings, type DynamicCRUDPageClasses, } from './dynamic-crud-page';
20
+ export { registerModelExtension, getModelExtension, clearModelExtensions, type ModelExtension, type ModelExtensionProps, } from './model-extension-registry';
19
21
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,wBAAwB,EACxB,4BAA4B,EAC5B,KAAK,qBAAqB,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,SAAS,CAAA;AACvB,cAAc,mBAAmB,CAAA;AACjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,OAAO,EACH,qBAAqB,EACrB,KAAK,gBAAgB,GACxB,MAAM,2BAA2B,CAAA;AAClC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,QAAQ,CAAA;AACtB,cAAc,mBAAmB,CAAA;AACjC,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,YAAY,EACR,kBAAkB,EAClB,YAAY,IAAI,yBAAyB,EACzC,iBAAiB,EACjB,oBAAoB,GACvB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACH,wBAAwB,EACxB,4BAA4B,EAC5B,KAAK,qBAAqB,GAC7B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EACH,eAAe,EACf,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,GAC9B,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACH,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,mBAAmB,GAC3B,MAAM,4BAA4B,CAAA"}
package/dist/index.js CHANGED
@@ -20,3 +20,5 @@ export { defaultGetDynamicColumns, makeDefaultGetDynamicColumns, } from './dynam
20
20
  export { DynamicRecordDialog } from './dialogs/dynamic-record';
21
21
  export { ExportDialog } from './dialogs/export';
22
22
  export { ImportDialog } from './dialogs/import';
23
+ export { DynamicCRUDPage, } from './dynamic-crud-page';
24
+ export { registerModelExtension, getModelExtension, clearModelExtensions, } 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,19 @@
1
+ import * as React from 'react';
2
+ export interface ModelExtensionProps {
3
+ model: string;
4
+ onRefresh: () => void;
5
+ }
6
+ export interface ModelExtension {
7
+ headerExtras?: React.ComponentType<ModelExtensionProps>;
8
+ toolbarExtras?: React.ComponentType<ModelExtensionProps>;
9
+ hideCreate?: boolean;
10
+ hideExport?: boolean;
11
+ hideImport?: boolean;
12
+ hideRefresh?: boolean;
13
+ title?: string;
14
+ newLabel?: string;
15
+ }
16
+ export declare function registerModelExtension(model: string, ext: ModelExtension): void;
17
+ export declare function getModelExtension(model: string): ModelExtension | undefined;
18
+ export declare function clearModelExtensions(): void;
19
+ //# sourceMappingURL=model-extension-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-extension-registry.d.ts","sourceRoot":"","sources":["../src/model-extension-registry.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,MAAM,WAAW,mBAAmB;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,IAAI,CAAA;CACxB;AAED,MAAM,WAAW,cAAc;IAC3B,YAAY,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,mBAAmB,CAAC,CAAA;IACvD,aAAa,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,mBAAmB,CAAC,CAAA;IACxD,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAID,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,CAE/E;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS,CAE3E;AAED,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C"}
@@ -0,0 +1,10 @@
1
+ const registry = new Map();
2
+ export function registerModelExtension(model, ext) {
3
+ registry.set(model, ext);
4
+ }
5
+ export function getModelExtension(model) {
6
+ return registry.get(model);
7
+ }
8
+ export function clearModelExtensions() {
9
+ registry.clear();
10
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "6.1.0",
3
+ "version": "7.0.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  "date-fns": ">=3",
31
31
  "react-day-picker": ">=8",
32
32
  "@asteby/metacore-sdk": "^2.2.0",
33
- "@asteby/metacore-ui": "^0.6.0"
33
+ "@asteby/metacore-ui": "^0.7.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "@tanstack/react-router": {
@@ -56,7 +56,7 @@
56
56
  "typescript": "^5.6.0",
57
57
  "zustand": "^5.0.0",
58
58
  "@asteby/metacore-sdk": "2.2.0",
59
- "@asteby/metacore-ui": "0.6.0"
59
+ "@asteby/metacore-ui": "0.7.0"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsc -p tsconfig.json",
@@ -1,6 +1,6 @@
1
1
  // ExportDialog — lets users pick format (csv/json) + columns and kicks off
2
2
  // either a sync download or an async export job (polled via /exports/:id/status).
3
- // Ported from the ops starter. Axios-like client is provided by <ApiProvider>.
3
+ // Axios-like client is provided by <ApiProvider>.
4
4
  import { useState, useEffect, useCallback } from 'react'
5
5
  import {
6
6
  Dialog,
@@ -1,6 +1,5 @@
1
1
  // ImportDialog — three-step CSV/JSON import flow (upload → validate → import
2
- // with per-row error report). Ported from the ops starter. Axios-like client
3
- // is provided by <ApiProvider>.
2
+ // with per-row error report). Axios-like client is provided by <ApiProvider>.
4
3
  import { useState, useEffect, useRef } from 'react'
5
4
  import {
6
5
  Dialog,
@@ -3,7 +3,7 @@
3
3
  // badge (static + endpoint-loaded options), avatar, phone, date, boolean,
4
4
  // relation-badge-list, media-gallery, image, plus a generic text fallback.
5
5
  //
6
- // The implementation was previously duplicated across `link` and `ops`
6
+ // The implementation was previously duplicated across multiple host apps
7
7
  // (~550 LOC each, drifting). It now lives here so a single fix propagates
8
8
  // to every host. Hosts inject app-specific URL helpers via the `helpers`
9
9
  // argument so the SDK stays free of environment-bound code.
@@ -31,7 +31,7 @@ import {
31
31
  FilterableColumnHeader,
32
32
  type ColumnFilterMeta,
33
33
  } from '@asteby/metacore-ui/data-table'
34
- import { generateBadgeStyles } from '@asteby/metacore-ui/lib'
34
+ import { generateBadgeStyles, getInitials } from '@asteby/metacore-ui/lib'
35
35
  import { OptionsContext } from './options-context'
36
36
  import { DynamicIcon } from './dynamic-icon'
37
37
  import type { TableMetadata, ColumnDefinition } from './types'
@@ -255,22 +255,26 @@ export function makeDefaultGetDynamicColumns(
255
255
  ),
256
256
  cell: ({ row }) => {
257
257
  const value = getNestedValue(row.original, col.key)
258
+ // Kernel emits the renderer flag as `type`; older hosts used
259
+ // `cellStyle`. Accept both so a single backend works across
260
+ // SDK versions.
261
+ const renderAs = col.cellStyle ?? col.type
258
262
 
259
263
  // Endpoint-loaded badge options (preloaded into OptionsContext)
260
- if (col.cellStyle === 'badge' && col.useOptions && col.searchEndpoint) {
264
+ if (renderAs === 'badge' && col.useOptions && col.searchEndpoint) {
261
265
  if (!value) return <span className="text-muted-foreground">-</span>
262
266
  return <BadgeWithEndpointOptions endpoint={col.searchEndpoint} value={value} />
263
267
  }
264
268
 
265
269
  // Static badge options — map value → label/icon/color
266
- if (col.cellStyle === 'badge' && col.options && col.options.length > 0) {
270
+ if (renderAs === 'badge' && col.options && col.options.length > 0) {
267
271
  if (!value && value !== 0) return <span className="text-muted-foreground">-</span>
268
272
  const option = col.options.find((o) => o.value === String(value))
269
273
  if (option) return <OptionBadge option={option} fallback={String(value)} />
270
274
  return <Badge variant="outline">{String(value)}</Badge>
271
275
  }
272
276
 
273
- if (col.cellStyle === 'relation-badge-list') {
277
+ if (renderAs === 'relation-badge-list') {
274
278
  return renderRelationBadges(value, col)
275
279
  }
276
280
 
@@ -325,12 +329,7 @@ export function makeDefaultGetDynamicColumns(
325
329
  className="object-cover"
326
330
  />
327
331
  <AvatarFallback className="text-[10px] font-bold bg-primary/5 text-primary rounded-lg">
328
- {String(name)
329
- .split(' ')
330
- .map((n: string) => n[0])
331
- .slice(0, 2)
332
- .join('')
333
- .toUpperCase()}
332
+ {getInitials(String(name))}
334
333
  </AvatarFallback>
335
334
  </Avatar>
336
335
  <div className="flex flex-col min-w-0 overflow-hidden">
@@ -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
@@ -33,3 +33,16 @@ export {
33
33
  export { DynamicRecordDialog } from './dialogs/dynamic-record'
34
34
  export { ExportDialog } from './dialogs/export'
35
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