@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.
- package/CHANGELOG.md +66 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +206 -18
- package/dist/dynamic-form-schema.d.ts +9 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +29 -0
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +42 -11
- package/dist/types.d.ts +9 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/dynamic-form.test.ts +28 -0
- package/src/dynamic-columns.tsx +374 -39
- package/src/dynamic-form-schema.ts +29 -0
- package/src/dynamic-select-field.tsx +77 -16
- package/src/types.ts +35 -1
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 (!
|
|
149
|
+
if (!fieldRef || typeof window === 'undefined') return
|
|
97
150
|
window.dispatchEvent(
|
|
98
151
|
new CustomEvent('metacore:create-record', {
|
|
99
152
|
detail: {
|
|
100
|
-
model:
|
|
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=
|
|
131
|
-
{
|
|
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
|
-
|
|
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
|
-
{
|
|
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 ??
|
|
197
|
-
aria-label={`Crear ${field.label ??
|
|
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:
|
|
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
|