@asteby/metacore-runtime-react 18.17.3 → 18.19.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 +49 -0
- package/dist/action-modal-dispatcher.js +16 -5
- package/dist/collection-cell.d.ts +22 -0
- package/dist/collection-cell.d.ts.map +1 -0
- package/dist/collection-cell.js +141 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +2 -1
- package/dist/dynamic-form-schema.d.ts +41 -1
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +61 -0
- package/dist/dynamic-line-items.d.ts +7 -1
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +46 -12
- package/dist/dynamic-select-field.d.ts +17 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +48 -11
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/types.d.ts +48 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +9 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +7 -2
- package/package.json +1 -1
- package/src/__tests__/collection-cell.test.tsx +115 -0
- package/src/__tests__/dependent-options.test.tsx +337 -0
- package/src/action-modal-dispatcher.tsx +15 -4
- package/src/collection-cell.tsx +277 -0
- package/src/dynamic-columns.tsx +2 -5
- package/src/dynamic-form-schema.ts +72 -1
- package/src/dynamic-line-items.tsx +86 -13
- package/src/dynamic-select-field.tsx +69 -12
- package/src/index.ts +13 -1
- package/src/types.ts +49 -0
- package/src/use-options-resolver.ts +15 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Dependent (cascading) options contract:
|
|
4
|
+
// - resolveDependsValue (pure): a cell `dependsOn` resolves from the row
|
|
5
|
+
// (sibling) first, then the header form values; empty/unset → ''.
|
|
6
|
+
// - useOptionsResolver: a `filterValue` is forwarded as `&filter_value=` and
|
|
7
|
+
// re-fetches when it changes; an empty value omits the param.
|
|
8
|
+
// - DynamicLineItems → cell with `dependsOn`: the picker is disabled while the
|
|
9
|
+
// header field is empty, and once the header field has a value the options
|
|
10
|
+
// request carries that value as `filter_value` and re-fetches on change.
|
|
11
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
13
|
+
|
|
14
|
+
// Identity translator so any raw i18n keys surface verbatim.
|
|
15
|
+
vi.mock('react-i18next', () => ({
|
|
16
|
+
useTranslation: () => ({ t: (k: string) => k }),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
resolveDependsValue,
|
|
21
|
+
getDependsOn,
|
|
22
|
+
getOptionsConfig,
|
|
23
|
+
resolveOptionsSource,
|
|
24
|
+
} from '../dynamic-form-schema'
|
|
25
|
+
import { useOptionsResolver } from '../use-options-resolver'
|
|
26
|
+
import { DynamicLineItems } from '../dynamic-line-items'
|
|
27
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
28
|
+
import type { ActionFieldDef } from '../types'
|
|
29
|
+
import { useState } from 'react'
|
|
30
|
+
|
|
31
|
+
afterEach(cleanup)
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Pure resolution
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
describe('getDependsOn / resolveDependsValue', () => {
|
|
37
|
+
const field = (over: Partial<ActionFieldDef>): ActionFieldDef =>
|
|
38
|
+
({ key: 'product_id', label: 'Producto', type: 'dynamic_select', ...over })
|
|
39
|
+
|
|
40
|
+
it('reads camelCase dependsOn and snake_case depends_on', () => {
|
|
41
|
+
expect(getDependsOn(field({ dependsOn: 'wh' }))).toBe('wh')
|
|
42
|
+
expect(getDependsOn(field({ depends_on: 'wh' }))).toBe('wh')
|
|
43
|
+
expect(getDependsOn(field({}))).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('resolves from header form values', () => {
|
|
47
|
+
const f = field({ depends_on: 'source_warehouse_id' })
|
|
48
|
+
expect(resolveDependsValue(f, { source_warehouse_id: 'W1' })).toBe('W1')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('prefers a sibling row value over the header', () => {
|
|
52
|
+
const f = field({ dependsOn: 'warehouse_id' })
|
|
53
|
+
const out = resolveDependsValue(f, { warehouse_id: 'HEADER' }, { warehouse_id: 'ROW' })
|
|
54
|
+
expect(out).toBe('ROW')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('falls back to header when the row value is blank', () => {
|
|
58
|
+
const f = field({ dependsOn: 'warehouse_id' })
|
|
59
|
+
const out = resolveDependsValue(f, { warehouse_id: 'HEADER' }, { warehouse_id: '' })
|
|
60
|
+
expect(out).toBe('HEADER')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns empty string when the dependency is unset', () => {
|
|
64
|
+
const f = field({ dependsOn: 'warehouse_id' })
|
|
65
|
+
expect(resolveDependsValue(f, {})).toBe('')
|
|
66
|
+
expect(resolveDependsValue(field({}), { a: 1 })).toBe('')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('getOptionsConfig / resolveOptionsSource', () => {
|
|
71
|
+
const field = (over: Partial<ActionFieldDef>): ActionFieldDef =>
|
|
72
|
+
({ key: 'product_id', label: 'Producto', type: 'dynamic_select', ...over })
|
|
73
|
+
|
|
74
|
+
it('reads camelCase optionsConfig and snake_case options_config', () => {
|
|
75
|
+
expect(getOptionsConfig(field({ optionsConfig: { source: 'stock' } }))?.source).toBe('stock')
|
|
76
|
+
expect(getOptionsConfig(field({ options_config: { source: 'stock' } }))?.source).toBe('stock')
|
|
77
|
+
expect(getOptionsConfig(field({}))).toBeUndefined()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('routes to the source model with field=value when optionsConfig.source is present', () => {
|
|
81
|
+
const f = field({
|
|
82
|
+
ref: 'products.Product',
|
|
83
|
+
options_config: { source: 'stock', filter_by: 'warehouse_id', value: 'product_id', description: 'quantity' },
|
|
84
|
+
})
|
|
85
|
+
const out = resolveOptionsSource(f)
|
|
86
|
+
expect(out.endpoint).toBe('/options/stock')
|
|
87
|
+
expect(out.fieldKey).toBe('product_id')
|
|
88
|
+
// source wins over ref — the ref pointer is not used for routing.
|
|
89
|
+
expect(out.ref).toBeUndefined()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('falls back to the field key when optionsConfig.value is absent', () => {
|
|
93
|
+
const f = field({ key: 'item_id', options_config: { source: 'stock' } })
|
|
94
|
+
const out = resolveOptionsSource(f)
|
|
95
|
+
expect(out.endpoint).toBe('/options/stock')
|
|
96
|
+
expect(out.fieldKey).toBe('item_id')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('keeps ref-based resolution when there is no optionsConfig.source', () => {
|
|
100
|
+
const f = field({ ref: 'products.Product' })
|
|
101
|
+
const out = resolveOptionsSource(f)
|
|
102
|
+
expect(out.endpoint).toBeUndefined()
|
|
103
|
+
expect(out.ref).toBe('products.Product')
|
|
104
|
+
expect(out.fieldKey).toBe('id')
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// useOptionsResolver — filter_value forwarding + re-fetch
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
function Harness({ filterValue }: { filterValue?: string }) {
|
|
112
|
+
const { options } = useOptionsResolver({
|
|
113
|
+
modelKey: '',
|
|
114
|
+
fieldKey: 'id',
|
|
115
|
+
ref: 'products.Product',
|
|
116
|
+
filterValue,
|
|
117
|
+
})
|
|
118
|
+
return <div data-testid="count">{options.length}</div>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('useOptionsResolver filter_value', () => {
|
|
122
|
+
it('forwards a non-empty filter_value and re-fetches on change', async () => {
|
|
123
|
+
const get = vi.fn(async () => ({
|
|
124
|
+
data: { success: true, data: [{ id: '1', label: 'One' }], meta: { type: 'dynamic', count: 1 } },
|
|
125
|
+
}))
|
|
126
|
+
const client = { get } as unknown as ApiClient
|
|
127
|
+
|
|
128
|
+
const { rerender } = render(
|
|
129
|
+
<ApiProvider client={client}>
|
|
130
|
+
<Harness filterValue="W1" />
|
|
131
|
+
</ApiProvider>,
|
|
132
|
+
)
|
|
133
|
+
await waitFor(() => expect(screen.getByTestId('count').textContent).toBe('1'))
|
|
134
|
+
|
|
135
|
+
// First call carried filter_value=W1.
|
|
136
|
+
const firstParams = get.mock.calls[0][1]?.params
|
|
137
|
+
expect(firstParams.filter_value).toBe('W1')
|
|
138
|
+
|
|
139
|
+
// Change the parent value → a fresh request with the new filter_value.
|
|
140
|
+
rerender(
|
|
141
|
+
<ApiProvider client={client}>
|
|
142
|
+
<Harness filterValue="W2" />
|
|
143
|
+
</ApiProvider>,
|
|
144
|
+
)
|
|
145
|
+
await waitFor(() => expect(get.mock.calls.length).toBeGreaterThan(1))
|
|
146
|
+
const lastParams = get.mock.calls[get.mock.calls.length - 1][1]?.params
|
|
147
|
+
expect(lastParams.filter_value).toBe('W2')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('omits filter_value when empty', async () => {
|
|
151
|
+
const get = vi.fn(async () => ({
|
|
152
|
+
data: { success: true, data: [], meta: { type: 'dynamic', count: 0 } },
|
|
153
|
+
}))
|
|
154
|
+
const client = { get } as unknown as ApiClient
|
|
155
|
+
render(
|
|
156
|
+
<ApiProvider client={client}>
|
|
157
|
+
<Harness filterValue="" />
|
|
158
|
+
</ApiProvider>,
|
|
159
|
+
)
|
|
160
|
+
await waitFor(() => expect(get).toHaveBeenCalled())
|
|
161
|
+
expect(get.mock.calls[0][1]?.params.filter_value).toBeUndefined()
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// DynamicLineItems — a cell that dependsOn a HEADER field
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
const lineItemsField: ActionFieldDef = {
|
|
169
|
+
key: 'items',
|
|
170
|
+
label: 'Renglones',
|
|
171
|
+
type: 'array',
|
|
172
|
+
itemFields: [
|
|
173
|
+
{
|
|
174
|
+
key: 'product_id',
|
|
175
|
+
label: 'Producto',
|
|
176
|
+
type: 'dynamic_select',
|
|
177
|
+
ref: 'products.Product',
|
|
178
|
+
// depends on the HEADER field, not a sibling row cell.
|
|
179
|
+
depends_on: 'source_warehouse_id',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Drives DynamicLineItems with one pre-existing row + a switchable header value.
|
|
185
|
+
function LineItemsHost({ headerValue }: { headerValue: string }) {
|
|
186
|
+
const [rows, setRows] = useState<any[]>([{ product_id: '' }])
|
|
187
|
+
return (
|
|
188
|
+
<DynamicLineItems
|
|
189
|
+
field={lineItemsField}
|
|
190
|
+
value={rows}
|
|
191
|
+
onChange={setRows}
|
|
192
|
+
formValues={{ source_warehouse_id: headerValue }}
|
|
193
|
+
/>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
describe('DynamicLineItems cascading cell', () => {
|
|
198
|
+
it('disables the picker while the header field is empty', () => {
|
|
199
|
+
const get = vi.fn(async () => ({
|
|
200
|
+
data: { success: true, data: [], meta: { type: 'dynamic', count: 0 } },
|
|
201
|
+
}))
|
|
202
|
+
const client = { get } as unknown as ApiClient
|
|
203
|
+
render(
|
|
204
|
+
<ApiProvider client={client}>
|
|
205
|
+
<LineItemsHost headerValue="" />
|
|
206
|
+
</ApiProvider>,
|
|
207
|
+
)
|
|
208
|
+
// The combobox trigger is disabled and shows the dependency hint.
|
|
209
|
+
const trigger = screen.getByRole('combobox')
|
|
210
|
+
expect((trigger as HTMLButtonElement).disabled).toBe(true)
|
|
211
|
+
expect(trigger.getAttribute('data-depends-blocked')).toBe('')
|
|
212
|
+
// No options request fires while blocked.
|
|
213
|
+
expect(get).not.toHaveBeenCalled()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('sends filter_value from the header field and re-fetches when it changes', async () => {
|
|
217
|
+
const get = vi.fn(async () => ({
|
|
218
|
+
data: {
|
|
219
|
+
success: true,
|
|
220
|
+
data: [{ id: 'p1', label: 'Tornillo', description: 'disp. 12' }],
|
|
221
|
+
meta: { type: 'dynamic', count: 1 },
|
|
222
|
+
},
|
|
223
|
+
}))
|
|
224
|
+
const client = { get } as unknown as ApiClient
|
|
225
|
+
|
|
226
|
+
const { rerender } = render(
|
|
227
|
+
<ApiProvider client={client}>
|
|
228
|
+
<LineItemsHost headerValue="W1" />
|
|
229
|
+
</ApiProvider>,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
// Open the popover so the typeahead fetches.
|
|
233
|
+
const trigger = screen.getByRole('combobox')
|
|
234
|
+
expect((trigger as HTMLButtonElement).disabled).toBe(false)
|
|
235
|
+
await act(async () => {
|
|
236
|
+
trigger.click()
|
|
237
|
+
})
|
|
238
|
+
await waitFor(() => expect(get).toHaveBeenCalled())
|
|
239
|
+
const firstParams = get.mock.calls[0][1]?.params
|
|
240
|
+
expect(firstParams.filter_value).toBe('W1')
|
|
241
|
+
|
|
242
|
+
// Switch the header warehouse → the cell picker re-fetches scoped to W2.
|
|
243
|
+
rerender(
|
|
244
|
+
<ApiProvider client={client}>
|
|
245
|
+
<LineItemsHost headerValue="W2" />
|
|
246
|
+
</ApiProvider>,
|
|
247
|
+
)
|
|
248
|
+
await waitFor(() => {
|
|
249
|
+
const last = get.mock.calls[get.mock.calls.length - 1][1]?.params
|
|
250
|
+
expect(last.filter_value).toBe('W2')
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// optionsConfig.source routing — the picker queries the SOURCE model, not `ref`
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
const sourceLineItemsField: ActionFieldDef = {
|
|
259
|
+
key: 'items',
|
|
260
|
+
label: 'Renglones',
|
|
261
|
+
type: 'array',
|
|
262
|
+
itemFields: [
|
|
263
|
+
{
|
|
264
|
+
key: 'product_id',
|
|
265
|
+
label: 'Producto',
|
|
266
|
+
type: 'dynamic_select',
|
|
267
|
+
// A `ref` is present but optionsConfig.source MUST win for routing.
|
|
268
|
+
ref: 'products.Product',
|
|
269
|
+
depends_on: 'source_warehouse_id',
|
|
270
|
+
options_config: {
|
|
271
|
+
type: 'dynamic',
|
|
272
|
+
source: 'stock',
|
|
273
|
+
filter_by: 'warehouse_id',
|
|
274
|
+
value: 'product_id',
|
|
275
|
+
label_ref: 'products.Product',
|
|
276
|
+
description: 'quantity',
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function SourceLineItemsHost({ headerValue }: { headerValue: string }) {
|
|
283
|
+
const [rows, setRows] = useState<any[]>([{ product_id: '' }])
|
|
284
|
+
return (
|
|
285
|
+
<DynamicLineItems
|
|
286
|
+
field={sourceLineItemsField}
|
|
287
|
+
value={rows}
|
|
288
|
+
onChange={setRows}
|
|
289
|
+
formValues={{ source_warehouse_id: headerValue }}
|
|
290
|
+
/>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
describe('DynamicLineItems cell with optionsConfig.source', () => {
|
|
295
|
+
it('queries /options/<source>?field=<value>&filter_value=<dependsOn> and re-fetches on parent change', async () => {
|
|
296
|
+
const get = vi.fn(async () => ({
|
|
297
|
+
data: {
|
|
298
|
+
success: true,
|
|
299
|
+
data: [{ id: 'p1', label: 'Tornillo', description: 'disp. 12' }],
|
|
300
|
+
meta: { type: 'dynamic', count: 1 },
|
|
301
|
+
},
|
|
302
|
+
}))
|
|
303
|
+
const client = { get } as unknown as ApiClient
|
|
304
|
+
|
|
305
|
+
const { rerender } = render(
|
|
306
|
+
<ApiProvider client={client}>
|
|
307
|
+
<SourceLineItemsHost headerValue="W1" />
|
|
308
|
+
</ApiProvider>,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
const trigger = screen.getByRole('combobox')
|
|
312
|
+
expect((trigger as HTMLButtonElement).disabled).toBe(false)
|
|
313
|
+
await act(async () => {
|
|
314
|
+
trigger.click()
|
|
315
|
+
})
|
|
316
|
+
await waitFor(() => expect(get).toHaveBeenCalled())
|
|
317
|
+
|
|
318
|
+
// URL hits the SOURCE model, not the `ref`.
|
|
319
|
+
const [firstUrl, firstCfg] = get.mock.calls[0]
|
|
320
|
+
expect(firstUrl).toBe('/options/stock')
|
|
321
|
+
expect(firstCfg?.params.field).toBe('product_id')
|
|
322
|
+
expect(firstCfg?.params.filter_value).toBe('W1')
|
|
323
|
+
|
|
324
|
+
// Parent change → re-fetch, still routed to the source, new filter_value.
|
|
325
|
+
rerender(
|
|
326
|
+
<ApiProvider client={client}>
|
|
327
|
+
<SourceLineItemsHost headerValue="W2" />
|
|
328
|
+
</ApiProvider>,
|
|
329
|
+
)
|
|
330
|
+
await waitFor(() => {
|
|
331
|
+
const [url, cfg] = get.mock.calls[get.mock.calls.length - 1]
|
|
332
|
+
expect(url).toBe('/options/stock')
|
|
333
|
+
expect(cfg?.params.field).toBe('product_id')
|
|
334
|
+
expect(cfg?.params.filter_value).toBe('W2')
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
})
|
|
@@ -42,7 +42,7 @@ import { DynamicLineItems } from './dynamic-line-items'
|
|
|
42
42
|
import { DynamicSelectField } from './dynamic-select-field'
|
|
43
43
|
import { DynamicDateField } from './dynamic-date-field'
|
|
44
44
|
import { UploadField } from './upload-field'
|
|
45
|
-
import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
|
|
45
|
+
import { isLineItemsField, resolveWidget, resolveDependsValue, getDependsOn } from './dynamic-form-schema'
|
|
46
46
|
import type { ActionFieldDef } from './types'
|
|
47
47
|
// Canonical registry lives in @asteby/metacore-sdk
|
|
48
48
|
import {
|
|
@@ -292,7 +292,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
292
292
|
{field.label}
|
|
293
293
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
294
294
|
</Label>
|
|
295
|
-
{renderField(field, formData[field.key], (v: any) => updateField(field.key, v))}
|
|
295
|
+
{renderField(field, formData[field.key], (v: any) => updateField(field.key, v), formData)}
|
|
296
296
|
</div>
|
|
297
297
|
)
|
|
298
298
|
})}
|
|
@@ -319,10 +319,16 @@ function renderField(
|
|
|
319
319
|
field: ActionFieldDef,
|
|
320
320
|
value: any,
|
|
321
321
|
onChange: (value: any) => void,
|
|
322
|
+
// Full current form values — lets a line-items grid (and any cascading
|
|
323
|
+
// header picker) resolve a `dependsOn` reference against sibling header
|
|
324
|
+
// fields. Omitted by callers that have no surrounding form (the field is
|
|
325
|
+
// then treated as having no resolvable dependency).
|
|
326
|
+
formValues?: Record<string, any>,
|
|
322
327
|
) {
|
|
323
328
|
// Repeatable line-items group → row grid (value is an array of row objects).
|
|
329
|
+
// The header form values flow in so a cell can depend on a header field.
|
|
324
330
|
if (isLineItemsField(field)) {
|
|
325
|
-
return <DynamicLineItems field={field} value={value} onChange={onChange} />
|
|
331
|
+
return <DynamicLineItems field={field} value={value} onChange={onChange} formValues={formValues} />
|
|
326
332
|
}
|
|
327
333
|
// Resolve the widget the same way DynamicForm does (explicit widget wins,
|
|
328
334
|
// else inferred from type) so action modals and the standalone form stay in
|
|
@@ -330,7 +336,12 @@ function renderField(
|
|
|
330
336
|
// dropped `dynamic_select` to a plain text input.
|
|
331
337
|
const widget = resolveWidget(field)
|
|
332
338
|
if (widget === 'dynamic_select') {
|
|
333
|
-
|
|
339
|
+
// A header-level dynamic_select may itself depend on another header
|
|
340
|
+
// field; resolve its filter_value from the form context.
|
|
341
|
+
const dependsValue = getDependsOn(field)
|
|
342
|
+
? resolveDependsValue(field, formValues)
|
|
343
|
+
: undefined
|
|
344
|
+
return <DynamicSelectField field={field} value={value} onChange={onChange} dependsValue={dependsValue} />
|
|
334
345
|
}
|
|
335
346
|
// File upload → themed picker that POSTs the file to the host upload
|
|
336
347
|
// endpoint and stores the returned url/path. Kept in sync with DynamicForm.
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Generic, brand-neutral table-cell renderer for jsonb / array / object column
|
|
2
|
+
// values. Kernel-derived dynamic tables surface raw jsonb columns (line items,
|
|
3
|
+
// nested config blobs, scalar arrays) with no per-column metadata; without this
|
|
4
|
+
// they rendered as raw `JSON.stringify(value)` which is unreadable. This renders
|
|
5
|
+
// a compact trigger (count badge / inline pairs) plus a Popover with a clean
|
|
6
|
+
// mini-table — no per-addon config required, safe on any shape.
|
|
7
|
+
|
|
8
|
+
import * as React from 'react'
|
|
9
|
+
import { List } from 'lucide-react'
|
|
10
|
+
import {
|
|
11
|
+
Badge,
|
|
12
|
+
Popover,
|
|
13
|
+
PopoverContent,
|
|
14
|
+
PopoverTrigger,
|
|
15
|
+
Table,
|
|
16
|
+
TableBody,
|
|
17
|
+
TableCell,
|
|
18
|
+
TableHead,
|
|
19
|
+
TableHeader,
|
|
20
|
+
TableRow,
|
|
21
|
+
cn,
|
|
22
|
+
} from '@asteby/metacore-ui'
|
|
23
|
+
import { humanizeToken } from './dynamic-columns-helpers'
|
|
24
|
+
|
|
25
|
+
const UUID_LIKE_RE =
|
|
26
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
27
|
+
|
|
28
|
+
/** snake_case / dotted / kebab key → Title Case (`product_id` → "Product ID"). */
|
|
29
|
+
export function prettifyKey(key: string): string {
|
|
30
|
+
const pretty = humanizeToken(key)
|
|
31
|
+
return pretty || key
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Render a single scalar (or near-scalar) value for compact display.
|
|
36
|
+
* - uuid-like or very long (32+ char) strings → first 8 chars + "…"
|
|
37
|
+
* - numbers / booleans → rendered as-is (booleans as ✓ / ✗)
|
|
38
|
+
* - nested object → "{…}", nested array → "[N]"
|
|
39
|
+
* - null / undefined / "" → "-"
|
|
40
|
+
*/
|
|
41
|
+
export function formatScalar(value: unknown): string {
|
|
42
|
+
if (value === null || value === undefined) return '-'
|
|
43
|
+
if (typeof value === 'boolean') return value ? '✓' : '✗'
|
|
44
|
+
if (typeof value === 'number') return String(value)
|
|
45
|
+
if (Array.isArray(value)) return `[${value.length}]`
|
|
46
|
+
if (typeof value === 'object') return '{…}'
|
|
47
|
+
const str = String(value)
|
|
48
|
+
if (str === '') return '-'
|
|
49
|
+
if (UUID_LIKE_RE.test(str) || str.length >= 32) {
|
|
50
|
+
return `${str.slice(0, 8)}…`
|
|
51
|
+
}
|
|
52
|
+
return str
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
56
|
+
typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Defensively coerce a raw cell value into something renderable. Strings that
|
|
60
|
+
* look like JSON (`[`/`{` start) are parsed; everything else is passed through.
|
|
61
|
+
*/
|
|
62
|
+
function parseValue(value: unknown): unknown {
|
|
63
|
+
if (typeof value !== 'string') return value
|
|
64
|
+
const trimmed = value.trim()
|
|
65
|
+
if (
|
|
66
|
+
(trimmed.startsWith('[') && trimmed.endsWith(']')) ||
|
|
67
|
+
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|
|
68
|
+
) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(trimmed)
|
|
71
|
+
} catch {
|
|
72
|
+
return value
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return value
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stable union of keys across an array of row objects, first-seen order. */
|
|
79
|
+
function unionKeys(rows: Record<string, unknown>[]): string[] {
|
|
80
|
+
const seen: string[] = []
|
|
81
|
+
const set = new Set<string>()
|
|
82
|
+
for (const row of rows) {
|
|
83
|
+
for (const key of Object.keys(row)) {
|
|
84
|
+
if (!set.has(key)) {
|
|
85
|
+
set.add(key)
|
|
86
|
+
seen.push(key)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return seen
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const PANEL_CLASS = 'w-auto max-w-[480px] max-h-[320px] overflow-auto p-0'
|
|
94
|
+
|
|
95
|
+
function MiniTable({ rows }: { rows: Record<string, unknown>[] }) {
|
|
96
|
+
const keys = unionKeys(rows)
|
|
97
|
+
if (keys.length === 0) {
|
|
98
|
+
return <div className="p-3 text-xs text-muted-foreground">-</div>
|
|
99
|
+
}
|
|
100
|
+
return (
|
|
101
|
+
<Table>
|
|
102
|
+
<TableHeader>
|
|
103
|
+
<TableRow>
|
|
104
|
+
{keys.map((key) => (
|
|
105
|
+
<TableHead key={key} className="text-xs whitespace-nowrap">
|
|
106
|
+
{prettifyKey(key)}
|
|
107
|
+
</TableHead>
|
|
108
|
+
))}
|
|
109
|
+
</TableRow>
|
|
110
|
+
</TableHeader>
|
|
111
|
+
<TableBody>
|
|
112
|
+
{rows.map((row, i) => (
|
|
113
|
+
<TableRow key={i}>
|
|
114
|
+
{keys.map((key) => (
|
|
115
|
+
<TableCell
|
|
116
|
+
key={key}
|
|
117
|
+
className="text-xs whitespace-nowrap"
|
|
118
|
+
>
|
|
119
|
+
{formatScalar(row[key])}
|
|
120
|
+
</TableCell>
|
|
121
|
+
))}
|
|
122
|
+
</TableRow>
|
|
123
|
+
))}
|
|
124
|
+
</TableBody>
|
|
125
|
+
</Table>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function ScalarList({ values }: { values: unknown[] }) {
|
|
130
|
+
return (
|
|
131
|
+
<ul className="p-3 space-y-1">
|
|
132
|
+
{values.map((v, i) => (
|
|
133
|
+
<li key={i} className="text-xs text-foreground">
|
|
134
|
+
{formatScalar(v)}
|
|
135
|
+
</li>
|
|
136
|
+
))}
|
|
137
|
+
</ul>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function PairList({ entries }: { entries: [string, unknown][] }) {
|
|
142
|
+
return (
|
|
143
|
+
<ul className="p-3 space-y-1">
|
|
144
|
+
{entries.map(([key, v]) => (
|
|
145
|
+
<li key={key} className="text-xs">
|
|
146
|
+
<span className="text-muted-foreground">
|
|
147
|
+
{prettifyKey(key)}:
|
|
148
|
+
</span>{' '}
|
|
149
|
+
<span className="text-foreground">{formatScalar(v)}</span>
|
|
150
|
+
</li>
|
|
151
|
+
))}
|
|
152
|
+
</ul>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Compact badge trigger that opens a popover panel. */
|
|
157
|
+
function PopoverShell({
|
|
158
|
+
label,
|
|
159
|
+
title,
|
|
160
|
+
children,
|
|
161
|
+
icon = true,
|
|
162
|
+
}: {
|
|
163
|
+
label: string
|
|
164
|
+
title: string
|
|
165
|
+
children: React.ReactNode
|
|
166
|
+
icon?: boolean
|
|
167
|
+
}) {
|
|
168
|
+
return (
|
|
169
|
+
<Popover>
|
|
170
|
+
<PopoverTrigger asChild>
|
|
171
|
+
<Badge
|
|
172
|
+
variant="secondary"
|
|
173
|
+
className="cursor-pointer gap-1 font-normal"
|
|
174
|
+
title={title}
|
|
175
|
+
>
|
|
176
|
+
{icon ? <List className="h-3 w-3" /> : null}
|
|
177
|
+
{label}
|
|
178
|
+
</Badge>
|
|
179
|
+
</PopoverTrigger>
|
|
180
|
+
<PopoverContent
|
|
181
|
+
align="start"
|
|
182
|
+
className={cn(PANEL_CLASS)}
|
|
183
|
+
>
|
|
184
|
+
{children}
|
|
185
|
+
</PopoverContent>
|
|
186
|
+
</Popover>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface CollectionCellProps {
|
|
191
|
+
value: unknown
|
|
192
|
+
/** Max items previewed inline for scalar arrays. */
|
|
193
|
+
maxInline?: number
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Generic renderer for jsonb / array / object cell values. Brand-neutral,
|
|
198
|
+
* compact, dark-mode friendly. Never throws on unexpected shapes.
|
|
199
|
+
*/
|
|
200
|
+
export function CollectionCell({ value, maxInline = 3 }: CollectionCellProps) {
|
|
201
|
+
const parsed = parseValue(value)
|
|
202
|
+
|
|
203
|
+
// Empty-ish → muted dash.
|
|
204
|
+
if (
|
|
205
|
+
parsed === null ||
|
|
206
|
+
parsed === undefined ||
|
|
207
|
+
parsed === '' ||
|
|
208
|
+
(Array.isArray(parsed) && parsed.length === 0) ||
|
|
209
|
+
(isPlainObject(parsed) && Object.keys(parsed).length === 0)
|
|
210
|
+
) {
|
|
211
|
+
return <span className="text-muted-foreground text-xs">-</span>
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Non-collection scalar fell through here (e.g. unparseable string): truncate.
|
|
215
|
+
if (!Array.isArray(parsed) && !isPlainObject(parsed)) {
|
|
216
|
+
const str = String(parsed)
|
|
217
|
+
return (
|
|
218
|
+
<span
|
|
219
|
+
className="text-muted-foreground text-xs truncate block max-w-[300px]"
|
|
220
|
+
title={str}
|
|
221
|
+
>
|
|
222
|
+
{str.length > 80 ? `${str.slice(0, 80)}…` : str}
|
|
223
|
+
</span>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ARRAY ------------------------------------------------------------------
|
|
228
|
+
if (Array.isArray(parsed)) {
|
|
229
|
+
const allObjects = parsed.every((item) => isPlainObject(item))
|
|
230
|
+
if (allObjects) {
|
|
231
|
+
const rows = parsed as Record<string, unknown>[]
|
|
232
|
+
const count = rows.length
|
|
233
|
+
const label = count === 1 ? '1 ítem' : `${count} ítems`
|
|
234
|
+
const title = rows
|
|
235
|
+
.map((row) =>
|
|
236
|
+
Object.entries(row)
|
|
237
|
+
.map(([k, v]) => `${prettifyKey(k)}: ${formatScalar(v)}`)
|
|
238
|
+
.join(', ')
|
|
239
|
+
)
|
|
240
|
+
.join(' | ')
|
|
241
|
+
return (
|
|
242
|
+
<PopoverShell label={label} title={title}>
|
|
243
|
+
<MiniTable rows={rows} />
|
|
244
|
+
</PopoverShell>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Array of scalars (or mixed): preview first N joined, "+N" overflow.
|
|
249
|
+
const preview = parsed.slice(0, maxInline).map(formatScalar).join(', ')
|
|
250
|
+
const overflow = parsed.length - maxInline
|
|
251
|
+
const label =
|
|
252
|
+
overflow > 0 ? `${preview} +${overflow}` : preview
|
|
253
|
+
const title = parsed.map(formatScalar).join(', ')
|
|
254
|
+
return (
|
|
255
|
+
<PopoverShell label={label} title={title} icon={false}>
|
|
256
|
+
<ScalarList values={parsed} />
|
|
257
|
+
</PopoverShell>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// PLAIN OBJECT -----------------------------------------------------------
|
|
262
|
+
const entries = Object.entries(parsed)
|
|
263
|
+
const inline = entries
|
|
264
|
+
.slice(0, maxInline)
|
|
265
|
+
.map(([k, v]) => `${prettifyKey(k)}: ${formatScalar(v)}`)
|
|
266
|
+
.join(', ')
|
|
267
|
+
const overflow = entries.length - maxInline
|
|
268
|
+
const label = overflow > 0 ? `${inline} +${overflow}` : inline
|
|
269
|
+
const title = entries
|
|
270
|
+
.map(([k, v]) => `${prettifyKey(k)}: ${formatScalar(v)}`)
|
|
271
|
+
.join(', ')
|
|
272
|
+
return (
|
|
273
|
+
<PopoverShell label={label} title={title} icon={false}>
|
|
274
|
+
<PairList entries={entries} />
|
|
275
|
+
</PopoverShell>
|
|
276
|
+
)
|
|
277
|
+
}
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -45,6 +45,7 @@ import { Progress } from './dialogs/_primitives'
|
|
|
45
45
|
import { humanizeToken } from './dynamic-columns-helpers'
|
|
46
46
|
import { OptionsContext } from './options-context'
|
|
47
47
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon'
|
|
48
|
+
import { CollectionCell } from './collection-cell'
|
|
48
49
|
import { isNilUuid, normalizeNilUuid } from './nil-uuid'
|
|
49
50
|
import type { TableMetadata, ColumnDefinition } from './types'
|
|
50
51
|
import { isColumnVisibleInTable } from './column-visibility'
|
|
@@ -1173,11 +1174,7 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1173
1174
|
|
|
1174
1175
|
default: {
|
|
1175
1176
|
if (typeof value === 'object' && value !== null) {
|
|
1176
|
-
return
|
|
1177
|
-
<span className="text-muted-foreground text-xs">
|
|
1178
|
-
{JSON.stringify(value)}
|
|
1179
|
-
</span>
|
|
1180
|
-
)
|
|
1177
|
+
return <CollectionCell value={value} />
|
|
1181
1178
|
}
|
|
1182
1179
|
if (
|
|
1183
1180
|
col.key === 'description' ||
|