@asteby/metacore-runtime-react 6.1.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.
- package/CHANGELOG.md +12 -2
- package/README.md +67 -44
- package/dist/dialogs/export.js +1 -1
- package/dist/dialogs/import.d.ts.map +1 -1
- package/dist/dialogs/import.js +1 -2
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +8 -4
- package/dist/dynamic-crud-page.d.ts +49 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -0
- package/dist/dynamic-crud-page.js +106 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +36 -10
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/metadata-cache.js +3 -3
- package/dist/model-extension-registry.d.ts +19 -0
- package/dist/model-extension-registry.d.ts.map +1 -0
- package/dist/model-extension-registry.js +10 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/dialogs/export.tsx +1 -1
- package/src/dialogs/import.tsx +1 -2
- package/src/dynamic-columns.tsx +8 -4
- package/src/dynamic-crud-page.tsx +275 -0
- package/src/dynamic-table.tsx +34 -11
- package/src/index.ts +13 -0
- package/src/metadata-cache.ts +3 -3
- package/src/model-extension-registry.tsx +46 -0
- package/src/types.ts +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 6.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d7f1e55: Per-model extension registry, badge cell normalization, and auto-derived filter chips.
|
|
8
|
+
- `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.
|
|
9
|
+
- `defaultGetDynamicColumns` now accepts `type === 'badge'` (what the kernel emits) in addition to `cellStyle === 'badge'`. Columns marked `type: badge` previously rendered as plain text.
|
|
10
|
+
- `<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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
3
13
|
## 6.0.0
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
|
@@ -20,7 +30,7 @@
|
|
|
20
30
|
|
|
21
31
|
- e23eede: Publicación inicial a npm del ecosistema metacore.
|
|
22
32
|
|
|
23
|
-
Propaga los 13 paquetes del SDK al registry público para que
|
|
33
|
+
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
34
|
|
|
25
35
|
### Patch Changes
|
|
26
36
|
|
|
@@ -34,7 +44,7 @@
|
|
|
34
44
|
|
|
35
45
|
- 6d243b0: Initial release of the metacore frontend ecosystem.
|
|
36
46
|
|
|
37
|
-
11 packages extracted from
|
|
47
|
+
11 packages extracted from host application frontends into a publishable monorepo with auto-propagation via Changesets + Renovate.
|
|
38
48
|
|
|
39
49
|
### Patch Changes
|
|
40
50
|
|
package/README.md
CHANGED
|
@@ -1,59 +1,82 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
-
React runtime for [
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
canonical action registry and `AddonAPI` contract. Build order:
|
|
57
|
+
## How it talks to the kernel
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
package/dist/dialogs/export.js
CHANGED
|
@@ -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
|
-
//
|
|
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":"
|
|
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"}
|
package/dist/dialogs/import.js
CHANGED
|
@@ -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).
|
|
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,
|
|
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,CAmVnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
|
package/dist/dynamic-columns.js
CHANGED
|
@@ -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
|
|
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.
|
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
172
|
+
if (renderAs === 'relation-badge-list') {
|
|
169
173
|
return renderRelationBadges(value, col);
|
|
170
174
|
}
|
|
171
175
|
switch (col.type) {
|
|
@@ -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;
|
|
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"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
440
|
+
if (!metadata)
|
|
440
441
|
return map;
|
|
441
|
-
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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';
|
package/dist/metadata-cache.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Metadata cache — a zustand store that memoizes table/modal metadata
|
|
2
|
-
// responses across dynamic-table mounts.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
+
}
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/dialogs/export.tsx
CHANGED
|
@@ -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
|
-
//
|
|
3
|
+
// Axios-like client is provided by <ApiProvider>.
|
|
4
4
|
import { useState, useEffect, useCallback } from 'react'
|
|
5
5
|
import {
|
|
6
6
|
Dialog,
|
package/src/dialogs/import.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// ImportDialog — three-step CSV/JSON import flow (upload → validate → import
|
|
2
|
-
// with per-row error report).
|
|
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,
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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
|
|
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.
|
|
@@ -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 (
|
|
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 (
|
|
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 (
|
|
277
|
+
if (renderAs === 'relation-badge-list') {
|
|
274
278
|
return renderRelationBadges(value, col)
|
|
275
279
|
}
|
|
276
280
|
|
|
@@ -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
|
+
}
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// DynamicTable — metadata-driven CRUD table used by every metacore host.
|
|
2
|
-
//
|
|
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
|
|
481
|
-
|
|
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'
|
package/src/metadata-cache.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Metadata cache — a zustand store that memoizes table/modal metadata
|
|
2
|
-
// responses across dynamic-table mounts.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
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
|