@asteby/metacore-runtime-react 34.1.0 → 35.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -90
- package/dist/action-modal-dispatcher.d.ts +4 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +102 -23
- package/dist/addon-loader.d.ts +1 -1
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +8 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +99 -61
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +23 -8
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +6 -3
- package/dist/use-print-document.d.ts +10 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +28 -1
- package/package.json +1 -1
- package/src/__tests__/filename-from-disposition.test.ts +30 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
- package/src/__tests__/record-detail-display.test.tsx +30 -0
- package/src/action-modal-dispatcher.tsx +110 -22
- package/src/addon-loader.tsx +7 -1
- package/src/dialogs/dynamic-record.tsx +139 -65
- package/src/dynamic-columns.tsx +28 -12
- package/src/dynamic-select-field.tsx +7 -3
- package/src/use-print-document.ts +38 -2
|
@@ -10,7 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
10
10
|
// flows through <ApiProvider> from runtime-react. Host-specific runtime values —
|
|
11
11
|
// the image-url resolver and the org IANA timezone — are passed as props so the
|
|
12
12
|
// SDK stays transport- and host-agnostic.
|
|
13
|
-
import { createContext, useCallback, useContext, useEffect,
|
|
13
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
14
14
|
import { useTranslation } from 'react-i18next';
|
|
15
15
|
/** Model key of the open create/edit dialog — used to hide "+" on self-FK pickers
|
|
16
16
|
* (e.g. Customer.parent_id) so they don't nest another "Crear Cliente" modal. */
|
|
@@ -23,9 +23,7 @@ import { es } from 'date-fns/locale';
|
|
|
23
23
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react';
|
|
24
24
|
import { BarcodeScanner } from '../barcode-scanner';
|
|
25
25
|
import { useApi } from '../api-context';
|
|
26
|
-
import { toastServerError, extractFieldErrors,
|
|
27
|
-
import { validateValues, bagHasErrors } from '../validator';
|
|
28
|
-
import { validationCatalog } from '../validation-catalog';
|
|
26
|
+
import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../server-error';
|
|
29
27
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field';
|
|
30
28
|
import { DynamicRelations } from '../dynamic-relations';
|
|
31
29
|
import { useOptionsResolver } from '../use-options-resolver';
|
|
@@ -169,6 +167,23 @@ function isRelationField(field) {
|
|
|
169
167
|
!!getFieldRef(field) ||
|
|
170
168
|
!!field.searchEndpoint);
|
|
171
169
|
}
|
|
170
|
+
// looksLikeForeignKey — true only for real FKs. A bare `*_id` suffix is NOT
|
|
171
|
+
// enough: columns like `external_id`, `trace_id`, or `invoice_uid` are plain
|
|
172
|
+
// text identifiers from a PAC/provider, not belongs_to relations. Treating them
|
|
173
|
+
// as relations rendered an InitialsAvatar ("6" chip next to "6a8c…") and made
|
|
174
|
+
// fiscal detail modals look broken.
|
|
175
|
+
function looksLikeForeignKey(field) {
|
|
176
|
+
if (isRelationField(field))
|
|
177
|
+
return true;
|
|
178
|
+
if (typeof field.key !== 'string' || !field.key.endsWith('_id'))
|
|
179
|
+
return false;
|
|
180
|
+
const t = String(field.type || '').toLowerCase();
|
|
181
|
+
return (t === 'uuid' ||
|
|
182
|
+
t === 'search' ||
|
|
183
|
+
t === 'relation' ||
|
|
184
|
+
t === 'dynamic_select' ||
|
|
185
|
+
t === 'belongs_to');
|
|
186
|
+
}
|
|
172
187
|
function formatDisplayValue(rawValue, field) {
|
|
173
188
|
// Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
|
|
174
189
|
const value = normalizeNilUuid(rawValue);
|
|
@@ -284,19 +299,9 @@ export function stripHiddenFieldValues(values, fields, mode) {
|
|
|
284
299
|
}
|
|
285
300
|
return out;
|
|
286
301
|
}
|
|
287
|
-
function toastValidationFailed(t, lang, localized) {
|
|
288
|
-
toast.error(t('validation.failed', { defaultValue: validationCatalog(lang).failed }), {
|
|
289
|
-
description: Object.values(localized).filter(Boolean).join('\n'),
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
302
|
export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, lockedFields, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl = identityImageUrl, timeZone, currency, onChange, }) {
|
|
293
303
|
const api = useApi();
|
|
294
|
-
const { t
|
|
295
|
-
// Unique per dialog instance. The footer submit lives OUTSIDE <form>, so
|
|
296
|
-
// it binds via `form={id}`. A hardcoded id made nested create (product +
|
|
297
|
-
// "Crear categoría") submit the PARENT form — toast "Revisa los campos
|
|
298
|
-
// marcados" with no marks on the inner modal.
|
|
299
|
-
const formId = useId();
|
|
304
|
+
const { t } = useTranslation();
|
|
300
305
|
const [modalMeta, setModalMeta] = useState(schema ? schema : null);
|
|
301
306
|
const [relations, setRelations] = useState([]);
|
|
302
307
|
const [record, setRecord] = useState(null);
|
|
@@ -490,25 +495,24 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
490
495
|
const labelForKey = (key) => {
|
|
491
496
|
const f = (modalMeta?.fields ?? []).find(x => x.key === key);
|
|
492
497
|
if (f?.label)
|
|
493
|
-
return
|
|
498
|
+
return f.label;
|
|
494
499
|
return key.replace(/[._-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
495
500
|
};
|
|
496
|
-
const lang = i18n.language;
|
|
497
501
|
// Turn a failed submit (422 `errors` map, or a bare `{errors}` body) into
|
|
498
502
|
// inline field errors + a summary toast. When there is no field map, fall
|
|
499
503
|
// back to the existing single cause-carrying toast.
|
|
500
504
|
const handleSubmitError = (err) => {
|
|
501
505
|
const map = extractFieldErrors(err);
|
|
502
506
|
if (map) {
|
|
503
|
-
const
|
|
504
|
-
for (const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
setFieldErrors(
|
|
508
|
-
|
|
507
|
+
const next = {};
|
|
508
|
+
for (const [key, issues] of Object.entries(map)) {
|
|
509
|
+
next[key] = localizeFieldIssue(issues[0], labelForKey(key), t);
|
|
510
|
+
}
|
|
511
|
+
setFieldErrors(next);
|
|
512
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }));
|
|
509
513
|
return;
|
|
510
514
|
}
|
|
511
|
-
toastServerError(err, { t,
|
|
515
|
+
toastServerError(err, { t, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) });
|
|
512
516
|
};
|
|
513
517
|
const handleSubmit = async (e) => {
|
|
514
518
|
e?.preventDefault();
|
|
@@ -520,15 +524,15 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
520
524
|
// fields are gated: a field hidden by its `visible_when` predicate
|
|
521
525
|
// must not block submit even when it is declared required (matching
|
|
522
526
|
// the render, which drops it via the same filter).
|
|
523
|
-
const
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
setFieldErrors(
|
|
531
|
-
|
|
527
|
+
const missing = {};
|
|
528
|
+
for (const field of filterVisibleFields(modalMeta.fields, mode, formValues)) {
|
|
529
|
+
if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
|
|
530
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (Object.keys(missing).length) {
|
|
534
|
+
setFieldErrors(missing);
|
|
535
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }));
|
|
532
536
|
return;
|
|
533
537
|
}
|
|
534
538
|
}
|
|
@@ -649,21 +653,22 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
649
653
|
// then advance. Mirrors handleSubmit's required check but scoped to the step.
|
|
650
654
|
const goNextStep = () => {
|
|
651
655
|
const step = groups[clampedStep];
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
656
|
+
const missing = {};
|
|
657
|
+
for (const field of step?.fields ?? []) {
|
|
658
|
+
if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
|
|
659
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (Object.keys(missing).length) {
|
|
663
|
+
setFieldErrors(missing);
|
|
664
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }));
|
|
660
665
|
return;
|
|
661
666
|
}
|
|
662
667
|
setFieldErrors({});
|
|
663
668
|
setStepIndex(Math.min(clampedStep + 1, groups.length - 1));
|
|
664
669
|
};
|
|
665
670
|
const goBackStep = () => setStepIndex(Math.max(clampedStep - 1, 0));
|
|
666
|
-
return (_jsx(RecordDialogModelContext.Provider, { value: model, children: _jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90dvh] flex flex-col p-0 gap-0 overflow-hidden", style: { maxHeight: '90dvh' }, 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:
|
|
671
|
+
return (_jsx(RecordDialogModelContext.Provider, { value: model, children: _jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90dvh] flex flex-col p-0 gap-0 overflow-hidden", style: { maxHeight: '90dvh' }, 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 gap-y-4", children: [isSteps && (_jsx(WizardProgress, { groups: groups, stepIndex: clampedStep })), (isSteps ? [groups[clampedStep]] : groups).map(group => (_jsx(FieldSection, { group: group, children: _jsx("div", { className: "grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2", children: renderFields(group.fields) }) }, group.key))), record?.external_url && (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2", children: _jsx("div", { className: "sm:col-span-2 min-w-0", 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, lineSubtable: true, embedOnly: true, canCreate: mode === 'edit', canEdit: mode === 'edit', canDelete: mode === 'edit', onChange: handleChildChange }) }))] }) }) }) })) : 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: [isSteps && clampedStep > 0 ? (_jsx(Button, { variant: "outline", onClick: goBackStep, disabled: saving || deleting, children: "Anterior" })) : (_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 && isSteps && !isLastStep && (_jsx(Button, { type: "button", onClick: goNextStep, disabled: saving || loading, children: "Siguiente" })), isEditable && (!isSteps || isLastStep) && (_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] }))] })] })] }) }) }));
|
|
667
672
|
}
|
|
668
673
|
function LoadingSkeleton() {
|
|
669
674
|
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))) }));
|
|
@@ -698,9 +703,7 @@ export function ReadonlyEditField({ field, value }) {
|
|
|
698
703
|
// ReadonlyRelationField — a locked/readonly FK field (customer_id, category_id…)
|
|
699
704
|
// resolves the record's label instead of showing the raw id, mirroring
|
|
700
705
|
// RelationViewValue's lookup but rendered as a disabled input to match the rest
|
|
701
|
-
// of ReadonlyEditField.
|
|
702
|
-
// seeding a POS-selected customer into a vehicle create modal) would show a bare
|
|
703
|
-
// UUID — the exact readability bug this dialog otherwise avoids elsewhere.
|
|
706
|
+
// of ReadonlyEditField.
|
|
704
707
|
function ReadonlyRelationField({ field, value, fieldRef, }) {
|
|
705
708
|
const rawVal = value && typeof value === 'object' ? (value.value ?? value.id) : value;
|
|
706
709
|
const needResolve = fieldRef != null || !!field.searchEndpoint;
|
|
@@ -720,7 +723,9 @@ function ReadonlyRelationField({ field, value, fieldRef, }) {
|
|
|
720
723
|
// RelationViewValue — read-only FK lead. Resolves the relation's label + image
|
|
721
724
|
// from (1) the sibling object the table served, then (2) the canonical options
|
|
722
725
|
// endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
|
|
723
|
-
|
|
726
|
+
// When `stack` is true (`display: "image_stack"`), the landscape mark sits ON
|
|
727
|
+
// TOP of the label — wide logos (brand marks) fit without cropping.
|
|
728
|
+
function RelationViewValue({ field, value, record, stack = false, }) {
|
|
724
729
|
const getImageUrl = useContext(ImageUrlContext);
|
|
725
730
|
const sib = relationSiblingValue(field, record);
|
|
726
731
|
const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib);
|
|
@@ -804,15 +809,15 @@ export function ViewValue({ field, value: rawValue, record, getImageUrl: getImag
|
|
|
804
809
|
return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
|
|
805
810
|
}
|
|
806
811
|
const value = normalizeNilUuid(rawValue);
|
|
807
|
-
// Landscape stack on a relation FK (brand marks, product cards)
|
|
808
|
-
|
|
809
|
-
|
|
812
|
+
// Landscape stack on a relation FK (brand marks, product cards): image ON
|
|
813
|
+
// TOP, label UNDERNEATH. Checked before the default relation lead so a
|
|
814
|
+
// `display: "image_stack"` FK does not fall through to the side-by-side chip.
|
|
815
|
+
if (renderAs === 'image_stack' && looksLikeForeignKey(field)) {
|
|
810
816
|
return _jsx(RelationViewValue, { field: field, value: value, record: record, stack: true });
|
|
811
817
|
}
|
|
812
|
-
// Relation (search / dynamic_select / ref /
|
|
813
|
-
// label.
|
|
814
|
-
|
|
815
|
-
if (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id'))) {
|
|
818
|
+
// Relation (search / dynamic_select / ref / uuid *_id FK) → resolved
|
|
819
|
+
// thumbnail + label. Plain text `*_id` columns (external_id, …) stay text.
|
|
820
|
+
if (looksLikeForeignKey(field)) {
|
|
816
821
|
return _jsx(RelationViewValue, { field: field, value: value, record: record });
|
|
817
822
|
}
|
|
818
823
|
// The value is itself a resolved object the backend served inline — render
|
|
@@ -827,16 +832,24 @@ export function ViewValue({ field, value: rawValue, record, getImageUrl: getImag
|
|
|
827
832
|
if (field.type === 'color') {
|
|
828
833
|
return value ? (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "h-5 w-5 rounded-full border shadow-sm", style: { backgroundColor: value } }), _jsx("span", { className: "text-sm", children: value })] })) : (_jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "-" }));
|
|
829
834
|
}
|
|
830
|
-
// Landscape stack for image/logo URL columns
|
|
835
|
+
// Landscape stack for image/logo URL columns (and `type: image` with
|
|
836
|
+
// `cellStyle: image_stack`). Wide marks sit above an optional caption.
|
|
831
837
|
if (renderAs === 'image_stack') {
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
+
if (value && isLucideIconName(value)) {
|
|
839
|
+
return _jsx(IconNameViewValue, { name: value });
|
|
840
|
+
}
|
|
841
|
+
const labelField = (field.styleConfig &&
|
|
842
|
+
field.styleConfig.label_field) ||
|
|
843
|
+
(field.styleConfig && field.styleConfig.labelField);
|
|
844
|
+
let caption;
|
|
845
|
+
if (labelField && record && typeof record === 'object') {
|
|
846
|
+
const raw = record[labelField];
|
|
847
|
+
if (raw != null && String(raw) !== '')
|
|
848
|
+
caption = String(raw);
|
|
849
|
+
}
|
|
850
|
+
return value || caption ? (_jsx("div", { className: "py-1", children: _jsx(ImageStack, { src: value ? String(value) : undefined, label: caption, getImageUrl: getImageUrl, size: "lg" }) })) : (_jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "Sin imagen" }));
|
|
838
851
|
}
|
|
839
|
-
if (field.type === 'image') {
|
|
852
|
+
if (field.type === 'image' || renderAs === 'image') {
|
|
840
853
|
if (isLucideIconName(value)) {
|
|
841
854
|
return _jsx(IconNameViewValue, { name: value });
|
|
842
855
|
}
|
|
@@ -1007,8 +1020,33 @@ function StructuredViewValue({ value, field, locale, t, }) {
|
|
|
1007
1020
|
if (isEmpty) {
|
|
1008
1021
|
return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
|
|
1009
1022
|
}
|
|
1023
|
+
// Line-items arrays with a declared itemFields schema → mini-table.
|
|
1024
|
+
// Plain objects (PAC provider_data, fiscal_data bags) → readable key/value
|
|
1025
|
+
// list; nested objects/arrays render as pretty JSON instead of `key: {…}`
|
|
1026
|
+
// stubs that looked broken in fiscal detail modals.
|
|
1027
|
+
const hasItemFields = !!(field?.itemFields ?? field?.item_fields);
|
|
1028
|
+
if (!hasItemFields &&
|
|
1029
|
+
value !== null &&
|
|
1030
|
+
typeof value === 'object' &&
|
|
1031
|
+
!Array.isArray(value)) {
|
|
1032
|
+
return _jsx(JsonObjectViewValue, { value: value });
|
|
1033
|
+
}
|
|
1010
1034
|
return (_jsx("div", { className: "text-sm py-1", children: _jsx(CollectionCell, { value: value, itemFields: field?.itemFields ?? field?.item_fields, variant: "inline", locale: locale, t: t, getImageUrl: getImageUrl }) }));
|
|
1011
1035
|
}
|
|
1036
|
+
/** Flatten a jsonb/object bag into labeled rows; nest as pretty JSON. */
|
|
1037
|
+
function JsonObjectViewValue({ value }) {
|
|
1038
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
|
|
1039
|
+
if (entries.length === 0) {
|
|
1040
|
+
return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
|
|
1041
|
+
}
|
|
1042
|
+
return (_jsx("dl", { className: "grid gap-2 py-1 text-sm", children: entries.map(([key, raw]) => {
|
|
1043
|
+
const label = humanizeToken(key);
|
|
1044
|
+
const isNest = raw !== null &&
|
|
1045
|
+
typeof raw === 'object' &&
|
|
1046
|
+
!(raw instanceof Date);
|
|
1047
|
+
return (_jsxs("div", { className: "min-w-0", children: [_jsx("dt", { className: "text-[11px] font-medium uppercase tracking-wide text-muted-foreground", children: label }), _jsx("dd", { className: "mt-0.5 break-words text-foreground", children: isNest ? (_jsx("pre", { className: "max-h-48 overflow-auto rounded-md bg-muted/40 p-2 text-[11px] leading-relaxed whitespace-pre-wrap", children: JSON.stringify(raw, null, 2) })) : raw === null || raw === '' ? (_jsx("span", { className: "text-muted-foreground", children: "\u2014" })) : (String(raw)) })] }, key));
|
|
1048
|
+
}) }));
|
|
1049
|
+
}
|
|
1012
1050
|
export function EditField({ field, value, onChange, record }) {
|
|
1013
1051
|
const { t, i18n } = useTranslation();
|
|
1014
1052
|
const editFieldImageUrl = useContext(ImageUrlContext);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAE9B,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"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"}
|
package/dist/dynamic-columns.js
CHANGED
|
@@ -23,7 +23,7 @@ import { generateBadgeStyles, getInitials, relationChipStyles, } from '@asteby/m
|
|
|
23
23
|
import { Progress } from './dialogs/_primitives';
|
|
24
24
|
import { humanizeToken } from './dynamic-columns-helpers';
|
|
25
25
|
import { objectLabel } from './dynamic-relation-helpers';
|
|
26
|
-
import {
|
|
26
|
+
import { OptionBadge, RelationThumbnail, ImageStack, statusColorFor, useIsDarkTheme, } from './display-value';
|
|
27
27
|
import { MediaValue } from './rich-url';
|
|
28
28
|
import { OptionsContext } from './options-context';
|
|
29
29
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon';
|
|
@@ -468,6 +468,10 @@ export const resolveRelationSubtitle = (col, row) => {
|
|
|
468
468
|
* carries an `image`. Falls back to the raw id when no sibling was resolved, and
|
|
469
469
|
* to an empty marker when there is no value at all. Domain-agnostic: works for
|
|
470
470
|
* every `belongs_to` column (category, supplier, brand, …) without per-addon code.
|
|
471
|
+
*
|
|
472
|
+
* When `stack` is true (column `display: "image_stack"`), the landscape mark
|
|
473
|
+
* sits ON TOP of the label — wide logos fit without cropping and the cell
|
|
474
|
+
* stays readable in dense tables.
|
|
471
475
|
*/
|
|
472
476
|
const RelationCell = ({ col, row, getImageUrl, stack = false }) => {
|
|
473
477
|
const display = resolveRelationLabel(col, row);
|
|
@@ -658,7 +662,15 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
658
662
|
return _jsx(ReferenceCell, { col: col, row: row.original });
|
|
659
663
|
}
|
|
660
664
|
// Landscape stack: wide image ON TOP, label UNDERNEATH.
|
|
665
|
+
// Declared via `display: "image_stack"` on an image column
|
|
666
|
+
// or on a belongs_to FK whose sibling carries a logo/photo
|
|
667
|
+
// (brand marks, product cards). Fits logos that are wider
|
|
668
|
+
// than tall without cropping into a square thumb.
|
|
661
669
|
if (renderAs === 'image_stack') {
|
|
670
|
+
// FK relation (brand_id → brands) OR any column that
|
|
671
|
+
// already resolved a sibling with an image — stack it.
|
|
672
|
+
// Don't require `col.ref` alone: enrichment sometimes
|
|
673
|
+
// leaves type=text while cellStyle carries image_stack.
|
|
662
674
|
const looksRelation = !!col.ref ||
|
|
663
675
|
(typeof col.key === 'string' &&
|
|
664
676
|
col.key.endsWith('_id') &&
|
|
@@ -872,13 +884,6 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
872
884
|
: 'FileText', className: "h-4 w-4" }) }, i));
|
|
873
885
|
}), remaining > 0 && (_jsxs("div", { className: "flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-medium ring-2 ring-background", children: ["+", remaining] }))] }));
|
|
874
886
|
}
|
|
875
|
-
case 'image_stack': {
|
|
876
|
-
const imageValue = value ||
|
|
877
|
-
(Array.isArray(row.original.media)
|
|
878
|
-
? row.original.media.find((m) => m.type === 'image')?.url
|
|
879
|
-
: null);
|
|
880
|
-
return (_jsx(ImageCell, { value: imageValue, getImageUrl: getImageUrl, stack: true }));
|
|
881
|
-
}
|
|
882
887
|
case 'image': {
|
|
883
888
|
const imageValue = value ||
|
|
884
889
|
(Array.isArray(row.original.media)
|
|
@@ -886,6 +891,16 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
|
|
|
886
891
|
: null);
|
|
887
892
|
return _jsx(ImageCell, { value: imageValue, getImageUrl: getImageUrl });
|
|
888
893
|
}
|
|
894
|
+
case 'image_stack': {
|
|
895
|
+
// Defensive: normally handled above before the
|
|
896
|
+
// switch; kept so a late `type: image_stack` without
|
|
897
|
+
// cellStyle still stacks.
|
|
898
|
+
const labelField = styleCfg(col, 'label_field', 'labelField');
|
|
899
|
+
const caption = labelField
|
|
900
|
+
? String(getNestedValue(row.original, labelField) ?? '')
|
|
901
|
+
: undefined;
|
|
902
|
+
return (_jsx(ImageCell, { value: value, getImageUrl: getImageUrl, label: caption || undefined, stack: true }));
|
|
903
|
+
}
|
|
889
904
|
default: {
|
|
890
905
|
if (typeof value === 'object' && value !== null) {
|
|
891
906
|
return (_jsx(CollectionCell, { value: value, locale: currentLanguage, t: t, itemFields: col.itemFields ?? col.item_fields, getImageUrl: getImageUrl }));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AA4CA,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAEhF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,gDAAgD,CAAA;AAEjF;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,EACxB,KAAK,EACL,IAAI,EACJ,IAAS,GACZ,EAAE;IACC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,+BA4BA;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,GAAG,OAAO,CAAC,GAAG,IAAI,CAAA;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CAChB,sCA4BA;AAMD,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;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,SAAS,cAAc,EAAE,GAAG,IAAI,CAAA;IAChD;;;OAGG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAA;IAC5B;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;CACvB;AAED,wBAAgB,kBAAkB,CAAC,EAC/B,KAAK,EACL,KAAK,EACL,QAAQ,EACR,UAAU,EACV,YAAY,EACZ,WAAW,EACX,QAAgB,EAChB,aAAoB,EACpB,kBAA0B,EAC1B,UAAkB,GACrB,EAAE,uBAAuB,+BAiTzB;AAED,eAAe,kBAAkB,CAAA"}
|
|
@@ -23,6 +23,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
23
23
|
// value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
|
|
24
24
|
// case — start empty and never hit this.
|
|
25
25
|
import { useEffect, useRef, useState } from 'react';
|
|
26
|
+
import { useTranslation } from 'react-i18next';
|
|
26
27
|
import { Badge, Button, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, InitialsAvatar, } from '@asteby/metacore-ui/primitives';
|
|
27
28
|
import { Check, ChevronsUpDown, Loader2, Plus, ScanLine } from 'lucide-react';
|
|
28
29
|
import { resolveColorCss } from '@asteby/metacore-ui/lib';
|
|
@@ -88,6 +89,8 @@ function useDebounced(value, ms) {
|
|
|
88
89
|
return useDebouncedValue(value, ms);
|
|
89
90
|
}
|
|
90
91
|
export function DynamicSelectField({ field, value, onChange, seedOption, dependsValue, dependsHint, readonly = false, staticOptions = null, descriptionAsBadge = false, hideCreate = false, }) {
|
|
92
|
+
const { t } = useTranslation();
|
|
93
|
+
const ph = (fallback) => field.placeholder ? t(field.placeholder, { defaultValue: field.placeholder }) : fallback;
|
|
91
94
|
const [open, setOpen] = useState(false);
|
|
92
95
|
const [search, setSearch] = useState('');
|
|
93
96
|
const [scanOpen, setScanOpen] = useState(false);
|
|
@@ -206,7 +209,7 @@ export function DynamicSelectField({ field, value, onChange, seedOption, depends
|
|
|
206
209
|
// the eager fetch is in flight the label falls back to the seed/raw value,
|
|
207
210
|
// then snaps to the name once options arrive.
|
|
208
211
|
if (readonly) {
|
|
209
|
-
return (_jsx(Button, { type: "button", variant: "outline", role: "combobox", id: field.key, disabled: true, "aria-readonly": "true", className: "w-full min-w-0 cursor-default justify-start font-normal opacity-100", children: _jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [selectedOption ? _jsx(OptionLead, { option: selectedOption, size: 20 }) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground'), children: selectedOption?.label ?? (loading ? 'Cargando…' :
|
|
212
|
+
return (_jsx(Button, { type: "button", variant: "outline", role: "combobox", id: field.key, disabled: true, "aria-readonly": "true", className: "w-full min-w-0 cursor-default justify-start font-normal opacity-100", children: _jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [selectedOption ? _jsx(OptionLead, { option: selectedOption, size: 20 }) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground'), children: selectedOption?.label ?? (loading ? 'Cargando…' : ph('—')) })] }) }));
|
|
210
213
|
}
|
|
211
214
|
// w-full + min-w-0: as a grid cell child, the row must be allowed to shrink
|
|
212
215
|
// to the cell. Without min-w-0 the combobox+button row sizes to its content
|
|
@@ -215,10 +218,10 @@ export function DynamicSelectField({ field, value, onChange, seedOption, depends
|
|
|
215
218
|
return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsxs(Popover, { open: open && !blockedByDependency, onOpenChange: (o) => { if (!blockedByDependency)
|
|
216
219
|
setOpen(o); }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", role: "combobox", "aria-expanded": open, id: field.key, disabled: blockedByDependency, className: "min-w-0 flex-1 justify-between font-normal", "data-empty": !value, "data-depends-blocked": blockedByDependency ? '' : undefined, children: [_jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [value && selectedOption ? (_jsx(OptionLead, { option: selectedOption, size: 20 })) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground'), children: blockedByDependency
|
|
217
220
|
? (dependsHint || DEFAULT_DEPENDS_HINT)
|
|
218
|
-
: selectedLabel ||
|
|
221
|
+
: selectedLabel || ph('Buscar…') }), descriptionAsBadge && selectedOption?.description ? (_jsx(Badge, { variant: "secondary", className: "shrink-0 font-normal tabular-nums", children: selectedOption.description })) : null] }), _jsx(ChevronsUpDown, { className: "ml-2 size-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "p-0", align: "start",
|
|
219
222
|
// Match the trigger width without an arbitrary Tailwind class
|
|
220
223
|
// (those don't always survive a consuming app's Tailwind scan).
|
|
221
|
-
style: { width: 'var(--radix-popover-trigger-width)' }, children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder:
|
|
224
|
+
style: { width: 'var(--radix-popover-trigger-width)' }, children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder: ph('Buscar…'), value: search, onValueChange: setSearch }), _jsxs(CommandList, { children: [loading && (_jsxs("div", { className: "text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm", children: [_jsx(Loader2, { className: "size-4 animate-spin" }), "Buscando\u2026"] })), !loading && options.length === 0 && (_jsx(CommandEmpty, { children: useStatic
|
|
222
225
|
? debounced
|
|
223
226
|
? 'Sin resultados'
|
|
224
227
|
: 'Sin opciones'
|
|
@@ -12,9 +12,18 @@ export interface PrintDocumentArgs {
|
|
|
12
12
|
* open → open the PDF in a new tab (user prints from the viewer).
|
|
13
13
|
*/
|
|
14
14
|
mode?: 'print' | 'download' | 'open';
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Hint filename for download mode. Prefer leaving this unset: the server
|
|
17
|
+
* expands `{{record.*}}` into Content-Disposition. A raw template string
|
|
18
|
+
* (e.g. "cfdi-{{record.number}}.pdf") must NOT be used as a.download —
|
|
19
|
+
* that is how downloads end up literally named with mustache braces.
|
|
20
|
+
*/
|
|
16
21
|
filename?: string;
|
|
17
22
|
}
|
|
23
|
+
/** Parse filename from Content-Disposition (RFC 5987 / quoted). */
|
|
24
|
+
export declare function filenameFromContentDisposition(header: string | undefined | null): string | undefined;
|
|
25
|
+
/** True when a caller passed an unexpanded mustache template as filename. */
|
|
26
|
+
export declare function looksLikeFilenameTemplate(name: string | undefined): boolean;
|
|
18
27
|
/**
|
|
19
28
|
* Returns a `printDocument(args)` callback. Resolves once the PDF has been
|
|
20
29
|
* fetched and the browser action (print/download/open) has been kicked off;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-print-document.d.ts","sourceRoot":"","sources":["../src/use-print-document.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,iBAAiB;IAC9B,0EAA0E;IAC1E,KAAK,EAAE,MAAM,CAAA;IACb,qBAAqB;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,UAAU,GAAG,MAAM,CAAA;IACpC
|
|
1
|
+
{"version":3,"file":"use-print-document.d.ts","sourceRoot":"","sources":["../src/use-print-document.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,iBAAiB;IAC9B,0EAA0E;IAC1E,KAAK,EAAE,MAAM,CAAA;IACb,qBAAqB;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,UAAU,GAAG,MAAM,CAAA;IACpC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,mEAAmE;AACnE,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAapG;AAED,6EAA6E;AAC7E,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAE3E;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,0CASrB,iBAAiB,KAAG,OAAO,CAAC,MAAM,CAAC,CAmE7C"}
|
|
@@ -18,6 +18,27 @@
|
|
|
18
18
|
// this hook constructs no client of its own.
|
|
19
19
|
import { useCallback } from 'react';
|
|
20
20
|
import { useApi } from './api-context';
|
|
21
|
+
/** Parse filename from Content-Disposition (RFC 5987 / quoted). */
|
|
22
|
+
export function filenameFromContentDisposition(header) {
|
|
23
|
+
if (!header)
|
|
24
|
+
return undefined;
|
|
25
|
+
const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header);
|
|
26
|
+
if (star?.[1]) {
|
|
27
|
+
try {
|
|
28
|
+
return decodeURIComponent(star[1].trim().replace(/^"|"$/g, ''));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return star[1].trim().replace(/^"|"$/g, '');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const plain = /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i.exec(header);
|
|
35
|
+
const raw = (plain?.[1] ?? plain?.[2] ?? '').trim();
|
|
36
|
+
return raw || undefined;
|
|
37
|
+
}
|
|
38
|
+
/** True when a caller passed an unexpanded mustache template as filename. */
|
|
39
|
+
export function looksLikeFilenameTemplate(name) {
|
|
40
|
+
return !!name && /\{\{/.test(name);
|
|
41
|
+
}
|
|
21
42
|
/**
|
|
22
43
|
* Returns a `printDocument(args)` callback. Resolves once the PDF has been
|
|
23
44
|
* fetched and the browser action (print/download/open) has been kicked off;
|
|
@@ -35,9 +56,15 @@ export function usePrintDocument() {
|
|
|
35
56
|
const blobUrl = URL.createObjectURL(blob);
|
|
36
57
|
const cleanup = () => setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
|
37
58
|
if (mode === 'download') {
|
|
59
|
+
const headers = res.headers || {};
|
|
60
|
+
const fromHeader = filenameFromContentDisposition(headers['content-disposition'] || headers['Content-Disposition']) || undefined;
|
|
61
|
+
// Prefer server-expanded name; never use a raw {{record.*}} template.
|
|
62
|
+
const downloadName = fromHeader ||
|
|
63
|
+
(!looksLikeFilenameTemplate(filename) ? filename : undefined) ||
|
|
64
|
+
`${key}.pdf`;
|
|
38
65
|
const a = document.createElement('a');
|
|
39
66
|
a.href = blobUrl;
|
|
40
|
-
a.download =
|
|
67
|
+
a.download = downloadName;
|
|
41
68
|
document.body.appendChild(a);
|
|
42
69
|
a.click();
|
|
43
70
|
a.remove();
|
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
filenameFromContentDisposition,
|
|
4
|
+
looksLikeFilenameTemplate,
|
|
5
|
+
} from '../use-print-document'
|
|
6
|
+
|
|
7
|
+
describe('filenameFromContentDisposition', () => {
|
|
8
|
+
it('parses quoted filename', () => {
|
|
9
|
+
expect(filenameFromContentDisposition('inline; filename="cfdi-F-950.pdf"')).toBe(
|
|
10
|
+
'cfdi-F-950.pdf',
|
|
11
|
+
)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('parses RFC 5987 filename*', () => {
|
|
15
|
+
expect(
|
|
16
|
+
filenameFromContentDisposition("attachment; filename*=UTF-8''cfdi-F%20950.pdf"),
|
|
17
|
+
).toBe('cfdi-F 950.pdf')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns undefined for empty', () => {
|
|
21
|
+
expect(filenameFromContentDisposition(undefined)).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('looksLikeFilenameTemplate', () => {
|
|
26
|
+
it('detects mustache', () => {
|
|
27
|
+
expect(looksLikeFilenameTemplate('cfdi-{{record.number}}.pdf')).toBe(true)
|
|
28
|
+
expect(looksLikeFilenameTemplate('cfdi-F-950.pdf')).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readRecordPath, scalarDefaultFromRecord, unwrapRecordScalar } from '../action-modal-dispatcher'
|
|
3
|
+
import type { ActionFieldDef } from '../types'
|
|
4
|
+
|
|
5
|
+
describe('scalarDefaultFromRecord', () => {
|
|
6
|
+
it('unwraps FK cells with value/label', () => {
|
|
7
|
+
expect(unwrapRecordScalar({ value: 'abc', label: 'Cliente SA' })).toBe('abc')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('reads dotted paths', () => {
|
|
11
|
+
const record = { fiscal_data: { forma_pago: '03' } }
|
|
12
|
+
expect(readRecordPath(record, 'fiscal_data.forma_pago')).toBe('03')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('uses defaultFromRecord string', () => {
|
|
16
|
+
const field = { key: 'forma_pago', defaultFromRecord: 'fiscal_data.forma_pago' } as ActionFieldDef & {
|
|
17
|
+
defaultFromRecord: string
|
|
18
|
+
}
|
|
19
|
+
const record = { fiscal_data: { forma_pago: '01' }, forma_pago: '99' }
|
|
20
|
+
expect(scalarDefaultFromRecord(field, record)).toBe('01')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('tries defaultFromRecord array in order', () => {
|
|
24
|
+
const field = {
|
|
25
|
+
key: 'uso_cfdi',
|
|
26
|
+
defaultFromRecord: ['fiscal_data.uso_cfdi', 'uso_cfdi'],
|
|
27
|
+
} as ActionFieldDef & { defaultFromRecord: string[] }
|
|
28
|
+
const record = { uso_cfdi: 'G03' }
|
|
29
|
+
expect(scalarDefaultFromRecord(field, record)).toBe('G03')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('falls back to record[field.key]', () => {
|
|
33
|
+
const field = { key: 'customer_id' } as ActionFieldDef
|
|
34
|
+
const record = { customer_id: '11111111-1111-4111-8111-111111111111' }
|
|
35
|
+
expect(scalarDefaultFromRecord(field, record)).toBe('11111111-1111-4111-8111-111111111111')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -110,4 +110,34 @@ describe('ViewValue — detail dialog display mapping', () => {
|
|
|
110
110
|
)
|
|
111
111
|
expect(container.textContent).toContain('—')
|
|
112
112
|
})
|
|
113
|
+
|
|
114
|
+
it('renders plain text external_id without a relation initials avatar', () => {
|
|
115
|
+
const { container } = render(
|
|
116
|
+
<ViewValue
|
|
117
|
+
field={{ key: 'external_id', label: 'ID externo', type: 'text' }}
|
|
118
|
+
value="6a8c931523ea7"
|
|
119
|
+
record={{}}
|
|
120
|
+
/>
|
|
121
|
+
)
|
|
122
|
+
expect(screen.getByText('6a8c931523ea7')).toBeTruthy()
|
|
123
|
+
// Must NOT render the InitialsAvatar lead ("6" chip) used for FKs.
|
|
124
|
+
expect(container.querySelector('[data-slot="avatar"]')).toBeNull()
|
|
125
|
+
expect(container.textContent).not.toMatch(/^6\s*6a8c/)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('renders nested provider_data objects as pretty JSON, not key: {…}', () => {
|
|
129
|
+
render(
|
|
130
|
+
<ViewValue
|
|
131
|
+
field={{ key: 'provider_data', label: 'Datos del proveedor', type: 'json' }}
|
|
132
|
+
value={{
|
|
133
|
+
forma_pago: '03',
|
|
134
|
+
INV: { Folio: 950, Serie: 'F' },
|
|
135
|
+
}}
|
|
136
|
+
record={{}}
|
|
137
|
+
/>
|
|
138
|
+
)
|
|
139
|
+
expect(screen.getByText('03')).toBeTruthy()
|
|
140
|
+
expect(screen.getByText(/"Folio": 950/)).toBeTruthy()
|
|
141
|
+
expect(screen.queryByText(/Inv:\s*\{\.\.\.\}/i)).toBeNull()
|
|
142
|
+
})
|
|
113
143
|
})
|