@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
package/src/dynamic-form.tsx
CHANGED
|
@@ -129,6 +129,7 @@ export function DynamicForm({
|
|
|
129
129
|
field={field}
|
|
130
130
|
value={values[field.key]}
|
|
131
131
|
onChange={(v: any) => update(field.key, v)}
|
|
132
|
+
initialValues={initialValues}
|
|
132
133
|
/>
|
|
133
134
|
{errors[field.key] && (
|
|
134
135
|
<span className="text-red-500 text-sm" role="alert">{errors[field.key]}</span>
|
|
@@ -155,9 +156,38 @@ interface FieldRendererProps {
|
|
|
155
156
|
field: ActionFieldDef
|
|
156
157
|
value: any
|
|
157
158
|
onChange: (v: any) => void
|
|
159
|
+
/** The form's initial record — used to seed an FK picker's existing label/image. */
|
|
160
|
+
initialValues?: Record<string, any>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// seedOptionFromSibling builds a pre-resolved option for an FK field from the
|
|
164
|
+
// resolved sibling the backend served on the initial record (e.g. a line item's
|
|
165
|
+
// `product = { value, label, image }` alongside `product_id`). Lets the picker
|
|
166
|
+
// show the name + thumbnail for an existing value without a lookup. Returns
|
|
167
|
+
// undefined when the sibling carries nothing renderable.
|
|
168
|
+
function seedOptionFromSibling(
|
|
169
|
+
field: ActionFieldDef,
|
|
170
|
+
value: any,
|
|
171
|
+
initialValues?: Record<string, any>,
|
|
172
|
+
): ResolvedOption | undefined {
|
|
173
|
+
if (!field.key.endsWith('_id')) return undefined
|
|
174
|
+
const sib = initialValues?.[field.key.replace(/_id$/, '')]
|
|
175
|
+
if (!sib || typeof sib !== 'object') return undefined
|
|
176
|
+
const label = sib.label ?? sib.name ?? ''
|
|
177
|
+
if (!label && !sib.image) return undefined
|
|
178
|
+
const id = String(sib.value ?? sib.id ?? value ?? '')
|
|
179
|
+
return {
|
|
180
|
+
id,
|
|
181
|
+
value: id,
|
|
182
|
+
label: String(label),
|
|
183
|
+
name: String(label),
|
|
184
|
+
image: sib.image,
|
|
185
|
+
color: sib.color,
|
|
186
|
+
icon: sib.icon,
|
|
187
|
+
}
|
|
158
188
|
}
|
|
159
189
|
|
|
160
|
-
function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
190
|
+
function FieldRenderer({ field, value, onChange, initialValues }: FieldRendererProps) {
|
|
161
191
|
// Repeatable line-items group → render the row grid. Its value is an array
|
|
162
192
|
// of row objects rather than a scalar.
|
|
163
193
|
if (isLineItemsField(field)) {
|
|
@@ -168,7 +198,8 @@ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
|
168
198
|
// Preferred for FK fields with large option sets — no UUID typing, no
|
|
169
199
|
// dumping every row into a plain <select>.
|
|
170
200
|
if (widget === 'dynamic_select') {
|
|
171
|
-
|
|
201
|
+
const seedOption = seedOptionFromSibling(field, value, initialValues)
|
|
202
|
+
return <DynamicSelectField field={field} value={value} onChange={onChange} seedOption={seedOption} />
|
|
172
203
|
}
|
|
173
204
|
// File upload → themed picker that POSTs to the host upload endpoint and
|
|
174
205
|
// stores the returned file url/path as the field value.
|
|
@@ -20,6 +20,20 @@ function isEnumLikeColumn(col: ColumnDefinition): boolean {
|
|
|
20
20
|
|
|
21
21
|
export type DynamicRelationKind = 'one_to_many' | 'many_to_many'
|
|
22
22
|
|
|
23
|
+
// Server-managed / audit columns that must never become editable form inputs.
|
|
24
|
+
// They're set by the backend and several ship as resolved objects (e.g.
|
|
25
|
+
// `created_by = { name, avatar, email }`) that would render as `[object Object]`.
|
|
26
|
+
const MANAGED_RELATION_COLUMNS = new Set([
|
|
27
|
+
'id',
|
|
28
|
+
'created_at',
|
|
29
|
+
'updated_at',
|
|
30
|
+
'deleted_at',
|
|
31
|
+
'created_by',
|
|
32
|
+
'created_by_id',
|
|
33
|
+
'updated_by',
|
|
34
|
+
'updated_by_id',
|
|
35
|
+
])
|
|
36
|
+
|
|
23
37
|
// Pulls a human label off a resolved relation/user object a backend serves:
|
|
24
38
|
// `{ value, label }` (FK sibling), `{ name, … }` (user object such as
|
|
25
39
|
// created_by) or `{ title }`. Returns undefined for plain/empty objects so the
|
|
@@ -146,6 +160,10 @@ export function deriveRelationFormFields(
|
|
|
146
160
|
for (const col of metadata.columns) {
|
|
147
161
|
if (col.key === foreignKey) continue
|
|
148
162
|
if (col.hidden) continue
|
|
163
|
+
// Managed/audit columns are server-owned and ship resolved objects
|
|
164
|
+
// (`created_by = { name, avatar, … }`); making them editable inputs
|
|
165
|
+
// renders `[object Object]`. Never surface them in the inline form.
|
|
166
|
+
if (MANAGED_RELATION_COLUMNS.has(col.key.toLowerCase())) continue
|
|
149
167
|
out.push({
|
|
150
168
|
key: col.key,
|
|
151
169
|
label: col.label,
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -25,6 +25,8 @@ import { Plus, Trash2, Pencil } from 'lucide-react'
|
|
|
25
25
|
import { useApi } from './api-context'
|
|
26
26
|
import { useMetadataCache } from './metadata-cache'
|
|
27
27
|
import { DynamicForm } from './dynamic-form'
|
|
28
|
+
import { useImageUrl } from './image-url-context'
|
|
29
|
+
import { OptionThumb } from './dynamic-select-field'
|
|
28
30
|
import { useOptionsResolver } from './use-options-resolver'
|
|
29
31
|
import type { ApiResponse, TableMetadata } from './types'
|
|
30
32
|
import {
|
|
@@ -169,6 +171,7 @@ function OneToManyRelation({
|
|
|
169
171
|
onChange,
|
|
170
172
|
}: DynamicRelationOneToManyProps) {
|
|
171
173
|
const api = useApi()
|
|
174
|
+
const getImageUrl = useImageUrl()
|
|
172
175
|
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
|
|
173
176
|
const cachedMeta = getMetadata(model)
|
|
174
177
|
const labels = { ...DEFAULT_STRINGS, ...(strings || {}) }
|
|
@@ -305,6 +308,22 @@ function OneToManyRelation({
|
|
|
305
308
|
<div className="flex-1 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-2 text-sm">
|
|
306
309
|
{visibleColumns.map(col => {
|
|
307
310
|
const cell = formatRelationCell(row, col)
|
|
311
|
+
// FK column whose backend-resolved sibling
|
|
312
|
+
// carries an image → render a thumbnail + label
|
|
313
|
+
// instead of plain text (e.g. a line item's
|
|
314
|
+
// product photo). The sibling is the column key
|
|
315
|
+
// with the trailing `_id` stripped.
|
|
316
|
+
const isFk = !!col.ref || col.key.endsWith('_id')
|
|
317
|
+
const sibling = isFk ? (row as any)[col.key.replace(/_id$/, '')] : undefined
|
|
318
|
+
if (sibling && typeof sibling === 'object' && sibling.image) {
|
|
319
|
+
const label = sibling.label ?? sibling.name ?? cell
|
|
320
|
+
return (
|
|
321
|
+
<span key={col.key} className="flex min-w-0 items-center gap-2" title={String(label)}>
|
|
322
|
+
<OptionThumb image={getImageUrl(sibling.image)} size={20} />
|
|
323
|
+
<span className="truncate">{label}</span>
|
|
324
|
+
</span>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
308
327
|
return (
|
|
309
328
|
<span key={col.key} className="truncate" title={cell}>
|
|
310
329
|
{cell}
|
|
@@ -48,7 +48,7 @@ import type { ActionFieldDef } from './types'
|
|
|
48
48
|
* not a gallery). Inline style for the box dimensions: arbitrary Tailwind
|
|
49
49
|
* classes from a federated addon don't always survive the host's class scan.
|
|
50
50
|
*/
|
|
51
|
-
function OptionThumb({ image, size = 20 }: { image?: string | null; size?: number }) {
|
|
51
|
+
export function OptionThumb({ image, size = 20 }: { image?: string | null; size?: number }) {
|
|
52
52
|
const box = { width: size, height: size }
|
|
53
53
|
if (!image) {
|
|
54
54
|
return (
|
|
@@ -83,7 +83,7 @@ function OptionThumb({ image, size = 20 }: { image?: string | null; size?: numbe
|
|
|
83
83
|
* else a declared icon, else a color dot (enum/status options with a color).
|
|
84
84
|
* Returns null when the option carries none, so plain text options stay plain.
|
|
85
85
|
*/
|
|
86
|
-
function OptionLead({
|
|
86
|
+
export function OptionLead({
|
|
87
87
|
option,
|
|
88
88
|
size = 20,
|
|
89
89
|
}: {
|
|
@@ -138,9 +138,16 @@ export interface DynamicSelectFieldProps {
|
|
|
138
138
|
field: ActionFieldDef
|
|
139
139
|
value: any
|
|
140
140
|
onChange: (v: any) => void
|
|
141
|
+
/**
|
|
142
|
+
* Pre-resolved option for the CURRENT value (label + image/color/icon) the
|
|
143
|
+
* caller already has — e.g. the relation sibling the table served. Lets the
|
|
144
|
+
* trigger show the name + thumbnail for an existing value without waiting for
|
|
145
|
+
* a lookup (which only loads once the popover opens). Matched by id == value.
|
|
146
|
+
*/
|
|
147
|
+
seedOption?: ResolvedOption | null
|
|
141
148
|
}
|
|
142
149
|
|
|
143
|
-
export function DynamicSelectField({ field, value, onChange }: DynamicSelectFieldProps) {
|
|
150
|
+
export function DynamicSelectField({ field, value, onChange, seedOption }: DynamicSelectFieldProps) {
|
|
144
151
|
const [open, setOpen] = useState(false)
|
|
145
152
|
const [search, setSearch] = useState('')
|
|
146
153
|
const debounced = useDebounced(search, 250)
|
|
@@ -172,6 +179,7 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
|
|
|
172
179
|
const selectedOption =
|
|
173
180
|
(picked && String(picked.id) === String(value) ? picked : null) ??
|
|
174
181
|
options.find((o) => String(o.id) === String(value)) ??
|
|
182
|
+
(seedOption && String(seedOption.id) === String(value) ? seedOption : null) ??
|
|
175
183
|
null
|
|
176
184
|
|
|
177
185
|
const selectedLabel = selectedOption?.label ?? (value ? String(value) : '')
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Image-url resolver context — its own module so any renderer (record dialog,
|
|
2
|
+
// relation cells, …) can consume the host's storage-path → URL resolver without
|
|
3
|
+
// importing from `dialogs/dynamic-record`. That dialog imports
|
|
4
|
+
// `dynamic-relations` (which renders `dynamic-relation`), so the relation cell
|
|
5
|
+
// cannot import the context back from the dialog without a circular import —
|
|
6
|
+
// hence this standalone module is the single source of truth.
|
|
7
|
+
import { createContext, useContext } from 'react'
|
|
8
|
+
|
|
9
|
+
/** Resolves a (possibly relative) storage path into a fetchable URL. */
|
|
10
|
+
export type GetImageUrl = (path: string | null | undefined) => string
|
|
11
|
+
|
|
12
|
+
/** Default resolver: pass the path through unchanged (works same-origin). */
|
|
13
|
+
export const identityImageUrl: GetImageUrl = (p) => p ?? ''
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Threads the host's image-url resolver to nested field/cell components without
|
|
17
|
+
* prop-drilling. Provided by `DynamicRecordDialog`; consumers outside a provider
|
|
18
|
+
* fall back to `identityImageUrl` (the relative path, which renders same-origin).
|
|
19
|
+
*/
|
|
20
|
+
export const ImageUrlContext = createContext<GetImageUrl>(identityImageUrl)
|
|
21
|
+
|
|
22
|
+
/** Reads the nearest image-url resolver (identity outside a provider). */
|
|
23
|
+
export const useImageUrl = () => useContext(ImageUrlContext)
|
package/src/index.ts
CHANGED
|
@@ -68,7 +68,8 @@ export {
|
|
|
68
68
|
} from './dynamic-columns'
|
|
69
69
|
export { humanizeToken } from './dynamic-columns-helpers'
|
|
70
70
|
export { NIL_UUID, isNilUuid, normalizeNilUuid } from './nil-uuid'
|
|
71
|
-
export { DynamicRecordDialog } from './dialogs/dynamic-record'
|
|
71
|
+
export { DynamicRecordDialog, ViewValue } from './dialogs/dynamic-record'
|
|
72
|
+
export type { DynamicRecordDialogProps, FieldDef, FieldOption, GetImageUrl } from './dialogs/dynamic-record'
|
|
72
73
|
export { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
73
74
|
export { ViewRecordDialog } from './dialogs/view-record-dialog'
|
|
74
75
|
export type {
|