@asteby/metacore-runtime-react 18.4.0 → 18.5.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 +20 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +1 -4
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +51 -18
- package/dist/org-runtime-context.d.ts +17 -0
- package/dist/org-runtime-context.d.ts.map +1 -0
- package/dist/org-runtime-context.js +24 -0
- package/package.json +1 -1
- package/src/dialogs/dynamic-record.tsx +1 -4
- package/src/dynamic-relation.tsx +136 -56
- package/src/org-runtime-context.ts +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 18.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d7c792d: Render one_to_many relations as a rich table (headers + currency/image/date/badge cells)
|
|
8
|
+
|
|
9
|
+
`OneToManyRelation` now renders the child list as a real metadata-driven table
|
|
10
|
+
using the same metacore-ui `<Table>` primitives and the exact
|
|
11
|
+
`makeDefaultGetDynamicColumns` cell factory as `<DynamicTable>`, instead of a
|
|
12
|
+
bare flex grid of unlabeled values. Line items now get column headers, money in
|
|
13
|
+
the org currency right-aligned (e.g. `100,00 MXN`), FK thumbnails + labels,
|
|
14
|
+
dates in the org timezone, status/option badges and creator names — matching
|
|
15
|
+
the main dynamic table. The inline edit (DynamicForm dialog) and delete
|
|
16
|
+
(AlertDialog) actions are preserved as a trailing actions column.
|
|
17
|
+
|
|
18
|
+
The org `timeZone`/`currency` contexts were extracted from
|
|
19
|
+
`dialogs/dynamic-record` into a shared `org-runtime-context` module so the
|
|
20
|
+
relation table can consume them without a circular import. `ManyToManyRelation`
|
|
21
|
+
is unchanged.
|
|
22
|
+
|
|
3
23
|
## 18.4.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AA6C1C,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AA6C1C,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAK1F,YAAY,EAAE,WAAW,EAAE,CAAA;AAE3B,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,QAAQ;IACrB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAA;IACpH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,WAAW,EAAE,CAAA;IACvB,YAAY,CAAC,EAAE,GAAG,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;;;OAQG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACpC;AAiCD,MAAM,WAAW,wBAAwB;IACrC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;2DAEuD;IACvD,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpG;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAC3B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAA;IAC1C;;;;OAIG;IACH,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAqID,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,GAAG,OAAO,CAUjE;AAED,wBAAgB,mBAAmB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,MAAM,EACN,cAAc,EACd,aAAa,EACb,WAA8B,EAC9B,QAAQ,EACR,QAAQ,GACX,EAAE,wBAAwB,+BAuW1B;AAgGD,wBAAgB,SAAS,CAAC,EACtB,KAAK,EACL,KAAK,EAAE,QAAQ,EACf,MAAM,EACN,WAAW,EAAE,eAAe,EAC5B,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,YAAY,GACzB,EAAE;IACC,KAAK,EAAE,QAAQ,CAAA;IACf,KAAK,EAAE,GAAG,CAAA;IACV,MAAM,EAAE,GAAG,CAAA;IACX,mFAAmF;IACnF,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB,+BA0JA"}
|
|
@@ -28,6 +28,7 @@ import { isNilUuid, normalizeNilUuid } from '../nil-uuid';
|
|
|
28
28
|
import { humanizeToken } from '../dynamic-columns-helpers';
|
|
29
29
|
import { formatDateCell } from '../dynamic-columns';
|
|
30
30
|
import { ImageUrlContext, identityImageUrl } from '../image-url-context';
|
|
31
|
+
import { TimeZoneContext, CurrencyContext } from '../org-runtime-context';
|
|
31
32
|
// localizedModelName resolves the (possibly addon-i18n) model name: prefer the
|
|
32
33
|
// translated titleKey, fall back to the backend-provided raw title.
|
|
33
34
|
function localizedModelName(meta, t) {
|
|
@@ -157,10 +158,6 @@ const MODE_CONFIG = {
|
|
|
157
158
|
// Context threading host runtime values to nested field components (uploads,
|
|
158
159
|
// image leads, tz-aware dates) without prop-drilling through every renderer.
|
|
159
160
|
const ModelContext = createContext('');
|
|
160
|
-
const TimeZoneContext = createContext(undefined);
|
|
161
|
-
// Org ISO-4217 currency (org config, like the timezone) used as the fallback
|
|
162
|
-
// for money fields that don't carry an explicit per-field currency.
|
|
163
|
-
const CurrencyContext = createContext(undefined);
|
|
164
161
|
// Money-key heuristic mirroring the backend's `inferDisplayCellStyle`: lets the
|
|
165
162
|
// dialog format obvious money fields as currency even when the backend hasn't
|
|
166
163
|
// stamped `cellStyle:'currency'` yet. Case-insensitive; matches a key that
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-relation.d.ts","sourceRoot":"","sources":["../src/dynamic-relation.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dynamic-relation.d.ts","sourceRoot":"","sources":["../src/dynamic-relation.tsx"],"names":[],"mappings":"AA+DA,YAAY,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AACrE,OAAO,EACH,kBAAkB,EAClB,uBAAuB,EACvB,kBAAkB,EAClB,yBAAyB,EACzB,wBAAwB,EACxB,aAAa,EACb,wBAAwB,EACxB,kBAAkB,EAClB,WAAW,EACX,eAAe,EACf,cAAc,GACjB,MAAM,4BAA4B,CAAA;AAEnC,MAAM,WAAW,sBAAsB;IACnC,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,wBAAwB,EAAE,MAAM,CAAA;IAChC,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,iBAAiB,EAAE,MAAM,CAAA;IACzB,uBAAuB,EAAE,MAAM,CAAA;IAC/B,WAAW,EAAE,MAAM,CAAA;CACtB;AAiBD,UAAU,WAAW;IACjB,6BAA6B;IAC7B,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAA;IACzB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,uCAAuC;IACvC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,2BAA2B;IAC3B,OAAO,CAAC,EAAE,OAAO,CAAC,sBAAsB,CAAC,CAAA;IACzC,yBAAyB;IACzB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACxB;AAED,MAAM,WAAW,6BAA8B,SAAQ,WAAW;IAC9D,IAAI,EAAE,aAAa,CAAA;IACnB,yFAAyF;IACzF,KAAK,EAAE,MAAM,CAAA;IACb,kDAAkD;IAClD,UAAU,EAAE,MAAM,CAAA;IAClB,mDAAmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,8BAA+B,SAAQ,WAAW;IAC/D,IAAI,EAAE,cAAc,CAAA;IACpB,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAA;IACf,sEAAsE;IACtE,UAAU,EAAE,MAAM,CAAA;IAClB,6BAA6B;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,mEAAmE;IACnE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,uEAAuE;IACvE,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,MAAM,oBAAoB,GAC1B,6BAA6B,GAC7B,8BAA8B,CAAA;AAEpC,wBAAgB,eAAe,CAAC,KAAK,EAAE,oBAAoB,+BAK1D"}
|
package/dist/dynamic-relation.js
CHANGED
|
@@ -5,15 +5,19 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
// - "many_to_many": multi-select sobre la tabla destino con sync a la pivot.
|
|
6
6
|
// La RFC completa vive en `packages/runtime-react/docs/relations.md`.
|
|
7
7
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
8
|
-
import {
|
|
8
|
+
import { useTranslation } from 'react-i18next';
|
|
9
|
+
import { flexRender, getCoreRowModel, useReactTable, } from '@tanstack/react-table';
|
|
10
|
+
import { cn } from '@asteby/metacore-ui/lib';
|
|
11
|
+
import { Button, Skeleton, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Dialog, DialogContent, DialogHeader, DialogTitle, MultiSelect, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@asteby/metacore-ui/primitives';
|
|
9
12
|
import { Plus, Trash2, Pencil } from 'lucide-react';
|
|
10
13
|
import { useApi } from './api-context';
|
|
11
14
|
import { useMetadataCache } from './metadata-cache';
|
|
12
15
|
import { DynamicForm } from './dynamic-form';
|
|
13
16
|
import { useImageUrl } from './image-url-context';
|
|
14
|
-
import {
|
|
17
|
+
import { useTimeZone, useCurrency } from './org-runtime-context';
|
|
18
|
+
import { makeDefaultGetDynamicColumns } from './dynamic-columns';
|
|
15
19
|
import { useOptionsResolver } from './use-options-resolver';
|
|
16
|
-
import { buildCreatePayload, buildPivotAttachPayload, buildPivotRowIndex, buildRelationFilterParams, deriveRelationFormFields, diffSelection, extractSelectedTargetIds,
|
|
20
|
+
import { buildCreatePayload, buildPivotAttachPayload, buildPivotRowIndex, buildRelationFilterParams, deriveRelationFormFields, diffSelection, extractSelectedTargetIds, pickOptionLabel, relationRowKey, } from './dynamic-relation-helpers';
|
|
17
21
|
export { buildCreatePayload, buildPivotAttachPayload, buildPivotRowIndex, buildRelationFilterParams, deriveRelationFormFields, diffSelection, extractSelectedTargetIds, formatRelationCell, objectLabel, pickOptionLabel, relationRowKey, } from './dynamic-relation-helpers';
|
|
18
22
|
const DEFAULT_STRINGS = {
|
|
19
23
|
title: '',
|
|
@@ -38,6 +42,9 @@ export function DynamicRelation(props) {
|
|
|
38
42
|
function OneToManyRelation({ kind, model, foreignKey, parentId, filters, endpoint, hiddenColumns = [], canCreate = true, canDelete = true, canEdit = true, strings, className, onChange, }) {
|
|
39
43
|
const api = useApi();
|
|
40
44
|
const getImageUrl = useImageUrl();
|
|
45
|
+
const timeZone = useTimeZone();
|
|
46
|
+
const currency = useCurrency();
|
|
47
|
+
const { i18n } = useTranslation();
|
|
41
48
|
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache();
|
|
42
49
|
const cachedMeta = getMetadata(model);
|
|
43
50
|
const labels = { ...DEFAULT_STRINGS, ...(strings || {}) };
|
|
@@ -88,6 +95,43 @@ function OneToManyRelation({ kind, model, foreignKey, parentId, filters, endpoin
|
|
|
88
95
|
const hidden = new Set([foreignKey, ...Object.keys(filters || {}), ...hiddenColumns]);
|
|
89
96
|
return metadata.columns.filter(c => !hidden.has(c.key) && !c.hidden);
|
|
90
97
|
}, [metadata, foreignKey, filtersKey, hiddenColumns]);
|
|
98
|
+
// Reuse the EXACT column factory the main `<DynamicTable>` uses so each cell
|
|
99
|
+
// renders identically — money in the org currency right-aligned, FK chips
|
|
100
|
+
// with thumbnails, dates in the org timezone, status/option badges, creator
|
|
101
|
+
// names — instead of a hand-rolled parallel formatting stack. Stable per
|
|
102
|
+
// image-url resolver.
|
|
103
|
+
const buildColumns = useMemo(() => makeDefaultGetDynamicColumns({ getImageUrl }), [getImageUrl]);
|
|
104
|
+
const showActions = canEdit || canDelete;
|
|
105
|
+
const columns = useMemo(() => {
|
|
106
|
+
if (!metadata)
|
|
107
|
+
return [];
|
|
108
|
+
// Feed the factory a metadata view scoped to the visible columns only,
|
|
109
|
+
// with model-level actions stripped — the relation list owns its own
|
|
110
|
+
// inline edit/delete column (appended below), and the factory's
|
|
111
|
+
// select/actions columns don't belong in an embedded child list.
|
|
112
|
+
const scopedMeta = {
|
|
113
|
+
...metadata,
|
|
114
|
+
columns: visibleColumns,
|
|
115
|
+
actions: [],
|
|
116
|
+
hasActions: false,
|
|
117
|
+
enableCRUDActions: false,
|
|
118
|
+
};
|
|
119
|
+
const base = buildColumns(scopedMeta, () => { }, (key, opts) => opts?.defaultValue ?? key, i18n?.language || 'es', new Map(), timeZone, currency).filter((c) => c.id !== 'select' && c.id !== 'actions');
|
|
120
|
+
if (!showActions)
|
|
121
|
+
return base;
|
|
122
|
+
const actionsCol = {
|
|
123
|
+
id: 'actions',
|
|
124
|
+
header: () => _jsx("span", { className: "sr-only", children: labels.editLabel }),
|
|
125
|
+
size: 80,
|
|
126
|
+
cell: ({ row }) => (_jsxs("div", { className: "flex items-center justify-end gap-1", children: [canEdit && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => { setEditingRow(row.original); setFormOpen(true); }, "aria-label": labels.editLabel, children: _jsx(Pencil, { className: "h-4 w-4" }) })), canDelete && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => setRowToDelete(row.original), "aria-label": labels.removeLabel, children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })),
|
|
127
|
+
};
|
|
128
|
+
return [...base, actionsCol];
|
|
129
|
+
}, [metadata, visibleColumns, buildColumns, i18n?.language, timeZone, currency, showActions, canEdit, canDelete, labels.editLabel, labels.removeLabel]);
|
|
130
|
+
const table = useReactTable({
|
|
131
|
+
data: rows,
|
|
132
|
+
columns,
|
|
133
|
+
getCoreRowModel: getCoreRowModel(),
|
|
134
|
+
});
|
|
91
135
|
const handleSubmit = useCallback(async (values) => {
|
|
92
136
|
setSubmitting(true);
|
|
93
137
|
try {
|
|
@@ -137,21 +181,10 @@ function OneToManyRelation({ kind, model, foreignKey, parentId, filters, endpoin
|
|
|
137
181
|
setSubmitting(false);
|
|
138
182
|
}
|
|
139
183
|
}, [api, dataEndpoint, fetchAll, onChange, rowToDelete]);
|
|
140
|
-
return (_jsxs("div", { className: className, "data-relation-kind": kind, "data-relation-model": model, children: [(labels.title || canCreate) && (_jsxs("div", { className: "flex items-center justify-between pb-3", children: [labels.title ? _jsx("h3", { className: "text-sm font-medium", children: labels.title }) : _jsx("span", {}), canCreate && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => { setEditingRow(null); setFormOpen(true); }, children: [_jsx(Plus, { className: "h-4 w-4 mr-1" }), labels.addLabel] }))] })), loading ? (_jsx("div", { className: "space-y-2", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Skeleton, { className: "h-10 w-full" }, `rel-skeleton-${i}`))) })) : rows.length === 0 ? (_jsx("div", { className: "text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30", children: labels.emptyState })) : (_jsx("div", { className: "border rounded-md
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// instead of plain text (e.g. a line item's
|
|
145
|
-
// product photo). The sibling is the column key
|
|
146
|
-
// with the trailing `_id` stripped.
|
|
147
|
-
const isFk = !!col.ref || col.key.endsWith('_id');
|
|
148
|
-
const sibling = isFk ? row[col.key.replace(/_id$/, '')] : undefined;
|
|
149
|
-
if (sibling && typeof sibling === 'object' && sibling.image) {
|
|
150
|
-
const label = sibling.label ?? sibling.name ?? cell;
|
|
151
|
-
return (_jsxs("span", { className: "flex min-w-0 items-center gap-2", title: String(label), children: [_jsx(OptionThumb, { image: getImageUrl(sibling.image), size: 20 }), _jsx("span", { className: "truncate", children: label })] }, col.key));
|
|
152
|
-
}
|
|
153
|
-
return (_jsx("span", { className: "truncate", title: cell, children: cell }, col.key));
|
|
154
|
-
}) }), _jsxs("div", { className: "flex items-center gap-1 shrink-0", children: [canEdit && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => { setEditingRow(row); setFormOpen(true); }, "aria-label": labels.editLabel, children: _jsx(Pencil, { className: "h-4 w-4" }) })), canDelete && (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => setRowToDelete(row), "aria-label": labels.removeLabel, children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }, relationRowKey(row, idx, foreignKey)))) })), _jsx(Dialog, { open: formOpen, onOpenChange: (open) => { setFormOpen(open); if (!open)
|
|
184
|
+
return (_jsxs("div", { className: className, "data-relation-kind": kind, "data-relation-model": model, children: [(labels.title || canCreate) && (_jsxs("div", { className: "flex items-center justify-between pb-3", children: [labels.title ? _jsx("h3", { className: "text-sm font-medium", children: labels.title }) : _jsx("span", {}), canCreate && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => { setEditingRow(null); setFormOpen(true); }, children: [_jsx(Plus, { className: "h-4 w-4 mr-1" }), labels.addLabel] }))] })), loading ? (_jsx("div", { className: "space-y-2", children: Array.from({ length: 3 }).map((_, i) => (_jsx(Skeleton, { className: "h-10 w-full" }, `rel-skeleton-${i}`))) })) : rows.length === 0 ? (_jsx("div", { className: "text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30", children: labels.emptyState })) : (_jsx("div", { className: "overflow-x-auto border rounded-md bg-card", children: _jsxs(Table, { noWrapper: true, className: "w-full", children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: "border-b-0 hover:bg-transparent", children: headerGroup.headers.map((header) => {
|
|
185
|
+
const isActions = header.id === 'actions';
|
|
186
|
+
return (_jsx(TableHead, { colSpan: header.colSpan, style: header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined, className: cn('bg-card border-b h-10', isActions && 'text-right'), children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
|
|
187
|
+
}) }, headerGroup.id))) }), _jsx(TableBody, { children: table.getRowModel().rows.map((row, idx) => (_jsx(TableRow, { children: row.getVisibleCells().map((cell) => (_jsx(TableCell, { style: cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined, className: "py-2", children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id))) }, relationRowKey(row.original, idx, foreignKey)))) })] }) })), _jsx(Dialog, { open: formOpen, onOpenChange: (open) => { setFormOpen(open); if (!open)
|
|
155
188
|
setEditingRow(null); }, children: _jsxs(DialogContent, { children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: editingRow ? labels.editLabel : labels.addLabel }) }), _jsx(DynamicForm, { fields: formFields, initialValues: editingRow || undefined, onSubmit: handleSubmit, onCancel: () => { setFormOpen(false); setEditingRow(null); }, submitLabel: labels.saveLabel, cancelLabel: labels.cancelLabel, disabled: submitting })] }) }), _jsx(AlertDialog, { open: !!rowToDelete, onOpenChange: (open) => !open && setRowToDelete(null), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: labels.confirmRemoveTitle }), _jsx(AlertDialogDescription, { children: labels.confirmRemoveDescription })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { disabled: submitting, children: labels.cancelLabel }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); handleDelete(); }, className: "bg-red-600 hover:bg-red-700", disabled: submitting, children: labels.removeLabel })] })] }) })] }));
|
|
156
189
|
}
|
|
157
190
|
function ManyToManyRelation({ kind, through, references, foreignKey, referencesKey, parentId, filters, pivotEndpoint, referencesEndpoint, displayKey, canCreate = true, canDelete = true, strings, className, onChange, }) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IANA timezone (e.g. the org's `America/Mexico_City`) used to render
|
|
3
|
+
* datetime/timestamp cells. `undefined` outside a provider → renderers fall
|
|
4
|
+
* back to the viewer's browser zone (legacy behaviour).
|
|
5
|
+
*/
|
|
6
|
+
export declare const TimeZoneContext: import("react").Context<string | undefined>;
|
|
7
|
+
/** Reads the nearest org timezone (undefined outside a provider). */
|
|
8
|
+
export declare const useTimeZone: () => string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Org ISO-4217 currency (org config, like the timezone) used as the fallback
|
|
11
|
+
* for money cells/fields that don't carry an explicit per-column currency.
|
|
12
|
+
* `undefined` outside a provider → renderers fall back to 'USD'.
|
|
13
|
+
*/
|
|
14
|
+
export declare const CurrencyContext: import("react").Context<string | undefined>;
|
|
15
|
+
/** Reads the nearest org currency (undefined outside a provider). */
|
|
16
|
+
export declare const useCurrency: () => string | undefined;
|
|
17
|
+
//# sourceMappingURL=org-runtime-context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"org-runtime-context.d.ts","sourceRoot":"","sources":["../src/org-runtime-context.ts"],"names":[],"mappings":"AASA;;;;GAIG;AACH,eAAO,MAAM,eAAe,6CAA+C,CAAA;AAE3E,qEAAqE;AACrE,eAAO,MAAM,WAAW,0BAAoC,CAAA;AAE5D;;;;GAIG;AACH,eAAO,MAAM,eAAe,6CAA+C,CAAA;AAE3E,qEAAqE;AACrE,eAAO,MAAM,WAAW,0BAAoC,CAAA"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Org runtime contexts — thread the org's display config (timezone, currency)
|
|
2
|
+
// to nested field/cell/relation renderers without prop-drilling. Lives in its
|
|
3
|
+
// own module (mirroring `image-url-context`) so any renderer can consume them
|
|
4
|
+
// without importing from `dialogs/dynamic-record`. That dialog imports
|
|
5
|
+
// `dynamic-relations` → `dynamic-relation`, so the relation table cannot import
|
|
6
|
+
// these contexts back from the dialog without a circular import — hence this
|
|
7
|
+
// standalone module is the single source of truth.
|
|
8
|
+
import { createContext, useContext } from 'react';
|
|
9
|
+
/**
|
|
10
|
+
* IANA timezone (e.g. the org's `America/Mexico_City`) used to render
|
|
11
|
+
* datetime/timestamp cells. `undefined` outside a provider → renderers fall
|
|
12
|
+
* back to the viewer's browser zone (legacy behaviour).
|
|
13
|
+
*/
|
|
14
|
+
export const TimeZoneContext = createContext(undefined);
|
|
15
|
+
/** Reads the nearest org timezone (undefined outside a provider). */
|
|
16
|
+
export const useTimeZone = () => useContext(TimeZoneContext);
|
|
17
|
+
/**
|
|
18
|
+
* Org ISO-4217 currency (org config, like the timezone) used as the fallback
|
|
19
|
+
* for money cells/fields that don't carry an explicit per-column currency.
|
|
20
|
+
* `undefined` outside a provider → renderers fall back to 'USD'.
|
|
21
|
+
*/
|
|
22
|
+
export const CurrencyContext = createContext(undefined);
|
|
23
|
+
/** Reads the nearest org currency (undefined outside a provider). */
|
|
24
|
+
export const useCurrency = () => useContext(CurrencyContext);
|
package/package.json
CHANGED
|
@@ -57,6 +57,7 @@ import { humanizeToken } from '../dynamic-columns-helpers'
|
|
|
57
57
|
import { formatDateCell } from '../dynamic-columns'
|
|
58
58
|
import type { ActionFieldDef, RelationMeta } from '../types'
|
|
59
59
|
import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context'
|
|
60
|
+
import { TimeZoneContext, CurrencyContext } from '../org-runtime-context'
|
|
60
61
|
|
|
61
62
|
// Re-export the resolver type so `index.ts`'s
|
|
62
63
|
// `export type { … GetImageUrl } from './dialogs/dynamic-record'` keeps working.
|
|
@@ -350,10 +351,6 @@ const MODE_CONFIG = {
|
|
|
350
351
|
// Context threading host runtime values to nested field components (uploads,
|
|
351
352
|
// image leads, tz-aware dates) without prop-drilling through every renderer.
|
|
352
353
|
const ModelContext = createContext('')
|
|
353
|
-
const TimeZoneContext = createContext<string | undefined>(undefined)
|
|
354
|
-
// Org ISO-4217 currency (org config, like the timezone) used as the fallback
|
|
355
|
-
// for money fields that don't carry an explicit per-field currency.
|
|
356
|
-
const CurrencyContext = createContext<string | undefined>(undefined)
|
|
357
354
|
|
|
358
355
|
// Money-key heuristic mirroring the backend's `inferDisplayCellStyle`: lets the
|
|
359
356
|
// dialog format obvious money fields as currency even when the backend hasn't
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -4,6 +4,18 @@
|
|
|
4
4
|
// - "many_to_many": multi-select sobre la tabla destino con sync a la pivot.
|
|
5
5
|
// La RFC completa vive en `packages/runtime-react/docs/relations.md`.
|
|
6
6
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
7
|
+
import { useTranslation } from 'react-i18next'
|
|
8
|
+
import {
|
|
9
|
+
type ColumnDef,
|
|
10
|
+
type Row,
|
|
11
|
+
type Cell,
|
|
12
|
+
type HeaderGroup,
|
|
13
|
+
type Header,
|
|
14
|
+
flexRender,
|
|
15
|
+
getCoreRowModel,
|
|
16
|
+
useReactTable,
|
|
17
|
+
} from '@tanstack/react-table'
|
|
18
|
+
import { cn } from '@asteby/metacore-ui/lib'
|
|
7
19
|
import {
|
|
8
20
|
Button,
|
|
9
21
|
Skeleton,
|
|
@@ -20,13 +32,20 @@ import {
|
|
|
20
32
|
DialogHeader,
|
|
21
33
|
DialogTitle,
|
|
22
34
|
MultiSelect,
|
|
35
|
+
Table,
|
|
36
|
+
TableBody,
|
|
37
|
+
TableCell,
|
|
38
|
+
TableHead,
|
|
39
|
+
TableHeader,
|
|
40
|
+
TableRow,
|
|
23
41
|
} from '@asteby/metacore-ui/primitives'
|
|
24
42
|
import { Plus, Trash2, Pencil } from 'lucide-react'
|
|
25
43
|
import { useApi } from './api-context'
|
|
26
44
|
import { useMetadataCache } from './metadata-cache'
|
|
27
45
|
import { DynamicForm } from './dynamic-form'
|
|
28
46
|
import { useImageUrl } from './image-url-context'
|
|
29
|
-
import {
|
|
47
|
+
import { useTimeZone, useCurrency } from './org-runtime-context'
|
|
48
|
+
import { makeDefaultGetDynamicColumns } from './dynamic-columns'
|
|
30
49
|
import { useOptionsResolver } from './use-options-resolver'
|
|
31
50
|
import type { ApiResponse, TableMetadata } from './types'
|
|
32
51
|
import {
|
|
@@ -37,7 +56,6 @@ import {
|
|
|
37
56
|
deriveRelationFormFields,
|
|
38
57
|
diffSelection,
|
|
39
58
|
extractSelectedTargetIds,
|
|
40
|
-
formatRelationCell,
|
|
41
59
|
pickOptionLabel,
|
|
42
60
|
relationRowKey,
|
|
43
61
|
type DynamicRelationKind,
|
|
@@ -172,6 +190,9 @@ function OneToManyRelation({
|
|
|
172
190
|
}: DynamicRelationOneToManyProps) {
|
|
173
191
|
const api = useApi()
|
|
174
192
|
const getImageUrl = useImageUrl()
|
|
193
|
+
const timeZone = useTimeZone()
|
|
194
|
+
const currency = useCurrency()
|
|
195
|
+
const { i18n } = useTranslation()
|
|
175
196
|
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
|
|
176
197
|
const cachedMeta = getMetadata(model)
|
|
177
198
|
const labels = { ...DEFAULT_STRINGS, ...(strings || {}) }
|
|
@@ -228,6 +249,81 @@ function OneToManyRelation({
|
|
|
228
249
|
return metadata.columns.filter(c => !hidden.has(c.key) && !c.hidden)
|
|
229
250
|
}, [metadata, foreignKey, filtersKey, hiddenColumns])
|
|
230
251
|
|
|
252
|
+
// Reuse the EXACT column factory the main `<DynamicTable>` uses so each cell
|
|
253
|
+
// renders identically — money in the org currency right-aligned, FK chips
|
|
254
|
+
// with thumbnails, dates in the org timezone, status/option badges, creator
|
|
255
|
+
// names — instead of a hand-rolled parallel formatting stack. Stable per
|
|
256
|
+
// image-url resolver.
|
|
257
|
+
const buildColumns = useMemo(
|
|
258
|
+
() => makeDefaultGetDynamicColumns({ getImageUrl }),
|
|
259
|
+
[getImageUrl],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
const showActions = canEdit || canDelete
|
|
263
|
+
|
|
264
|
+
const columns = useMemo<ColumnDef<any>[]>(() => {
|
|
265
|
+
if (!metadata) return []
|
|
266
|
+
// Feed the factory a metadata view scoped to the visible columns only,
|
|
267
|
+
// with model-level actions stripped — the relation list owns its own
|
|
268
|
+
// inline edit/delete column (appended below), and the factory's
|
|
269
|
+
// select/actions columns don't belong in an embedded child list.
|
|
270
|
+
const scopedMeta = {
|
|
271
|
+
...metadata,
|
|
272
|
+
columns: visibleColumns,
|
|
273
|
+
actions: [],
|
|
274
|
+
hasActions: false,
|
|
275
|
+
enableCRUDActions: false,
|
|
276
|
+
} as TableMetadata
|
|
277
|
+
const base = buildColumns(
|
|
278
|
+
scopedMeta,
|
|
279
|
+
() => {},
|
|
280
|
+
(key: string, opts?: any) => opts?.defaultValue ?? key,
|
|
281
|
+
i18n?.language || 'es',
|
|
282
|
+
new Map(),
|
|
283
|
+
timeZone,
|
|
284
|
+
currency,
|
|
285
|
+
).filter((c) => c.id !== 'select' && c.id !== 'actions')
|
|
286
|
+
|
|
287
|
+
if (!showActions) return base
|
|
288
|
+
|
|
289
|
+
const actionsCol: ColumnDef<any> = {
|
|
290
|
+
id: 'actions',
|
|
291
|
+
header: () => <span className="sr-only">{labels.editLabel}</span>,
|
|
292
|
+
size: 80,
|
|
293
|
+
cell: ({ row }: { row: Row<any> }) => (
|
|
294
|
+
<div className="flex items-center justify-end gap-1">
|
|
295
|
+
{canEdit && (
|
|
296
|
+
<Button
|
|
297
|
+
size="sm"
|
|
298
|
+
variant="ghost"
|
|
299
|
+
onClick={() => { setEditingRow(row.original); setFormOpen(true) }}
|
|
300
|
+
aria-label={labels.editLabel}
|
|
301
|
+
>
|
|
302
|
+
<Pencil className="h-4 w-4" />
|
|
303
|
+
</Button>
|
|
304
|
+
)}
|
|
305
|
+
{canDelete && (
|
|
306
|
+
<Button
|
|
307
|
+
size="sm"
|
|
308
|
+
variant="ghost"
|
|
309
|
+
onClick={() => setRowToDelete(row.original)}
|
|
310
|
+
aria-label={labels.removeLabel}
|
|
311
|
+
>
|
|
312
|
+
<Trash2 className="h-4 w-4" />
|
|
313
|
+
</Button>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
),
|
|
317
|
+
}
|
|
318
|
+
return [...base, actionsCol]
|
|
319
|
+
}, [metadata, visibleColumns, buildColumns, i18n?.language, timeZone, currency, showActions, canEdit, canDelete, labels.editLabel, labels.removeLabel])
|
|
320
|
+
|
|
321
|
+
const table = useReactTable({
|
|
322
|
+
data: rows,
|
|
323
|
+
columns,
|
|
324
|
+
getCoreRowModel: getCoreRowModel(),
|
|
325
|
+
})
|
|
326
|
+
|
|
231
327
|
const handleSubmit = useCallback(async (values: Record<string, any>) => {
|
|
232
328
|
setSubmitting(true)
|
|
233
329
|
try {
|
|
@@ -299,62 +395,46 @@ function OneToManyRelation({
|
|
|
299
395
|
{labels.emptyState}
|
|
300
396
|
</div>
|
|
301
397
|
) : (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
>
|
|
308
|
-
|
|
309
|
-
{
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// carries an image → render a thumbnail + label
|
|
313
|
-
// instead of plain text (e.g. a line item's
|
|
314
|
-
// product photo). The sibling is the column key
|
|
315
|
-
// with the trailing `_id` stripped.
|
|
316
|
-
const isFk = !!col.ref || col.key.endsWith('_id')
|
|
317
|
-
const sibling = isFk ? (row as any)[col.key.replace(/_id$/, '')] : undefined
|
|
318
|
-
if (sibling && typeof sibling === 'object' && sibling.image) {
|
|
319
|
-
const label = sibling.label ?? sibling.name ?? cell
|
|
398
|
+
// Real metadata-driven table — same metacore-ui primitives and
|
|
399
|
+
// cell renderers as `<DynamicTable>` so headers, money/currency,
|
|
400
|
+
// FK thumbnails, dates and badges all match the main table.
|
|
401
|
+
<div className="overflow-x-auto border rounded-md bg-card">
|
|
402
|
+
<Table noWrapper className="w-full">
|
|
403
|
+
<TableHeader>
|
|
404
|
+
{table.getHeaderGroups().map((headerGroup: HeaderGroup<any>) => (
|
|
405
|
+
<TableRow key={headerGroup.id} className="border-b-0 hover:bg-transparent">
|
|
406
|
+
{headerGroup.headers.map((header: Header<any, unknown>) => {
|
|
407
|
+
const isActions = header.id === 'actions'
|
|
320
408
|
return (
|
|
321
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
409
|
+
<TableHead
|
|
410
|
+
key={header.id}
|
|
411
|
+
colSpan={header.colSpan}
|
|
412
|
+
style={header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined}
|
|
413
|
+
className={cn('bg-card border-b h-10', isActions && 'text-right')}
|
|
414
|
+
>
|
|
415
|
+
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
416
|
+
</TableHead>
|
|
325
417
|
)
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
<Button
|
|
347
|
-
size="sm"
|
|
348
|
-
variant="ghost"
|
|
349
|
-
onClick={() => setRowToDelete(row)}
|
|
350
|
-
aria-label={labels.removeLabel}
|
|
351
|
-
>
|
|
352
|
-
<Trash2 className="h-4 w-4" />
|
|
353
|
-
</Button>
|
|
354
|
-
)}
|
|
355
|
-
</div>
|
|
356
|
-
</div>
|
|
357
|
-
))}
|
|
418
|
+
})}
|
|
419
|
+
</TableRow>
|
|
420
|
+
))}
|
|
421
|
+
</TableHeader>
|
|
422
|
+
<TableBody>
|
|
423
|
+
{table.getRowModel().rows.map((row: Row<any>, idx: number) => (
|
|
424
|
+
<TableRow key={relationRowKey(row.original, idx, foreignKey)}>
|
|
425
|
+
{row.getVisibleCells().map((cell: Cell<any, unknown>) => (
|
|
426
|
+
<TableCell
|
|
427
|
+
key={cell.id}
|
|
428
|
+
style={cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined}
|
|
429
|
+
className="py-2"
|
|
430
|
+
>
|
|
431
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
432
|
+
</TableCell>
|
|
433
|
+
))}
|
|
434
|
+
</TableRow>
|
|
435
|
+
))}
|
|
436
|
+
</TableBody>
|
|
437
|
+
</Table>
|
|
358
438
|
</div>
|
|
359
439
|
)}
|
|
360
440
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Org runtime contexts — thread the org's display config (timezone, currency)
|
|
2
|
+
// to nested field/cell/relation renderers without prop-drilling. Lives in its
|
|
3
|
+
// own module (mirroring `image-url-context`) so any renderer can consume them
|
|
4
|
+
// without importing from `dialogs/dynamic-record`. That dialog imports
|
|
5
|
+
// `dynamic-relations` → `dynamic-relation`, so the relation table cannot import
|
|
6
|
+
// these contexts back from the dialog without a circular import — hence this
|
|
7
|
+
// standalone module is the single source of truth.
|
|
8
|
+
import { createContext, useContext } from 'react'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* IANA timezone (e.g. the org's `America/Mexico_City`) used to render
|
|
12
|
+
* datetime/timestamp cells. `undefined` outside a provider → renderers fall
|
|
13
|
+
* back to the viewer's browser zone (legacy behaviour).
|
|
14
|
+
*/
|
|
15
|
+
export const TimeZoneContext = createContext<string | undefined>(undefined)
|
|
16
|
+
|
|
17
|
+
/** Reads the nearest org timezone (undefined outside a provider). */
|
|
18
|
+
export const useTimeZone = () => useContext(TimeZoneContext)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Org ISO-4217 currency (org config, like the timezone) used as the fallback
|
|
22
|
+
* for money cells/fields that don't carry an explicit per-column currency.
|
|
23
|
+
* `undefined` outside a provider → renderers fall back to 'USD'.
|
|
24
|
+
*/
|
|
25
|
+
export const CurrencyContext = createContext<string | undefined>(undefined)
|
|
26
|
+
|
|
27
|
+
/** Reads the nearest org currency (undefined outside a provider). */
|
|
28
|
+
export const useCurrency = () => useContext(CurrencyContext)
|