@asteby/metacore-runtime-react 49.5.0 → 49.7.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/business/customer-picker.d.ts +8 -2
  3. package/dist/business/customer-picker.d.ts.map +1 -1
  4. package/dist/business/customer-picker.js +30 -27
  5. package/dist/business/index.d.ts +1 -0
  6. package/dist/business/index.d.ts.map +1 -1
  7. package/dist/business/index.js +1 -0
  8. package/dist/business/line-product-cell.d.ts +8 -10
  9. package/dist/business/line-product-cell.d.ts.map +1 -1
  10. package/dist/business/line-product-cell.js +13 -90
  11. package/dist/business/product-options.d.ts +41 -0
  12. package/dist/business/product-options.d.ts.map +1 -0
  13. package/dist/business/product-options.js +58 -0
  14. package/dist/business/product-picker.d.ts +5 -1
  15. package/dist/business/product-picker.d.ts.map +1 -1
  16. package/dist/business/product-picker.js +26 -35
  17. package/dist/business/relate-documents.d.ts +1 -1
  18. package/dist/business/relate-documents.d.ts.map +1 -1
  19. package/dist/business/relate-documents.js +6 -8
  20. package/dist/business/vehicle-picker.d.ts +8 -2
  21. package/dist/business/vehicle-picker.d.ts.map +1 -1
  22. package/dist/business/vehicle-picker.js +28 -28
  23. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  24. package/dist/dialogs/dynamic-record.js +28 -43
  25. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  26. package/dist/dynamic-multi-select-field.js +36 -6
  27. package/dist/dynamic-select-field.d.ts +9 -23
  28. package/dist/dynamic-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-select-field.js +105 -172
  30. package/dist/entity-select.d.ts +5 -2
  31. package/dist/entity-select.d.ts.map +1 -1
  32. package/dist/entity-select.js +23 -116
  33. package/dist/index.d.ts +9 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +10 -1
  36. package/dist/query-batch.d.ts.map +1 -1
  37. package/dist/query-batch.js +4 -0
  38. package/dist/record-picker-actions.d.ts +40 -2
  39. package/dist/record-picker-actions.d.ts.map +1 -1
  40. package/dist/record-picker-actions.js +44 -4
  41. package/dist/record-picker-dialog.d.ts +23 -0
  42. package/dist/record-picker-dialog.d.ts.map +1 -0
  43. package/dist/record-picker-dialog.js +37 -0
  44. package/dist/record-picker-option.d.ts +25 -0
  45. package/dist/record-picker-option.d.ts.map +1 -0
  46. package/dist/record-picker-option.js +55 -0
  47. package/dist/record-picker.d.ts +149 -0
  48. package/dist/record-picker.d.ts.map +1 -0
  49. package/dist/record-picker.js +372 -0
  50. package/dist/use-option-ids.d.ts +34 -0
  51. package/dist/use-option-ids.d.ts.map +1 -0
  52. package/dist/use-option-ids.js +138 -0
  53. package/dist/use-options-resolver.d.ts +4 -0
  54. package/dist/use-options-resolver.d.ts.map +1 -1
  55. package/dist/use-options-resolver.js +10 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/business-pickers.test.tsx +6 -3
  58. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  59. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  60. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  61. package/src/__tests__/edit-field-polish.test.tsx +6 -3
  62. package/src/__tests__/option-ids.test.tsx +181 -0
  63. package/src/__tests__/record-picker.test.tsx +469 -0
  64. package/src/business/customer-picker.tsx +87 -98
  65. package/src/business/index.ts +1 -0
  66. package/src/business/line-product-cell.tsx +55 -180
  67. package/src/business/product-options.tsx +119 -0
  68. package/src/business/product-picker.tsx +63 -126
  69. package/src/business/relate-documents.tsx +36 -45
  70. package/src/business/vehicle-picker.tsx +76 -99
  71. package/src/dialogs/dynamic-record.tsx +44 -97
  72. package/src/dynamic-multi-select-field.tsx +53 -15
  73. package/src/dynamic-select-field.tsx +188 -379
  74. package/src/entity-select.tsx +53 -202
  75. package/src/index.ts +35 -2
  76. package/src/query-batch.ts +3 -0
  77. package/src/record-picker-actions.tsx +101 -8
  78. package/src/record-picker-dialog.tsx +80 -0
  79. package/src/record-picker-option.tsx +96 -0
  80. package/src/record-picker.tsx +971 -0
  81. package/src/use-option-ids.ts +168 -0
  82. package/src/use-options-resolver.ts +11 -1
  83. package/dist/business/use-async-search.d.ts +0 -10
  84. package/dist/business/use-async-search.d.ts.map +0 -1
  85. package/dist/business/use-async-search.js +0 -38
  86. package/src/business/use-async-search.ts +0 -43
@@ -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 { useCallback, useEffect, useState } from 'react'
18
- import { Search, X, type LucideIcon } from 'lucide-react'
19
- import {
20
- Button,
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: 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
- const run = useCallback(
136
- async (q: string, signal: AbortSignal) => {
137
- if (q.length < minChars) {
138
- setResults([])
139
- return
140
- }
141
- setIsLoading(true)
142
- try {
143
- const rows = await fetcher(q, signal)
144
- if (!signal.aborted) setResults(rows)
145
- } catch {
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
- [fetcher, minChars],
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
- <div className="flex items-center" data-slot="entity-select">
207
- <Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
208
- <PopoverTrigger asChild>
209
- <Button
210
- variant="outline"
211
- disabled={disabled}
212
- className={'w-full min-w-0 flex-1 justify-start gap-2 font-normal' + (joined ? ` ${JOINED_TRIGGER_CLASS}` : '')}
213
- >
214
- {Icon && <Icon className="text-muted-foreground size-4 shrink-0" />}
215
- <span className="flex-1 truncate text-left">{label ?? placeholder}</span>
216
- {value && (
217
- <span
218
- role="button"
219
- tabIndex={0}
220
- onClick={clear}
221
- onKeyDown={(e) => {
222
- if (e.key === 'Enter' || e.key === ' ')
223
- clear(e as unknown as React.MouseEvent)
224
- }}
225
- className="hover:bg-accent ml-auto shrink-0 rounded-sm p-0.5"
226
- >
227
- <X className="size-3.5" />
228
- </span>
229
- )}
230
- </Button>
231
- </PopoverTrigger>
232
- <PopoverContent
233
- className="p-0"
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
- export { RecordPickerAction, PickerCreateItem, JOINED_TRIGGER_CLASS, hasRecordPickerAction } from './record-picker-actions'
479
- export type { RecordPickerActionProps } from './record-picker-actions'
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,
@@ -563,6 +588,14 @@ export {
563
588
  type UseOptionsResolverArgs,
564
589
  type UseOptionsResolverResult,
565
590
  } from './use-options-resolver'
591
+ export {
592
+ useResolveOptionIds,
593
+ OPTION_IDS_CHUNK,
594
+ type OptionIdResolution,
595
+ type UseResolveOptionIdsArgs,
596
+ type UseResolveOptionIdsResult,
597
+ } from './use-option-ids'
598
+ export { DELETED_RECORD_LABEL } from './dynamic-select-field'
566
599
  export {
567
600
  setOrgConfigBridge,
568
601
  getOrgConfigBridge,
@@ -375,6 +375,9 @@ export function tokenForGet(url: string, params?: Record<string, unknown>): stri
375
375
  if (value != null && value !== '') search.set(key, String(value))
376
376
  }
377
377
  }
378
+ // An `?ids=` resolve is not a page: the `o:` token has no slot for it, so
379
+ // it goes as its own GET instead of being turned into a first-page read.
380
+ if (search.has('ids')) return null
378
381
  const limit = Number(search.get('limit'))
379
382
  return optionsBatchToken(
380
383
  opt[1],
@@ -1,16 +1,21 @@
1
- // RecordPickerActions — create/edit affordances shared by every "pick a related
2
- // record" control (DynamicSelectField for declarative forms, EntitySelect for
3
- // hand-written screens). ONE implementation of the same contract:
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({ hasValue, label, onCreate, onEdit, disabled }: RecordPickerActionProps) {
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="-ml-px size-9 shrink-0 rounded-l-none text-muted-foreground hover:text-foreground focus-visible:z-10"
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
- /** "Crear …" row at the foot of a Command list. */
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
+ }