@asteby/metacore-runtime-react 18.1.0 → 18.3.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 +38 -0
- package/dist/dialogs/dynamic-record.d.ts +86 -2
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +305 -88
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +29 -3
- package/dist/dynamic-relation-helpers.d.ts.map +1 -1
- package/dist/dynamic-relation-helpers.js +18 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +14 -0
- package/dist/dynamic-select-field.d.ts +29 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -3
- package/dist/image-url-context.d.ts +13 -0
- package/dist/image-url-context.d.ts.map +1 -0
- package/dist/image-url-context.js +17 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/dynamic-relation.test.ts +17 -0
- package/src/dialogs/dynamic-record.tsx +476 -114
- package/src/dynamic-form.tsx +33 -2
- package/src/dynamic-relation-helpers.ts +18 -0
- package/src/dynamic-relation.tsx +19 -0
- package/src/dynamic-select-field.tsx +11 -3
- package/src/image-url-context.tsx +23 -0
- package/src/index.ts +2 -1
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
// DynamicRecordDialog — renders a create/edit/view modal for a model based
|
|
2
|
-
// on metadata fetched from `/metadata/modal/:model`.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// on metadata fetched from `/metadata/modal/:model`. This is the single,
|
|
3
|
+
// SDK-owned source of truth for declarative record rendering (the ops fork was
|
|
4
|
+
// consolidated back into here): tz-aware dates, FK image/label leads in both
|
|
5
|
+
// view and edit, resolved relation/user-object labels (never raw JSON), nil-UUID
|
|
6
|
+
// elision, pro option color/icon badges, and one_to_many child panels.
|
|
7
|
+
//
|
|
8
|
+
// Host-owned infra that was referenced by alias (axios client, branch store)
|
|
9
|
+
// flows through <ApiProvider> from runtime-react. Host-specific runtime values —
|
|
10
|
+
// the image-url resolver and the org IANA timezone — are passed as props so the
|
|
11
|
+
// SDK stays transport- and host-agnostic.
|
|
5
12
|
import { createContext, useContext, useEffect, useRef, useState } from 'react'
|
|
13
|
+
import { useTranslation } from 'react-i18next'
|
|
6
14
|
import type { ModelSchema } from './types'
|
|
7
15
|
import {
|
|
8
16
|
Dialog,
|
|
@@ -40,18 +48,35 @@ import { format, parseISO } from 'date-fns'
|
|
|
40
48
|
import { es } from 'date-fns/locale'
|
|
41
49
|
import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react'
|
|
42
50
|
import { useApi } from '../api-context'
|
|
43
|
-
import { DynamicSelectField } from '../dynamic-select-field'
|
|
51
|
+
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
|
|
52
|
+
import { DynamicRelations } from '../dynamic-relations'
|
|
53
|
+
import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
|
|
44
54
|
import { getFieldRef } from '../dynamic-form-schema'
|
|
45
|
-
import { normalizeNilUuid } from '../nil-uuid'
|
|
55
|
+
import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
|
|
46
56
|
import { humanizeToken } from '../dynamic-columns-helpers'
|
|
47
|
-
import
|
|
57
|
+
import { formatDateCell } from '../dynamic-columns'
|
|
58
|
+
import type { ActionFieldDef, RelationMeta } from '../types'
|
|
59
|
+
import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context'
|
|
48
60
|
|
|
49
|
-
|
|
61
|
+
// Re-export the resolver type so `index.ts`'s
|
|
62
|
+
// `export type { … GetImageUrl } from './dialogs/dynamic-record'` keeps working.
|
|
63
|
+
export type { GetImageUrl }
|
|
64
|
+
|
|
65
|
+
export interface FieldOption {
|
|
50
66
|
value: string
|
|
51
67
|
label: string
|
|
68
|
+
/**
|
|
69
|
+
* Pro option metadata the backend serves for enum/option fields (e.g.
|
|
70
|
+
* `product_type`) so the view renders a colored/iconed badge instead of the
|
|
71
|
+
* raw value ("storable" → "Almacenable"). All optional and driven entirely
|
|
72
|
+
* by the served metadata — plain options stay plain.
|
|
73
|
+
*/
|
|
74
|
+
color?: string
|
|
75
|
+
icon?: string
|
|
76
|
+
image?: string
|
|
52
77
|
}
|
|
53
78
|
|
|
54
|
-
interface FieldDef {
|
|
79
|
+
export interface FieldDef {
|
|
55
80
|
key: string
|
|
56
81
|
label: string
|
|
57
82
|
type: 'text' | 'textarea' | 'select' | 'search' | 'number' | 'date' | 'email' | 'url' | 'boolean' | 'image' | string
|
|
@@ -68,8 +93,9 @@ interface FieldDef {
|
|
|
68
93
|
* v0.46.x serves it on modal fields, not just action fields). When present
|
|
69
94
|
* the native form renders an async searchable picker (`DynamicSelectField`)
|
|
70
95
|
* against `/api/options/<ref>?field=id` — with option thumbnails when the
|
|
71
|
-
* remote rows carry an `image` — instead of a raw FK text input.
|
|
72
|
-
* the
|
|
96
|
+
* remote rows carry an `image` — instead of a raw FK text input. View mode
|
|
97
|
+
* shows the resolved thumbnail + label. Tolerates the snake_case
|
|
98
|
+
* `source`/`relation` aliases the manifest may serve.
|
|
73
99
|
*/
|
|
74
100
|
ref?: string
|
|
75
101
|
source?: string
|
|
@@ -88,9 +114,30 @@ interface FieldDef {
|
|
|
88
114
|
// `ModelSchema` (see ./types.ts) is structurally assignable here.
|
|
89
115
|
interface ModalMetadata {
|
|
90
116
|
title?: string
|
|
117
|
+
/**
|
|
118
|
+
* i18n key for the model name (e.g. "accounting.model.account"). The backend
|
|
119
|
+
* can't always localize it (the addon bundle is only registered at install
|
|
120
|
+
* time), so it ships the key and we translate here — the frontend loads each
|
|
121
|
+
* addon's i18n live from the hub, so it resolves without a reinstall.
|
|
122
|
+
*/
|
|
123
|
+
titleKey?: string
|
|
91
124
|
createTitle?: string
|
|
92
125
|
editTitle?: string
|
|
93
126
|
fields?: FieldDef[]
|
|
127
|
+
/**
|
|
128
|
+
* Backend-localized CRUD success messages (modal metadata). Preferred over
|
|
129
|
+
* the raw response message which is not localized.
|
|
130
|
+
*/
|
|
131
|
+
messages?: { created?: string; updated?: string; deleted?: string }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type TFn = (key: string) => string
|
|
135
|
+
|
|
136
|
+
// localizedModelName resolves the (possibly addon-i18n) model name: prefer the
|
|
137
|
+
// translated titleKey, fall back to the backend-provided raw title.
|
|
138
|
+
function localizedModelName(meta: ModalMetadata, t: TFn): string {
|
|
139
|
+
if (meta.titleKey && t(meta.titleKey) !== meta.titleKey) return t(meta.titleKey)
|
|
140
|
+
return meta.title || ''
|
|
94
141
|
}
|
|
95
142
|
|
|
96
143
|
export interface DynamicRecordDialogProps {
|
|
@@ -100,7 +147,10 @@ export interface DynamicRecordDialogProps {
|
|
|
100
147
|
model: string
|
|
101
148
|
recordId?: string | null
|
|
102
149
|
endpoint?: string
|
|
103
|
-
|
|
150
|
+
/** Fired after a successful save; receives the persisted record (when the
|
|
151
|
+
* backend returns it) so callers — e.g. the inline-create bridge behind a
|
|
152
|
+
* dynamic_select "+" — can auto-select the new row. */
|
|
153
|
+
onSaved?: (record?: any) => void
|
|
104
154
|
/**
|
|
105
155
|
* Optional override invoked instead of the default `POST` when the dialog
|
|
106
156
|
* is in `create` mode. Hosts may use this to route writes through custom
|
|
@@ -133,28 +183,111 @@ export interface DynamicRecordDialogProps {
|
|
|
133
183
|
* the action.
|
|
134
184
|
*/
|
|
135
185
|
onEdit?: () => void
|
|
186
|
+
/**
|
|
187
|
+
* Deliberate escape hatch: open the full `/m/:model/:id` detail page (with
|
|
188
|
+
* cross-module related records) for records too heavy for the modal.
|
|
189
|
+
* Rendered as a footer link in view mode when provided.
|
|
190
|
+
*/
|
|
191
|
+
onOpenFullPage?: () => void
|
|
192
|
+
/**
|
|
193
|
+
* The row object the table already loaded. When provided, the dialog renders
|
|
194
|
+
* instantly from it (no spinner) and reuses the table's pro siblings — the
|
|
195
|
+
* resolved relation (`row.category = {value,label}`), served option lists and
|
|
196
|
+
* image urls. A background fetch only fills in fields the list row omitted.
|
|
197
|
+
*/
|
|
198
|
+
initialRecord?: Record<string, any> | null
|
|
199
|
+
/**
|
|
200
|
+
* Host resolver turning a (possibly relative) storage path into a fetchable
|
|
201
|
+
* URL for images/avatars/thumbnails. Defaults to identity. Pass the host's
|
|
202
|
+
* `getImageUrl` so addon-served relative paths render.
|
|
203
|
+
*/
|
|
204
|
+
getImageUrl?: GetImageUrl
|
|
205
|
+
/**
|
|
206
|
+
* Org IANA timezone (e.g. `America/Mexico_City`). Threaded into the tz-aware
|
|
207
|
+
* `formatDateCell` so datetime/timestamp instants render in the org zone
|
|
208
|
+
* regardless of the viewer's browser timezone. Pure `date` values pin to UTC.
|
|
209
|
+
*/
|
|
210
|
+
timeZone?: string
|
|
136
211
|
}
|
|
137
212
|
|
|
138
213
|
function resolvePath(obj: any, path: string): any {
|
|
139
214
|
return path.split('.').reduce((acc, part) => acc?.[part], obj)
|
|
140
215
|
}
|
|
141
216
|
|
|
217
|
+
// objectLabel pulls a human label off a resolved relation/user object the
|
|
218
|
+
// backend serves: `{value,label}` (FK sibling), `{name,...}` (user object such
|
|
219
|
+
// as created_by), or `{title}`. Returns undefined for plain/empty objects.
|
|
220
|
+
function objectLabel(value: any): string | undefined {
|
|
221
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
222
|
+
const label = value.label ?? value.name ?? value.title
|
|
223
|
+
if (label != null && label !== '') return String(label)
|
|
224
|
+
return undefined
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// pickImage reads an image-ish path off a resolved object (FK sibling, user).
|
|
228
|
+
function pickImage(value: any): string | undefined {
|
|
229
|
+
if (!value || typeof value !== 'object') return undefined
|
|
230
|
+
const img = value.image ?? value.avatar ?? value.logo ?? value.thumbnail
|
|
231
|
+
return typeof img === 'string' && img !== '' ? img : undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// relationSiblingValue reads the resolved relation the table served alongside an
|
|
235
|
+
// FK column. A field `category_id` (search/dynamic_select/ref) ships a sibling
|
|
236
|
+
// `record.category = {value,label,image?}` (or a bare string/{name}); returns
|
|
237
|
+
// the raw sibling (object or string) so the caller can extract label + image.
|
|
238
|
+
function relationSiblingValue(field: FieldDef, record: any): any {
|
|
239
|
+
if (!record) return undefined
|
|
240
|
+
const candidates: string[] = []
|
|
241
|
+
const ref = getFieldRef(field as ActionFieldDef)
|
|
242
|
+
if (ref) candidates.push(ref)
|
|
243
|
+
if (typeof field.key === 'string' && field.key.endsWith('_id')) candidates.push(field.key.slice(0, -3))
|
|
244
|
+
for (const key of candidates) {
|
|
245
|
+
const sib = record[key]
|
|
246
|
+
if (sib === undefined || sib === null) continue
|
|
247
|
+
if (typeof sib === 'string') {
|
|
248
|
+
if (sib === '' || isNilUuid(sib)) continue
|
|
249
|
+
return sib
|
|
250
|
+
}
|
|
251
|
+
if (typeof sib === 'object') return sib
|
|
252
|
+
}
|
|
253
|
+
return undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// servedOption matches a field's served option list (enum/select with
|
|
257
|
+
// {value,label,color,icon,image}) against the current value.
|
|
258
|
+
function servedOption(field: FieldDef, value: any): FieldOption | undefined {
|
|
259
|
+
if (!field.options?.length) return undefined
|
|
260
|
+
return field.options.find(o => o.value === String(value ?? ''))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// createdBySibling reads the resolver object the backend serves for the
|
|
264
|
+
// auto-injected `created_by` avatar column: {name, avatar, email}.
|
|
265
|
+
function createdBySibling(value: any, record: any): { name?: string; avatar?: string; email?: string } | undefined {
|
|
266
|
+
const obj = (value && typeof value === 'object' ? value : undefined) ?? record?.created_by
|
|
267
|
+
if (obj && typeof obj === 'object' && (obj.name || obj.avatar || obj.email)) return obj
|
|
268
|
+
return undefined
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// isRelationField — a field that resolves to another row (so view renders a lead
|
|
272
|
+
// + label and edit renders the searchable picker).
|
|
273
|
+
function isRelationField(field: FieldDef): boolean {
|
|
274
|
+
return (
|
|
275
|
+
field.type === 'search' ||
|
|
276
|
+
field.type === 'dynamic_select' ||
|
|
277
|
+
field.widget === 'dynamic_select' ||
|
|
278
|
+
!!getFieldRef(field as ActionFieldDef) ||
|
|
279
|
+
!!field.searchEndpoint
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
142
283
|
function formatDisplayValue(rawValue: any, field: FieldDef): string {
|
|
143
284
|
// Unset nullable FK serialized as the nil UUID renders as empty, not zeros.
|
|
144
285
|
const value = normalizeNilUuid(rawValue)
|
|
145
286
|
if (value === null || value === undefined || value === '') return '—'
|
|
287
|
+
const objLabel = objectLabel(value)
|
|
288
|
+
if (objLabel !== undefined) return objLabel
|
|
146
289
|
if (field.type === 'boolean' || typeof value === 'boolean') return value ? 'Sí' : 'No'
|
|
147
290
|
|
|
148
|
-
if (field.type === 'date') {
|
|
149
|
-
try {
|
|
150
|
-
return new Date(value).toLocaleDateString('es-MX', {
|
|
151
|
-
day: 'numeric', month: 'long', year: 'numeric',
|
|
152
|
-
})
|
|
153
|
-
} catch {
|
|
154
|
-
return String(value)
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
291
|
if (field.type === 'select' && field.options?.length) {
|
|
159
292
|
const match = field.options.find(o => o.value === String(value))
|
|
160
293
|
// Matched option label wins (localized); humanize the raw token only
|
|
@@ -167,21 +300,27 @@ function formatDisplayValue(rawValue: any, field: FieldDef): string {
|
|
|
167
300
|
|
|
168
301
|
const MODE_CONFIG = {
|
|
169
302
|
create: {
|
|
170
|
-
getTitle: (meta: ModalMetadata) =>
|
|
303
|
+
getTitle: (meta: ModalMetadata, t: TFn) => {
|
|
304
|
+
const name = localizedModelName(meta, t)
|
|
305
|
+
return name ? `Crear ${name}` : (meta.createTitle || meta.title || 'Nuevo registro')
|
|
306
|
+
},
|
|
171
307
|
description: 'Completa los campos para crear un nuevo registro.',
|
|
172
308
|
submitLabel: 'Crear',
|
|
173
309
|
submittingLabel: 'Creando...',
|
|
174
310
|
cancelLabel: 'Cancelar',
|
|
175
311
|
},
|
|
176
312
|
edit: {
|
|
177
|
-
getTitle: (meta: ModalMetadata) =>
|
|
313
|
+
getTitle: (meta: ModalMetadata, t: TFn) => {
|
|
314
|
+
const name = localizedModelName(meta, t)
|
|
315
|
+
return name ? `Editar ${name}` : (meta.editTitle || meta.title || 'Editar registro')
|
|
316
|
+
},
|
|
178
317
|
description: 'Modifica los campos y guarda los cambios.',
|
|
179
318
|
submitLabel: 'Guardar cambios',
|
|
180
319
|
submittingLabel: 'Guardando...',
|
|
181
320
|
cancelLabel: 'Cancelar',
|
|
182
321
|
},
|
|
183
322
|
view: {
|
|
184
|
-
getTitle: (meta: ModalMetadata) => meta.title || 'Ver registro',
|
|
323
|
+
getTitle: (meta: ModalMetadata, t: TFn) => localizedModelName(meta, t) || meta.title || 'Ver registro',
|
|
185
324
|
description: 'Información detallada del registro.',
|
|
186
325
|
submitLabel: '',
|
|
187
326
|
submittingLabel: '',
|
|
@@ -189,7 +328,10 @@ const MODE_CONFIG = {
|
|
|
189
328
|
},
|
|
190
329
|
}
|
|
191
330
|
|
|
331
|
+
// Context threading host runtime values to nested field components (uploads,
|
|
332
|
+
// image leads, tz-aware dates) without prop-drilling through every renderer.
|
|
192
333
|
const ModelContext = createContext('')
|
|
334
|
+
const TimeZoneContext = createContext<string | undefined>(undefined)
|
|
193
335
|
|
|
194
336
|
export function DynamicRecordDialog({
|
|
195
337
|
open,
|
|
@@ -205,11 +347,17 @@ export function DynamicRecordDialog({
|
|
|
205
347
|
schema,
|
|
206
348
|
onDelete,
|
|
207
349
|
onEdit,
|
|
350
|
+
onOpenFullPage,
|
|
351
|
+
initialRecord,
|
|
352
|
+
getImageUrl = identityImageUrl,
|
|
353
|
+
timeZone,
|
|
208
354
|
}: DynamicRecordDialogProps) {
|
|
209
355
|
const api = useApi()
|
|
356
|
+
const { t } = useTranslation()
|
|
210
357
|
const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(
|
|
211
358
|
schema ? (schema as ModalMetadata) : null,
|
|
212
359
|
)
|
|
360
|
+
const [relations, setRelations] = useState<RelationMeta[]>([])
|
|
213
361
|
const [record, setRecord] = useState<any | null>(null)
|
|
214
362
|
const [formValues, setFormValues] = useState<Record<string, any>>({})
|
|
215
363
|
const [loading, setLoading] = useState(false)
|
|
@@ -221,14 +369,40 @@ export function DynamicRecordDialog({
|
|
|
221
369
|
const isEditable = mode === 'create' || mode === 'edit'
|
|
222
370
|
const config = MODE_CONFIG[mode]
|
|
223
371
|
|
|
372
|
+
// ── Fetch metadata + record when dialog opens ──────────────────────────
|
|
224
373
|
useEffect(() => {
|
|
225
374
|
if (!open) return
|
|
226
375
|
if (!isCreate && !recordId) return
|
|
227
376
|
|
|
228
377
|
let cancelled = false
|
|
229
378
|
|
|
379
|
+
// Seed instantly from the row the table already has so view/edit render
|
|
380
|
+
// without a spinner. The list row carries the pro siblings (resolved
|
|
381
|
+
// relation, served options, image url) the table cells used.
|
|
382
|
+
const seed = !isCreate && initialRecord ? initialRecord : null
|
|
383
|
+
if (seed) setRecord(seed)
|
|
384
|
+
|
|
385
|
+
const seedForm = (meta: ModalMetadata, rec: any) => {
|
|
386
|
+
const initial: Record<string, any> = {}
|
|
387
|
+
for (const field of meta.fields ?? []) {
|
|
388
|
+
initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? ''
|
|
389
|
+
}
|
|
390
|
+
setFormValues(initial)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// A field value is "missing" from the seed row when the list omitted that
|
|
394
|
+
// column. Sibling pro fields aren't form fields, so we only check the
|
|
395
|
+
// declared field keys.
|
|
396
|
+
const seedIsComplete = (meta: ModalMetadata, rec: any) =>
|
|
397
|
+
(meta.fields ?? []).every(f => {
|
|
398
|
+
if (f.hidden) return true
|
|
399
|
+
const v = resolvePath(rec, f.key)
|
|
400
|
+
return v !== undefined
|
|
401
|
+
})
|
|
402
|
+
|
|
230
403
|
const load = async () => {
|
|
231
|
-
|
|
404
|
+
// Only show the skeleton when we have nothing to render yet.
|
|
405
|
+
if (!seed) setLoading(true)
|
|
232
406
|
try {
|
|
233
407
|
let meta: ModalMetadata | null = schema ? (schema as ModalMetadata) : null
|
|
234
408
|
if (!meta) {
|
|
@@ -247,7 +421,15 @@ export function DynamicRecordDialog({
|
|
|
247
421
|
: field.defaultValue) ?? ''
|
|
248
422
|
}
|
|
249
423
|
setFormValues(initial)
|
|
250
|
-
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Render immediately from the seed row.
|
|
428
|
+
if (seed && meta) seedForm(meta, seed)
|
|
429
|
+
|
|
430
|
+
// Only hit the record endpoint if the seed is absent or missing
|
|
431
|
+
// some declared field — keeps the modal instant for full rows.
|
|
432
|
+
if (!seed || (meta && !seedIsComplete(meta, seed))) {
|
|
251
433
|
const recordEndpoint = endpoint
|
|
252
434
|
? `${endpoint}/${recordId}`
|
|
253
435
|
: `/dynamic/${model}/${recordId}`
|
|
@@ -256,17 +438,15 @@ export function DynamicRecordDialog({
|
|
|
256
438
|
if (cancelled) return
|
|
257
439
|
|
|
258
440
|
const rec = recRes.data?.data ?? recRes.data
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}
|
|
265
|
-
setFormValues(initial)
|
|
441
|
+
// Merge so the fetched record fills gaps without dropping the
|
|
442
|
+
// table's pro siblings (the detail endpoint may omit them).
|
|
443
|
+
const merged = seed ? { ...seed, ...rec } : rec
|
|
444
|
+
setRecord(merged)
|
|
445
|
+
if (meta) seedForm(meta, merged)
|
|
266
446
|
}
|
|
267
447
|
} catch (err) {
|
|
268
448
|
console.error('[DynamicRecordDialog] load error:', err)
|
|
269
|
-
toast.error('Error al cargar los datos')
|
|
449
|
+
if (!seed) toast.error('Error al cargar los datos')
|
|
270
450
|
} finally {
|
|
271
451
|
if (!cancelled) setLoading(false)
|
|
272
452
|
}
|
|
@@ -274,16 +454,55 @@ export function DynamicRecordDialog({
|
|
|
274
454
|
|
|
275
455
|
load()
|
|
276
456
|
return () => { cancelled = true }
|
|
277
|
-
|
|
457
|
+
// initialRecord intentionally omitted: the row identity is captured per open
|
|
458
|
+
// via recordId; re-seeding mid-open would clobber edits.
|
|
459
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
460
|
+
}, [open, recordId, model, endpoint, isCreate, schema])
|
|
278
461
|
|
|
462
|
+
// Reset when closed
|
|
279
463
|
useEffect(() => {
|
|
280
464
|
if (!open) {
|
|
281
465
|
setModalMeta(null)
|
|
466
|
+
setRelations([])
|
|
282
467
|
setRecord(null)
|
|
283
468
|
setFormValues({})
|
|
284
469
|
}
|
|
285
470
|
}, [open])
|
|
286
471
|
|
|
472
|
+
// Fetch the model's declared one_to_many/many_to_many edges so view AND edit
|
|
473
|
+
// show child records (e.g. a sales order's line items) below the scalar
|
|
474
|
+
// fields. The modal form is driven by MODAL metadata (fields); relations live
|
|
475
|
+
// on TABLE metadata, hence the separate fetch. Skipped on create (no parent
|
|
476
|
+
// record yet). View renders them read-only; edit lets the user add/edit/delete.
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
if (!open || mode === 'create' || !recordId) {
|
|
479
|
+
setRelations([])
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
let cancelled = false
|
|
483
|
+
api.get(`/metadata/table/${model}`)
|
|
484
|
+
.then(res => {
|
|
485
|
+
if (cancelled) return
|
|
486
|
+
const meta = res.data?.data ?? res.data
|
|
487
|
+
const rels: RelationMeta[] = Array.isArray(meta?.relations) ? meta.relations : []
|
|
488
|
+
// Localize each panel header: the backend serves `label` as an
|
|
489
|
+
// i18n key (addon bundle, loaded live) and the SDK renders it verbatim.
|
|
490
|
+
setRelations(
|
|
491
|
+
rels.map(rel => ({
|
|
492
|
+
...rel,
|
|
493
|
+
label:
|
|
494
|
+
rel.label && t(rel.label) !== rel.label
|
|
495
|
+
? t(rel.label)
|
|
496
|
+
: rel.label || rel.name,
|
|
497
|
+
})),
|
|
498
|
+
)
|
|
499
|
+
})
|
|
500
|
+
.catch(() => {
|
|
501
|
+
if (!cancelled) setRelations([])
|
|
502
|
+
})
|
|
503
|
+
return () => { cancelled = true }
|
|
504
|
+
}, [open, mode, model, recordId, api, t])
|
|
505
|
+
|
|
287
506
|
const handleSubmit = async (e?: React.FormEvent) => {
|
|
288
507
|
e?.preventDefault()
|
|
289
508
|
if (!modalMeta) return
|
|
@@ -300,17 +519,17 @@ export function DynamicRecordDialog({
|
|
|
300
519
|
setSaving(true)
|
|
301
520
|
try {
|
|
302
521
|
if (isCreate && onCreate) {
|
|
303
|
-
await onCreate(formValues)
|
|
304
|
-
toast.success('Registro creado correctamente')
|
|
305
|
-
onSaved?.()
|
|
522
|
+
const created = await onCreate(formValues)
|
|
523
|
+
toast.success(modalMeta?.messages?.created || 'Registro creado correctamente')
|
|
524
|
+
onSaved?.(created ?? undefined)
|
|
306
525
|
onOpenChange(false)
|
|
307
526
|
return
|
|
308
527
|
}
|
|
309
528
|
|
|
310
529
|
if (!isCreate && recordId && onUpdate) {
|
|
311
|
-
await onUpdate(String(recordId), formValues)
|
|
312
|
-
toast.success('Guardado correctamente')
|
|
313
|
-
onSaved?.()
|
|
530
|
+
const updated = await onUpdate(String(recordId), formValues)
|
|
531
|
+
toast.success(modalMeta?.messages?.updated || 'Guardado correctamente')
|
|
532
|
+
onSaved?.(updated ?? undefined)
|
|
314
533
|
onOpenChange(false)
|
|
315
534
|
return
|
|
316
535
|
}
|
|
@@ -327,8 +546,15 @@ export function DynamicRecordDialog({
|
|
|
327
546
|
}
|
|
328
547
|
|
|
329
548
|
if (res.data?.success !== false) {
|
|
330
|
-
|
|
331
|
-
|
|
549
|
+
// Prefer the addon's localized message (modal metadata), then a
|
|
550
|
+
// localized fallback. NOT res.data.message — the dynamic CRUD
|
|
551
|
+
// endpoint returns a raw English string that would leak into the toast.
|
|
552
|
+
toast.success(
|
|
553
|
+
modalMeta?.messages?.[isCreate ? 'created' : 'updated']
|
|
554
|
+
|| (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'),
|
|
555
|
+
)
|
|
556
|
+
// Hand the persisted record back so callers can auto-select it.
|
|
557
|
+
onSaved?.(res.data?.data ?? res.data ?? undefined)
|
|
332
558
|
onOpenChange(false)
|
|
333
559
|
} else {
|
|
334
560
|
toast.error(res.data?.message || 'Error al guardar')
|
|
@@ -354,7 +580,7 @@ export function DynamicRecordDialog({
|
|
|
354
580
|
}
|
|
355
581
|
}
|
|
356
582
|
|
|
357
|
-
const title = modalMeta ? config.getTitle(modalMeta) : ''
|
|
583
|
+
const title = modalMeta ? config.getTitle(modalMeta, t) : ''
|
|
358
584
|
|
|
359
585
|
const visibleFields = modalMeta?.fields?.filter(f => {
|
|
360
586
|
if (f.hidden) return false
|
|
@@ -375,6 +601,8 @@ export function DynamicRecordDialog({
|
|
|
375
601
|
<LoadingSkeleton />
|
|
376
602
|
) : modalMeta ? (
|
|
377
603
|
<ModelContext.Provider value={model}>
|
|
604
|
+
<ImageUrlContext.Provider value={getImageUrl}>
|
|
605
|
+
<TimeZoneContext.Provider value={timeZone}>
|
|
378
606
|
<form
|
|
379
607
|
id="dynamic-record-form"
|
|
380
608
|
onSubmit={handleSubmit}
|
|
@@ -414,39 +642,68 @@ export function DynamicRecordDialog({
|
|
|
414
642
|
</div>
|
|
415
643
|
)}
|
|
416
644
|
</form>
|
|
645
|
+
|
|
646
|
+
{/* Child records (line items, etc.) for declared relations.
|
|
647
|
+
View = strictly read-only; edit = add/edit/delete. */}
|
|
648
|
+
{!isCreate && record && relations.length > 0 && (
|
|
649
|
+
<div className="mt-6">
|
|
650
|
+
<DynamicRelations
|
|
651
|
+
record={record}
|
|
652
|
+
relations={relations}
|
|
653
|
+
canCreate={mode === 'edit'}
|
|
654
|
+
canEdit={mode === 'edit'}
|
|
655
|
+
canDelete={mode === 'edit'}
|
|
656
|
+
/>
|
|
657
|
+
</div>
|
|
658
|
+
)}
|
|
659
|
+
</TimeZoneContext.Provider>
|
|
660
|
+
</ImageUrlContext.Provider>
|
|
417
661
|
</ModelContext.Provider>
|
|
418
662
|
) : null}
|
|
419
663
|
</div>
|
|
420
664
|
|
|
421
|
-
<DialogFooter className="p-4 border-t shrink-0">
|
|
422
|
-
|
|
423
|
-
{config.cancelLabel}
|
|
424
|
-
</Button>
|
|
425
|
-
{isView && onDelete && (
|
|
665
|
+
<DialogFooter className="p-4 border-t shrink-0 sm:justify-between">
|
|
666
|
+
{isView && onOpenFullPage ? (
|
|
426
667
|
<Button
|
|
427
|
-
variant="
|
|
428
|
-
|
|
429
|
-
|
|
668
|
+
variant="ghost"
|
|
669
|
+
size="sm"
|
|
670
|
+
className="text-muted-foreground"
|
|
671
|
+
onClick={() => { onOpenChange(false); onOpenFullPage() }}
|
|
430
672
|
>
|
|
431
|
-
|
|
432
|
-
|
|
673
|
+
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
|
674
|
+
Ver página completa
|
|
433
675
|
</Button>
|
|
434
|
-
)}
|
|
435
|
-
|
|
436
|
-
<Button onClick={
|
|
437
|
-
|
|
676
|
+
) : <span />}
|
|
677
|
+
<div className="flex items-center gap-2">
|
|
678
|
+
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
|
|
679
|
+
{config.cancelLabel}
|
|
438
680
|
</Button>
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
681
|
+
{isView && onDelete && (
|
|
682
|
+
<Button
|
|
683
|
+
variant="destructive"
|
|
684
|
+
onClick={handleDelete}
|
|
685
|
+
disabled={deleting || loading}
|
|
686
|
+
>
|
|
687
|
+
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
688
|
+
{deleting ? 'Eliminando...' : 'Eliminar'}
|
|
689
|
+
</Button>
|
|
690
|
+
)}
|
|
691
|
+
{isView && onEdit && (
|
|
692
|
+
<Button onClick={onEdit} disabled={deleting || loading}>
|
|
693
|
+
Editar
|
|
694
|
+
</Button>
|
|
695
|
+
)}
|
|
696
|
+
{isEditable && (
|
|
697
|
+
<Button
|
|
698
|
+
type="submit"
|
|
699
|
+
form="dynamic-record-form"
|
|
700
|
+
disabled={saving || loading}
|
|
701
|
+
>
|
|
702
|
+
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
703
|
+
{saving ? config.submittingLabel : config.submitLabel}
|
|
704
|
+
</Button>
|
|
705
|
+
)}
|
|
706
|
+
</div>
|
|
450
707
|
</DialogFooter>
|
|
451
708
|
</DialogContent>
|
|
452
709
|
</Dialog>
|
|
@@ -495,12 +752,113 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
|
495
752
|
)
|
|
496
753
|
}
|
|
497
754
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
755
|
+
// RelationViewValue — read-only FK lead. Resolves the relation's label + image
|
|
756
|
+
// from (1) the sibling object the table served, then (2) the canonical options
|
|
757
|
+
// endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
|
|
758
|
+
function RelationViewValue({ field, value, record }: { field: FieldDef; value: any; record: any }) {
|
|
759
|
+
const getImageUrl = useContext(ImageUrlContext)
|
|
760
|
+
const sib = relationSiblingValue(field, record)
|
|
761
|
+
const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib)
|
|
762
|
+
const sibImage = pickImage(sib)
|
|
763
|
+
// The raw FK id, tolerating an inline resolved object as the value itself.
|
|
764
|
+
const rawVal = value && typeof value === 'object' ? (value.value ?? value.id) : value
|
|
765
|
+
const inlineLabel = sibLabel ?? objectLabel(value)
|
|
766
|
+
const inlineImage = sibImage ?? pickImage(value)
|
|
767
|
+
|
|
768
|
+
const fieldRef = getFieldRef(field as ActionFieldDef)
|
|
769
|
+
// Only resolve over the network when we still lack both label and image and
|
|
770
|
+
// there is something to look up.
|
|
771
|
+
const needResolve = !inlineLabel && !inlineImage && !!(fieldRef || field.searchEndpoint) && rawVal != null && rawVal !== ''
|
|
772
|
+
const { options } = useOptionsResolver({
|
|
773
|
+
modelKey: '',
|
|
774
|
+
fieldKey: 'id',
|
|
775
|
+
ref: fieldRef,
|
|
776
|
+
endpoint: fieldRef ? undefined : field.searchEndpoint,
|
|
777
|
+
query: '',
|
|
778
|
+
limit: 50,
|
|
779
|
+
enabled: needResolve,
|
|
780
|
+
})
|
|
781
|
+
const resolved = options.find(o => String(o.id) === String(rawVal))
|
|
782
|
+
|
|
783
|
+
const label =
|
|
784
|
+
inlineLabel ??
|
|
785
|
+
resolved?.label ??
|
|
786
|
+
(rawVal != null && rawVal !== '' && !isNilUuid(rawVal) ? String(rawVal) : undefined)
|
|
787
|
+
const image = inlineImage ?? resolved?.image ?? undefined
|
|
788
|
+
|
|
789
|
+
if (!label && !image) {
|
|
790
|
+
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const lead: Pick<ResolvedOption, 'image' | 'color' | 'icon'> = {
|
|
794
|
+
image: image ? getImageUrl(image) : null,
|
|
795
|
+
color: resolved?.color ?? null,
|
|
796
|
+
icon: resolved?.icon ?? null,
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return (
|
|
800
|
+
<div className="flex items-center gap-2 py-1">
|
|
801
|
+
<OptionLead option={lead} size={24} />
|
|
802
|
+
<span className="text-sm">{label ?? '—'}</span>
|
|
803
|
+
</div>
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export function ViewValue({
|
|
808
|
+
field,
|
|
809
|
+
value: rawValue,
|
|
810
|
+
record,
|
|
811
|
+
getImageUrl: getImageUrlProp,
|
|
812
|
+
timeZone: timeZoneProp,
|
|
813
|
+
}: {
|
|
814
|
+
field: FieldDef
|
|
815
|
+
value: any
|
|
816
|
+
record: any
|
|
817
|
+
/** Optional override; when omitted falls back to the nearest provider/identity. */
|
|
818
|
+
getImageUrl?: GetImageUrl
|
|
819
|
+
/** Optional override; when omitted falls back to the nearest provider. */
|
|
820
|
+
timeZone?: string
|
|
821
|
+
}) {
|
|
822
|
+
const ctxImageUrl = useContext(ImageUrlContext)
|
|
823
|
+
const ctxTimeZone = useContext(TimeZoneContext)
|
|
824
|
+
const getImageUrl = getImageUrlProp ?? ctxImageUrl
|
|
825
|
+
const timeZone = timeZoneProp ?? ctxTimeZone
|
|
826
|
+
|
|
827
|
+
// created_by / avatar resolver sibling → name (+ avatar) instead of "—".
|
|
828
|
+
if (field.type === 'avatar' || field.key === 'created_by' || field.key === 'created_by_id') {
|
|
829
|
+
const user = createdBySibling(rawValue, record)
|
|
830
|
+
if (user) {
|
|
831
|
+
return (
|
|
832
|
+
<div className="flex items-center gap-2 py-1">
|
|
833
|
+
{user.avatar ? (
|
|
834
|
+
<img src={getImageUrl(String(user.avatar))} alt={user.name ?? ''} className="h-6 w-6 rounded-full object-cover" />
|
|
835
|
+
) : null}
|
|
836
|
+
<span className="text-sm">{user.name ?? user.email ?? '—'}</span>
|
|
837
|
+
</div>
|
|
838
|
+
)
|
|
839
|
+
}
|
|
840
|
+
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Nil/zero UUID (unset nullable FK serialized as all-zeros) → empty marker.
|
|
844
|
+
if (isNilUuid(rawValue)) {
|
|
845
|
+
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
846
|
+
}
|
|
847
|
+
|
|
501
848
|
const value = normalizeNilUuid(rawValue)
|
|
502
|
-
|
|
503
|
-
|
|
849
|
+
|
|
850
|
+
// Relation (search / dynamic_select / ref / any *_id) → resolved thumbnail +
|
|
851
|
+
// label. The *_id catch-all covers plain-typed FK columns not tagged as a
|
|
852
|
+
// relation field.
|
|
853
|
+
if (isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id'))) {
|
|
854
|
+
return <RelationViewValue field={field} value={value} record={record} />
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// The value is itself a resolved object the backend served inline — render
|
|
858
|
+
// its label/name, never the raw JSON.
|
|
859
|
+
const inlineLabel = objectLabel(value)
|
|
860
|
+
if (inlineLabel !== undefined) {
|
|
861
|
+
return <p className="text-sm py-1">{inlineLabel}</p>
|
|
504
862
|
}
|
|
505
863
|
|
|
506
864
|
if (field.type === 'boolean' || typeof value === 'boolean') {
|
|
@@ -527,7 +885,7 @@ function ViewValue({ field, value: rawValue }: { field: FieldDef; value: any; re
|
|
|
527
885
|
|
|
528
886
|
if (field.type === 'image') {
|
|
529
887
|
return value ? (
|
|
530
|
-
<img src={value} alt={field.label} className="h-16 w-16 rounded-lg object-cover border" />
|
|
888
|
+
<img src={getImageUrl(String(value))} alt={field.label} className="h-16 w-16 rounded-lg object-cover border" />
|
|
531
889
|
) : (
|
|
532
890
|
<p className="text-sm py-1 text-muted-foreground">Sin imagen</p>
|
|
533
891
|
)
|
|
@@ -546,11 +904,40 @@ function ViewValue({ field, value: rawValue }: { field: FieldDef; value: any; re
|
|
|
546
904
|
)
|
|
547
905
|
}
|
|
548
906
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
907
|
+
// Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
|
|
908
|
+
// day); instants render in the org timezone with a full-precision tooltip.
|
|
909
|
+
if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp') {
|
|
910
|
+
const renderAs = field.type === 'date' ? 'date' : field.type
|
|
911
|
+
const formatted = formatDateCell(value, renderAs, es, timeZone)
|
|
912
|
+
if (formatted) {
|
|
913
|
+
return (
|
|
914
|
+
<p className="text-sm py-1" title={formatted.title}>
|
|
915
|
+
{formatted.display}
|
|
916
|
+
</p>
|
|
917
|
+
)
|
|
553
918
|
}
|
|
919
|
+
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Enum/option field with served options → colored/iconed badge using the
|
|
923
|
+
// served label (e.g. "Almacenable" instead of "storable").
|
|
924
|
+
const opt = servedOption(field, value)
|
|
925
|
+
if (opt) {
|
|
926
|
+
const lead: Pick<ResolvedOption, 'image' | 'color' | 'icon'> = {
|
|
927
|
+
image: opt.image ? getImageUrl(opt.image) : null,
|
|
928
|
+
color: opt.color ?? null,
|
|
929
|
+
icon: opt.icon ?? null,
|
|
930
|
+
}
|
|
931
|
+
return (
|
|
932
|
+
<Badge
|
|
933
|
+
variant="secondary"
|
|
934
|
+
className="w-fit flex items-center gap-1"
|
|
935
|
+
style={opt.color && !opt.icon ? { backgroundColor: opt.color, color: '#fff', borderColor: 'transparent' } : undefined}
|
|
936
|
+
>
|
|
937
|
+
<OptionLead option={lead} size={16} />
|
|
938
|
+
{opt.label}
|
|
939
|
+
</Badge>
|
|
940
|
+
)
|
|
554
941
|
}
|
|
555
942
|
|
|
556
943
|
const display = formatDisplayValue(value, field)
|
|
@@ -597,18 +984,15 @@ function EditField({ field, value, onChange }: {
|
|
|
597
984
|
}
|
|
598
985
|
|
|
599
986
|
// Media widgets: the kernel may serve an explicit `widget: 'upload'` (or the
|
|
600
|
-
// `image` type) for a file/photo column.
|
|
601
|
-
// that POSTs to the host upload endpoint — same control as the Brand logo.
|
|
987
|
+
// `image` type) for a file/photo column.
|
|
602
988
|
if (field.type === 'image' || field.widget === 'upload') {
|
|
603
989
|
return <ImageUploadField field={field} value={value} onChange={onChange} />
|
|
604
990
|
}
|
|
605
991
|
|
|
606
992
|
// FK columns: a `ref` (kernel-derived belongs_to target) or an explicit
|
|
607
|
-
// `widget: 'dynamic_select'` renders the async searchable picker
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
// `options` are handled by the enum <Select> branch below; a ref column does
|
|
611
|
-
// not ship inline options, so this never shadows a static enum.
|
|
993
|
+
// `widget: 'dynamic_select'` renders the SDK's async searchable picker — with
|
|
994
|
+
// option thumbnails and the inline-create "+" — against /api/options/<ref>.
|
|
995
|
+
// Static inline `options` are handled by the enum <Select> branch below.
|
|
612
996
|
if ((getFieldRef(field as ActionFieldDef) || field.widget === 'dynamic_select') && !field.options?.length) {
|
|
613
997
|
return <DynamicSelectField field={field as ActionFieldDef} value={value} onChange={onChange} />
|
|
614
998
|
}
|
|
@@ -710,6 +1094,7 @@ function EditField({ field, value, onChange }: {
|
|
|
710
1094
|
function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
|
|
711
1095
|
const api = useApi()
|
|
712
1096
|
const model = useContext(ModelContext)
|
|
1097
|
+
const getImageUrl = useContext(ImageUrlContext)
|
|
713
1098
|
const [uploading, setUploading] = useState(false)
|
|
714
1099
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
715
1100
|
|
|
@@ -737,7 +1122,7 @@ function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef;
|
|
|
737
1122
|
<div className="flex items-center gap-3">
|
|
738
1123
|
{value ? (
|
|
739
1124
|
<div className="relative">
|
|
740
|
-
<img src={value} alt="" className="h-16 w-16 rounded-lg object-cover border" />
|
|
1125
|
+
<img src={getImageUrl(String(value))} alt="" className="h-16 w-16 rounded-lg object-cover border" />
|
|
741
1126
|
<button
|
|
742
1127
|
type="button"
|
|
743
1128
|
onClick={() => onChange('')}
|
|
@@ -775,29 +1160,6 @@ function extractArray(res: any): any[] {
|
|
|
775
1160
|
|
|
776
1161
|
const searchCache = new Map<string, any[]>()
|
|
777
1162
|
|
|
778
|
-
function SearchViewValue({ field, value }: { field: FieldDef; value: any }) {
|
|
779
|
-
const api = useApi()
|
|
780
|
-
const [label, setLabel] = useState(String(value))
|
|
781
|
-
|
|
782
|
-
useEffect(() => {
|
|
783
|
-
if (!field.searchEndpoint || !value) return
|
|
784
|
-
const cacheKey = field.searchEndpoint
|
|
785
|
-
const cached = searchCache.get(cacheKey)
|
|
786
|
-
if (cached) {
|
|
787
|
-
const match = cached.find((item: any) => item.value === value || item.id === value)
|
|
788
|
-
if (match) { setLabel(match.label || match.name || String(value)); return }
|
|
789
|
-
}
|
|
790
|
-
api.get(field.searchEndpoint, { params: { search: '', limit: 50 } }).then(res => {
|
|
791
|
-
const items = extractArray(res)
|
|
792
|
-
searchCache.set(cacheKey, items)
|
|
793
|
-
const match = items.find((item: any) => item.value === value || item.id === value)
|
|
794
|
-
if (match) setLabel(match.label || match.name || String(value))
|
|
795
|
-
}).catch(() => {})
|
|
796
|
-
}, [value, field.searchEndpoint])
|
|
797
|
-
|
|
798
|
-
return <p className="text-sm py-1">{label}</p>
|
|
799
|
-
}
|
|
800
|
-
|
|
801
1163
|
function SearchField({ field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
|
|
802
1164
|
const api = useApi()
|
|
803
1165
|
const [open, setOpen] = useState(false)
|
|
@@ -819,7 +1181,7 @@ function SearchField({ field, value, onChange }: { field: FieldDef; value: any;
|
|
|
819
1181
|
const match = items.find((item: any) => item.value === value || item.id === value)
|
|
820
1182
|
if (match) setSelectedLabel(match.label || match.name || '')
|
|
821
1183
|
}).catch(() => {})
|
|
822
|
-
}, [value, field.searchEndpoint])
|
|
1184
|
+
}, [value, field.searchEndpoint, api])
|
|
823
1185
|
|
|
824
1186
|
useEffect(() => {
|
|
825
1187
|
if (!open || !field.searchEndpoint) return
|
|
@@ -837,7 +1199,7 @@ function SearchField({ field, value, onChange }: { field: FieldDef; value: any;
|
|
|
837
1199
|
.finally(() => setLoading(false))
|
|
838
1200
|
}, query ? 250 : 0)
|
|
839
1201
|
return () => clearTimeout(timer)
|
|
840
|
-
}, [query, open, field.searchEndpoint])
|
|
1202
|
+
}, [query, open, field.searchEndpoint, api])
|
|
841
1203
|
|
|
842
1204
|
return (
|
|
843
1205
|
<Popover open={open} onOpenChange={setOpen}>
|
|
@@ -888,9 +1250,9 @@ function SearchField({ field, value, onChange }: { field: FieldDef; value: any;
|
|
|
888
1250
|
>
|
|
889
1251
|
{isSelected && <Check className="mr-2 h-3.5 w-3.5 shrink-0 text-primary" />}
|
|
890
1252
|
{item.image && (
|
|
891
|
-
<
|
|
1253
|
+
<OptionThumb image={item.image} size={20} />
|
|
892
1254
|
)}
|
|
893
|
-
<div className="flex flex-col min-w-0">
|
|
1255
|
+
<div className="flex flex-col min-w-0 ml-2">
|
|
894
1256
|
<span className="truncate">{itemLabel}</span>
|
|
895
1257
|
{item.description && (
|
|
896
1258
|
<span className="text-[11px] text-muted-foreground truncate">{item.description}</span>
|