@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.
@@ -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, useId, useRef, useState } from 'react';
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, localizeFieldErrorMap } from '../server-error';
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, i18n } = useTranslation();
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 t(f.label, { defaultValue: f.label });
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 labels = {};
504
- for (const f of modalMeta?.fields ?? [])
505
- labels[f.key] = labelForKey(f.key);
506
- const localized = localizeFieldErrorMap(map, t, { labels, language: lang });
507
- setFieldErrors(localized);
508
- toastValidationFailed(t, lang, localized);
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, language: lang, fallback: t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }) });
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 visible = filterVisibleFields(modalMeta.fields, mode, formValues);
524
- const bag = validateValues(visible, formValues);
525
- if (bagHasErrors(bag)) {
526
- const labels = {};
527
- for (const f of visible)
528
- labels[f.key] = t(f.label, { defaultValue: f.label });
529
- const localized = localizeFieldErrorMap(bag, t, { labels, language: lang });
530
- setFieldErrors(localized);
531
- toastValidationFailed(t, lang, localized);
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 bag = validateValues((step?.fields ?? []), formValues);
653
- if (bagHasErrors(bag)) {
654
- const labels = {};
655
- for (const f of step?.fields ?? [])
656
- labels[f.key] = t(f.label, { defaultValue: f.label });
657
- const localized = localizeFieldErrorMap(bag, t, { labels, language: lang });
658
- setFieldErrors(localized);
659
- toastValidationFailed(t, lang, localized);
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: formId, 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: formId, disabled: saving || loading, children: [saving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), saving ? config.submittingLabel : config.submitLabel] }))] })] })] }) }) }));
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. Without this, a locked relation field (e.g. `lockedFields`
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
- function RelationViewValue({ field, value, record, stack = false }) {
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
- if (renderAs === 'image_stack' &&
809
- (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id')))) {
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 / any *_id) → resolved thumbnail +
813
- // label. The *_id catch-all covers plain-typed FK columns not tagged as a
814
- // relation field.
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
- const caption = (typeof field.styleConfig?.label_field === 'string' &&
833
- record?.[field.styleConfig.label_field]) ||
834
- (typeof field.styleConfig?.labelField === 'string' &&
835
- record?.[field.styleConfig.labelField]) ||
836
- undefined;
837
- return (_jsx("div", { className: "py-1", children: _jsx(ImageStack, { src: value ? String(value) : undefined, label: caption ? String(caption) : field.label, getImageUrl: getImageUrl, size: "lg" }) }));
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":"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"}
1
+ {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"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"}
@@ -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 { ImageStack, OptionBadge, RelationThumbnail, statusColorFor, useIsDarkTheme, } from './display-value';
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":"AA2CA,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,+BA8SzB;AAED,eAAe,kBAAkB,CAAA"}
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…' : field.placeholder || '—') })] }) }));
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 || field.placeholder || '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",
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: field.placeholder || '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
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
- /** Filename for the download mode (defaults to "<key>.pdf"). */
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,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,0CASrB,iBAAiB,KAAG,OAAO,CAAC,MAAM,CAAC,CAyD7C"}
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 = filename || `${key}.pdf`;
67
+ a.download = downloadName;
41
68
  document.body.appendChild(a);
42
69
  a.click();
43
70
  a.remove();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "34.1.0",
3
+ "version": "35.0.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
  })