@asteby/metacore-runtime-react 13.8.5 → 13.10.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.
@@ -34,10 +34,48 @@ import {
34
34
  PopoverContent,
35
35
  PopoverTrigger,
36
36
  } from '@asteby/metacore-ui/primitives'
37
- import { Check, ChevronsUpDown, Loader2, Plus } from 'lucide-react'
37
+ import { Check, ChevronsUpDown, ImageIcon, Loader2, Plus } from 'lucide-react'
38
38
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
39
+ import { getFieldRef } from './dynamic-form-schema'
39
40
  import type { ActionFieldDef } from './types'
40
41
 
42
+ /**
43
+ * Small square thumbnail for an option's `image`. Falls back to a neutral
44
+ * placeholder icon when the option has no image so rows/triggers stay aligned.
45
+ * `size` is in pixels (kept small — 20–24px — so the picker reads as a list,
46
+ * not a gallery). Inline style for the box dimensions: arbitrary Tailwind
47
+ * classes from a federated addon don't always survive the host's class scan.
48
+ */
49
+ function OptionThumb({ image, size = 20 }: { image?: string | null; size?: number }) {
50
+ const box = { width: size, height: size }
51
+ if (!image) {
52
+ return (
53
+ <span
54
+ className="text-muted-foreground bg-muted flex shrink-0 items-center justify-center rounded-sm"
55
+ style={box}
56
+ aria-hidden
57
+ >
58
+ <ImageIcon className="size-3 opacity-60" />
59
+ </span>
60
+ )
61
+ }
62
+ return (
63
+ <img
64
+ src={image}
65
+ alt=""
66
+ aria-hidden
67
+ loading="lazy"
68
+ className="shrink-0 rounded-sm object-cover"
69
+ style={box}
70
+ // A broken image url shouldn't leave a torn-icon glyph; collapse to
71
+ // the neutral placeholder background instead.
72
+ onError={(e) => {
73
+ e.currentTarget.style.visibility = 'hidden'
74
+ }}
75
+ />
76
+ )
77
+ }
78
+
41
79
  function useDebounced<T>(value: T, ms: number): T {
42
80
  const [debounced, setDebounced] = useState(value)
43
81
  useEffect(() => {
@@ -61,13 +99,17 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
61
99
  // shows a name (not a UUID) without a round-trip.
62
100
  const [picked, setPicked] = useState<ResolvedOption | null>(null)
63
101
 
102
+ // Tolerate the snake_case `source`/`relation` aliases the kernel may serve
103
+ // for the FK target, not just camelCase `ref`.
104
+ const fieldRef = getFieldRef(field)
105
+
64
106
  const { options, loading } = useOptionsResolver({
65
107
  modelKey: '',
66
108
  fieldKey: 'id',
67
- ref: field.ref,
109
+ ref: fieldRef,
68
110
  // searchEndpoint only drives the URL when there's no ref — ref is the
69
111
  // canonical, kernel-derived path and wins.
70
- endpoint: field.ref ? undefined : field.searchEndpoint,
112
+ endpoint: fieldRef ? undefined : field.searchEndpoint,
71
113
  query: debounced,
72
114
  limit: 20,
73
115
  // Don't fetch until the popover opens (and keep fetching as the query
@@ -75,10 +117,21 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
75
117
  enabled: open,
76
118
  })
77
119
 
78
- const selectedLabel =
79
- (picked && String(picked.id) === String(value) ? picked.label : null) ??
80
- options.find((o) => String(o.id) === String(value))?.label ??
81
- (value ? String(value) : '')
120
+ // The currently-selected option, resolved either from what the user picked
121
+ // (cached in `picked`) or from the loaded page. Drives both the trigger
122
+ // label and its thumbnail.
123
+ const selectedOption =
124
+ (picked && String(picked.id) === String(value) ? picked : null) ??
125
+ options.find((o) => String(o.id) === String(value)) ??
126
+ null
127
+
128
+ const selectedLabel = selectedOption?.label ?? (value ? String(value) : '')
129
+
130
+ // Only switch the picker into "with thumbnails" mode when the data actually
131
+ // carries images — a relation whose options have no `image` keeps the plain
132
+ // text list it had before (no empty placeholder column).
133
+ const hasImages =
134
+ !!selectedOption?.image || options.some((o) => !!o.image)
82
135
 
83
136
  const handlePick = (opt: ResolvedOption) => {
84
137
  setPicked(opt)
@@ -93,11 +146,11 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
93
146
  // hands back the new record and we select it immediately. No host import →
94
147
  // no circular dependency; works for ANY dynamic_select with a `ref`.
95
148
  const openCreate = () => {
96
- if (!field.ref || typeof window === 'undefined') return
149
+ if (!fieldRef || typeof window === 'undefined') return
97
150
  window.dispatchEvent(
98
151
  new CustomEvent('metacore:create-record', {
99
152
  detail: {
100
- model: field.ref,
153
+ model: fieldRef,
101
154
  onCreated: (rec: any) => {
102
155
  if (rec && rec.id != null) {
103
156
  const id = String(rec.id)
@@ -127,8 +180,13 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
127
180
  className="min-w-0 flex-1 justify-between font-normal"
128
181
  data-empty={!value}
129
182
  >
130
- <span className={'min-w-0 flex-1 truncate text-left ' + (selectedLabel ? '' : 'text-muted-foreground')}>
131
- {selectedLabel || field.placeholder || 'Buscar…'}
183
+ <span className="flex min-w-0 flex-1 items-center gap-2 text-left">
184
+ {hasImages && value ? (
185
+ <OptionThumb image={selectedOption?.image} size={20} />
186
+ ) : null}
187
+ <span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
188
+ {selectedLabel || field.placeholder || 'Buscar…'}
189
+ </span>
132
190
  </span>
133
191
  <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
134
192
  </Button>
@@ -168,8 +226,11 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
168
226
  value={String(opt.id)}
169
227
  onSelect={() => handlePick(opt)}
170
228
  >
171
- <Check className={'mr-2 size-4 ' + (isSel ? 'opacity-100' : 'opacity-0')} />
172
- <div className="flex min-w-0 flex-col">
229
+ <Check className={'mr-2 size-4 shrink-0 ' + (isSel ? 'opacity-100' : 'opacity-0')} />
230
+ {hasImages && (
231
+ <OptionThumb image={opt.image} size={24} />
232
+ )}
233
+ <div className="ml-2 flex min-w-0 flex-col">
173
234
  <span className="truncate">{opt.label}</span>
174
235
  {opt.description && (
175
236
  <span className="text-muted-foreground truncate text-xs">
@@ -186,15 +247,15 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
186
247
  </Command>
187
248
  </PopoverContent>
188
249
  </Popover>
189
- {field.ref && (
250
+ {fieldRef && (
190
251
  <Button
191
252
  type="button"
192
253
  variant="outline"
193
254
  size="icon"
194
255
  className="size-9 shrink-0"
195
256
  onClick={openCreate}
196
- title={`Crear ${field.label ?? field.ref}`}
197
- aria-label={`Crear ${field.label ?? field.ref}`}
257
+ title={`Crear ${field.label ?? fieldRef}`}
258
+ aria-label={`Crear ${field.label ?? fieldRef}`}
198
259
  >
199
260
  <Plus className="size-4" />
200
261
  </Button>
package/src/types.ts CHANGED
@@ -77,7 +77,33 @@ export type ColumnVisibility = 'all' | 'table' | 'modal' | 'list' | (string & {}
77
77
  export interface ColumnDefinition {
78
78
  key: string
79
79
  label: string
80
- type: 'text' | 'number' | 'date' | 'select' | 'search' | 'relation-badge-list' | 'avatar' | 'boolean' | 'phone' | 'media-gallery' | 'image'
80
+ type:
81
+ | 'text'
82
+ | 'number'
83
+ | 'date'
84
+ | 'select'
85
+ | 'search'
86
+ | 'relation-badge-list'
87
+ | 'avatar'
88
+ | 'boolean'
89
+ | 'phone'
90
+ | 'media-gallery'
91
+ | 'image'
92
+ // Declarative pro cell renderers (resolved via `cellStyle ?? type`).
93
+ | 'url'
94
+ | 'link'
95
+ | 'email'
96
+ | 'currency'
97
+ | 'percent'
98
+ | 'progress'
99
+ | 'badge'
100
+ | 'status'
101
+ | 'tags'
102
+ | 'color'
103
+ | 'code'
104
+ | 'truncate-text'
105
+ | 'creator'
106
+ | 'user'
81
107
  sortable: boolean
82
108
  filterable: boolean
83
109
  hidden?: boolean
@@ -173,6 +199,14 @@ export interface ActionFieldDef {
173
199
  * `useOptionsResolver` against `/api/options/<ref>?field=id`.
174
200
  */
175
201
  ref?: string
202
+ /**
203
+ * snake_case aliases the kernel manifest may serve for a belongs_to FK
204
+ * target instead of `ref`. Treated as equivalent to `ref` by the SDK so a
205
+ * declared relation renders a searchable picker regardless of which key the
206
+ * backend emits.
207
+ */
208
+ source?: string
209
+ relation?: string
176
210
  /**
177
211
  * Columns of a repeatable line-items group. Mirrors the kernel v3
178
212
  * `ActionField.item_fields` (json `item_fields`). Present on a field