@asteby/metacore-runtime-react 49.5.0 → 49.6.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 +6 -0
- package/dist/business/customer-picker.d.ts +8 -2
- package/dist/business/customer-picker.d.ts.map +1 -1
- package/dist/business/customer-picker.js +30 -27
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-product-cell.d.ts +8 -10
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +13 -90
- package/dist/business/product-options.d.ts +41 -0
- package/dist/business/product-options.d.ts.map +1 -0
- package/dist/business/product-options.js +58 -0
- package/dist/business/product-picker.d.ts +5 -1
- package/dist/business/product-picker.d.ts.map +1 -1
- package/dist/business/product-picker.js +26 -35
- package/dist/business/relate-documents.d.ts +1 -1
- package/dist/business/relate-documents.d.ts.map +1 -1
- package/dist/business/relate-documents.js +6 -8
- package/dist/business/vehicle-picker.d.ts +8 -2
- package/dist/business/vehicle-picker.d.ts.map +1 -1
- package/dist/business/vehicle-picker.js +28 -28
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +28 -43
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +17 -5
- package/dist/dynamic-select-field.d.ts +7 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +67 -161
- package/dist/entity-select.d.ts +5 -2
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +23 -116
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/record-picker-actions.d.ts +40 -2
- package/dist/record-picker-actions.d.ts.map +1 -1
- package/dist/record-picker-actions.js +44 -4
- package/dist/record-picker-dialog.d.ts +23 -0
- package/dist/record-picker-dialog.d.ts.map +1 -0
- package/dist/record-picker-dialog.js +37 -0
- package/dist/record-picker-option.d.ts +25 -0
- package/dist/record-picker-option.d.ts.map +1 -0
- package/dist/record-picker-option.js +55 -0
- package/dist/record-picker.d.ts +149 -0
- package/dist/record-picker.d.ts.map +1 -0
- package/dist/record-picker.js +372 -0
- package/package.json +1 -1
- package/src/__tests__/business-pickers.test.tsx +6 -3
- package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
- package/src/__tests__/document-form-dialog.test.tsx +4 -3
- package/src/__tests__/document-lines-grid.test.tsx +2 -1
- package/src/__tests__/record-picker.test.tsx +469 -0
- package/src/business/customer-picker.tsx +87 -98
- package/src/business/index.ts +1 -0
- package/src/business/line-product-cell.tsx +55 -180
- package/src/business/product-options.tsx +119 -0
- package/src/business/product-picker.tsx +63 -126
- package/src/business/relate-documents.tsx +36 -45
- package/src/business/vehicle-picker.tsx +76 -99
- package/src/dialogs/dynamic-record.tsx +44 -97
- package/src/dynamic-multi-select-field.tsx +39 -13
- package/src/dynamic-select-field.tsx +143 -368
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +27 -2
- package/src/record-picker-actions.tsx +101 -8
- package/src/record-picker-dialog.tsx +80 -0
- package/src/record-picker-option.tsx +96 -0
- package/src/record-picker.tsx +971 -0
- package/dist/business/use-async-search.d.ts +0 -10
- package/dist/business/use-async-search.d.ts.map +0 -1
- package/dist/business/use-async-search.js +0 -38
- package/src/business/use-async-search.ts +0 -43
package/src/entity-select.tsx
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// EntitySelect — the shared, permission-aware single-select for a related model.
|
|
2
2
|
//
|
|
3
|
+
// Now a thin configuration of <RecordPicker> (record-picker.tsx); prefer it for
|
|
4
|
+
// new screens. Kept with its exact props for consumers (POS, collections and
|
|
5
|
+
// other federated addons).
|
|
6
|
+
//
|
|
3
7
|
// A searchable async combobox over a kernel model's records, plus the two
|
|
4
8
|
// affordances every "pick a related record" control should have, exactly like
|
|
5
9
|
// the dynamic create modal's relation fields (Categoría/Marca) do:
|
|
@@ -14,23 +18,11 @@
|
|
|
14
18
|
// `/metadata/modal/:model` schema via <CreateRecordDialog>, so no per-model form
|
|
15
19
|
// code is needed. This lives in the SDK so POS, purchases and any future addon
|
|
16
20
|
// share ONE implementation instead of each re-porting a bespoke picker.
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
Command,
|
|
22
|
-
CommandGroup,
|
|
23
|
-
CommandInput,
|
|
24
|
-
CommandItem,
|
|
25
|
-
CommandList,
|
|
26
|
-
Popover,
|
|
27
|
-
PopoverContent,
|
|
28
|
-
PopoverTrigger,
|
|
29
|
-
} from '@asteby/metacore-ui'
|
|
30
|
-
import { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
31
|
-
import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions'
|
|
21
|
+
import type { LucideIcon } from 'lucide-react'
|
|
22
|
+
import { RecordPicker, useLatestSearch } from './record-picker'
|
|
23
|
+
import { recordLabel } from './record-picker-actions'
|
|
24
|
+
import { useRecordPickerDialog } from './record-picker-dialog'
|
|
32
25
|
import { useCan } from './permissions-context'
|
|
33
|
-
import { useApi } from './api-context'
|
|
34
26
|
|
|
35
27
|
/** One searchable option: `value` is the id, `label` the display text. */
|
|
36
28
|
export interface EntitySelectOption {
|
|
@@ -96,13 +88,16 @@ function capabilityNamespace(model: string): string {
|
|
|
96
88
|
return model.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
97
89
|
}
|
|
98
90
|
|
|
91
|
+
/**
|
|
92
|
+
* @deprecated Thin wrapper over {@link RecordPicker}; prefer it for new code.
|
|
93
|
+
*/
|
|
99
94
|
export function EntitySelect({
|
|
100
95
|
model,
|
|
101
96
|
value,
|
|
102
97
|
label,
|
|
103
98
|
onSelect,
|
|
104
99
|
fetcher,
|
|
105
|
-
icon
|
|
100
|
+
icon,
|
|
106
101
|
placeholder = 'Seleccionar…',
|
|
107
102
|
searchPlaceholder = 'Buscar…',
|
|
108
103
|
emptyText = 'Sin resultados',
|
|
@@ -116,197 +111,53 @@ export function EntitySelect({
|
|
|
116
111
|
lockedCreateFields,
|
|
117
112
|
}: EntitySelectProps) {
|
|
118
113
|
const can = useCan()
|
|
119
|
-
const api = useApi()
|
|
120
|
-
|
|
121
114
|
const ns = capabilityNamespace(model)
|
|
122
115
|
const mayCreate = canCreate ?? can(`${ns}.create`)
|
|
123
116
|
const mayEdit = canEdit ?? can(`${ns}.update`)
|
|
124
|
-
const base = endpoint ?? `/data/${model}/me`
|
|
125
|
-
|
|
126
|
-
const [open, setOpen] = useState(false)
|
|
127
|
-
const [dialogOpen, setDialogOpen] = useState(false)
|
|
128
|
-
const [dialogRecordId, setDialogRecordId] = useState<string | undefined>(undefined)
|
|
129
|
-
const [searchTerm, setSearchTerm] = useState('')
|
|
130
|
-
const [results, setResults] = useState<EntitySelectOption[]>([])
|
|
131
|
-
const [isLoading, setIsLoading] = useState(false)
|
|
132
|
-
|
|
133
|
-
const minChars = preload ? 0 : 2
|
|
134
117
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (!signal.aborted) setResults([])
|
|
147
|
-
} finally {
|
|
148
|
-
if (!signal.aborted) setIsLoading(false)
|
|
149
|
-
}
|
|
118
|
+
// Read the {id,label} off a saved record and select it (create) or
|
|
119
|
+
// refresh its label (edit).
|
|
120
|
+
const { openCreate, openEdit, dialog } = useRecordPickerDialog({
|
|
121
|
+
model,
|
|
122
|
+
endpoint,
|
|
123
|
+
prefillField: labelField,
|
|
124
|
+
createDefaults,
|
|
125
|
+
lockedCreateFields,
|
|
126
|
+
onSaved: (rec) => {
|
|
127
|
+
const id = rec.id != null ? String(rec.id) : value ?? ''
|
|
128
|
+
onSelect(id, recordLabel(rec, labelField) ?? label ?? id)
|
|
150
129
|
},
|
|
151
|
-
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
useEffect(() => {
|
|
155
|
-
if (!open) return
|
|
156
|
-
const controller = new AbortController()
|
|
157
|
-
const timeout = setTimeout(() => run(searchTerm, controller.signal), 250)
|
|
158
|
-
return () => {
|
|
159
|
-
clearTimeout(timeout)
|
|
160
|
-
controller.abort()
|
|
161
|
-
}
|
|
162
|
-
}, [searchTerm, run, open])
|
|
163
|
-
|
|
164
|
-
const pick = (row: EntitySelectOption) => {
|
|
165
|
-
onSelect(row.value, row.label)
|
|
166
|
-
setOpen(false)
|
|
167
|
-
setSearchTerm('')
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const clear = (e: React.MouseEvent) => {
|
|
171
|
-
e.stopPropagation()
|
|
172
|
-
onSelect(null, null)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const openCreate = () => {
|
|
176
|
-
setOpen(false)
|
|
177
|
-
setDialogRecordId(undefined)
|
|
178
|
-
setDialogOpen(true)
|
|
179
|
-
}
|
|
180
|
-
const openEdit = () => {
|
|
181
|
-
if (!value) return
|
|
182
|
-
setOpen(false)
|
|
183
|
-
setDialogRecordId(value)
|
|
184
|
-
setDialogOpen(true)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Read the {id,label} off a saved record and select it. The transport
|
|
188
|
-
// matches the standard org-scoped CRUD the dynamic modal uses, so create/edit
|
|
189
|
-
// stay consistent with the rest of the app.
|
|
190
|
-
const selectSaved = (rec: Record<string, unknown> | undefined | null) => {
|
|
191
|
-
if (!rec) return
|
|
192
|
-
const id = rec.id != null ? String(rec.id) : value ?? ''
|
|
193
|
-
const lbl =
|
|
194
|
-
(rec[labelField] != null && String(rec[labelField])) ||
|
|
195
|
-
(rec.name != null && String(rec.name)) ||
|
|
196
|
-
label ||
|
|
197
|
-
id
|
|
198
|
-
onSelect(id, lbl)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const onCreate = mayCreate ? openCreate : undefined
|
|
202
|
-
const onEdit = mayEdit ? openEdit : undefined
|
|
203
|
-
const joined = hasRecordPickerAction(!!value, onCreate, onEdit)
|
|
130
|
+
})
|
|
131
|
+
const search = useLatestSearch(fetcher)
|
|
204
132
|
|
|
205
133
|
return (
|
|
206
|
-
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
align="start"
|
|
235
|
-
collisionPadding={8}
|
|
236
|
-
style={{ width: 'max(var(--radix-popover-trigger-width), 14rem)', maxWidth: 'calc(100vw - 1rem)' }}
|
|
237
|
-
>
|
|
238
|
-
<Command shouldFilter={false}>
|
|
239
|
-
<CommandInput
|
|
240
|
-
placeholder={searchPlaceholder}
|
|
241
|
-
value={searchTerm}
|
|
242
|
-
onValueChange={setSearchTerm}
|
|
243
|
-
/>
|
|
244
|
-
<CommandList>
|
|
245
|
-
{isLoading && (
|
|
246
|
-
<div className="text-muted-foreground py-4 text-center text-sm">
|
|
247
|
-
Buscando…
|
|
248
|
-
</div>
|
|
249
|
-
)}
|
|
250
|
-
{!isLoading &&
|
|
251
|
-
searchTerm.length >= minChars &&
|
|
252
|
-
results.length === 0 && <div className="text-muted-foreground py-6 text-center text-sm">{emptyText}</div>}
|
|
253
|
-
{!isLoading && results.length > 0 && (
|
|
254
|
-
<CommandGroup className="max-h-64 overflow-auto">
|
|
255
|
-
{results.map((row) => (
|
|
256
|
-
<CommandItem
|
|
257
|
-
key={row.value}
|
|
258
|
-
value={row.value}
|
|
259
|
-
onSelect={() => pick(row)}
|
|
260
|
-
className="flex flex-col items-start gap-0.5"
|
|
261
|
-
>
|
|
262
|
-
<span className="text-sm font-medium">{row.label}</span>
|
|
263
|
-
{row.description && (
|
|
264
|
-
<span className="text-muted-foreground text-xs">
|
|
265
|
-
{row.description}
|
|
266
|
-
</span>
|
|
267
|
-
)}
|
|
268
|
-
</CommandItem>
|
|
269
|
-
))}
|
|
270
|
-
</CommandGroup>
|
|
271
|
-
)}
|
|
272
|
-
{!isLoading && !preload && searchTerm.length < minChars && (
|
|
273
|
-
<div className="text-muted-foreground flex flex-col items-center gap-1 py-6">
|
|
274
|
-
<Search className="size-5" />
|
|
275
|
-
<span className="text-xs">Escribe al menos 2 caracteres</span>
|
|
276
|
-
</div>
|
|
277
|
-
)}
|
|
278
|
-
{onCreate && !isLoading && <PickerCreateItem label={model} onSelect={openCreate} />}
|
|
279
|
-
</CommandList>
|
|
280
|
-
</Command>
|
|
281
|
-
</PopoverContent>
|
|
282
|
-
</Popover>
|
|
283
|
-
|
|
284
|
-
{/* Selected → edit (pencil); empty → create (+). Each gated by perms. */}
|
|
285
|
-
<RecordPickerAction hasValue={!!value} label={model} onCreate={onCreate} onEdit={onEdit} disabled={disabled} />
|
|
286
|
-
|
|
287
|
-
{dialogOpen && (
|
|
288
|
-
<CreateRecordDialog
|
|
289
|
-
modelKey={model}
|
|
290
|
-
open={dialogOpen}
|
|
291
|
-
onOpenChange={setDialogOpen}
|
|
292
|
-
recordId={dialogRecordId}
|
|
293
|
-
endpoint={base}
|
|
294
|
-
defaults={dialogRecordId ? undefined : createDefaults}
|
|
295
|
-
lockedFields={dialogRecordId ? undefined : lockedCreateFields}
|
|
296
|
-
onCreate={async (data) => {
|
|
297
|
-
const res = await api.post(base, data)
|
|
298
|
-
const rec = (res.data?.data ?? res.data) as Record<string, unknown>
|
|
299
|
-
selectSaved(rec)
|
|
300
|
-
return rec.id != null ? { id: String(rec.id) } : undefined
|
|
301
|
-
}}
|
|
302
|
-
onUpdate={async (id, data) => {
|
|
303
|
-
const res = await api.put(`${base}/${id}`, data)
|
|
304
|
-
const rec = (res.data?.data ?? res.data) as Record<string, unknown>
|
|
305
|
-
selectSaved({ id, ...rec })
|
|
306
|
-
return { id: String(id) }
|
|
307
|
-
}}
|
|
308
|
-
/>
|
|
309
|
-
)}
|
|
310
|
-
</div>
|
|
134
|
+
<>
|
|
135
|
+
<RecordPicker<EntitySelectOption>
|
|
136
|
+
slot="entity-select"
|
|
137
|
+
search={search}
|
|
138
|
+
minChars={preload ? 0 : 2}
|
|
139
|
+
minCharsText="Escribe al menos 2 caracteres"
|
|
140
|
+
getKey={(o) => o.value}
|
|
141
|
+
getLabel={(o) => o.label}
|
|
142
|
+
getDescription={(o) => o.description}
|
|
143
|
+
showCheck={false}
|
|
144
|
+
value={value}
|
|
145
|
+
selected={value ? { value, label: label ?? value } : null}
|
|
146
|
+
onSelect={(o) => onSelect(o.value, o.label)}
|
|
147
|
+
onClear={() => onSelect(null, null)}
|
|
148
|
+
icon={icon}
|
|
149
|
+
placeholder={placeholder}
|
|
150
|
+
renderValue={(sel) => (
|
|
151
|
+
<span className={'min-w-0 flex-1 truncate' + (sel ? '' : ' text-muted-foreground')}>{label ?? placeholder}</span>
|
|
152
|
+
)}
|
|
153
|
+
searchPlaceholder={searchPlaceholder}
|
|
154
|
+
emptyText={emptyText}
|
|
155
|
+
entityLabel={model}
|
|
156
|
+
onCreate={mayCreate ? (q) => openCreate(q) : undefined}
|
|
157
|
+
onEdit={mayEdit && value ? () => openEdit(value) : undefined}
|
|
158
|
+
disabled={disabled}
|
|
159
|
+
/>
|
|
160
|
+
{dialog}
|
|
161
|
+
</>
|
|
311
162
|
)
|
|
312
163
|
}
|
package/src/index.ts
CHANGED
|
@@ -475,8 +475,33 @@ export type { DynamicRecordDialogProps, FieldDef, FieldOption, GetImageUrl } fro
|
|
|
475
475
|
export { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
476
476
|
export { EntitySelect } from './entity-select'
|
|
477
477
|
export type { EntitySelectProps, EntitySelectOption } from './entity-select'
|
|
478
|
-
|
|
479
|
-
|
|
478
|
+
// RecordPicker — the ONE "pick a related record" control (search, single /
|
|
479
|
+
// multiple, create / edit, rich options, field / cell, portaled list). Every
|
|
480
|
+
// picker above (DynamicSelectField, EntitySelect, Customer/Product/Vehicle
|
|
481
|
+
// pickers, LineProductCell) is a thin configuration of it.
|
|
482
|
+
export { RecordPicker, useRecordSearch, useLatestSearch, RECORD_PICKER_Z_INDEX } from './record-picker'
|
|
483
|
+
export type {
|
|
484
|
+
RecordPickerProps,
|
|
485
|
+
RecordPickerItemState,
|
|
486
|
+
RecordPickerVariant,
|
|
487
|
+
RecordPickerTrigger,
|
|
488
|
+
UseRecordSearchOptions,
|
|
489
|
+
UseRecordSearchResult,
|
|
490
|
+
} from './record-picker'
|
|
491
|
+
export {
|
|
492
|
+
RecordPickerAction,
|
|
493
|
+
PickerCreateItem,
|
|
494
|
+
JOINED_TRIGGER_CLASS,
|
|
495
|
+
hasRecordPickerAction,
|
|
496
|
+
requestRecordCreate,
|
|
497
|
+
requestRecordEdit,
|
|
498
|
+
recordLabel,
|
|
499
|
+
withSearchPrefill,
|
|
500
|
+
} from './record-picker-actions'
|
|
501
|
+
export type { RecordPickerActionProps, RecordCreateRequest, RecordEditRequest } from './record-picker-actions'
|
|
502
|
+
export { useRecordPickerDialog } from './record-picker-dialog'
|
|
503
|
+
export type { UseRecordPickerDialogOptions, UseRecordPickerDialogResult } from './record-picker-dialog'
|
|
504
|
+
export { OptionLead, OptionThumb } from './record-picker-option'
|
|
480
505
|
export { ViewRecordDialog } from './dialogs/view-record-dialog'
|
|
481
506
|
export type {
|
|
482
507
|
ModelKey,
|
|
@@ -1,16 +1,21 @@
|
|
|
1
|
-
// RecordPickerActions — create/edit affordances
|
|
2
|
-
// record"
|
|
3
|
-
//
|
|
1
|
+
// RecordPickerActions — create/edit affordances of the shared <RecordPicker>
|
|
2
|
+
// (record-picker.tsx), the ONE "pick a related record" control of the runtime.
|
|
3
|
+
// Every picker (DynamicSelectField, EntitySelect, the business pickers) gets
|
|
4
|
+
// the same contract from here:
|
|
4
5
|
//
|
|
5
6
|
// - nothing selected → "+" joined to the trigger: opens the model's CREATE
|
|
6
7
|
// modal and the new record ends up selected;
|
|
7
8
|
// - a record selected → pencil joined to the trigger: opens THAT record's
|
|
8
9
|
// EDIT modal and the label refreshes on save;
|
|
9
10
|
// - "Crear …" at the foot of the result list, so creating is one step away
|
|
10
|
-
// from a search with no hits.
|
|
11
|
+
// from a search with no hits (prefilled with the searched text).
|
|
11
12
|
//
|
|
12
13
|
// The action renders as a segment of the trigger (shared border, no gap) so the
|
|
13
14
|
// control reads as a single field instead of a select plus a loose button.
|
|
15
|
+
//
|
|
16
|
+
// The create/edit modals themselves are the HOST's (decoupled window events the
|
|
17
|
+
// host's RecordCreateBridge listens for) or, for hand-written screens, the
|
|
18
|
+
// in-tree <CreateRecordDialog> (see record-picker-dialog.tsx).
|
|
14
19
|
import type { ReactNode } from 'react'
|
|
15
20
|
import { Pencil, Plus } from 'lucide-react'
|
|
16
21
|
import { Button, CommandGroup, CommandItem, CommandSeparator } from '@asteby/metacore-ui/primitives'
|
|
@@ -26,6 +31,13 @@ export interface RecordPickerActionProps {
|
|
|
26
31
|
onCreate?: () => void
|
|
27
32
|
onEdit?: () => void
|
|
28
33
|
disabled?: boolean
|
|
34
|
+
/** Accessible name override for the "+" (default `Crear <label>`). */
|
|
35
|
+
createLabel?: string
|
|
36
|
+
/** Accessible name override for the pencil (default `Editar <label>`). */
|
|
37
|
+
editLabel?: string
|
|
38
|
+
/** `cell` → compact, borderless segment for table cells. */
|
|
39
|
+
variant?: 'field' | 'cell'
|
|
40
|
+
className?: string
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
/**
|
|
@@ -33,11 +45,22 @@ export interface RecordPickerActionProps {
|
|
|
33
45
|
* otherwise (and `onCreate`). Renders nothing when neither applies — callers
|
|
34
46
|
* should then drop {@link JOINED_TRIGGER_CLASS} from the trigger.
|
|
35
47
|
*/
|
|
36
|
-
export function RecordPickerAction({
|
|
48
|
+
export function RecordPickerAction({
|
|
49
|
+
hasValue,
|
|
50
|
+
label,
|
|
51
|
+
onCreate,
|
|
52
|
+
onEdit,
|
|
53
|
+
disabled,
|
|
54
|
+
createLabel,
|
|
55
|
+
editLabel,
|
|
56
|
+
variant = 'field',
|
|
57
|
+
className,
|
|
58
|
+
}: RecordPickerActionProps) {
|
|
37
59
|
const edit = hasValue && !!onEdit
|
|
38
60
|
const create = !hasValue && !!onCreate
|
|
39
61
|
if (!edit && !create) return null
|
|
40
|
-
const name = edit ? `Editar ${label}` : `Crear ${label}`
|
|
62
|
+
const name = edit ? editLabel ?? `Editar ${label}` : createLabel ?? `Crear ${label}`
|
|
63
|
+
const size = variant === 'cell' ? 'size-8 border-transparent bg-transparent shadow-none' : 'size-9'
|
|
41
64
|
return (
|
|
42
65
|
<Button
|
|
43
66
|
type="button"
|
|
@@ -53,7 +76,10 @@ export function RecordPickerAction({ hasValue, label, onCreate, onEdit, disabled
|
|
|
53
76
|
title={name}
|
|
54
77
|
data-slot="record-picker-action"
|
|
55
78
|
data-action={edit ? 'edit' : 'create'}
|
|
56
|
-
className=
|
|
79
|
+
className={
|
|
80
|
+
`-ml-px ${size} shrink-0 rounded-l-none text-muted-foreground hover:text-foreground focus-visible:z-10` +
|
|
81
|
+
(className ? ` ${className}` : '')
|
|
82
|
+
}
|
|
57
83
|
>
|
|
58
84
|
{edit ? <Pencil className="size-4" /> : <Plus className="size-4" />}
|
|
59
85
|
</Button>
|
|
@@ -65,7 +91,12 @@ export function hasRecordPickerAction(hasValue: boolean, onCreate?: unknown, onE
|
|
|
65
91
|
return hasValue ? !!onEdit : !!onCreate
|
|
66
92
|
}
|
|
67
93
|
|
|
68
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* "Crear …" row at the foot of a cmdk `<Command>` list.
|
|
96
|
+
*
|
|
97
|
+
* @deprecated <RecordPicker> renders its own create footer (with the searched
|
|
98
|
+
* text prefilled). Kept for hand-written cmdk lists in consumers.
|
|
99
|
+
*/
|
|
69
100
|
export function PickerCreateItem({ label, onSelect, children }: { label: string; onSelect: () => void; children?: ReactNode }) {
|
|
70
101
|
return (
|
|
71
102
|
<>
|
|
@@ -79,3 +110,65 @@ export function PickerCreateItem({ label, onSelect, children }: { label: string;
|
|
|
79
110
|
</>
|
|
80
111
|
)
|
|
81
112
|
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Host create/edit modal — decoupled window events.
|
|
116
|
+
//
|
|
117
|
+
// The SDK never imports host code: a picker fires `metacore:create-record` /
|
|
118
|
+
// `metacore:edit-record` and the host's RecordCreateBridge opens the model's
|
|
119
|
+
// REAL modal (full fields from /metadata/modal), handing the saved record back
|
|
120
|
+
// through the callback. Works for ANY model.
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/** Detail of the `metacore:create-record` event (contract with the host). */
|
|
124
|
+
export interface RecordCreateRequest {
|
|
125
|
+
model: string
|
|
126
|
+
/** Seed values for the new record. Keys that aren't fields of the model are ignored by the dialog. */
|
|
127
|
+
defaults?: Record<string, unknown>
|
|
128
|
+
/** Fields rendered locked in the create modal. */
|
|
129
|
+
lockedFields?: string[]
|
|
130
|
+
onCreated?: (record: any) => void
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Detail of the `metacore:edit-record` event (contract with the host). */
|
|
134
|
+
export interface RecordEditRequest {
|
|
135
|
+
model: string
|
|
136
|
+
recordId: string
|
|
137
|
+
onSaved?: (record: any) => void
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Ask the host to open `model`'s create modal. No-op outside a browser. */
|
|
141
|
+
export function requestRecordCreate(req: RecordCreateRequest): void {
|
|
142
|
+
if (typeof window === 'undefined') return
|
|
143
|
+
window.dispatchEvent(new CustomEvent('metacore:create-record', { detail: req }))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Ask the host to open the edit modal of `recordId`. No-op outside a browser. */
|
|
147
|
+
export function requestRecordEdit(req: RecordEditRequest): void {
|
|
148
|
+
if (typeof window === 'undefined') return
|
|
149
|
+
window.dispatchEvent(new CustomEvent('metacore:edit-record', { detail: req }))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Display label of a saved record: `labelField`, else name / label / title. */
|
|
153
|
+
export function recordLabel(rec: Record<string, unknown> | null | undefined, labelField = 'name'): string | undefined {
|
|
154
|
+
if (!rec) return undefined
|
|
155
|
+
for (const k of [labelField, 'name', 'label', 'title']) {
|
|
156
|
+
const v = rec[k]
|
|
157
|
+
if (v != null && v !== '') return String(v)
|
|
158
|
+
}
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create defaults with the searched text seeded into `field` (the model's
|
|
164
|
+
* label column: name, plate…). Explicit `defaults` win over the prefill.
|
|
165
|
+
*/
|
|
166
|
+
export function withSearchPrefill(
|
|
167
|
+
query: string | undefined,
|
|
168
|
+
field: string | null | undefined,
|
|
169
|
+
defaults?: Record<string, unknown>,
|
|
170
|
+
): Record<string, unknown> | undefined {
|
|
171
|
+
const q = (query ?? '').trim()
|
|
172
|
+
if (!q || !field) return defaults
|
|
173
|
+
return { [field]: q, ...(defaults ?? {}) }
|
|
174
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// useRecordPickerDialog — create/edit plumbing for pickers on hand-written
|
|
2
|
+
// screens that sit outside the host's RecordCreateBridge (POS, federated
|
|
3
|
+
// panels): the in-tree, metadata-driven <CreateRecordDialog> posting to the
|
|
4
|
+
// standard org-scoped CRUD (`/data/<model>/me`). Declarative pickers use the
|
|
5
|
+
// host events instead (requestRecordCreate / requestRecordEdit).
|
|
6
|
+
//
|
|
7
|
+
// One implementation for EntitySelect, CustomerPicker and VehiclePicker: the
|
|
8
|
+
// searched text prefills the new record's label field, the saved record is
|
|
9
|
+
// handed back to select it (create) or refresh its label (edit).
|
|
10
|
+
import { useState, type ReactNode } from 'react'
|
|
11
|
+
import { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
12
|
+
import { useApi } from './api-context'
|
|
13
|
+
import { withSearchPrefill } from './record-picker-actions'
|
|
14
|
+
|
|
15
|
+
export interface UseRecordPickerDialogOptions {
|
|
16
|
+
/** Kernel model key. */
|
|
17
|
+
model: string
|
|
18
|
+
/** CRUD base. Default `/data/<model>/me`; edit PUTs `<base>/<id>`. */
|
|
19
|
+
endpoint?: string
|
|
20
|
+
/** Field seeded with the searched text on create (null = none). Default "name". */
|
|
21
|
+
prefillField?: string | null
|
|
22
|
+
/** Seed values for create (win over the prefill). */
|
|
23
|
+
createDefaults?: Record<string, unknown>
|
|
24
|
+
/** Fields of `createDefaults` rendered locked on create. */
|
|
25
|
+
lockedCreateFields?: string[]
|
|
26
|
+
/** The saved record (`{ id, ...fields }`). */
|
|
27
|
+
onSaved: (record: Record<string, unknown>, kind: 'create' | 'edit') => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UseRecordPickerDialogResult {
|
|
31
|
+
openCreate: (query?: string) => void
|
|
32
|
+
openEdit: (id: string) => void
|
|
33
|
+
/** Render it next to the picker. */
|
|
34
|
+
dialog: ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const unwrap = (res: { data?: any }) => ((res.data?.data ?? res.data) ?? {}) as Record<string, unknown>
|
|
38
|
+
|
|
39
|
+
export function useRecordPickerDialog({
|
|
40
|
+
model,
|
|
41
|
+
endpoint,
|
|
42
|
+
prefillField = 'name',
|
|
43
|
+
createDefaults,
|
|
44
|
+
lockedCreateFields,
|
|
45
|
+
onSaved,
|
|
46
|
+
}: UseRecordPickerDialogOptions): UseRecordPickerDialogResult {
|
|
47
|
+
const api = useApi()
|
|
48
|
+
const base = endpoint ?? `/data/${model}/me`
|
|
49
|
+
const [state, setState] = useState<{ recordId?: string; defaults?: Record<string, unknown> } | null>(null)
|
|
50
|
+
|
|
51
|
+
const dialog = state ? (
|
|
52
|
+
<CreateRecordDialog
|
|
53
|
+
modelKey={model}
|
|
54
|
+
open
|
|
55
|
+
onOpenChange={(o) => {
|
|
56
|
+
if (!o) setState(null)
|
|
57
|
+
}}
|
|
58
|
+
recordId={state.recordId}
|
|
59
|
+
endpoint={base}
|
|
60
|
+
defaults={state.recordId ? undefined : state.defaults}
|
|
61
|
+
lockedFields={state.recordId ? undefined : lockedCreateFields}
|
|
62
|
+
onCreate={async (data) => {
|
|
63
|
+
const rec = unwrap(await api.post(base, data))
|
|
64
|
+
onSaved(rec, 'create')
|
|
65
|
+
return rec.id != null ? { id: String(rec.id) } : undefined
|
|
66
|
+
}}
|
|
67
|
+
onUpdate={async (id, data) => {
|
|
68
|
+
const rec = unwrap(await api.put(`${base}/${id}`, data))
|
|
69
|
+
onSaved({ ...rec, id: rec.id ?? id }, 'edit')
|
|
70
|
+
return { id: String(id) }
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
) : null
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
openCreate: (query = '') => setState({ defaults: withSearchPrefill(query, prefillField, createDefaults) }),
|
|
77
|
+
openEdit: (id: string) => setState({ recordId: id }),
|
|
78
|
+
dialog,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Option visuals shared by every RecordPicker configuration: a thumbnail
|
|
2
|
+
// (image / lucide icon name / initials fallback) and the leading visual of an
|
|
3
|
+
// option (image, else icon, else color dot, else initials).
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { InitialsAvatar } from '@asteby/metacore-ui/primitives'
|
|
6
|
+
import { resolveColorCss } from '@asteby/metacore-ui/lib'
|
|
7
|
+
import { DynamicIcon, isLucideIconName } from './dynamic-icon'
|
|
8
|
+
import type { ResolvedOption } from './use-options-resolver'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Small square thumbnail for an option's `image`. When the option has no image
|
|
12
|
+
* it falls back to a deterministic initials avatar (shared `InitialsAvatar`)
|
|
13
|
+
* derived from `name`, so an imageless reference reads as a colored badge rather
|
|
14
|
+
* than an empty placeholder — and rows/triggers stay aligned. `size` is in
|
|
15
|
+
* pixels (kept small — 20–24px — so the picker reads as a list, not a gallery).
|
|
16
|
+
* Inline style for the box dimensions: arbitrary Tailwind classes from a
|
|
17
|
+
* federated addon don't always survive the host's class scan.
|
|
18
|
+
*/
|
|
19
|
+
export function OptionThumb({
|
|
20
|
+
image,
|
|
21
|
+
name,
|
|
22
|
+
size = 20,
|
|
23
|
+
}: {
|
|
24
|
+
image?: string | null
|
|
25
|
+
name?: string | null
|
|
26
|
+
size?: number
|
|
27
|
+
}) {
|
|
28
|
+
const [broken, setBroken] = useState(false)
|
|
29
|
+
const box = { width: size, height: size }
|
|
30
|
+
// Missing or 404 image (bare avatar filenames often 404) → initials, never
|
|
31
|
+
// an invisible box that looks like "no avatar".
|
|
32
|
+
if (!image || broken) {
|
|
33
|
+
return <InitialsAvatar name={name} size={size} rounded="sm" tone="neutral" />
|
|
34
|
+
}
|
|
35
|
+
// A lucide icon name stored where an image url/path is expected (the `icon`
|
|
36
|
+
// form widget's icon mode) → render the glyph instead of a broken <img>.
|
|
37
|
+
if (isLucideIconName(image)) {
|
|
38
|
+
return (
|
|
39
|
+
<span className="flex shrink-0 items-center justify-center rounded-sm bg-muted" style={box} aria-hidden>
|
|
40
|
+
<DynamicIcon name={image} className="size-4" />
|
|
41
|
+
</span>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
<img
|
|
46
|
+
src={image}
|
|
47
|
+
alt=""
|
|
48
|
+
aria-hidden
|
|
49
|
+
loading="lazy"
|
|
50
|
+
className="shrink-0 rounded-sm object-cover"
|
|
51
|
+
style={box}
|
|
52
|
+
onError={() => setBroken(true)}
|
|
53
|
+
/>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Leading visual for an option: a photo thumbnail (FK relations with an image),
|
|
59
|
+
* else a declared icon, else a color dot (enum/status options with a color).
|
|
60
|
+
* Returns null when the option carries none, so plain text options stay plain.
|
|
61
|
+
*/
|
|
62
|
+
export function OptionLead({
|
|
63
|
+
option,
|
|
64
|
+
size = 20,
|
|
65
|
+
}: {
|
|
66
|
+
option?: Pick<ResolvedOption, 'image' | 'color' | 'icon' | 'label'> | null
|
|
67
|
+
size?: number
|
|
68
|
+
}) {
|
|
69
|
+
if (!option) return null
|
|
70
|
+
if (option.image) return <OptionThumb image={option.image} name={option.label} size={size} />
|
|
71
|
+
if (option.icon) {
|
|
72
|
+
return (
|
|
73
|
+
<span
|
|
74
|
+
className="flex shrink-0 items-center justify-center"
|
|
75
|
+
style={{ width: size, height: size, color: option.color ? resolveColorCss(option.color) : undefined }}
|
|
76
|
+
aria-hidden
|
|
77
|
+
>
|
|
78
|
+
<DynamicIcon name={option.icon} className="size-4" />
|
|
79
|
+
</span>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
if (option.color) {
|
|
83
|
+
return (
|
|
84
|
+
<span
|
|
85
|
+
className="shrink-0 rounded-full"
|
|
86
|
+
style={{ width: Math.round(size * 0.5), height: Math.round(size * 0.5), background: resolveColorCss(option.color) }}
|
|
87
|
+
aria-hidden
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
// No image/icon/color: an imageless reference option. Show its initials
|
|
92
|
+
// (shared InitialsAvatar) so it stays visually aligned with the sibling
|
|
93
|
+
// options that DO carry a thumbnail, rather than a blank gap.
|
|
94
|
+
if (option.label) return <InitialsAvatar name={option.label} size={size} rounded="sm" tone="neutral" />
|
|
95
|
+
return null
|
|
96
|
+
}
|