@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/action-modal-dispatcher.js +16 -5
  3. package/dist/collection-cell.d.ts +22 -0
  4. package/dist/collection-cell.d.ts.map +1 -0
  5. package/dist/collection-cell.js +141 -0
  6. package/dist/dynamic-columns.d.ts.map +1 -1
  7. package/dist/dynamic-columns.js +2 -1
  8. package/dist/dynamic-form-schema.d.ts +41 -1
  9. package/dist/dynamic-form-schema.d.ts.map +1 -1
  10. package/dist/dynamic-form-schema.js +61 -0
  11. package/dist/dynamic-line-items.d.ts +7 -1
  12. package/dist/dynamic-line-items.d.ts.map +1 -1
  13. package/dist/dynamic-line-items.js +46 -12
  14. package/dist/dynamic-select-field.d.ts +17 -1
  15. package/dist/dynamic-select-field.d.ts.map +1 -1
  16. package/dist/dynamic-select-field.js +48 -11
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/types.d.ts +48 -0
  21. package/dist/types.d.ts.map +1 -1
  22. package/dist/use-options-resolver.d.ts +9 -0
  23. package/dist/use-options-resolver.d.ts.map +1 -1
  24. package/dist/use-options-resolver.js +7 -2
  25. package/package.json +1 -1
  26. package/src/__tests__/collection-cell.test.tsx +115 -0
  27. package/src/__tests__/dependent-options.test.tsx +337 -0
  28. package/src/action-modal-dispatcher.tsx +15 -4
  29. package/src/collection-cell.tsx +277 -0
  30. package/src/dynamic-columns.tsx +2 -5
  31. package/src/dynamic-form-schema.ts +72 -1
  32. package/src/dynamic-line-items.tsx +86 -13
  33. package/src/dynamic-select-field.tsx +69 -12
  34. package/src/index.ts +13 -1
  35. package/src/types.ts +49 -0
  36. package/src/use-options-resolver.ts +15 -1
@@ -2,7 +2,7 @@
2
2
  // callers (and unit tests) can use the zod schema without pulling in React or
3
3
  // metacore-ui primitives.
4
4
  import { z, type ZodTypeAny } from 'zod'
5
- import type { ActionFieldDef, FieldValidation } from './types'
5
+ import type { ActionFieldDef, FieldValidation, FieldOptionsConfig } from './types'
6
6
  import { resolveValidatorToken } from './use-org-config-bridge'
7
7
 
8
8
  /**
@@ -252,6 +252,77 @@ export function fieldHasRef(field: ActionFieldDef): boolean {
252
252
  return getFieldRef(field) !== undefined
253
253
  }
254
254
 
255
+ /**
256
+ * Resolves a field's cascade dependency — the key of another form field whose
257
+ * current value scopes this picker's options (`filter_value`). Tolerates the
258
+ * camelCase `dependsOn` (authored SDK shape) and the snake_case `depends_on`
259
+ * the kernel manifest serves. Returns the trimmed field key, or `undefined`
260
+ * when the field declares no dependency.
261
+ */
262
+ export function getDependsOn(field: ActionFieldDef): string | undefined {
263
+ const dep = field.dependsOn ?? field.depends_on
264
+ if (typeof dep === 'string' && dep.trim() !== '') return dep.trim()
265
+ return undefined
266
+ }
267
+
268
+ /**
269
+ * Resolves the cascade `filter_value` for a field from the surrounding form
270
+ * context. The depended-on key is matched against the current row first (a
271
+ * sibling item-field on the same line) and then the header form values, so a
272
+ * line-items cell can depend on either a sibling cell OR a header field (e.g.
273
+ * `source_warehouse_id`). Returns the stringified value, or `''` when the
274
+ * field has no dependency or the depended-on value is empty/unset.
275
+ */
276
+ export function resolveDependsValue(
277
+ field: ActionFieldDef,
278
+ formValues?: Record<string, any> | null,
279
+ rowValues?: Record<string, any> | null,
280
+ ): string {
281
+ const dep = getDependsOn(field)
282
+ if (!dep) return ''
283
+ const raw =
284
+ (rowValues && rowValues[dep] != null && rowValues[dep] !== '' ? rowValues[dep] : undefined) ??
285
+ (formValues ? formValues[dep] : undefined)
286
+ if (raw == null || raw === '') return ''
287
+ return String(raw)
288
+ }
289
+
290
+ /**
291
+ * Reads a field's enriched options-resolution config, tolerating the camelCase
292
+ * `optionsConfig` (authored SDK shape) and the snake_case `options_config` the
293
+ * kernel manifest serves. Returns `undefined` when the field declares none.
294
+ */
295
+ export function getOptionsConfig(field: ActionFieldDef): FieldOptionsConfig | undefined {
296
+ const cfg = field.optionsConfig ?? field.options_config
297
+ return cfg && typeof cfg === 'object' ? cfg : undefined
298
+ }
299
+
300
+ /**
301
+ * Resolves where a picker should fetch its options from, honouring an
302
+ * `optionsConfig.source` (the dependent/scoped routing the kernel serves) and
303
+ * falling back to the field's `ref` for retrocompat.
304
+ *
305
+ * - With `optionsConfig.source`: query the SOURCE model →
306
+ * `{ endpoint: '/options/<source>', fieldKey: <value ?? field.key> }`.
307
+ * - Without it: keep `ref`-based resolution → `{ ref }` (the hook's canonical
308
+ * path), `fieldKey` defaulting to `'id'`.
309
+ *
310
+ * The returned shape feeds straight into `useOptionsResolver` args.
311
+ */
312
+ export function resolveOptionsSource(field: ActionFieldDef): {
313
+ endpoint?: string
314
+ ref?: string
315
+ fieldKey: string
316
+ } {
317
+ const cfg = getOptionsConfig(field)
318
+ const source = typeof cfg?.source === 'string' ? cfg.source.trim() : ''
319
+ if (source) {
320
+ const value = typeof cfg?.value === 'string' && cfg.value.trim() !== '' ? cfg.value.trim() : field.key
321
+ return { endpoint: `/options/${source}`, fieldKey: value }
322
+ }
323
+ return { ref: getFieldRef(field), fieldKey: 'id' }
324
+ }
325
+
255
326
  /**
256
327
  * Normalizes an upload field's config, tolerating both the camelCase authored
257
328
  * SDK shape and the snake_case the kernel serves (`max_size`, `storage_path`).
@@ -18,6 +18,7 @@ import {
18
18
  SelectTrigger,
19
19
  SelectValue,
20
20
  } from '@asteby/metacore-ui/primitives'
21
+ import { useEffect, useRef } from 'react'
21
22
  import { Plus, Trash2, Check } from 'lucide-react'
22
23
  import type { ActionFieldDef } from './types'
23
24
  import {
@@ -26,8 +27,12 @@ import {
26
27
  computeLineItemTotals,
27
28
  evaluateBalance,
28
29
  toNumber,
30
+ getDependsOn,
31
+ resolveDependsValue,
32
+ getOptionsConfig,
33
+ resolveOptionsSource,
29
34
  } from './dynamic-form-schema'
30
- import { DynamicSelectField } from './dynamic-select-field'
35
+ import { DynamicSelectField, DEFAULT_DEPENDS_HINT } from './dynamic-select-field'
31
36
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
32
37
 
33
38
  export interface DynamicLineItemsProps {
@@ -35,6 +40,12 @@ export interface DynamicLineItemsProps {
35
40
  value: any[] | undefined
36
41
  onChange: (rows: any[]) => void
37
42
  disabled?: boolean
43
+ /**
44
+ * Current values of the surrounding (header) form. Threaded into each cell
45
+ * so a cell field with `dependsOn` can scope its options by a HEADER field
46
+ * (e.g. `source_warehouse_id`), not just a sibling cell on the same row.
47
+ */
48
+ formValues?: Record<string, any>
38
49
  }
39
50
 
40
51
  const fmtNumber = (n: number): string =>
@@ -53,7 +64,7 @@ function emptyRow(itemFields: ActionFieldDef[]): Record<string, any> {
53
64
  return row
54
65
  }
55
66
 
56
- export function DynamicLineItems({ field, value, onChange, disabled = false }: DynamicLineItemsProps) {
67
+ export function DynamicLineItems({ field, value, onChange, disabled = false, formValues }: DynamicLineItemsProps) {
57
68
  const itemFields = getItemFields(field)
58
69
  const rows: any[] = Array.isArray(value) ? value : []
59
70
 
@@ -137,6 +148,8 @@ export function DynamicLineItems({ field, value, onChange, disabled = false }: D
137
148
  value={row?.[col.key]}
138
149
  onChange={(v: any) => handleCell(idx, col.key, v)}
139
150
  disabled={disabled}
151
+ formValues={formValues}
152
+ rowValues={row}
140
153
  />
141
154
  </td>
142
155
  ))}
@@ -234,21 +247,39 @@ interface CellRendererProps {
234
247
  value: any
235
248
  onChange: (v: any) => void
236
249
  disabled?: boolean
250
+ /** Header form values — for resolving a cell's `dependsOn` to a header field. */
251
+ formValues?: Record<string, any>
252
+ /** This row's values — for resolving a cell's `dependsOn` to a sibling cell. */
253
+ rowValues?: Record<string, any>
237
254
  }
238
255
 
239
256
  // Per-cell widget. Mirrors the flat FieldRenderer in dynamic-form.tsx but
240
257
  // without the per-field Label (the column header is the label) and sized for a
241
258
  // table cell. Nested line-items inside a row are not supported (a row column is
242
259
  // a scalar widget).
243
- function CellRenderer({ field, value, onChange, disabled }: CellRendererProps) {
260
+ function CellRenderer({ field, value, onChange, disabled, formValues, rowValues }: CellRendererProps) {
244
261
  const widget = resolveWidget(field)
262
+ // Cascade scope for a cell with `dependsOn`: resolved from this row first
263
+ // (a sibling cell) then the header form (e.g. `source_warehouse_id`).
264
+ const dependsValue = getDependsOn(field)
265
+ ? resolveDependsValue(field, formValues, rowValues)
266
+ : undefined
245
267
  // Async searchable picker per row cell — e.g. the account_id column of a
246
268
  // journal entry's debit/credit lines. Same widget as the flat form.
247
269
  if (widget === 'dynamic_select') {
248
- return <DynamicSelectField field={field} value={value} onChange={onChange} />
270
+ return <DynamicSelectField field={field} value={value} onChange={onChange} dependsValue={dependsValue} />
249
271
  }
250
- if (widget === 'select' && field.ref) {
251
- return <RefCell field={field} value={value} onChange={onChange} disabled={disabled} />
272
+ if (widget === 'select' && (field.ref || getOptionsConfig(field)?.source)) {
273
+ return (
274
+ <RefCell
275
+ field={field}
276
+ value={value}
277
+ onChange={onChange}
278
+ disabled={disabled}
279
+ formValues={formValues}
280
+ rowValues={rowValues}
281
+ />
282
+ )
252
283
  }
253
284
  switch (widget) {
254
285
  case 'textarea':
@@ -320,21 +351,63 @@ function CellRenderer({ field, value, onChange, disabled }: CellRendererProps) {
320
351
  }
321
352
  }
322
353
 
323
- function RefCell({ field, value, onChange, disabled }: CellRendererProps) {
354
+ function RefCell({ field, value, onChange, disabled, formValues, rowValues }: CellRendererProps) {
355
+ // Cascade: resolve the value of the field this cell `dependsOn` from the
356
+ // row (sibling) first, then the header form. While empty, the picker is
357
+ // disabled with a hint instead of listing the whole (unscoped) table.
358
+ const dependsOn = getDependsOn(field)
359
+ const scope = dependsOn ? resolveDependsValue(field, formValues, rowValues) : ''
360
+ const blockedByDependency = !!dependsOn && scope === ''
361
+
362
+ // optionsConfig.source → query the source model (`/options/<source>` with
363
+ // `field=<value>`); else fall back to the field's `ref`.
364
+ const optSource = resolveOptionsSource(field)
365
+
324
366
  const { options, loading } = useOptionsResolver({
325
367
  modelKey: '',
326
- fieldKey: 'id',
327
- ref: field.ref,
368
+ fieldKey: optSource.fieldKey,
369
+ ref: optSource.ref,
370
+ endpoint: optSource.endpoint,
371
+ filterValue: dependsOn ? scope : undefined,
372
+ enabled: !blockedByDependency,
328
373
  })
374
+
375
+ // Clear the selection when the parent scope changes (skip initial mount).
376
+ const prevScopeRef = useRef<string>(scope)
377
+ useEffect(() => {
378
+ if (!dependsOn) return
379
+ if (prevScopeRef.current !== scope) {
380
+ prevScopeRef.current = scope
381
+ if (value) onChange('')
382
+ }
383
+ }, [dependsOn, scope, value, onChange])
384
+
385
+ const placeholder = blockedByDependency
386
+ ? DEFAULT_DEPENDS_HINT
387
+ : loading
388
+ ? 'Cargando…'
389
+ : field.placeholder || 'Seleccionar...'
390
+
329
391
  return (
330
- <Select value={value || ''} onValueChange={onChange} disabled={disabled || loading}>
331
- <SelectTrigger className="w-full">
332
- <SelectValue placeholder={loading ? 'Cargando…' : field.placeholder || 'Seleccionar...'} />
392
+ <Select
393
+ value={value || ''}
394
+ onValueChange={onChange}
395
+ disabled={disabled || loading || blockedByDependency}
396
+ >
397
+ <SelectTrigger className="w-full" data-depends-blocked={blockedByDependency ? '' : undefined}>
398
+ <SelectValue placeholder={placeholder} />
333
399
  </SelectTrigger>
334
400
  <SelectContent>
335
401
  {options.map((opt: ResolvedOption) => (
336
402
  <SelectItem key={String(opt.id)} value={String(opt.id)}>
337
- {opt.label}
403
+ <span className="flex flex-col">
404
+ <span className="truncate">{opt.label}</span>
405
+ {opt.description && (
406
+ <span className="text-muted-foreground truncate text-xs">
407
+ {opt.description}
408
+ </span>
409
+ )}
410
+ </span>
338
411
  </SelectItem>
339
412
  ))}
340
413
  </SelectContent>
@@ -21,7 +21,7 @@
21
21
  // in a fetched page (we match by id against loaded options, else show the raw
22
22
  // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
23
23
  // case — start empty and never hit this.
24
- import { useEffect, useState } from 'react'
24
+ import { useEffect, useRef, useState } from 'react'
25
25
  import {
26
26
  Button,
27
27
  Command,
@@ -38,9 +38,16 @@ import { Check, ChevronsUpDown, ImageIcon, Loader2, Plus } from 'lucide-react'
38
38
  import { resolveColorCss } from '@asteby/metacore-ui/lib'
39
39
  import { DynamicIcon } from './dynamic-icon'
40
40
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
41
- import { getFieldRef } from './dynamic-form-schema'
41
+ import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
42
42
  import type { ActionFieldDef } from './types'
43
43
 
44
+ /**
45
+ * Default hint shown when a cascading picker's depended-on field is still
46
+ * empty. Domain-neutral on purpose; a caller may override it per field via
47
+ * `dependsHint`.
48
+ */
49
+ export const DEFAULT_DEPENDS_HINT = 'Selecciona primero el campo del que depende'
50
+
44
51
  /**
45
52
  * Small square thumbnail for an option's `image`. Falls back to a neutral
46
53
  * placeholder icon when the option has no image so rows/triggers stay aligned.
@@ -145,9 +152,26 @@ export interface DynamicSelectFieldProps {
145
152
  * a lookup (which only loads once the popover opens). Matched by id == value.
146
153
  */
147
154
  seedOption?: ResolvedOption | null
155
+ /**
156
+ * Cascade scope: the current value of the field this picker `dependsOn`
157
+ * (the caller resolves it from the form context). Forwarded as
158
+ * `filter_value`. When the field declares a `dependsOn` and this is empty,
159
+ * the picker is disabled with `dependsHint` and the current selection is
160
+ * cleared. Changing it re-fetches and clears the selection.
161
+ */
162
+ dependsValue?: string
163
+ /** Overrides the disabled-state hint shown while `dependsValue` is empty. */
164
+ dependsHint?: string
148
165
  }
149
166
 
150
- export function DynamicSelectField({ field, value, onChange, seedOption }: DynamicSelectFieldProps) {
167
+ export function DynamicSelectField({
168
+ field,
169
+ value,
170
+ onChange,
171
+ seedOption,
172
+ dependsValue,
173
+ dependsHint,
174
+ }: DynamicSelectFieldProps) {
151
175
  const [open, setOpen] = useState(false)
152
176
  const [search, setSearch] = useState('')
153
177
  const debounced = useDebounced(search, 250)
@@ -159,20 +183,49 @@ export function DynamicSelectField({ field, value, onChange, seedOption }: Dynam
159
183
  // for the FK target, not just camelCase `ref`.
160
184
  const fieldRef = getFieldRef(field)
161
185
 
186
+ // Options routing: an `optionsConfig.source` (dependent/scoped picker) wins
187
+ // over the field's `ref` — query the SOURCE model with `field=<value>`;
188
+ // otherwise keep the canonical `ref`-based resolution.
189
+ const source = resolveOptionsSource(field)
190
+
191
+ // Cascade: a `dependsOn` field whose value is still empty leaves this
192
+ // picker disabled until the parent is set. `dependsValue` is the resolved
193
+ // value the caller threaded from the form context.
194
+ const dependsOn = getDependsOn(field)
195
+ const scope = dependsValue ? String(dependsValue) : ''
196
+ const blockedByDependency = !!dependsOn && scope === ''
197
+
162
198
  const { options, loading } = useOptionsResolver({
163
199
  modelKey: '',
164
- fieldKey: 'id',
165
- ref: fieldRef,
166
- // searchEndpoint only drives the URL when there's no ref — ref is the
167
- // canonical, kernel-derived path and wins.
168
- endpoint: fieldRef ? undefined : field.searchEndpoint,
200
+ fieldKey: source.fieldKey,
201
+ ref: source.ref,
202
+ // optionsConfig.source `/options/<source>`. Else searchEndpoint only
203
+ // drives the URL when there's no ref — ref is the canonical,
204
+ // kernel-derived path and wins.
205
+ endpoint: source.endpoint ?? (source.ref ? undefined : field.searchEndpoint),
169
206
  query: debounced,
170
207
  limit: 20,
208
+ // Cascade scope forwarded as filter_value (only when this field
209
+ // declares a dependency). Re-fetches when the parent value changes.
210
+ filterValue: dependsOn ? scope : undefined,
171
211
  // Don't fetch until the popover opens (and keep fetching as the query
172
- // changes while open).
173
- enabled: open,
212
+ // changes while open). A picker blocked by an unset dependency never
213
+ // fetches.
214
+ enabled: open && !blockedByDependency,
174
215
  })
175
216
 
217
+ // When the depended-on value changes, the previously-picked option no longer
218
+ // belongs to the new scope, so clear the selection (skip the initial mount).
219
+ const prevScopeRef = useRef<string>(scope)
220
+ useEffect(() => {
221
+ if (!dependsOn) return
222
+ if (prevScopeRef.current !== scope) {
223
+ prevScopeRef.current = scope
224
+ setPicked(null)
225
+ if (value) onChange('')
226
+ }
227
+ }, [dependsOn, scope, value, onChange])
228
+
176
229
  // The currently-selected option, resolved either from what the user picked
177
230
  // (cached in `picked`) or from the loaded page. Drives both the trigger
178
231
  // label and its thumbnail.
@@ -225,7 +278,7 @@ export function DynamicSelectField({ field, value, onChange, seedOption }: Dynam
225
278
  // "+" off-screen — it only "fit" once a short value was selected.
226
279
  return (
227
280
  <div className="flex w-full min-w-0 items-center gap-1.5">
228
- <Popover open={open} onOpenChange={setOpen}>
281
+ <Popover open={open && !blockedByDependency} onOpenChange={(o: boolean) => { if (!blockedByDependency) setOpen(o) }}>
229
282
  <PopoverTrigger asChild>
230
283
  <Button
231
284
  type="button"
@@ -233,15 +286,19 @@ export function DynamicSelectField({ field, value, onChange, seedOption }: Dynam
233
286
  role="combobox"
234
287
  aria-expanded={open}
235
288
  id={field.key}
289
+ disabled={blockedByDependency}
236
290
  className="min-w-0 flex-1 justify-between font-normal"
237
291
  data-empty={!value}
292
+ data-depends-blocked={blockedByDependency ? '' : undefined}
238
293
  >
239
294
  <span className="flex min-w-0 flex-1 items-center gap-2 text-left">
240
295
  {hasVisual && value ? (
241
296
  <OptionLead option={selectedOption} size={20} />
242
297
  ) : null}
243
298
  <span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
244
- {selectedLabel || field.placeholder || 'Buscar…'}
299
+ {blockedByDependency
300
+ ? (dependsHint || DEFAULT_DEPENDS_HINT)
301
+ : selectedLabel || field.placeholder || 'Buscar…'}
245
302
  </span>
246
303
  </span>
247
304
  <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
package/src/index.ts CHANGED
@@ -101,6 +101,12 @@ export {
101
101
  type DynamicColumnsHelpers,
102
102
  } from './dynamic-columns'
103
103
  export { humanizeToken } from './dynamic-columns-helpers'
104
+ export {
105
+ CollectionCell,
106
+ formatScalar,
107
+ prettifyKey,
108
+ type CollectionCellProps,
109
+ } from './collection-cell'
104
110
  export { NIL_UUID, isNilUuid, normalizeNilUuid } from './nil-uuid'
105
111
  export { DynamicRecordDialog, ViewValue } from './dialogs/dynamic-record'
106
112
  export type { DynamicRecordDialogProps, FieldDef, FieldOption, GetImageUrl } from './dialogs/dynamic-record'
@@ -163,7 +169,13 @@ export {
163
169
  resolveValidatorToken,
164
170
  type OrgConfigBridge,
165
171
  } from './use-org-config-bridge'
166
- export { registerValidator } from './dynamic-form-schema'
172
+ export {
173
+ registerValidator,
174
+ getDependsOn,
175
+ resolveDependsValue,
176
+ getOptionsConfig,
177
+ resolveOptionsSource,
178
+ } from './dynamic-form-schema'
167
179
  export {
168
180
  ActivityValueRenderer,
169
181
  type ActivityValueRendererProps,
package/src/types.ts CHANGED
@@ -231,6 +231,30 @@ export interface ActionFieldDef {
231
231
  */
232
232
  source?: string
233
233
  relation?: string
234
+ /**
235
+ * Cascade dependency: the key of ANOTHER field in the same action form
236
+ * (a header field or a sibling item-field) whose current value supplies
237
+ * this picker's `filter_value`. While the depended-on field is empty the
238
+ * picker is disabled with a hint; once it has a value the picker fetches
239
+ * options scoped by it and re-fetches whenever it changes (clearing the
240
+ * current selection). Without `dependsOn` the picker lists everything
241
+ * (retrocompat). Tolerates the snake_case `depends_on` the kernel serves.
242
+ */
243
+ dependsOn?: string
244
+ /** snake_case alias served by the kernel manifest for `dependsOn`. */
245
+ depends_on?: string
246
+ /**
247
+ * Enriched options routing the kernel serves for a dependent/scoped picker.
248
+ * When it carries a `source`, the picker queries that source MODEL (not the
249
+ * field's `ref`): URL `/options/<source>`, query field = `value` (falling
250
+ * back to the field's own key), and the cascade `filter_value` is the value
251
+ * of the `dependsOn` field. `description` is projected into the option
252
+ * subtitle. Tolerates the snake_case `options_config` the kernel emits.
253
+ * Absent → the picker keeps its `ref`-based behaviour (retrocompat).
254
+ */
255
+ optionsConfig?: FieldOptionsConfig
256
+ /** snake_case alias served by the kernel manifest for `optionsConfig`. */
257
+ options_config?: FieldOptionsConfig
234
258
  /**
235
259
  * Columns of a repeatable line-items group. Mirrors the kernel v3
236
260
  * `ActionField.item_fields` (json `item_fields`). Present on a field
@@ -298,6 +322,31 @@ export interface FieldBalanceRule {
298
322
  require_nonzero?: boolean
299
323
  }
300
324
 
325
+ /**
326
+ * Enriched options-resolution config the kernel attaches to a dependent/scoped
327
+ * picker field (json `options_config`). When `source` is present the SDK queries
328
+ * the source model instead of the field's `ref`. All keys are snake_case as the
329
+ * kernel serves them; the SDK reads them as-is via `getOptionsConfig`.
330
+ */
331
+ export interface FieldOptionsConfig {
332
+ /** Discriminator the kernel sets (e.g. `'dynamic'`). Informational. */
333
+ type?: string
334
+ /** Source MODEL the candidates come from → URL `/options/<source>`. */
335
+ source?: string
336
+ /** Column of `source` compared against the cascade `filter_value`. */
337
+ filter_by?: string
338
+ /** Column of `source` used as the option value → query `?field=<value>`. */
339
+ value?: string
340
+ /** Related model used to resolve the option label by id (host-side enrich). */
341
+ label_ref?: string
342
+ /** Column of `source` projected into `option.description` (e.g. qty). */
343
+ description?: string
344
+ /** Optional ordering column. */
345
+ order_by?: string
346
+ /** Optional column projected into `option.image`. */
347
+ image?: string
348
+ }
349
+
301
350
  export interface ActionDefinition {
302
351
  key: string
303
352
  name: string
@@ -58,6 +58,15 @@ export interface UseOptionsResolverArgs {
58
58
  * server returns the first page unfiltered.
59
59
  */
60
60
  query?: string
61
+ /**
62
+ * Cascade scope forwarded as `?filter_value=`. Set by a dependent picker
63
+ * from the current value of the field it `dependsOn` (e.g. a product
64
+ * picker scoped to the header's `source_warehouse_id`). When empty/undefined
65
+ * the param is omitted (no scope — the picker lists everything). Changing it
66
+ * re-fetches; an empty string is treated as "not set" so a cleared parent
67
+ * does not query for the empty-string scope.
68
+ */
69
+ filterValue?: string
61
70
  /**
62
71
  * Server-side pagination cap. Defaults to 50 (kernel
63
72
  * DefaultOptionsLimit) if omitted.
@@ -105,6 +114,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
105
114
  limit,
106
115
  enabled = true,
107
116
  endpoint,
117
+ filterValue,
108
118
  } = args
109
119
 
110
120
  const api = useApi()
@@ -159,6 +169,10 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
159
169
  const params: Record<string, string | number> = { field: effectiveField }
160
170
  if (query) params.q = query
161
171
  if (typeof limit === 'number' && limit > 0) params.limit = limit
172
+ // Cascade scope: a dependent picker passes the value of the field it
173
+ // `dependsOn`. Skip empty strings so a cleared parent omits the param
174
+ // (no scope) rather than querying for the empty-string filter_value.
175
+ if (filterValue) params.filter_value = filterValue
162
176
 
163
177
  api.get(url, { params, signal: controller.signal })
164
178
  .then((res) => {
@@ -200,7 +214,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
200
214
  return () => {
201
215
  controller.abort()
202
216
  }
203
- }, [api, url, effectiveField, query, limit, enabled, refreshKey])
217
+ }, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey])
204
218
 
205
219
  return {
206
220
  options,