@asteby/metacore-runtime-react 18.1.0 → 18.2.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.
@@ -1,9 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // DynamicRecordDialog — renders a create/edit/view modal for a model based
3
- // on metadata fetched from `/metadata/modal/:model`. Ported from the ops
4
- // starter. Host-owned infra that was referenced by alias (axios client,
5
- // branch store) now flows through <ApiProvider> from runtime-react.
3
+ // on metadata fetched from `/metadata/modal/:model`. This is the single,
4
+ // SDK-owned source of truth for declarative record rendering (the ops fork was
5
+ // consolidated back into here): tz-aware dates, FK image/label leads in both
6
+ // view and edit, resolved relation/user-object labels (never raw JSON), nil-UUID
7
+ // elision, pro option color/icon badges, and one_to_many child panels.
8
+ //
9
+ // Host-owned infra that was referenced by alias (axios client, branch store)
10
+ // flows through <ApiProvider> from runtime-react. Host-specific runtime values —
11
+ // the image-url resolver and the org IANA timezone — are passed as props so the
12
+ // SDK stays transport- and host-agnostic.
6
13
  import { createContext, useContext, useEffect, useRef, useState } from 'react';
14
+ import { useTranslation } from 'react-i18next';
7
15
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Button, Input, Textarea, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Skeleton, Badge, Popover, PopoverContent, PopoverTrigger, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@asteby/metacore-ui/primitives';
8
16
  import { cn } from '@asteby/metacore-ui/lib';
9
17
  import { Calendar } from './_primitives';
@@ -12,30 +20,103 @@ import { format, parseISO } from 'date-fns';
12
20
  import { es } from 'date-fns/locale';
13
21
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react';
14
22
  import { useApi } from '../api-context';
15
- import { DynamicSelectField } from '../dynamic-select-field';
23
+ import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field';
24
+ import { DynamicRelations } from '../dynamic-relations';
25
+ import { useOptionsResolver } from '../use-options-resolver';
16
26
  import { getFieldRef } from '../dynamic-form-schema';
17
- import { normalizeNilUuid } from '../nil-uuid';
27
+ import { isNilUuid, normalizeNilUuid } from '../nil-uuid';
18
28
  import { humanizeToken } from '../dynamic-columns-helpers';
29
+ import { formatDateCell } from '../dynamic-columns';
30
+ const identityImageUrl = (p) => p ?? '';
31
+ // localizedModelName resolves the (possibly addon-i18n) model name: prefer the
32
+ // translated titleKey, fall back to the backend-provided raw title.
33
+ function localizedModelName(meta, t) {
34
+ if (meta.titleKey && t(meta.titleKey) !== meta.titleKey)
35
+ return t(meta.titleKey);
36
+ return meta.title || '';
37
+ }
19
38
  function resolvePath(obj, path) {
20
39
  return path.split('.').reduce((acc, part) => acc?.[part], obj);
21
40
  }
41
+ // objectLabel pulls a human label off a resolved relation/user object the
42
+ // backend serves: `{value,label}` (FK sibling), `{name,...}` (user object such
43
+ // as created_by), or `{title}`. Returns undefined for plain/empty objects.
44
+ function objectLabel(value) {
45
+ if (!value || typeof value !== 'object' || Array.isArray(value))
46
+ return undefined;
47
+ const label = value.label ?? value.name ?? value.title;
48
+ if (label != null && label !== '')
49
+ return String(label);
50
+ return undefined;
51
+ }
52
+ // pickImage reads an image-ish path off a resolved object (FK sibling, user).
53
+ function pickImage(value) {
54
+ if (!value || typeof value !== 'object')
55
+ return undefined;
56
+ const img = value.image ?? value.avatar ?? value.logo ?? value.thumbnail;
57
+ return typeof img === 'string' && img !== '' ? img : undefined;
58
+ }
59
+ // relationSiblingValue reads the resolved relation the table served alongside an
60
+ // FK column. A field `category_id` (search/dynamic_select/ref) ships a sibling
61
+ // `record.category = {value,label,image?}` (or a bare string/{name}); returns
62
+ // the raw sibling (object or string) so the caller can extract label + image.
63
+ function relationSiblingValue(field, record) {
64
+ if (!record)
65
+ return undefined;
66
+ const candidates = [];
67
+ const ref = getFieldRef(field);
68
+ if (ref)
69
+ candidates.push(ref);
70
+ if (typeof field.key === 'string' && field.key.endsWith('_id'))
71
+ candidates.push(field.key.slice(0, -3));
72
+ for (const key of candidates) {
73
+ const sib = record[key];
74
+ if (sib === undefined || sib === null)
75
+ continue;
76
+ if (typeof sib === 'string') {
77
+ if (sib === '' || isNilUuid(sib))
78
+ continue;
79
+ return sib;
80
+ }
81
+ if (typeof sib === 'object')
82
+ return sib;
83
+ }
84
+ return undefined;
85
+ }
86
+ // servedOption matches a field's served option list (enum/select with
87
+ // {value,label,color,icon,image}) against the current value.
88
+ function servedOption(field, value) {
89
+ if (!field.options?.length)
90
+ return undefined;
91
+ return field.options.find(o => o.value === String(value ?? ''));
92
+ }
93
+ // createdBySibling reads the resolver object the backend serves for the
94
+ // auto-injected `created_by` avatar column: {name, avatar, email}.
95
+ function createdBySibling(value, record) {
96
+ const obj = (value && typeof value === 'object' ? value : undefined) ?? record?.created_by;
97
+ if (obj && typeof obj === 'object' && (obj.name || obj.avatar || obj.email))
98
+ return obj;
99
+ return undefined;
100
+ }
101
+ // isRelationField — a field that resolves to another row (so view renders a lead
102
+ // + label and edit renders the searchable picker).
103
+ function isRelationField(field) {
104
+ return (field.type === 'search' ||
105
+ field.type === 'dynamic_select' ||
106
+ field.widget === 'dynamic_select' ||
107
+ !!getFieldRef(field) ||
108
+ !!field.searchEndpoint);
109
+ }
22
110
  function formatDisplayValue(rawValue, field) {
23
111
  // Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
24
112
  const value = normalizeNilUuid(rawValue);
25
113
  if (value === null || value === undefined || value === '')
26
114
  return '—';
115
+ const objLabel = objectLabel(value);
116
+ if (objLabel !== undefined)
117
+ return objLabel;
27
118
  if (field.type === 'boolean' || typeof value === 'boolean')
28
119
  return value ? 'Sí' : 'No';
29
- if (field.type === 'date') {
30
- try {
31
- return new Date(value).toLocaleDateString('es-MX', {
32
- day: 'numeric', month: 'long', year: 'numeric',
33
- });
34
- }
35
- catch {
36
- return String(value);
37
- }
38
- }
39
120
  if (field.type === 'select' && field.options?.length) {
40
121
  const match = field.options.find(o => o.value === String(value));
41
122
  // Matched option label wins (localized); humanize the raw token only
@@ -46,31 +127,43 @@ function formatDisplayValue(rawValue, field) {
46
127
  }
47
128
  const MODE_CONFIG = {
48
129
  create: {
49
- getTitle: (meta) => meta.createTitle || meta.title || 'Nuevo registro',
130
+ getTitle: (meta, t) => {
131
+ const name = localizedModelName(meta, t);
132
+ return name ? `Crear ${name}` : (meta.createTitle || meta.title || 'Nuevo registro');
133
+ },
50
134
  description: 'Completa los campos para crear un nuevo registro.',
51
135
  submitLabel: 'Crear',
52
136
  submittingLabel: 'Creando...',
53
137
  cancelLabel: 'Cancelar',
54
138
  },
55
139
  edit: {
56
- getTitle: (meta) => meta.editTitle || meta.title || 'Editar registro',
140
+ getTitle: (meta, t) => {
141
+ const name = localizedModelName(meta, t);
142
+ return name ? `Editar ${name}` : (meta.editTitle || meta.title || 'Editar registro');
143
+ },
57
144
  description: 'Modifica los campos y guarda los cambios.',
58
145
  submitLabel: 'Guardar cambios',
59
146
  submittingLabel: 'Guardando...',
60
147
  cancelLabel: 'Cancelar',
61
148
  },
62
149
  view: {
63
- getTitle: (meta) => meta.title || 'Ver registro',
150
+ getTitle: (meta, t) => localizedModelName(meta, t) || meta.title || 'Ver registro',
64
151
  description: 'Información detallada del registro.',
65
152
  submitLabel: '',
66
153
  submittingLabel: '',
67
154
  cancelLabel: 'Cerrar',
68
155
  },
69
156
  };
157
+ // Context threading host runtime values to nested field components (uploads,
158
+ // image leads, tz-aware dates) without prop-drilling through every renderer.
70
159
  const ModelContext = createContext('');
71
- export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, schema, onDelete, onEdit, }) {
160
+ const ImageUrlContext = createContext(identityImageUrl);
161
+ const TimeZoneContext = createContext(undefined);
162
+ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl = identityImageUrl, timeZone, }) {
72
163
  const api = useApi();
164
+ const { t } = useTranslation();
73
165
  const [modalMeta, setModalMeta] = useState(schema ? schema : null);
166
+ const [relations, setRelations] = useState([]);
74
167
  const [record, setRecord] = useState(null);
75
168
  const [formValues, setFormValues] = useState({});
76
169
  const [loading, setLoading] = useState(false);
@@ -80,14 +173,39 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
80
173
  const isView = mode === 'view';
81
174
  const isEditable = mode === 'create' || mode === 'edit';
82
175
  const config = MODE_CONFIG[mode];
176
+ // ── Fetch metadata + record when dialog opens ──────────────────────────
83
177
  useEffect(() => {
84
178
  if (!open)
85
179
  return;
86
180
  if (!isCreate && !recordId)
87
181
  return;
88
182
  let cancelled = false;
183
+ // Seed instantly from the row the table already has so view/edit render
184
+ // without a spinner. The list row carries the pro siblings (resolved
185
+ // relation, served options, image url) the table cells used.
186
+ const seed = !isCreate && initialRecord ? initialRecord : null;
187
+ if (seed)
188
+ setRecord(seed);
189
+ const seedForm = (meta, rec) => {
190
+ const initial = {};
191
+ for (const field of meta.fields ?? []) {
192
+ initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? '';
193
+ }
194
+ setFormValues(initial);
195
+ };
196
+ // A field value is "missing" from the seed row when the list omitted that
197
+ // column. Sibling pro fields aren't form fields, so we only check the
198
+ // declared field keys.
199
+ const seedIsComplete = (meta, rec) => (meta.fields ?? []).every(f => {
200
+ if (f.hidden)
201
+ return true;
202
+ const v = resolvePath(rec, f.key);
203
+ return v !== undefined;
204
+ });
89
205
  const load = async () => {
90
- setLoading(true);
206
+ // Only show the skeleton when we have nothing to render yet.
207
+ if (!seed)
208
+ setLoading(true);
91
209
  try {
92
210
  let meta = schema ? schema : null;
93
211
  if (!meta) {
@@ -106,8 +224,14 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
106
224
  : field.defaultValue) ?? '';
107
225
  }
108
226
  setFormValues(initial);
227
+ return;
109
228
  }
110
- else {
229
+ // Render immediately from the seed row.
230
+ if (seed && meta)
231
+ seedForm(meta, seed);
232
+ // Only hit the record endpoint if the seed is absent or missing
233
+ // some declared field — keeps the modal instant for full rows.
234
+ if (!seed || (meta && !seedIsComplete(meta, seed))) {
111
235
  const recordEndpoint = endpoint
112
236
  ? `${endpoint}/${recordId}`
113
237
  : `/dynamic/${model}/${recordId}`;
@@ -115,17 +239,18 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
115
239
  if (cancelled)
116
240
  return;
117
241
  const rec = recRes.data?.data ?? recRes.data;
118
- setRecord(rec);
119
- const initial = {};
120
- for (const field of meta?.fields ?? []) {
121
- initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? '';
122
- }
123
- setFormValues(initial);
242
+ // Merge so the fetched record fills gaps without dropping the
243
+ // table's pro siblings (the detail endpoint may omit them).
244
+ const merged = seed ? { ...seed, ...rec } : rec;
245
+ setRecord(merged);
246
+ if (meta)
247
+ seedForm(meta, merged);
124
248
  }
125
249
  }
126
250
  catch (err) {
127
251
  console.error('[DynamicRecordDialog] load error:', err);
128
- toast.error('Error al cargar los datos');
252
+ if (!seed)
253
+ toast.error('Error al cargar los datos');
129
254
  }
130
255
  finally {
131
256
  if (!cancelled)
@@ -134,14 +259,51 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
134
259
  };
135
260
  load();
136
261
  return () => { cancelled = true; };
137
- }, [open, recordId, model, endpoint, isCreate, schema, defaults]);
262
+ // initialRecord intentionally omitted: the row identity is captured per open
263
+ // via recordId; re-seeding mid-open would clobber edits.
264
+ // eslint-disable-next-line react-hooks/exhaustive-deps
265
+ }, [open, recordId, model, endpoint, isCreate, schema]);
266
+ // Reset when closed
138
267
  useEffect(() => {
139
268
  if (!open) {
140
269
  setModalMeta(null);
270
+ setRelations([]);
141
271
  setRecord(null);
142
272
  setFormValues({});
143
273
  }
144
274
  }, [open]);
275
+ // Fetch the model's declared one_to_many/many_to_many edges so view AND edit
276
+ // show child records (e.g. a sales order's line items) below the scalar
277
+ // fields. The modal form is driven by MODAL metadata (fields); relations live
278
+ // on TABLE metadata, hence the separate fetch. Skipped on create (no parent
279
+ // record yet). View renders them read-only; edit lets the user add/edit/delete.
280
+ useEffect(() => {
281
+ if (!open || mode === 'create' || !recordId) {
282
+ setRelations([]);
283
+ return;
284
+ }
285
+ let cancelled = false;
286
+ api.get(`/metadata/table/${model}`)
287
+ .then(res => {
288
+ if (cancelled)
289
+ return;
290
+ const meta = res.data?.data ?? res.data;
291
+ const rels = Array.isArray(meta?.relations) ? meta.relations : [];
292
+ // Localize each panel header: the backend serves `label` as an
293
+ // i18n key (addon bundle, loaded live) and the SDK renders it verbatim.
294
+ setRelations(rels.map(rel => ({
295
+ ...rel,
296
+ label: rel.label && t(rel.label) !== rel.label
297
+ ? t(rel.label)
298
+ : rel.label || rel.name,
299
+ })));
300
+ })
301
+ .catch(() => {
302
+ if (!cancelled)
303
+ setRelations([]);
304
+ });
305
+ return () => { cancelled = true; };
306
+ }, [open, mode, model, recordId, api, t]);
145
307
  const handleSubmit = async (e) => {
146
308
  e?.preventDefault();
147
309
  if (!modalMeta)
@@ -157,16 +319,16 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
157
319
  setSaving(true);
158
320
  try {
159
321
  if (isCreate && onCreate) {
160
- await onCreate(formValues);
161
- toast.success('Registro creado correctamente');
162
- onSaved?.();
322
+ const created = await onCreate(formValues);
323
+ toast.success(modalMeta?.messages?.created || 'Registro creado correctamente');
324
+ onSaved?.(created ?? undefined);
163
325
  onOpenChange(false);
164
326
  return;
165
327
  }
166
328
  if (!isCreate && recordId && onUpdate) {
167
- await onUpdate(String(recordId), formValues);
168
- toast.success('Guardado correctamente');
169
- onSaved?.();
329
+ const updated = await onUpdate(String(recordId), formValues);
330
+ toast.success(modalMeta?.messages?.updated || 'Guardado correctamente');
331
+ onSaved?.(updated ?? undefined);
170
332
  onOpenChange(false);
171
333
  return;
172
334
  }
@@ -182,8 +344,13 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
182
344
  res = await api.put(updateEndpoint, formValues);
183
345
  }
184
346
  if (res.data?.success !== false) {
185
- toast.success(res.data?.message || (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'));
186
- onSaved?.();
347
+ // Prefer the addon's localized message (modal metadata), then a
348
+ // localized fallback. NOT res.data.message — the dynamic CRUD
349
+ // endpoint returns a raw English string that would leak into the toast.
350
+ toast.success(modalMeta?.messages?.[isCreate ? 'created' : 'updated']
351
+ || (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'));
352
+ // Hand the persisted record back so callers can auto-select it.
353
+ onSaved?.(res.data?.data ?? res.data ?? undefined);
187
354
  onOpenChange(false);
188
355
  }
189
356
  else {
@@ -213,7 +380,7 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
213
380
  setDeleting(false);
214
381
  }
215
382
  };
216
- const title = modalMeta ? config.getTitle(modalMeta) : '';
383
+ const title = modalMeta ? config.getTitle(modalMeta, t) : '';
217
384
  const visibleFields = modalMeta?.fields?.filter(f => {
218
385
  if (f.hidden)
219
386
  return false;
@@ -221,10 +388,10 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
221
388
  return false;
222
389
  return true;
223
390
  }) ?? [];
224
- 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: _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 => {
225
- const isFullWidth = field.type === 'textarea';
226
- 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));
227
- }), 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'] }) }))] }) })) : null }), _jsxs(DialogFooter, { className: "p-4 border-t shrink-0", 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] }))] })] }) }));
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: _jsxs(TimeZoneContext.Provider, { value: timeZone, 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 => {
392
+ const isFullWidth = field.type === 'textarea';
393
+ 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));
394
+ }), 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] }))] })] })] }) }));
228
395
  }
229
396
  function LoadingSkeleton() {
230
397
  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))) }));
@@ -233,12 +400,75 @@ function FieldRow({ field, record, value, mode, onChange }) {
233
400
  const isReadonly = field.readonly || mode === 'view';
234
401
  return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-xs font-medium text-muted-foreground uppercase tracking-wide", children: [field.label, field.required && mode !== 'view' && (_jsx("span", { className: "text-destructive ml-0.5", children: "*" }))] }), isReadonly ? (_jsx(ViewValue, { field: field, value: value, record: record })) : (_jsx(EditField, { field: field, value: value, onChange: onChange }))] }));
235
402
  }
236
- function ViewValue({ field, value: rawValue }) {
237
- // Normalize the nil UUID to undefined up front so the search/url/color/
238
- // image/select branches all fall through to their empty states.
403
+ // RelationViewValue read-only FK lead. Resolves the relation's label + image
404
+ // from (1) the sibling object the table served, then (2) the canonical options
405
+ // endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
406
+ function RelationViewValue({ field, value, record }) {
407
+ const getImageUrl = useContext(ImageUrlContext);
408
+ const sib = relationSiblingValue(field, record);
409
+ const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib);
410
+ const sibImage = pickImage(sib);
411
+ // The raw FK id, tolerating an inline resolved object as the value itself.
412
+ const rawVal = value && typeof value === 'object' ? (value.value ?? value.id) : value;
413
+ const inlineLabel = sibLabel ?? objectLabel(value);
414
+ const inlineImage = sibImage ?? pickImage(value);
415
+ const fieldRef = getFieldRef(field);
416
+ // Only resolve over the network when we still lack both label and image and
417
+ // there is something to look up.
418
+ const needResolve = !inlineLabel && !inlineImage && !!(fieldRef || field.searchEndpoint) && rawVal != null && rawVal !== '';
419
+ const { options } = useOptionsResolver({
420
+ modelKey: '',
421
+ fieldKey: 'id',
422
+ ref: fieldRef,
423
+ endpoint: fieldRef ? undefined : field.searchEndpoint,
424
+ query: '',
425
+ limit: 50,
426
+ enabled: needResolve,
427
+ });
428
+ const resolved = options.find(o => String(o.id) === String(rawVal));
429
+ const label = inlineLabel ??
430
+ resolved?.label ??
431
+ (rawVal != null && rawVal !== '' && !isNilUuid(rawVal) ? String(rawVal) : undefined);
432
+ const image = inlineImage ?? resolved?.image ?? undefined;
433
+ if (!label && !image) {
434
+ return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
435
+ }
436
+ const lead = {
437
+ image: image ? getImageUrl(image) : null,
438
+ color: resolved?.color ?? null,
439
+ icon: resolved?.icon ?? null,
440
+ };
441
+ 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
+ }
443
+ export function ViewValue({ field, value: rawValue, record, getImageUrl: getImageUrlProp, timeZone: timeZoneProp, }) {
444
+ const ctxImageUrl = useContext(ImageUrlContext);
445
+ const ctxTimeZone = useContext(TimeZoneContext);
446
+ const getImageUrl = getImageUrlProp ?? ctxImageUrl;
447
+ const timeZone = timeZoneProp ?? ctxTimeZone;
448
+ // created_by / avatar resolver sibling → name (+ avatar) instead of "—".
449
+ if (field.type === 'avatar' || field.key === 'created_by' || field.key === 'created_by_id') {
450
+ const user = createdBySibling(rawValue, record);
451
+ if (user) {
452
+ return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [user.avatar ? (_jsx("img", { src: getImageUrl(String(user.avatar)), alt: user.name ?? '', className: "h-6 w-6 rounded-full object-cover" })) : null, _jsx("span", { className: "text-sm", children: user.name ?? user.email ?? '—' })] }));
453
+ }
454
+ return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
455
+ }
456
+ // Nil/zero UUID (unset nullable FK serialized as all-zeros) → empty marker.
457
+ if (isNilUuid(rawValue)) {
458
+ return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
459
+ }
239
460
  const value = normalizeNilUuid(rawValue);
240
- if (field.type === 'search' && value) {
241
- return _jsx(SearchViewValue, { field: field, value: value });
461
+ // Relation (search / dynamic_select / ref / any *_id) → resolved thumbnail +
462
+ // label. The *_id catch-all covers plain-typed FK columns not tagged as a
463
+ // relation field.
464
+ if (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id'))) {
465
+ return _jsx(RelationViewValue, { field: field, value: value, record: record });
466
+ }
467
+ // The value is itself a resolved object the backend served inline — render
468
+ // its label/name, never the raw JSON.
469
+ const inlineLabel = objectLabel(value);
470
+ if (inlineLabel !== undefined) {
471
+ return _jsx("p", { className: "text-sm py-1", children: inlineLabel });
242
472
  }
243
473
  if (field.type === 'boolean' || typeof value === 'boolean') {
244
474
  return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [_jsx(Switch, { checked: !!value, disabled: true }), _jsx("span", { className: "text-sm text-muted-foreground", children: value ? 'Sí' : 'No' })] }));
@@ -247,16 +477,31 @@ function ViewValue({ field, value: rawValue }) {
247
477
  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: "-" }));
248
478
  }
249
479
  if (field.type === 'image') {
250
- return value ? (_jsx("img", { src: value, alt: field.label, className: "h-16 w-16 rounded-lg object-cover border" })) : (_jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "Sin imagen" }));
480
+ return value ? (_jsx("img", { src: getImageUrl(String(value)), alt: field.label, className: "h-16 w-16 rounded-lg object-cover border" })) : (_jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "Sin imagen" }));
251
481
  }
252
482
  if (field.type === 'url' && value) {
253
483
  return (_jsx("a", { href: value, target: "_blank", rel: "noreferrer", className: "text-sm text-primary hover:underline truncate", children: value }));
254
484
  }
255
- if (field.type === 'select' && field.options?.length) {
256
- const match = field.options.find(o => o.value === String(value ?? ''));
257
- if (match) {
258
- return _jsx(Badge, { variant: "secondary", className: "w-fit", children: match.label });
485
+ // Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
486
+ // day); instants render in the org timezone with a full-precision tooltip.
487
+ if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp') {
488
+ const renderAs = field.type === 'date' ? 'date' : field.type;
489
+ const formatted = formatDateCell(value, renderAs, es, timeZone);
490
+ if (formatted) {
491
+ return (_jsx("p", { className: "text-sm py-1", title: formatted.title, children: formatted.display }));
259
492
  }
493
+ return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
494
+ }
495
+ // Enum/option field with served options → colored/iconed badge using the
496
+ // served label (e.g. "Almacenable" instead of "storable").
497
+ const opt = servedOption(field, value);
498
+ if (opt) {
499
+ const lead = {
500
+ image: opt.image ? getImageUrl(opt.image) : null,
501
+ color: opt.color ?? null,
502
+ icon: opt.icon ?? null,
503
+ };
504
+ return (_jsxs(Badge, { variant: "secondary", className: "w-fit flex items-center gap-1", style: opt.color && !opt.icon ? { backgroundColor: opt.color, color: '#fff', borderColor: 'transparent' } : undefined, children: [_jsx(OptionLead, { option: lead, size: 16 }), opt.label] }));
260
505
  }
261
506
  const display = formatDisplayValue(value, field);
262
507
  if (field.type === 'textarea') {
@@ -272,17 +517,14 @@ function EditField({ field, value, onChange }) {
272
517
  return (_jsx(Textarea, { value: value ?? '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder, rows: 4 }));
273
518
  }
274
519
  // Media widgets: the kernel may serve an explicit `widget: 'upload'` (or the
275
- // `image` type) for a file/photo column. Both render the themed dropzone
276
- // that POSTs to the host upload endpoint — same control as the Brand logo.
520
+ // `image` type) for a file/photo column.
277
521
  if (field.type === 'image' || field.widget === 'upload') {
278
522
  return _jsx(ImageUploadField, { field: field, value: value, onChange: onChange });
279
523
  }
280
524
  // FK columns: a `ref` (kernel-derived belongs_to target) or an explicit
281
- // `widget: 'dynamic_select'` renders the async searchable picker against
282
- // /api/options/<ref>?field=id — with option thumbnails when the remote rows
283
- // carry an `image` instead of a raw FK uuid text input. Static inline
284
- // `options` are handled by the enum <Select> branch below; a ref column does
285
- // not ship inline options, so this never shadows a static enum.
525
+ // `widget: 'dynamic_select'` renders the SDK's async searchable picker — with
526
+ // option thumbnails and the inline-create "+" — against /api/options/<ref>.
527
+ // Static inline `options` are handled by the enum <Select> branch below.
286
528
  if ((getFieldRef(field) || field.widget === 'dynamic_select') && !field.options?.length) {
287
529
  return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange });
288
530
  }
@@ -315,6 +557,7 @@ function EditField({ field, value, onChange }) {
315
557
  function ImageUploadField({ field: _field, value, onChange }) {
316
558
  const api = useApi();
317
559
  const model = useContext(ModelContext);
560
+ const getImageUrl = useContext(ImageUrlContext);
318
561
  const [uploading, setUploading] = useState(false);
319
562
  const inputRef = useRef(null);
320
563
  async function handleFile(e) {
@@ -340,7 +583,7 @@ function ImageUploadField({ field: _field, value, onChange }) {
340
583
  inputRef.current.value = '';
341
584
  }
342
585
  }
343
- return (_jsxs("div", { className: "flex items-center gap-3", children: [value ? (_jsxs("div", { className: "relative", children: [_jsx("img", { src: value, alt: "", className: "h-16 w-16 rounded-lg object-cover border" }), _jsx("button", { type: "button", onClick: () => onChange(''), className: "absolute -top-1.5 -right-1.5 size-5 bg-destructive text-white rounded-full flex items-center justify-center hover:bg-destructive/90", children: _jsx(XIcon, { className: "size-3" }) })] })) : (_jsx("button", { type: "button", onClick: () => inputRef.current?.click(), disabled: uploading, className: "h-16 w-16 rounded-lg border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center gap-1 hover:border-primary/50 hover:bg-muted/50 transition-colors disabled:opacity-50", children: uploading ? (_jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" })) : (_jsx(Upload, { className: "size-4 text-muted-foreground" })) })), _jsx("input", { ref: inputRef, type: "file", accept: "image/*", onChange: handleFile, className: "hidden" }), !value && _jsx("span", { className: "text-xs text-muted-foreground", children: "PNG, JPG, WebP" })] }));
586
+ return (_jsxs("div", { className: "flex items-center gap-3", children: [value ? (_jsxs("div", { className: "relative", children: [_jsx("img", { src: getImageUrl(String(value)), alt: "", className: "h-16 w-16 rounded-lg object-cover border" }), _jsx("button", { type: "button", onClick: () => onChange(''), className: "absolute -top-1.5 -right-1.5 size-5 bg-destructive text-white rounded-full flex items-center justify-center hover:bg-destructive/90", children: _jsx(XIcon, { className: "size-3" }) })] })) : (_jsx("button", { type: "button", onClick: () => inputRef.current?.click(), disabled: uploading, className: "h-16 w-16 rounded-lg border-2 border-dashed border-muted-foreground/30 flex flex-col items-center justify-center gap-1 hover:border-primary/50 hover:bg-muted/50 transition-colors disabled:opacity-50", children: uploading ? (_jsx(Loader2, { className: "size-4 animate-spin text-muted-foreground" })) : (_jsx(Upload, { className: "size-4 text-muted-foreground" })) })), _jsx("input", { ref: inputRef, type: "file", accept: "image/*", onChange: handleFile, className: "hidden" }), !value && _jsx("span", { className: "text-xs text-muted-foreground", children: "PNG, JPG, WebP" })] }));
344
587
  }
345
588
  function extractArray(res) {
346
589
  const d = res.data;
@@ -351,31 +594,6 @@ function extractArray(res) {
351
594
  return [];
352
595
  }
353
596
  const searchCache = new Map();
354
- function SearchViewValue({ field, value }) {
355
- const api = useApi();
356
- const [label, setLabel] = useState(String(value));
357
- useEffect(() => {
358
- if (!field.searchEndpoint || !value)
359
- return;
360
- const cacheKey = field.searchEndpoint;
361
- const cached = searchCache.get(cacheKey);
362
- if (cached) {
363
- const match = cached.find((item) => item.value === value || item.id === value);
364
- if (match) {
365
- setLabel(match.label || match.name || String(value));
366
- return;
367
- }
368
- }
369
- api.get(field.searchEndpoint, { params: { search: '', limit: 50 } }).then(res => {
370
- const items = extractArray(res);
371
- searchCache.set(cacheKey, items);
372
- const match = items.find((item) => item.value === value || item.id === value);
373
- if (match)
374
- setLabel(match.label || match.name || String(value));
375
- }).catch(() => { });
376
- }, [value, field.searchEndpoint]);
377
- return _jsx("p", { className: "text-sm py-1", children: label });
378
- }
379
597
  function SearchField({ field, value, onChange }) {
380
598
  const api = useApi();
381
599
  const [open, setOpen] = useState(false);
@@ -401,7 +619,7 @@ function SearchField({ field, value, onChange }) {
401
619
  if (match)
402
620
  setSelectedLabel(match.label || match.name || '');
403
621
  }).catch(() => { });
404
- }, [value, field.searchEndpoint]);
622
+ }, [value, field.searchEndpoint, api]);
405
623
  useEffect(() => {
406
624
  if (!open || !field.searchEndpoint)
407
625
  return;
@@ -423,7 +641,7 @@ function SearchField({ field, value, onChange }) {
423
641
  .finally(() => setLoading(false));
424
642
  }, query ? 250 : 0);
425
643
  return () => clearTimeout(timer);
426
- }, [query, open, field.searchEndpoint]);
644
+ }, [query, open, field.searchEndpoint, api]);
427
645
  return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { variant: "outline", role: "combobox", className: cn("w-full justify-between font-normal h-9", !value && "text-muted-foreground"), children: [_jsx("span", { className: "truncate", children: selectedLabel || `Seleccionar ${field.label?.toLowerCase() || ''}...` }), _jsx(ChevronDown, { className: "ml-auto h-4 w-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "w-[--radix-popover-trigger-width] p-0", align: "start", side: "bottom", sideOffset: 4, children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder: `Buscar ${field.label?.toLowerCase() || ''}...`, value: query, onValueChange: setQuery }), _jsx(CommandList, { className: "max-h-[200px]", children: loading ? (_jsxs("div", { className: "py-6 text-center text-sm", children: [_jsx(Loader2, { className: "h-4 w-4 animate-spin mx-auto mb-1 text-muted-foreground" }), _jsx("span", { className: "text-muted-foreground text-xs", children: "Buscando..." })] })) : results.length === 0 ? (_jsx(CommandEmpty, { children: "Sin resultados." })) : (_jsx(CommandGroup, { children: results.map((item) => {
428
646
  const itemValue = item.value ?? item.id;
429
647
  const itemLabel = item.label ?? item.name ?? '';
@@ -433,6 +651,6 @@ function SearchField({ field, value, onChange }) {
433
651
  setSelectedLabel(itemLabel);
434
652
  setOpen(false);
435
653
  setQuery('');
436
- }, children: [isSelected && _jsx(Check, { className: "mr-2 h-3.5 w-3.5 shrink-0 text-primary" }), item.image && (_jsx("img", { src: item.image, className: "h-5 w-5 rounded mr-2 object-cover shrink-0", alt: "" })), _jsxs("div", { className: "flex flex-col min-w-0", children: [_jsx("span", { className: "truncate", children: itemLabel }), item.description && (_jsx("span", { className: "text-[11px] text-muted-foreground truncate", children: item.description }))] })] }, itemValue));
654
+ }, children: [isSelected && _jsx(Check, { className: "mr-2 h-3.5 w-3.5 shrink-0 text-primary" }), item.image && (_jsx(OptionThumb, { image: item.image, size: 20 })), _jsxs("div", { className: "flex flex-col min-w-0 ml-2", children: [_jsx("span", { className: "truncate", children: itemLabel }), item.description && (_jsx("span", { className: "text-[11px] text-muted-foreground truncate", children: item.description }))] })] }, itemValue));
437
655
  }) })) })] }) })] }));
438
656
  }
@@ -1,4 +1,25 @@
1
+ import { type ResolvedOption } from './use-options-resolver';
1
2
  import type { ActionFieldDef } from './types';
3
+ /**
4
+ * Small square thumbnail for an option's `image`. Falls back to a neutral
5
+ * placeholder icon when the option has no image so rows/triggers stay aligned.
6
+ * `size` is in pixels (kept small — 20–24px — so the picker reads as a list,
7
+ * not a gallery). Inline style for the box dimensions: arbitrary Tailwind
8
+ * classes from a federated addon don't always survive the host's class scan.
9
+ */
10
+ export declare function OptionThumb({ image, size }: {
11
+ image?: string | null;
12
+ size?: number;
13
+ }): import("react").JSX.Element;
14
+ /**
15
+ * Leading visual for an option: a photo thumbnail (FK relations with an image),
16
+ * else a declared icon, else a color dot (enum/status options with a color).
17
+ * Returns null when the option carries none, so plain text options stay plain.
18
+ */
19
+ export declare function OptionLead({ option, size, }: {
20
+ option?: Pick<ResolvedOption, 'image' | 'color' | 'icon'> | null;
21
+ size?: number;
22
+ }): import("react").JSX.Element | null;
2
23
  export interface DynamicSelectFieldProps {
3
24
  field: ActionFieldDef;
4
25
  value: any;
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAyCA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AA+F7C,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;CAC7B;AAED,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,uBAAuB,+BA0KrF;AAED,eAAe,kBAAkB,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;CAC7B;AAED,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,uBAAuB,+BA0KrF;AAED,eAAe,kBAAkB,CAAA"}