@asteby/metacore-runtime-react 18.2.0 → 18.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 +24 -0
- package/dist/dialogs/dynamic-record.d.ts +26 -4
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +51 -8
- package/dist/dynamic-columns-shim.d.ts +1 -1
- package/dist/dynamic-columns-shim.d.ts.map +1 -1
- package/dist/dynamic-columns.d.ts +6 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +8 -4
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +29 -3
- package/dist/dynamic-relation-helpers.d.ts.map +1 -1
- package/dist/dynamic-relation-helpers.js +18 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +14 -0
- package/dist/dynamic-select-field.d.ts +8 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +2 -1
- package/dist/dynamic-table.d.ts +7 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +3 -3
- package/dist/image-url-context.d.ts +13 -0
- package/dist/image-url-context.d.ts.map +1 -0
- package/dist/image-url-context.js +17 -0
- package/package.json +1 -1
- package/src/__tests__/dynamic-relation.test.ts +17 -0
- package/src/__tests__/org-currency-money.test.ts +62 -0
- package/src/dialogs/dynamic-record.tsx +74 -4
- package/src/dynamic-columns-shim.ts +1 -0
- package/src/dynamic-columns.tsx +9 -4
- package/src/dynamic-form.tsx +33 -2
- package/src/dynamic-relation-helpers.ts +18 -0
- package/src/dynamic-relation.tsx +19 -0
- package/src/dynamic-select-field.tsx +9 -1
- package/src/dynamic-table.tsx +9 -2
- package/src/image-url-context.tsx +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 18.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c7fb1ad: Org-currency-aware money formatting in dynamic tables + the record dialog.
|
|
8
|
+
|
|
9
|
+
`<DynamicTable>` and `<DynamicRecordDialog>` now accept an optional `currency`
|
|
10
|
+
prop (the org's ISO-4217 code, e.g. `MXN`, threaded from org config like
|
|
11
|
+
`timeZone`). Money columns (`type:'number'` + `cellStyle:'currency'`) without an
|
|
12
|
+
explicit per-column currency now fall back to the org currency instead of
|
|
13
|
+
hardcoded `USD` — `resolveCurrency(col, orgCurrency)`. The record dialog, which
|
|
14
|
+
previously showed raw numbers, now formats money fields as a currency string in
|
|
15
|
+
the view renderer: a field is treated as money when the backend stamps
|
|
16
|
+
`cellStyle:'currency'`, or — as a robustness fallback mirroring the backend's
|
|
17
|
+
`inferDisplayCellStyle` — when it's numeric and its key matches the money
|
|
18
|
+
heuristic (`price`/`amount`/`total`/`cost`/`subtotal`/`balance`/`paid`, as the
|
|
19
|
+
whole key or a `_<m>`/`<m>_` affix). Editable inputs stay numeric.
|
|
20
|
+
|
|
21
|
+
## 18.3.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- fc14b4f: Relation (one_to_many) cells now show the FK product image: when an FK column's backend-resolved sibling carries an `image`, the relation row renders a thumbnail + label instead of plain text. The nested line-item edit form drops server-managed/audit columns (`id`, `created_at`, `updated_at`, `deleted_at`, `created_by(_id)`, `updated_by(_id)`) so they no longer render as `[object Object]` inputs, and the nested `dynamic_select` is seeded with the existing value's label/image from the initial record so the trigger shows the name + thumbnail instead of a raw UUID. The image-url resolver context moved to its own `image-url-context` module to avoid a circular import.
|
|
26
|
+
|
|
3
27
|
## 18.2.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ModelSchema } from './types';
|
|
2
|
-
|
|
3
|
-
export type GetImageUrl
|
|
2
|
+
import { type GetImageUrl } from '../image-url-context';
|
|
3
|
+
export type { GetImageUrl };
|
|
4
4
|
export interface FieldOption {
|
|
5
5
|
value: string;
|
|
6
6
|
label: string;
|
|
@@ -45,6 +45,19 @@ export interface FieldDef {
|
|
|
45
45
|
* its SQL type. Unknown values fall through to the `type`-based default.
|
|
46
46
|
*/
|
|
47
47
|
widget?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Declarative display hint the backend stamps on the column/modal field
|
|
50
|
+
* (mirrors the table column's `cellStyle`). `'currency'` makes the view
|
|
51
|
+
* renderer format the numeric value in the org currency. Optional —
|
|
52
|
+
* absent, a money-key heuristic still detects obvious money fields.
|
|
53
|
+
*/
|
|
54
|
+
cellStyle?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Per-field style overrides served alongside `cellStyle` (e.g.
|
|
57
|
+
* `{ currency: 'MXN' }`). When it carries an explicit `currency` it wins
|
|
58
|
+
* over the org fallback.
|
|
59
|
+
*/
|
|
60
|
+
styleConfig?: Record<string, any>;
|
|
48
61
|
}
|
|
49
62
|
export interface DynamicRecordDialogProps {
|
|
50
63
|
open: boolean;
|
|
@@ -118,9 +131,16 @@ export interface DynamicRecordDialogProps {
|
|
|
118
131
|
* regardless of the viewer's browser timezone. Pure `date` values pin to UTC.
|
|
119
132
|
*/
|
|
120
133
|
timeZone?: string;
|
|
134
|
+
/**
|
|
135
|
+
* Org ISO-4217 currency code (e.g. `MXN`) used as the fallback for money
|
|
136
|
+
* fields (`cellStyle:'currency'` or the money-key heuristic) that lack an
|
|
137
|
+
* explicit per-field currency. Optional — defaults to 'USD'.
|
|
138
|
+
*/
|
|
139
|
+
currency?: string;
|
|
121
140
|
}
|
|
122
|
-
export declare function
|
|
123
|
-
export declare function
|
|
141
|
+
export declare function isMoneyField(field: FieldDef, value: any): boolean;
|
|
142
|
+
export declare function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl, timeZone, currency, }: DynamicRecordDialogProps): import("react").JSX.Element;
|
|
143
|
+
export declare function ViewValue({ field, value: rawValue, record, getImageUrl: getImageUrlProp, timeZone: timeZoneProp, currency: currencyProp, }: {
|
|
124
144
|
field: FieldDef;
|
|
125
145
|
value: any;
|
|
126
146
|
record: any;
|
|
@@ -128,5 +148,7 @@ export declare function ViewValue({ field, value: rawValue, record, getImageUrl:
|
|
|
128
148
|
getImageUrl?: GetImageUrl;
|
|
129
149
|
/** Optional override; when omitted falls back to the nearest provider. */
|
|
130
150
|
timeZone?: string;
|
|
151
|
+
/** Optional override; when omitted falls back to the nearest provider. */
|
|
152
|
+
currency?: string;
|
|
131
153
|
}): import("react").JSX.Element;
|
|
132
154
|
//# sourceMappingURL=dynamic-record.d.ts.map
|
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
|
|
@@ -27,7 +27,7 @@ import { getFieldRef } from '../dynamic-form-schema';
|
|
|
27
27
|
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
31
|
// localizedModelName resolves the (possibly addon-i18n) model name: prefer the
|
|
32
32
|
// translated titleKey, fall back to the backend-provided raw title.
|
|
33
33
|
function localizedModelName(meta, t) {
|
|
@@ -157,9 +157,32 @@ const MODE_CONFIG = {
|
|
|
157
157
|
// Context threading host runtime values to nested field components (uploads,
|
|
158
158
|
// image leads, tz-aware dates) without prop-drilling through every renderer.
|
|
159
159
|
const ModelContext = createContext('');
|
|
160
|
-
const ImageUrlContext = createContext(identityImageUrl);
|
|
161
160
|
const TimeZoneContext = createContext(undefined);
|
|
162
|
-
|
|
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
|
+
// Money-key heuristic mirroring the backend's `inferDisplayCellStyle`: lets the
|
|
165
|
+
// dialog format obvious money fields as currency even when the backend hasn't
|
|
166
|
+
// stamped `cellStyle:'currency'` yet. Case-insensitive; matches a key that
|
|
167
|
+
// equals one of these, or ends with `_<m>`, or starts with `<m>_`.
|
|
168
|
+
const MONEY_KEY_HEURISTIC = ['price', 'amount', 'total', 'cost', 'subtotal', 'balance', 'paid'];
|
|
169
|
+
// isMoneyField decides whether a field should render as currency. The explicit
|
|
170
|
+
// `cellStyle:'currency'` stamp always wins; otherwise a numeric value whose key
|
|
171
|
+
// matches the money heuristic qualifies (robustness fallback).
|
|
172
|
+
export function isMoneyField(field, value) {
|
|
173
|
+
if (field.cellStyle === 'currency')
|
|
174
|
+
return true;
|
|
175
|
+
if (value === null || value === undefined || value === '')
|
|
176
|
+
return false;
|
|
177
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
178
|
+
if (isNaN(num))
|
|
179
|
+
return false;
|
|
180
|
+
const key = String(field.key || '').toLowerCase();
|
|
181
|
+
if (!key)
|
|
182
|
+
return false;
|
|
183
|
+
return MONEY_KEY_HEURISTIC.some(m => key === m || key.endsWith(`_${m}`) || key.startsWith(`${m}_`));
|
|
184
|
+
}
|
|
185
|
+
export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl = identityImageUrl, timeZone, currency, }) {
|
|
163
186
|
const api = useApi();
|
|
164
187
|
const { t } = useTranslation();
|
|
165
188
|
const [modalMeta, setModalMeta] = useState(schema ? schema : null);
|
|
@@ -388,10 +411,10 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
388
411
|
return false;
|
|
389
412
|
return true;
|
|
390
413
|
}) ?? [];
|
|
391
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden", children: [_jsxs(DialogHeader, { className: "p-6 pb-4 border-b shrink-0", children: [_jsx(DialogTitle, { children: title }), _jsx(DialogDescription, { children: config.description })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx(LoadingSkeleton, {})) : modalMeta ? (_jsx(ModelContext.Provider, { value: model, children: _jsx(ImageUrlContext.Provider, { value: getImageUrl, children:
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
414
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden", children: [_jsxs(DialogHeader, { className: "p-6 pb-4 border-b shrink-0", children: [_jsx(DialogTitle, { children: title }), _jsx(DialogDescription, { children: config.description })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx(LoadingSkeleton, {})) : modalMeta ? (_jsx(ModelContext.Provider, { value: model, children: _jsx(ImageUrlContext.Provider, { value: getImageUrl, children: _jsx(TimeZoneContext.Provider, { value: timeZone, children: _jsxs(CurrencyContext.Provider, { value: currency, children: [_jsxs("form", { id: "dynamic-record-form", onSubmit: handleSubmit, className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4", children: [visibleFields.map(field => {
|
|
415
|
+
const isFullWidth = field.type === 'textarea';
|
|
416
|
+
return (_jsx("div", { className: isFullWidth ? 'sm:col-span-2' : '', children: _jsx(FieldRow, { field: field, record: record, value: formValues[field.key] ?? '', mode: mode, onChange: val => setFormValues((prev) => ({ ...prev, [field.key]: val })) }) }, field.key));
|
|
417
|
+
}), record?.external_url && (_jsx("div", { className: "sm:col-span-2", children: _jsxs("a", { href: record.external_url, target: "_blank", rel: "noreferrer", className: "inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), "Ver en ", record.external_provider ?? 'proveedor externo'] }) }))] }), !isCreate && record && relations.length > 0 && (_jsx("div", { className: "mt-6", children: _jsx(DynamicRelations, { record: record, relations: relations, canCreate: mode === 'edit', canEdit: mode === 'edit', canDelete: mode === 'edit' }) }))] }) }) }) })) : null }), _jsxs(DialogFooter, { className: "p-4 border-t shrink-0 sm:justify-between", children: [isView && onOpenFullPage ? (_jsxs(Button, { variant: "ghost", size: "sm", className: "text-muted-foreground", onClick: () => { onOpenChange(false); onOpenFullPage(); }, children: [_jsx(ExternalLink, { className: "mr-1.5 h-3.5 w-3.5" }), "Ver p\u00E1gina completa"] })) : _jsx("span", {}), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: saving || deleting, children: config.cancelLabel }), isView && onDelete && (_jsxs(Button, { variant: "destructive", onClick: handleDelete, disabled: deleting || loading, children: [deleting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), deleting ? 'Eliminando...' : 'Eliminar'] })), isView && onEdit && (_jsx(Button, { onClick: onEdit, disabled: deleting || loading, children: "Editar" })), isEditable && (_jsxs(Button, { type: "submit", form: "dynamic-record-form", disabled: saving || loading, children: [saving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), saving ? config.submittingLabel : config.submitLabel] }))] })] })] }) }));
|
|
395
418
|
}
|
|
396
419
|
function LoadingSkeleton() {
|
|
397
420
|
return (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4", children: Array.from({ length: 6 }).map((_, i) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-24" }), _jsx(Skeleton, { className: "h-9 w-full" })] }, i))) }));
|
|
@@ -440,11 +463,14 @@ function RelationViewValue({ field, value, record }) {
|
|
|
440
463
|
};
|
|
441
464
|
return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [_jsx(OptionLead, { option: lead, size: 24 }), _jsx("span", { className: "text-sm", children: label ?? '—' })] }));
|
|
442
465
|
}
|
|
443
|
-
export function ViewValue({ field, value: rawValue, record, getImageUrl: getImageUrlProp, timeZone: timeZoneProp, }) {
|
|
466
|
+
export function ViewValue({ field, value: rawValue, record, getImageUrl: getImageUrlProp, timeZone: timeZoneProp, currency: currencyProp, }) {
|
|
467
|
+
const { i18n } = useTranslation();
|
|
444
468
|
const ctxImageUrl = useContext(ImageUrlContext);
|
|
445
469
|
const ctxTimeZone = useContext(TimeZoneContext);
|
|
470
|
+
const ctxCurrency = useContext(CurrencyContext);
|
|
446
471
|
const getImageUrl = getImageUrlProp ?? ctxImageUrl;
|
|
447
472
|
const timeZone = timeZoneProp ?? ctxTimeZone;
|
|
473
|
+
const currency = currencyProp ?? ctxCurrency;
|
|
448
474
|
// created_by / avatar resolver sibling → name (+ avatar) instead of "—".
|
|
449
475
|
if (field.type === 'avatar' || field.key === 'created_by' || field.key === 'created_by_id') {
|
|
450
476
|
const user = createdBySibling(rawValue, record);
|
|
@@ -482,6 +508,23 @@ export function ViewValue({ field, value: rawValue, record, getImageUrl: getImag
|
|
|
482
508
|
if (field.type === 'url' && value) {
|
|
483
509
|
return (_jsx("a", { href: value, target: "_blank", rel: "noreferrer", className: "text-sm text-primary hover:underline truncate", children: value }));
|
|
484
510
|
}
|
|
511
|
+
// Money → org-currency string. Detected by the backend `cellStyle:'currency'`
|
|
512
|
+
// stamp or a numeric value whose key matches the money heuristic (fallback
|
|
513
|
+
// mirroring the table cell + backend `inferDisplayCellStyle`).
|
|
514
|
+
if (isMoneyField(field, value)) {
|
|
515
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
516
|
+
if (!isNaN(num)) {
|
|
517
|
+
const resolvedCurrency = field.styleConfig?.currency || currency || 'USD';
|
|
518
|
+
const localeTag = i18n.language || 'es';
|
|
519
|
+
const formatted = new Intl.NumberFormat(localeTag, {
|
|
520
|
+
style: 'currency',
|
|
521
|
+
currency: resolvedCurrency,
|
|
522
|
+
minimumFractionDigits: 2,
|
|
523
|
+
maximumFractionDigits: 2,
|
|
524
|
+
}).format(num);
|
|
525
|
+
return _jsx("p", { className: "text-sm py-1 tabular-nums", children: formatted });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
485
528
|
// Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
|
|
486
529
|
// day); instants render in the org timezone with a full-precision tooltip.
|
|
487
530
|
if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp') {
|
|
@@ -16,7 +16,7 @@ export interface ColumnFilterConfig {
|
|
|
16
16
|
searchEndpoint?: string;
|
|
17
17
|
}
|
|
18
18
|
/** Signature for the host-provided `getDynamicColumns` factory. */
|
|
19
|
-
export type GetDynamicColumns = (metadata: TableMetadata, handleAction: (action: string, row: any) => void, t: (key: string, options?: any) => string, language: string, columnFilterConfigs: Map<string, ColumnFilterConfig>, timeZone?: string) => ColumnDef<any>[];
|
|
19
|
+
export type GetDynamicColumns = (metadata: TableMetadata, handleAction: (action: string, row: any) => void, t: (key: string, options?: any) => string, language: string, columnFilterConfigs: Map<string, ColumnFilterConfig>, timeZone?: string, currency?: string) => ColumnDef<any>[];
|
|
20
20
|
/** Signature for the host-provided `DynamicIcon` renderer. */
|
|
21
21
|
export type DynamicIconComponent = React.ComponentType<{
|
|
22
22
|
name: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-columns-shim.d.ts","sourceRoot":"","sources":["../src/dynamic-columns-shim.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,kBAAkB;IAC/B,UAAU,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,cAAc,GAAG,MAAM,GAAG,MAAM,CAAA;IAClF,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,YAAY,EAAE,CAAA;IACvB,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,cAAc,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,mEAAmE;AACnE,MAAM,MAAM,iBAAiB,GAAG,CAC5B,QAAQ,EAAE,aAAa,EACvB,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,EAChD,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,MAAM,EACzC,QAAQ,EAAE,MAAM,EAChB,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,EACpD,QAAQ,CAAC,EAAE,MAAM,KAChB,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;AAErB,8DAA8D;AAC9D,MAAM,MAAM,oBAAoB,GAAG,KAAK,CAAC,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA"}
|
|
1
|
+
{"version":3,"file":"dynamic-columns-shim.d.ts","sourceRoot":"","sources":["../src/dynamic-columns-shim.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE5C,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,kBAAkB;IAC/B,UAAU,EAAE,QAAQ,GAAG,SAAS,GAAG,YAAY,GAAG,cAAc,GAAG,MAAM,GAAG,MAAM,CAAA;IAClF,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,YAAY,EAAE,CAAA;IACvB,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;IAC7D,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,cAAc,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,mEAAmE;AACnE,MAAM,MAAM,iBAAiB,GAAG,CAC5B,QAAQ,EAAE,aAAa,EACvB,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,EAChD,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,MAAM,EACzC,QAAQ,EAAE,MAAM,EAChB,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,EACpD,QAAQ,CAAC,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,KAChB,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;AAErB,8DAA8D;AAC9D,MAAM,MAAM,oBAAoB,GAAG,KAAK,CAAC,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAAA"}
|
|
@@ -16,6 +16,12 @@ export interface DynamicColumnsHelpers {
|
|
|
16
16
|
*/
|
|
17
17
|
apiBaseUrl?: string;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolves the active currency for a column: the column's explicit currency
|
|
21
|
+
* style wins, then the org-level fallback (org config, like `timeZone`), then
|
|
22
|
+
* 'USD' as a last resort.
|
|
23
|
+
*/
|
|
24
|
+
export declare const resolveCurrency: (col: ColumnDefinition, orgCurrency?: string) => string;
|
|
19
25
|
/**
|
|
20
26
|
* State-machine gate for per-row actions.
|
|
21
27
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAgC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,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;
|
|
1
|
+
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAgC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,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;AA0BD;;;;GAIG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,gBAAgB,EAAE,cAAc,MAAM,KAAG,MACzB,CAAA;AAoErD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAqKD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,EACd,QAAQ,CAAC,EAAE,MAAM,GAClB;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA6C5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AA0ED;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAwmBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
|
package/dist/dynamic-columns.js
CHANGED
|
@@ -44,8 +44,12 @@ const styleCfg = (col, ...keys) => {
|
|
|
44
44
|
return undefined;
|
|
45
45
|
};
|
|
46
46
|
const EmptyCell = () => _jsx("span", { className: "text-muted-foreground", children: "-" });
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Resolves the active currency for a column: the column's explicit currency
|
|
49
|
+
* style wins, then the org-level fallback (org config, like `timeZone`), then
|
|
50
|
+
* 'USD' as a last resort.
|
|
51
|
+
*/
|
|
52
|
+
export const resolveCurrency = (col, orgCurrency) => styleCfg(col, 'currency') || orgCurrency || 'USD';
|
|
49
53
|
const formatNumber = (value, opts, locale) => new Intl.NumberFormat(locale || undefined, opts).format(value);
|
|
50
54
|
/**
|
|
51
55
|
* Semantic status → badge color. Used by the `status` cell when no explicit
|
|
@@ -351,7 +355,7 @@ const AvatarCell = ({ name, desc, avatarSrc, getImageUrl }) => (_jsxs("div", { c
|
|
|
351
355
|
export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
352
356
|
const getImageUrl = helpers.getImageUrl ?? defaultGetImageUrl;
|
|
353
357
|
const apiBaseUrl = helpers.apiBaseUrl ?? '';
|
|
354
|
-
return function defaultGetDynamicColumns(metadata, onAction, t, currentLanguage, filterConfigs, timeZone) {
|
|
358
|
+
return function defaultGetDynamicColumns(metadata, onAction, t, currentLanguage, filterConfigs, timeZone, currency) {
|
|
355
359
|
const dateLocale = currentLanguage === 'en' ? enUS : es;
|
|
356
360
|
const columns = [
|
|
357
361
|
{
|
|
@@ -552,7 +556,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
552
556
|
const decimals = styleCfg(col, 'decimals') ?? 2;
|
|
553
557
|
return (_jsx("span", { className: "block text-right font-medium tabular-nums", children: formatNumber(num, {
|
|
554
558
|
style: 'currency',
|
|
555
|
-
currency: resolveCurrency(col),
|
|
559
|
+
currency: resolveCurrency(col, currency),
|
|
556
560
|
minimumFractionDigits: decimals,
|
|
557
561
|
maximumFractionDigits: decimals,
|
|
558
562
|
}, currentLanguage) }));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAGhB,MAAM,uBAAuB,CAAA;AAO9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAE5C,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,+
|
|
1
|
+
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAGhB,MAAM,uBAAuB,CAAA;AAO9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAE5C,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,+BAmGlB"}
|
package/dist/dynamic-form.js
CHANGED
|
@@ -77,10 +77,35 @@ export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitL
|
|
|
77
77
|
const fullWidth = isLineItemsField(field) ||
|
|
78
78
|
resolveWidget(field) === 'textarea' ||
|
|
79
79
|
resolveWidget(field) === 'richtext';
|
|
80
|
-
return (_jsxs("div", { className: 'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : ''), children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), _jsx(FieldRenderer, { field: field, value: values[field.key], onChange: (v) => update(field.key, v) }), errors[field.key] && (_jsx("span", { className: "text-red-500 text-sm", role: "alert", children: errors[field.key] }))] }, field.key));
|
|
80
|
+
return (_jsxs("div", { className: 'grid gap-2 ' + (fullWidth ? 'sm:col-span-2' : ''), children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), _jsx(FieldRenderer, { field: field, value: values[field.key], onChange: (v) => update(field.key, v), initialValues: initialValues }), errors[field.key] && (_jsx("span", { className: "text-red-500 text-sm", role: "alert", children: errors[field.key] }))] }, field.key));
|
|
81
81
|
}) }), _jsxs("div", { className: "flex justify-end gap-2 pt-2", children: [onCancel && (_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: submitting || disabled, children: cancelLabel })), _jsx(Button, { type: "submit", disabled: submitting || disabled || balanceBlocked, children: submitLabel })] })] }));
|
|
82
82
|
}
|
|
83
|
-
|
|
83
|
+
// seedOptionFromSibling builds a pre-resolved option for an FK field from the
|
|
84
|
+
// resolved sibling the backend served on the initial record (e.g. a line item's
|
|
85
|
+
// `product = { value, label, image }` alongside `product_id`). Lets the picker
|
|
86
|
+
// show the name + thumbnail for an existing value without a lookup. Returns
|
|
87
|
+
// undefined when the sibling carries nothing renderable.
|
|
88
|
+
function seedOptionFromSibling(field, value, initialValues) {
|
|
89
|
+
if (!field.key.endsWith('_id'))
|
|
90
|
+
return undefined;
|
|
91
|
+
const sib = initialValues?.[field.key.replace(/_id$/, '')];
|
|
92
|
+
if (!sib || typeof sib !== 'object')
|
|
93
|
+
return undefined;
|
|
94
|
+
const label = sib.label ?? sib.name ?? '';
|
|
95
|
+
if (!label && !sib.image)
|
|
96
|
+
return undefined;
|
|
97
|
+
const id = String(sib.value ?? sib.id ?? value ?? '');
|
|
98
|
+
return {
|
|
99
|
+
id,
|
|
100
|
+
value: id,
|
|
101
|
+
label: String(label),
|
|
102
|
+
name: String(label),
|
|
103
|
+
image: sib.image,
|
|
104
|
+
color: sib.color,
|
|
105
|
+
icon: sib.icon,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function FieldRenderer({ field, value, onChange, initialValues }) {
|
|
84
109
|
// Repeatable line-items group → render the row grid. Its value is an array
|
|
85
110
|
// of row objects rather than a scalar.
|
|
86
111
|
if (isLineItemsField(field)) {
|
|
@@ -91,7 +116,8 @@ function FieldRenderer({ field, value, onChange }) {
|
|
|
91
116
|
// Preferred for FK fields with large option sets — no UUID typing, no
|
|
92
117
|
// dumping every row into a plain <select>.
|
|
93
118
|
if (widget === 'dynamic_select') {
|
|
94
|
-
|
|
119
|
+
const seedOption = seedOptionFromSibling(field, value, initialValues);
|
|
120
|
+
return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange, seedOption: seedOption });
|
|
95
121
|
}
|
|
96
122
|
// File upload → themed picker that POSTs to the host upload endpoint and
|
|
97
123
|
// stores the returned file url/path as the field value.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-relation-helpers.d.ts","sourceRoot":"","sources":["../src/dynamic-relation-helpers.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAiB9E,MAAM,MAAM,mBAAmB,GAAG,aAAa,GAAG,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-relation-helpers.d.ts","sourceRoot":"","sources":["../src/dynamic-relation-helpers.ts"],"names":[],"mappings":"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"}
|
|
@@ -10,6 +10,19 @@ function isEnumLikeColumn(col) {
|
|
|
10
10
|
renderAs === 'badge' ||
|
|
11
11
|
!!col.options?.length);
|
|
12
12
|
}
|
|
13
|
+
// Server-managed / audit columns that must never become editable form inputs.
|
|
14
|
+
// They're set by the backend and several ship as resolved objects (e.g.
|
|
15
|
+
// `created_by = { name, avatar, email }`) that would render as `[object Object]`.
|
|
16
|
+
const MANAGED_RELATION_COLUMNS = new Set([
|
|
17
|
+
'id',
|
|
18
|
+
'created_at',
|
|
19
|
+
'updated_at',
|
|
20
|
+
'deleted_at',
|
|
21
|
+
'created_by',
|
|
22
|
+
'created_by_id',
|
|
23
|
+
'updated_by',
|
|
24
|
+
'updated_by_id',
|
|
25
|
+
]);
|
|
13
26
|
// Pulls a human label off a resolved relation/user object a backend serves:
|
|
14
27
|
// `{ value, label }` (FK sibling), `{ name, … }` (user object such as
|
|
15
28
|
// created_by) or `{ title }`. Returns undefined for plain/empty objects so the
|
|
@@ -122,6 +135,11 @@ export function deriveRelationFormFields(metadata, foreignKey) {
|
|
|
122
135
|
continue;
|
|
123
136
|
if (col.hidden)
|
|
124
137
|
continue;
|
|
138
|
+
// Managed/audit columns are server-owned and ship resolved objects
|
|
139
|
+
// (`created_by = { name, avatar, … }`); making them editable inputs
|
|
140
|
+
// renders `[object Object]`. Never surface them in the inline form.
|
|
141
|
+
if (MANAGED_RELATION_COLUMNS.has(col.key.toLowerCase()))
|
|
142
|
+
continue;
|
|
125
143
|
out.push({
|
|
126
144
|
key: col.key,
|
|
127
145
|
label: col.label,
|
|
@@ -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":"AA6CA,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
|
@@ -10,6 +10,8 @@ import { Plus, Trash2, Pencil } from 'lucide-react';
|
|
|
10
10
|
import { useApi } from './api-context';
|
|
11
11
|
import { useMetadataCache } from './metadata-cache';
|
|
12
12
|
import { DynamicForm } from './dynamic-form';
|
|
13
|
+
import { useImageUrl } from './image-url-context';
|
|
14
|
+
import { OptionThumb } from './dynamic-select-field';
|
|
13
15
|
import { useOptionsResolver } from './use-options-resolver';
|
|
14
16
|
import { buildCreatePayload, buildPivotAttachPayload, buildPivotRowIndex, buildRelationFilterParams, deriveRelationFormFields, diffSelection, extractSelectedTargetIds, formatRelationCell, pickOptionLabel, relationRowKey, } from './dynamic-relation-helpers';
|
|
15
17
|
export { buildCreatePayload, buildPivotAttachPayload, buildPivotRowIndex, buildRelationFilterParams, deriveRelationFormFields, diffSelection, extractSelectedTargetIds, formatRelationCell, objectLabel, pickOptionLabel, relationRowKey, } from './dynamic-relation-helpers';
|
|
@@ -35,6 +37,7 @@ export function DynamicRelation(props) {
|
|
|
35
37
|
}
|
|
36
38
|
function OneToManyRelation({ kind, model, foreignKey, parentId, filters, endpoint, hiddenColumns = [], canCreate = true, canDelete = true, canEdit = true, strings, className, onChange, }) {
|
|
37
39
|
const api = useApi();
|
|
40
|
+
const getImageUrl = useImageUrl();
|
|
38
41
|
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache();
|
|
39
42
|
const cachedMeta = getMetadata(model);
|
|
40
43
|
const labels = { ...DEFAULT_STRINGS, ...(strings || {}) };
|
|
@@ -136,6 +139,17 @@ function OneToManyRelation({ kind, model, foreignKey, parentId, filters, endpoin
|
|
|
136
139
|
}, [api, dataEndpoint, fetchAll, onChange, rowToDelete]);
|
|
137
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 divide-y bg-card", children: rows.map((row, idx) => (_jsxs("div", { className: "flex items-center justify-between gap-3 px-3 py-2", children: [_jsx("div", { className: "flex-1 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-2 text-sm", children: visibleColumns.map(col => {
|
|
138
141
|
const cell = formatRelationCell(row, col);
|
|
142
|
+
// FK column whose backend-resolved sibling
|
|
143
|
+
// carries an image → render a thumbnail + label
|
|
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
|
+
}
|
|
139
153
|
return (_jsx("span", { className: "truncate", title: cell, children: cell }, col.key));
|
|
140
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)
|
|
141
155
|
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 })] })] }) })] }));
|
|
@@ -24,7 +24,14 @@ export interface DynamicSelectFieldProps {
|
|
|
24
24
|
field: ActionFieldDef;
|
|
25
25
|
value: any;
|
|
26
26
|
onChange: (v: any) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Pre-resolved option for the CURRENT value (label + image/color/icon) the
|
|
29
|
+
* caller already has — e.g. the relation sibling the table served. Lets the
|
|
30
|
+
* trigger show the name + thumbnail for an existing value without waiting for
|
|
31
|
+
* a lookup (which only loads once the popover opens). Matched by id == value.
|
|
32
|
+
*/
|
|
33
|
+
seedOption?: ResolvedOption | null;
|
|
27
34
|
}
|
|
28
|
-
export declare function DynamicSelectField({ field, value, onChange }: DynamicSelectFieldProps): import("react").JSX.Element;
|
|
35
|
+
export declare function DynamicSelectField({ field, value, onChange, seedOption }: DynamicSelectFieldProps): import("react").JSX.Element;
|
|
29
36
|
export default DynamicSelectField;
|
|
30
37
|
//# sourceMappingURL=dynamic-select-field.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAuCA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,IAAS,EAAE,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,+BA4BzF;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC,GAAG,IAAI,CAAA;IAChE,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCAwBA;AAqBD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAuCA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,IAAS,EAAE,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,+BA4BzF;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EACvB,MAAM,EACN,IAAS,GACZ,EAAE;IACC,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC,GAAG,IAAI,CAAA;IAChE,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCAwBA;AAqBD,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAC1B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,IAAI,CAAA;CACrC;AAED,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,uBAAuB,+BA2KjG;AAED,eAAe,kBAAkB,CAAA"}
|
|
@@ -79,7 +79,7 @@ function useDebounced(value, ms) {
|
|
|
79
79
|
}, [value, ms]);
|
|
80
80
|
return debounced;
|
|
81
81
|
}
|
|
82
|
-
export function DynamicSelectField({ field, value, onChange }) {
|
|
82
|
+
export function DynamicSelectField({ field, value, onChange, seedOption }) {
|
|
83
83
|
const [open, setOpen] = useState(false);
|
|
84
84
|
const [search, setSearch] = useState('');
|
|
85
85
|
const debounced = useDebounced(search, 250);
|
|
@@ -107,6 +107,7 @@ export function DynamicSelectField({ field, value, onChange }) {
|
|
|
107
107
|
// label and its thumbnail.
|
|
108
108
|
const selectedOption = (picked && String(picked.id) === String(value) ? picked : null) ??
|
|
109
109
|
options.find((o) => String(o.id) === String(value)) ??
|
|
110
|
+
(seedOption && String(seedOption.id) === String(value) ? seedOption : null) ??
|
|
110
111
|
null;
|
|
111
112
|
const selectedLabel = selectedOption?.label ?? (value ? String(value) : '');
|
|
112
113
|
// Only switch the picker into "with thumbnails" mode when the data actually
|
package/dist/dynamic-table.d.ts
CHANGED
|
@@ -23,7 +23,13 @@ interface DynamicTableProps {
|
|
|
23
23
|
* Optional — omitting it preserves the legacy browser-local formatting.
|
|
24
24
|
*/
|
|
25
25
|
timeZone?: string;
|
|
26
|
+
/**
|
|
27
|
+
* ISO 4217 currency code (e.g. the org's `MXN`) used as the fallback for
|
|
28
|
+
* money cells (`type:'number'` + `cellStyle:'currency'`) that don't carry
|
|
29
|
+
* an explicit per-column currency. Optional — defaults to 'USD'.
|
|
30
|
+
*/
|
|
31
|
+
currency?: string;
|
|
26
32
|
}
|
|
27
|
-
export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, }: DynamicTableProps): import("react").JSX.Element;
|
|
33
|
+
export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, currency, }: DynamicTableProps): import("react").JSX.Element;
|
|
28
34
|
export {};
|
|
29
35
|
//# sourceMappingURL=dynamic-table.d.ts.map
|
|
@@ -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;AAUnF,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;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,GACX,EAAE,iBAAiB,+BAgyBnB"}
|
|
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;AAUnF,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;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,GACX,EAAE,iBAAiB,+BAgyBnB"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -31,7 +31,7 @@ import { getSearchableColumnKeys } from './column-visibility';
|
|
|
31
31
|
import { DynamicRecordDialog } from './dialogs/dynamic-record';
|
|
32
32
|
import { ExportDialog } from './dialogs/export';
|
|
33
33
|
import { ImportDialog } from './dialogs/import';
|
|
34
|
-
export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, }) {
|
|
34
|
+
export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, }) {
|
|
35
35
|
const { t, i18n } = useTranslation();
|
|
36
36
|
const api = useApi();
|
|
37
37
|
const currentBranch = useCurrentBranch();
|
|
@@ -555,12 +555,12 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
555
555
|
const rowMetadata = metadata.actions?.some((a) => a.placement === 'table' || a.placement === 'create')
|
|
556
556
|
? { ...metadata, actions: metadata.actions.filter((a) => !a.placement || a.placement === 'row') }
|
|
557
557
|
: metadata;
|
|
558
|
-
const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone);
|
|
558
|
+
const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency);
|
|
559
559
|
const filteredBase = baseColumns.filter((col) => !hiddenColumns.includes(col.id));
|
|
560
560
|
const actionsCol = filteredBase.find((c) => c.id === 'actions');
|
|
561
561
|
const otherCols = filteredBase.filter((c) => c.id !== 'actions');
|
|
562
562
|
return [...otherCols, ...extraColumns, ...(actionsCol ? [actionsCol] : [])];
|
|
563
|
-
}, [metadata, handleInternalAction, hiddenColumns, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone]);
|
|
563
|
+
}, [metadata, handleInternalAction, hiddenColumns, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency]);
|
|
564
564
|
const filters = useMemo(() => [], []);
|
|
565
565
|
const table = useReactTable({
|
|
566
566
|
data,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Resolves a (possibly relative) storage path into a fetchable URL. */
|
|
2
|
+
export type GetImageUrl = (path: string | null | undefined) => string;
|
|
3
|
+
/** Default resolver: pass the path through unchanged (works same-origin). */
|
|
4
|
+
export declare const identityImageUrl: GetImageUrl;
|
|
5
|
+
/**
|
|
6
|
+
* Threads the host's image-url resolver to nested field/cell components without
|
|
7
|
+
* prop-drilling. Provided by `DynamicRecordDialog`; consumers outside a provider
|
|
8
|
+
* fall back to `identityImageUrl` (the relative path, which renders same-origin).
|
|
9
|
+
*/
|
|
10
|
+
export declare const ImageUrlContext: import("react").Context<GetImageUrl>;
|
|
11
|
+
/** Reads the nearest image-url resolver (identity outside a provider). */
|
|
12
|
+
export declare const useImageUrl: () => GetImageUrl;
|
|
13
|
+
//# sourceMappingURL=image-url-context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"image-url-context.d.ts","sourceRoot":"","sources":["../src/image-url-context.tsx"],"names":[],"mappings":"AAQA,wEAAwE;AACxE,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,KAAK,MAAM,CAAA;AAErE,6EAA6E;AAC7E,eAAO,MAAM,gBAAgB,EAAE,WAA4B,CAAA;AAE3D;;;;GAIG;AACH,eAAO,MAAM,eAAe,sCAA+C,CAAA;AAE3E,0EAA0E;AAC1E,eAAO,MAAM,WAAW,mBAAoC,CAAA"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Image-url resolver context — its own module so any renderer (record dialog,
|
|
2
|
+
// relation cells, …) can consume the host's storage-path → URL resolver without
|
|
3
|
+
// importing from `dialogs/dynamic-record`. That dialog imports
|
|
4
|
+
// `dynamic-relations` (which renders `dynamic-relation`), so the relation cell
|
|
5
|
+
// cannot import the context back from the dialog without a circular import —
|
|
6
|
+
// hence this standalone module is the single source of truth.
|
|
7
|
+
import { createContext, useContext } from 'react';
|
|
8
|
+
/** Default resolver: pass the path through unchanged (works same-origin). */
|
|
9
|
+
export const identityImageUrl = (p) => p ?? '';
|
|
10
|
+
/**
|
|
11
|
+
* Threads the host's image-url resolver to nested field/cell components without
|
|
12
|
+
* prop-drilling. Provided by `DynamicRecordDialog`; consumers outside a provider
|
|
13
|
+
* fall back to `identityImageUrl` (the relative path, which renders same-origin).
|
|
14
|
+
*/
|
|
15
|
+
export const ImageUrlContext = createContext(identityImageUrl);
|
|
16
|
+
/** Reads the nearest image-url resolver (identity outside a provider). */
|
|
17
|
+
export const useImageUrl = () => useContext(ImageUrlContext);
|
package/package.json
CHANGED
|
@@ -114,6 +114,23 @@ describe('deriveRelationFormFields', () => {
|
|
|
114
114
|
expect(fields.find(f => f.key === 'id')).toBeUndefined()
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
+
// Server-managed / audit columns ship as resolved objects (created_by =
|
|
118
|
+
// { name, avatar }) and must never become editable inputs ([object Object]).
|
|
119
|
+
it('omite columnas managed/audit aunque no estén hidden', () => {
|
|
120
|
+
const meta: Pick<TableMetadata, 'columns'> = {
|
|
121
|
+
columns: [
|
|
122
|
+
{ key: 'sku', label: 'SKU', type: 'text', sortable: true, filterable: true },
|
|
123
|
+
{ key: 'created_by', label: 'Creado por', type: 'text', sortable: false, filterable: false },
|
|
124
|
+
{ key: 'created_by_id', label: 'Creado por id', type: 'text', sortable: false, filterable: false },
|
|
125
|
+
{ key: 'created_at', label: 'Creado', type: 'date', sortable: true, filterable: false },
|
|
126
|
+
{ key: 'updated_at', label: 'Actualizado', type: 'date', sortable: true, filterable: false },
|
|
127
|
+
{ key: 'deleted_at', label: 'Eliminado', type: 'date', sortable: false, filterable: false },
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
const fields = deriveRelationFormFields(meta, 'invoice_id')
|
|
131
|
+
expect(fields.map(f => f.key)).toEqual(['sku'])
|
|
132
|
+
})
|
|
133
|
+
|
|
117
134
|
it('mapea types de ColumnDefinition al ActionFieldDef.type', () => {
|
|
118
135
|
const fields = deriveRelationFormFields(baseMeta, 'invoice_id')
|
|
119
136
|
const byKey = Object.fromEntries(fields.map(f => [f.key, f]))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Locks the org-currency fallback contract for money rendering: the table cell
|
|
2
|
+
// (`resolveCurrency`) and the record dialog (`isMoneyField`). Pure logic, no DOM.
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
import { resolveCurrency } from '../dynamic-columns'
|
|
5
|
+
import { isMoneyField } from '../dialogs/dynamic-record'
|
|
6
|
+
import type { ColumnDefinition } from '../types'
|
|
7
|
+
import type { FieldDef } from '../dialogs/dynamic-record'
|
|
8
|
+
|
|
9
|
+
const col = (over: Partial<ColumnDefinition>): ColumnDefinition => ({
|
|
10
|
+
key: 'total',
|
|
11
|
+
label: 'Total',
|
|
12
|
+
type: 'number',
|
|
13
|
+
sortable: true,
|
|
14
|
+
filterable: true,
|
|
15
|
+
...over,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const field = (over: Partial<FieldDef>): FieldDef => ({
|
|
19
|
+
key: 'total',
|
|
20
|
+
label: 'Total',
|
|
21
|
+
type: 'number',
|
|
22
|
+
...over,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('resolveCurrency', () => {
|
|
26
|
+
it("defaults to 'USD' when neither the column nor the org provide one", () => {
|
|
27
|
+
expect(resolveCurrency(col({}))).toBe('USD')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('falls back to the org currency when the column has no explicit currency', () => {
|
|
31
|
+
expect(resolveCurrency(col({}), 'MXN')).toBe('MXN')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('prefers the explicit per-column currency over the org fallback', () => {
|
|
35
|
+
expect(resolveCurrency(col({ styleConfig: { currency: 'EUR' } }), 'MXN')).toBe('EUR')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('isMoneyField', () => {
|
|
40
|
+
it("recognizes the backend cellStyle:'currency' stamp regardless of key", () => {
|
|
41
|
+
expect(isMoneyField(field({ key: 'foo', cellStyle: 'currency' }), 100)).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('detects numeric money keys via the heuristic (no stamp needed)', () => {
|
|
45
|
+
expect(isMoneyField(field({ key: 'total' }), 100)).toBe(true)
|
|
46
|
+
expect(isMoneyField(field({ key: 'sub_total' }), 100)).toBe(true)
|
|
47
|
+
expect(isMoneyField(field({ key: 'tax_amount' }), 16)).toBe(true)
|
|
48
|
+
expect(isMoneyField(field({ key: 'unit_price' }), 5)).toBe(true)
|
|
49
|
+
expect(isMoneyField(field({ key: 'balance' }), 0)).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('accepts numeric strings the backend serializes', () => {
|
|
53
|
+
expect(isMoneyField(field({ key: 'total' }), '100')).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('ignores non-money keys and non-numeric values', () => {
|
|
57
|
+
expect(isMoneyField(field({ key: 'name' }), 'Acme')).toBe(false)
|
|
58
|
+
expect(isMoneyField(field({ key: 'quantity' }), 3)).toBe(false)
|
|
59
|
+
expect(isMoneyField(field({ key: 'total' }), 'n/a')).toBe(false)
|
|
60
|
+
expect(isMoneyField(field({ key: 'total' }), null)).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -56,10 +56,11 @@ import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
|
|
|
56
56
|
import { humanizeToken } from '../dynamic-columns-helpers'
|
|
57
57
|
import { formatDateCell } from '../dynamic-columns'
|
|
58
58
|
import type { ActionFieldDef, RelationMeta } from '../types'
|
|
59
|
+
import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context'
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
export type
|
|
62
|
-
|
|
61
|
+
// Re-export the resolver type so `index.ts`'s
|
|
62
|
+
// `export type { … GetImageUrl } from './dialogs/dynamic-record'` keeps working.
|
|
63
|
+
export type { GetImageUrl }
|
|
63
64
|
|
|
64
65
|
export interface FieldOption {
|
|
65
66
|
value: string
|
|
@@ -106,6 +107,19 @@ export interface FieldDef {
|
|
|
106
107
|
* its SQL type. Unknown values fall through to the `type`-based default.
|
|
107
108
|
*/
|
|
108
109
|
widget?: string
|
|
110
|
+
/**
|
|
111
|
+
* Declarative display hint the backend stamps on the column/modal field
|
|
112
|
+
* (mirrors the table column's `cellStyle`). `'currency'` makes the view
|
|
113
|
+
* renderer format the numeric value in the org currency. Optional —
|
|
114
|
+
* absent, a money-key heuristic still detects obvious money fields.
|
|
115
|
+
*/
|
|
116
|
+
cellStyle?: string
|
|
117
|
+
/**
|
|
118
|
+
* Per-field style overrides served alongside `cellStyle` (e.g.
|
|
119
|
+
* `{ currency: 'MXN' }`). When it carries an explicit `currency` it wins
|
|
120
|
+
* over the org fallback.
|
|
121
|
+
*/
|
|
122
|
+
styleConfig?: Record<string, any>
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
// Permissive shape: the wire payload may omit some fields (e.g. `title` is
|
|
@@ -207,6 +221,12 @@ export interface DynamicRecordDialogProps {
|
|
|
207
221
|
* regardless of the viewer's browser timezone. Pure `date` values pin to UTC.
|
|
208
222
|
*/
|
|
209
223
|
timeZone?: string
|
|
224
|
+
/**
|
|
225
|
+
* Org ISO-4217 currency code (e.g. `MXN`) used as the fallback for money
|
|
226
|
+
* fields (`cellStyle:'currency'` or the money-key heuristic) that lack an
|
|
227
|
+
* explicit per-field currency. Optional — defaults to 'USD'.
|
|
228
|
+
*/
|
|
229
|
+
currency?: string
|
|
210
230
|
}
|
|
211
231
|
|
|
212
232
|
function resolvePath(obj: any, path: string): any {
|
|
@@ -330,8 +350,31 @@ const MODE_CONFIG = {
|
|
|
330
350
|
// Context threading host runtime values to nested field components (uploads,
|
|
331
351
|
// image leads, tz-aware dates) without prop-drilling through every renderer.
|
|
332
352
|
const ModelContext = createContext('')
|
|
333
|
-
const ImageUrlContext = createContext<GetImageUrl>(identityImageUrl)
|
|
334
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
|
+
|
|
358
|
+
// Money-key heuristic mirroring the backend's `inferDisplayCellStyle`: lets the
|
|
359
|
+
// dialog format obvious money fields as currency even when the backend hasn't
|
|
360
|
+
// stamped `cellStyle:'currency'` yet. Case-insensitive; matches a key that
|
|
361
|
+
// equals one of these, or ends with `_<m>`, or starts with `<m>_`.
|
|
362
|
+
const MONEY_KEY_HEURISTIC = ['price', 'amount', 'total', 'cost', 'subtotal', 'balance', 'paid']
|
|
363
|
+
|
|
364
|
+
// isMoneyField decides whether a field should render as currency. The explicit
|
|
365
|
+
// `cellStyle:'currency'` stamp always wins; otherwise a numeric value whose key
|
|
366
|
+
// matches the money heuristic qualifies (robustness fallback).
|
|
367
|
+
export function isMoneyField(field: FieldDef, value: any): boolean {
|
|
368
|
+
if (field.cellStyle === 'currency') return true
|
|
369
|
+
if (value === null || value === undefined || value === '') return false
|
|
370
|
+
const num = typeof value === 'number' ? value : Number(value)
|
|
371
|
+
if (isNaN(num)) return false
|
|
372
|
+
const key = String(field.key || '').toLowerCase()
|
|
373
|
+
if (!key) return false
|
|
374
|
+
return MONEY_KEY_HEURISTIC.some(
|
|
375
|
+
m => key === m || key.endsWith(`_${m}`) || key.startsWith(`${m}_`),
|
|
376
|
+
)
|
|
377
|
+
}
|
|
335
378
|
|
|
336
379
|
export function DynamicRecordDialog({
|
|
337
380
|
open,
|
|
@@ -351,6 +394,7 @@ export function DynamicRecordDialog({
|
|
|
351
394
|
initialRecord,
|
|
352
395
|
getImageUrl = identityImageUrl,
|
|
353
396
|
timeZone,
|
|
397
|
+
currency,
|
|
354
398
|
}: DynamicRecordDialogProps) {
|
|
355
399
|
const api = useApi()
|
|
356
400
|
const { t } = useTranslation()
|
|
@@ -603,6 +647,7 @@ export function DynamicRecordDialog({
|
|
|
603
647
|
<ModelContext.Provider value={model}>
|
|
604
648
|
<ImageUrlContext.Provider value={getImageUrl}>
|
|
605
649
|
<TimeZoneContext.Provider value={timeZone}>
|
|
650
|
+
<CurrencyContext.Provider value={currency}>
|
|
606
651
|
<form
|
|
607
652
|
id="dynamic-record-form"
|
|
608
653
|
onSubmit={handleSubmit}
|
|
@@ -656,6 +701,7 @@ export function DynamicRecordDialog({
|
|
|
656
701
|
/>
|
|
657
702
|
</div>
|
|
658
703
|
)}
|
|
704
|
+
</CurrencyContext.Provider>
|
|
659
705
|
</TimeZoneContext.Provider>
|
|
660
706
|
</ImageUrlContext.Provider>
|
|
661
707
|
</ModelContext.Provider>
|
|
@@ -810,6 +856,7 @@ export function ViewValue({
|
|
|
810
856
|
record,
|
|
811
857
|
getImageUrl: getImageUrlProp,
|
|
812
858
|
timeZone: timeZoneProp,
|
|
859
|
+
currency: currencyProp,
|
|
813
860
|
}: {
|
|
814
861
|
field: FieldDef
|
|
815
862
|
value: any
|
|
@@ -818,11 +865,16 @@ export function ViewValue({
|
|
|
818
865
|
getImageUrl?: GetImageUrl
|
|
819
866
|
/** Optional override; when omitted falls back to the nearest provider. */
|
|
820
867
|
timeZone?: string
|
|
868
|
+
/** Optional override; when omitted falls back to the nearest provider. */
|
|
869
|
+
currency?: string
|
|
821
870
|
}) {
|
|
871
|
+
const { i18n } = useTranslation()
|
|
822
872
|
const ctxImageUrl = useContext(ImageUrlContext)
|
|
823
873
|
const ctxTimeZone = useContext(TimeZoneContext)
|
|
874
|
+
const ctxCurrency = useContext(CurrencyContext)
|
|
824
875
|
const getImageUrl = getImageUrlProp ?? ctxImageUrl
|
|
825
876
|
const timeZone = timeZoneProp ?? ctxTimeZone
|
|
877
|
+
const currency = currencyProp ?? ctxCurrency
|
|
826
878
|
|
|
827
879
|
// created_by / avatar resolver sibling → name (+ avatar) instead of "—".
|
|
828
880
|
if (field.type === 'avatar' || field.key === 'created_by' || field.key === 'created_by_id') {
|
|
@@ -904,6 +956,24 @@ export function ViewValue({
|
|
|
904
956
|
)
|
|
905
957
|
}
|
|
906
958
|
|
|
959
|
+
// Money → org-currency string. Detected by the backend `cellStyle:'currency'`
|
|
960
|
+
// stamp or a numeric value whose key matches the money heuristic (fallback
|
|
961
|
+
// mirroring the table cell + backend `inferDisplayCellStyle`).
|
|
962
|
+
if (isMoneyField(field, value)) {
|
|
963
|
+
const num = typeof value === 'number' ? value : Number(value)
|
|
964
|
+
if (!isNaN(num)) {
|
|
965
|
+
const resolvedCurrency = field.styleConfig?.currency || currency || 'USD'
|
|
966
|
+
const localeTag = i18n.language || 'es'
|
|
967
|
+
const formatted = new Intl.NumberFormat(localeTag, {
|
|
968
|
+
style: 'currency',
|
|
969
|
+
currency: resolvedCurrency,
|
|
970
|
+
minimumFractionDigits: 2,
|
|
971
|
+
maximumFractionDigits: 2,
|
|
972
|
+
}).format(num)
|
|
973
|
+
return <p className="text-sm py-1 tabular-nums">{formatted}</p>
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
907
977
|
// Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
|
|
908
978
|
// day); instants render in the org timezone with a full-precision tooltip.
|
|
909
979
|
if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp') {
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -93,9 +93,13 @@ const styleCfg = (
|
|
|
93
93
|
|
|
94
94
|
const EmptyCell = () => <span className="text-muted-foreground">-</span>
|
|
95
95
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Resolves the active currency for a column: the column's explicit currency
|
|
98
|
+
* style wins, then the org-level fallback (org config, like `timeZone`), then
|
|
99
|
+
* 'USD' as a last resort.
|
|
100
|
+
*/
|
|
101
|
+
export const resolveCurrency = (col: ColumnDefinition, orgCurrency?: string): string =>
|
|
102
|
+
styleCfg(col, 'currency') || orgCurrency || 'USD'
|
|
99
103
|
|
|
100
104
|
const formatNumber = (
|
|
101
105
|
value: number,
|
|
@@ -559,6 +563,7 @@ export function makeDefaultGetDynamicColumns(
|
|
|
559
563
|
currentLanguage?: string,
|
|
560
564
|
filterConfigs?: Map<string, ColumnFilterConfig>,
|
|
561
565
|
timeZone?: string,
|
|
566
|
+
currency?: string,
|
|
562
567
|
): ColumnDef<any>[] {
|
|
563
568
|
const dateLocale = currentLanguage === 'en' ? enUS : es
|
|
564
569
|
const columns: ColumnDef<any>[] = [
|
|
@@ -842,7 +847,7 @@ export function makeDefaultGetDynamicColumns(
|
|
|
842
847
|
num,
|
|
843
848
|
{
|
|
844
849
|
style: 'currency',
|
|
845
|
-
currency: resolveCurrency(col),
|
|
850
|
+
currency: resolveCurrency(col, currency),
|
|
846
851
|
minimumFractionDigits: decimals,
|
|
847
852
|
maximumFractionDigits: decimals,
|
|
848
853
|
},
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -129,6 +129,7 @@ export function DynamicForm({
|
|
|
129
129
|
field={field}
|
|
130
130
|
value={values[field.key]}
|
|
131
131
|
onChange={(v: any) => update(field.key, v)}
|
|
132
|
+
initialValues={initialValues}
|
|
132
133
|
/>
|
|
133
134
|
{errors[field.key] && (
|
|
134
135
|
<span className="text-red-500 text-sm" role="alert">{errors[field.key]}</span>
|
|
@@ -155,9 +156,38 @@ interface FieldRendererProps {
|
|
|
155
156
|
field: ActionFieldDef
|
|
156
157
|
value: any
|
|
157
158
|
onChange: (v: any) => void
|
|
159
|
+
/** The form's initial record — used to seed an FK picker's existing label/image. */
|
|
160
|
+
initialValues?: Record<string, any>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// seedOptionFromSibling builds a pre-resolved option for an FK field from the
|
|
164
|
+
// resolved sibling the backend served on the initial record (e.g. a line item's
|
|
165
|
+
// `product = { value, label, image }` alongside `product_id`). Lets the picker
|
|
166
|
+
// show the name + thumbnail for an existing value without a lookup. Returns
|
|
167
|
+
// undefined when the sibling carries nothing renderable.
|
|
168
|
+
function seedOptionFromSibling(
|
|
169
|
+
field: ActionFieldDef,
|
|
170
|
+
value: any,
|
|
171
|
+
initialValues?: Record<string, any>,
|
|
172
|
+
): ResolvedOption | undefined {
|
|
173
|
+
if (!field.key.endsWith('_id')) return undefined
|
|
174
|
+
const sib = initialValues?.[field.key.replace(/_id$/, '')]
|
|
175
|
+
if (!sib || typeof sib !== 'object') return undefined
|
|
176
|
+
const label = sib.label ?? sib.name ?? ''
|
|
177
|
+
if (!label && !sib.image) return undefined
|
|
178
|
+
const id = String(sib.value ?? sib.id ?? value ?? '')
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
value: id,
|
|
182
|
+
label: String(label),
|
|
183
|
+
name: String(label),
|
|
184
|
+
image: sib.image,
|
|
185
|
+
color: sib.color,
|
|
186
|
+
icon: sib.icon,
|
|
187
|
+
}
|
|
158
188
|
}
|
|
159
189
|
|
|
160
|
-
function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
190
|
+
function FieldRenderer({ field, value, onChange, initialValues }: FieldRendererProps) {
|
|
161
191
|
// Repeatable line-items group → render the row grid. Its value is an array
|
|
162
192
|
// of row objects rather than a scalar.
|
|
163
193
|
if (isLineItemsField(field)) {
|
|
@@ -168,7 +198,8 @@ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
|
168
198
|
// Preferred for FK fields with large option sets — no UUID typing, no
|
|
169
199
|
// dumping every row into a plain <select>.
|
|
170
200
|
if (widget === 'dynamic_select') {
|
|
171
|
-
|
|
201
|
+
const seedOption = seedOptionFromSibling(field, value, initialValues)
|
|
202
|
+
return <DynamicSelectField field={field} value={value} onChange={onChange} seedOption={seedOption} />
|
|
172
203
|
}
|
|
173
204
|
// File upload → themed picker that POSTs to the host upload endpoint and
|
|
174
205
|
// stores the returned file url/path as the field value.
|
|
@@ -20,6 +20,20 @@ function isEnumLikeColumn(col: ColumnDefinition): boolean {
|
|
|
20
20
|
|
|
21
21
|
export type DynamicRelationKind = 'one_to_many' | 'many_to_many'
|
|
22
22
|
|
|
23
|
+
// Server-managed / audit columns that must never become editable form inputs.
|
|
24
|
+
// They're set by the backend and several ship as resolved objects (e.g.
|
|
25
|
+
// `created_by = { name, avatar, email }`) that would render as `[object Object]`.
|
|
26
|
+
const MANAGED_RELATION_COLUMNS = new Set([
|
|
27
|
+
'id',
|
|
28
|
+
'created_at',
|
|
29
|
+
'updated_at',
|
|
30
|
+
'deleted_at',
|
|
31
|
+
'created_by',
|
|
32
|
+
'created_by_id',
|
|
33
|
+
'updated_by',
|
|
34
|
+
'updated_by_id',
|
|
35
|
+
])
|
|
36
|
+
|
|
23
37
|
// Pulls a human label off a resolved relation/user object a backend serves:
|
|
24
38
|
// `{ value, label }` (FK sibling), `{ name, … }` (user object such as
|
|
25
39
|
// created_by) or `{ title }`. Returns undefined for plain/empty objects so the
|
|
@@ -146,6 +160,10 @@ export function deriveRelationFormFields(
|
|
|
146
160
|
for (const col of metadata.columns) {
|
|
147
161
|
if (col.key === foreignKey) continue
|
|
148
162
|
if (col.hidden) continue
|
|
163
|
+
// Managed/audit columns are server-owned and ship resolved objects
|
|
164
|
+
// (`created_by = { name, avatar, … }`); making them editable inputs
|
|
165
|
+
// renders `[object Object]`. Never surface them in the inline form.
|
|
166
|
+
if (MANAGED_RELATION_COLUMNS.has(col.key.toLowerCase())) continue
|
|
149
167
|
out.push({
|
|
150
168
|
key: col.key,
|
|
151
169
|
label: col.label,
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -25,6 +25,8 @@ import { Plus, Trash2, Pencil } from 'lucide-react'
|
|
|
25
25
|
import { useApi } from './api-context'
|
|
26
26
|
import { useMetadataCache } from './metadata-cache'
|
|
27
27
|
import { DynamicForm } from './dynamic-form'
|
|
28
|
+
import { useImageUrl } from './image-url-context'
|
|
29
|
+
import { OptionThumb } from './dynamic-select-field'
|
|
28
30
|
import { useOptionsResolver } from './use-options-resolver'
|
|
29
31
|
import type { ApiResponse, TableMetadata } from './types'
|
|
30
32
|
import {
|
|
@@ -169,6 +171,7 @@ function OneToManyRelation({
|
|
|
169
171
|
onChange,
|
|
170
172
|
}: DynamicRelationOneToManyProps) {
|
|
171
173
|
const api = useApi()
|
|
174
|
+
const getImageUrl = useImageUrl()
|
|
172
175
|
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
|
|
173
176
|
const cachedMeta = getMetadata(model)
|
|
174
177
|
const labels = { ...DEFAULT_STRINGS, ...(strings || {}) }
|
|
@@ -305,6 +308,22 @@ function OneToManyRelation({
|
|
|
305
308
|
<div className="flex-1 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-2 text-sm">
|
|
306
309
|
{visibleColumns.map(col => {
|
|
307
310
|
const cell = formatRelationCell(row, col)
|
|
311
|
+
// FK column whose backend-resolved sibling
|
|
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
|
|
320
|
+
return (
|
|
321
|
+
<span key={col.key} className="flex min-w-0 items-center gap-2" title={String(label)}>
|
|
322
|
+
<OptionThumb image={getImageUrl(sibling.image)} size={20} />
|
|
323
|
+
<span className="truncate">{label}</span>
|
|
324
|
+
</span>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
308
327
|
return (
|
|
309
328
|
<span key={col.key} className="truncate" title={cell}>
|
|
310
329
|
{cell}
|
|
@@ -138,9 +138,16 @@ export interface DynamicSelectFieldProps {
|
|
|
138
138
|
field: ActionFieldDef
|
|
139
139
|
value: any
|
|
140
140
|
onChange: (v: any) => void
|
|
141
|
+
/**
|
|
142
|
+
* Pre-resolved option for the CURRENT value (label + image/color/icon) the
|
|
143
|
+
* caller already has — e.g. the relation sibling the table served. Lets the
|
|
144
|
+
* trigger show the name + thumbnail for an existing value without waiting for
|
|
145
|
+
* a lookup (which only loads once the popover opens). Matched by id == value.
|
|
146
|
+
*/
|
|
147
|
+
seedOption?: ResolvedOption | null
|
|
141
148
|
}
|
|
142
149
|
|
|
143
|
-
export function DynamicSelectField({ field, value, onChange }: DynamicSelectFieldProps) {
|
|
150
|
+
export function DynamicSelectField({ field, value, onChange, seedOption }: DynamicSelectFieldProps) {
|
|
144
151
|
const [open, setOpen] = useState(false)
|
|
145
152
|
const [search, setSearch] = useState('')
|
|
146
153
|
const debounced = useDebounced(search, 250)
|
|
@@ -172,6 +179,7 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
|
|
|
172
179
|
const selectedOption =
|
|
173
180
|
(picked && String(picked.id) === String(value) ? picked : null) ??
|
|
174
181
|
options.find((o) => String(o.id) === String(value)) ??
|
|
182
|
+
(seedOption && String(seedOption.id) === String(value) ? seedOption : null) ??
|
|
175
183
|
null
|
|
176
184
|
|
|
177
185
|
const selectedLabel = selectedOption?.label ?? (value ? String(value) : '')
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -97,6 +97,12 @@ interface DynamicTableProps {
|
|
|
97
97
|
* Optional — omitting it preserves the legacy browser-local formatting.
|
|
98
98
|
*/
|
|
99
99
|
timeZone?: string
|
|
100
|
+
/**
|
|
101
|
+
* ISO 4217 currency code (e.g. the org's `MXN`) used as the fallback for
|
|
102
|
+
* money cells (`type:'number'` + `cellStyle:'currency'`) that don't carry
|
|
103
|
+
* an explicit per-column currency. Optional — defaults to 'USD'.
|
|
104
|
+
*/
|
|
105
|
+
currency?: string
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
export function DynamicTable({
|
|
@@ -110,6 +116,7 @@ export function DynamicTable({
|
|
|
110
116
|
extraColumns = [],
|
|
111
117
|
getDynamicColumns = defaultGetDynamicColumns,
|
|
112
118
|
timeZone,
|
|
119
|
+
currency,
|
|
113
120
|
}: DynamicTableProps) {
|
|
114
121
|
const { t, i18n } = useTranslation()
|
|
115
122
|
const api = useApi()
|
|
@@ -598,12 +605,12 @@ export function DynamicTable({
|
|
|
598
605
|
const rowMetadata = metadata.actions?.some((a) => a.placement === 'table' || a.placement === 'create')
|
|
599
606
|
? { ...metadata, actions: metadata.actions.filter((a) => !a.placement || a.placement === 'row') }
|
|
600
607
|
: metadata
|
|
601
|
-
const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone)
|
|
608
|
+
const baseColumns = getDynamicColumns(rowMetadata, handleInternalAction, t, i18n.language, columnFilterConfigs, timeZone, currency)
|
|
602
609
|
const filteredBase = baseColumns.filter((col: ColumnDef<any>) => !hiddenColumns.includes(col.id as string))
|
|
603
610
|
const actionsCol = filteredBase.find((c: ColumnDef<any>) => c.id === 'actions')
|
|
604
611
|
const otherCols = filteredBase.filter((c: ColumnDef<any>) => c.id !== 'actions')
|
|
605
612
|
return [...otherCols, ...extraColumns, ...(actionsCol ? [actionsCol] : [])]
|
|
606
|
-
}, [metadata, handleInternalAction, hiddenColumns, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone])
|
|
613
|
+
}, [metadata, handleInternalAction, hiddenColumns, extraColumns, t, i18n.language, columnFilterConfigs, getDynamicColumns, timeZone, currency])
|
|
607
614
|
|
|
608
615
|
const filters = useMemo(() => [], [])
|
|
609
616
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Image-url resolver context — its own module so any renderer (record dialog,
|
|
2
|
+
// relation cells, …) can consume the host's storage-path → URL resolver without
|
|
3
|
+
// importing from `dialogs/dynamic-record`. That dialog imports
|
|
4
|
+
// `dynamic-relations` (which renders `dynamic-relation`), so the relation cell
|
|
5
|
+
// cannot import the context back from the dialog without a circular import —
|
|
6
|
+
// hence this standalone module is the single source of truth.
|
|
7
|
+
import { createContext, useContext } from 'react'
|
|
8
|
+
|
|
9
|
+
/** Resolves a (possibly relative) storage path into a fetchable URL. */
|
|
10
|
+
export type GetImageUrl = (path: string | null | undefined) => string
|
|
11
|
+
|
|
12
|
+
/** Default resolver: pass the path through unchanged (works same-origin). */
|
|
13
|
+
export const identityImageUrl: GetImageUrl = (p) => p ?? ''
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Threads the host's image-url resolver to nested field/cell components without
|
|
17
|
+
* prop-drilling. Provided by `DynamicRecordDialog`; consumers outside a provider
|
|
18
|
+
* fall back to `identityImageUrl` (the relative path, which renders same-origin).
|
|
19
|
+
*/
|
|
20
|
+
export const ImageUrlContext = createContext<GetImageUrl>(identityImageUrl)
|
|
21
|
+
|
|
22
|
+
/** Reads the nearest image-url resolver (identity outside a provider). */
|
|
23
|
+
export const useImageUrl = () => useContext(ImageUrlContext)
|