@asteby/metacore-runtime-react 13.10.0 → 13.10.2

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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 13.10.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 2813c61: fix(native-form): render rich widgets from column metadata (ref→searchable picker, image/upload→dropzone)
8
+
9
+ The native create/edit modal (`DynamicRecordDialog`, the one `CreateRecordDialog`
10
+ wraps and fetches `/metadata/modal/:model` for) only routed FK columns to its
11
+ searchable picker when they shipped a legacy `searchEndpoint` / `type: "search"`.
12
+ Now that the kernel serves a belongs_to column's `ref` (and an explicit
13
+ `widget`) on modal fields, a plain `ref` column degraded to a raw uuid text input.
14
+
15
+ `EditField` now honors:
16
+ - `field.ref` (or the snake_case `source`/`relation` aliases, or
17
+ `widget: "dynamic_select"`) → renders `DynamicSelectField`: an async typeahead
18
+ against `/api/options/<ref>?field=id` with option thumbnails when the remote
19
+ rows carry an `image` (e.g. a brand logo). Static inline `options` still take
20
+ the enum `<Select>` path — a `ref` column ships no inline options, so the FK
21
+ branch never shadows a static enum.
22
+ - `widget: "upload"` (alongside the existing `type: "image"`) → the themed file
23
+ dropzone, same control as the Brand logo.
24
+
25
+ Also fixes `deriveRelationFormFields` (the column→field mapper for
26
+ `DynamicRelation` inline child forms): it now carries `col.ref` through to the
27
+ field so a belongs_to column resolves to `dynamic_select`, and maps
28
+ `image`/`media-gallery` columns to media field types so they resolve to the
29
+ `upload` widget instead of a text input.
30
+
31
+ ## 13.10.1
32
+
33
+ ### Patch Changes
34
+
35
+ - 9107b10: Fix create-placement action submit hitting `/me/undefined/action/...` (400 Invalid record ID). `buildActionUrl` now omits the record segment when there is no record, posting to the collection route `/data/:model/me/action/:action`, so create modals declared as `placement:create` actions work.
36
+
3
37
  ## 13.10.0
4
38
 
5
39
  ### Minor Changes
@@ -35,7 +35,16 @@ export function ActionModalDispatcher({ open, onOpenChange, action, model, recor
35
35
  return null;
36
36
  }
37
37
  function buildActionUrl(endpoint, model, recordId, actionKey) {
38
- return endpoint ? `${endpoint}/${recordId}/action/${actionKey}` : `/data/${model}/me/${recordId}/action/${actionKey}`;
38
+ // A create-placement (collection) action has no record yet, so `recordId`
39
+ // is undefined. Omit the `/{id}` segment so the request hits the collection
40
+ // route (`/data/:model/me/action/:action`) instead of the per-record route
41
+ // (`/data/:model/me/:id/action/:action`), which would reject the literal
42
+ // "undefined" as an invalid record ID (ops dynamic.go ExecuteAction → 400).
43
+ const hasRecord = recordId != null && recordId !== '' && recordId !== 'undefined';
44
+ if (endpoint) {
45
+ return hasRecord ? `${endpoint}/${recordId}/action/${actionKey}` : `${endpoint}/action/${actionKey}`;
46
+ }
47
+ return hasRecord ? `/data/${model}/me/${recordId}/action/${actionKey}` : `/data/${model}/me/action/${actionKey}`;
39
48
  }
40
49
  function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }) {
41
50
  const { t } = useTranslation();
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAmE1C,MAAM,WAAW,wBAAwB;IACrC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpG;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAsDD,wBAAgB,mBAAmB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,MAAM,GACT,EAAE,wBAAwB,2CAsP1B"}
1
+ {"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAwF1C,MAAM,WAAW,wBAAwB;IACrC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpG;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAsDD,wBAAgB,mBAAmB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,MAAM,GACT,EAAE,wBAAwB,2CAsP1B"}
@@ -12,6 +12,8 @@ import { format, parseISO } from 'date-fns';
12
12
  import { es } from 'date-fns/locale';
13
13
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react';
14
14
  import { useApi } from '../api-context';
15
+ import { DynamicSelectField } from '../dynamic-select-field';
16
+ import { getFieldRef } from '../dynamic-form-schema';
15
17
  function resolvePath(obj, path) {
16
18
  return path.split('.').reduce((acc, part) => acc?.[part], obj);
17
19
  }
@@ -260,9 +262,21 @@ function EditField({ field, value, onChange }) {
260
262
  if (field.type === 'textarea') {
261
263
  return (_jsx(Textarea, { value: value ?? '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder, rows: 4 }));
262
264
  }
263
- if (field.type === 'image') {
265
+ // Media widgets: the kernel may serve an explicit `widget: 'upload'` (or the
266
+ // `image` type) for a file/photo column. Both render the themed dropzone
267
+ // that POSTs to the host upload endpoint — same control as the Brand logo.
268
+ if (field.type === 'image' || field.widget === 'upload') {
264
269
  return _jsx(ImageUploadField, { field: field, value: value, onChange: onChange });
265
270
  }
271
+ // FK columns: a `ref` (kernel-derived belongs_to target) or an explicit
272
+ // `widget: 'dynamic_select'` renders the async searchable picker against
273
+ // /api/options/<ref>?field=id — with option thumbnails when the remote rows
274
+ // carry an `image` — instead of a raw FK uuid text input. Static inline
275
+ // `options` are handled by the enum <Select> branch below; a ref column does
276
+ // not ship inline options, so this never shadows a static enum.
277
+ if ((getFieldRef(field) || field.widget === 'dynamic_select') && !field.options?.length) {
278
+ return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange });
279
+ }
266
280
  if (field.type === 'search' && field.searchEndpoint) {
267
281
  return _jsx(SearchField, { field: field, value: value, onChange: onChange });
268
282
  }
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-relation-helpers.d.ts","sourceRoot":"","sources":["../src/dynamic-relation-helpers.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE9E,MAAM,MAAM,mBAAmB,GAAG,aAAa,GAAG,cAAc,CAAA;AAEhE,MAAM,WAAW,YAAY;IACzB,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAC3B,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,aAAa;IAC1B,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAC3B,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACrC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,GAC7C,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmBxB;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAC9B,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAChC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAGrB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACpC,QAAQ,EAAE,IAAI,CAAC,aAAa,EAAE,SAAS,CAAC,GAAG,IAAI,GAAG,SAAS,EAC3D,UAAU,EAAE,MAAM,GACnB,cAAc,EAAE,CAelB;AAcD;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,GAAG,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,SAAS,EAChD,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,GACnB,MAAM,CAKR;AAMD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACnC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAcrB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACpC,SAAS,EAAE,aAAa,CAAC,YAAY,CAAC,GAAG,IAAI,GAAG,SAAS,EACzD,aAAa,EAAE,MAAM,GACtB,MAAM,EAAE,CASV;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAC9B,SAAS,EAAE,aAAa,CAAC,YAAY,CAAC,GAAG,IAAI,GAAG,SAAS,EACzD,aAAa,EAAE,MAAM,GACtB,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAU9B;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CACzB,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,EAC3B,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,GAC5B;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAYzC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC3B,GAAG,EAAE,aAAa,GAAG,IAAI,GAAG,SAAS,EACrC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,EAAE,aAAa,CAAC,gBAAgB,CAAC,GAAG,IAAI,GAAG,SAAS,GAC5D,MAAM,CAiBR"}
1
+ {"version":3,"file":"dynamic-relation-helpers.d.ts","sourceRoot":"","sources":["../src/dynamic-relation-helpers.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE9E,MAAM,MAAM,mBAAmB,GAAG,aAAa,GAAG,cAAc,CAAA;AAEhE,MAAM,WAAW,YAAY;IACzB,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAC3B,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,MAAM,WAAW,aAAa;IAC1B,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IAC3B,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACrC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,GAC7C,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmBxB;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAC9B,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAChC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAGrB;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACpC,QAAQ,EAAE,IAAI,CAAC,aAAa,EAAE,SAAS,CAAC,GAAG,IAAI,GAAG,SAAS,EAC3D,UAAU,EAAE,MAAM,GACnB,cAAc,EAAE,CAoBlB;AAkBD;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,GAAG,EAAE;IAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,SAAS,EAChD,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,GACnB,MAAM,CAKR;AAMD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACnC,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,aAAa,EAAE,MAAM,EACrB,QAAQ,EAAE,MAAM,GAAG,MAAM,EACzB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAcrB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACpC,SAAS,EAAE,aAAa,CAAC,YAAY,CAAC,GAAG,IAAI,GAAG,SAAS,EACzD,aAAa,EAAE,MAAM,GACtB,MAAM,EAAE,CASV;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAC9B,SAAS,EAAE,aAAa,CAAC,YAAY,CAAC,GAAG,IAAI,GAAG,SAAS,EACzD,aAAa,EAAE,MAAM,GACtB,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAU9B;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CACzB,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,EAC3B,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,GAC5B;IAAE,KAAK,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAYzC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC3B,GAAG,EAAE,aAAa,GAAG,IAAI,GAAG,SAAS,EACrC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,EAAE,aAAa,CAAC,gBAAgB,CAAC,GAAG,IAAI,GAAG,SAAS,GAC5D,MAAM,CAiBR"}
@@ -59,6 +59,11 @@ export function deriveRelationFormFields(metadata, foreignKey) {
59
59
  type: columnTypeToFieldType(col),
60
60
  required: false,
61
61
  options: col.options?.map(o => ({ value: String(o.value), label: o.label })),
62
+ // Carry the FK target through so a belongs_to column renders the
63
+ // async searchable picker (resolveWidget → 'dynamic_select') rather
64
+ // than a raw uuid text input. Without this the column lost its `ref`
65
+ // crossing the column→field boundary and degraded to plain text.
66
+ ref: col.ref,
62
67
  });
63
68
  }
64
69
  return out;
@@ -69,6 +74,10 @@ function columnTypeToFieldType(col) {
69
74
  case 'boolean': return 'boolean';
70
75
  case 'date': return 'date';
71
76
  case 'select': return 'select';
77
+ // Media columns map to the upload widget (resolveWidget → 'upload') so an
78
+ // image/photo column gets the file dropzone instead of a text input.
79
+ case 'image': return 'image';
80
+ case 'media-gallery': return 'media';
72
81
  case 'text':
73
82
  default:
74
83
  return 'string';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "13.10.0",
3
+ "version": "13.10.2",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,7 @@ import {
10
10
  pickOptionLabel,
11
11
  relationRowKey,
12
12
  } from '../dynamic-relation-helpers'
13
+ import { resolveWidget } from '../dynamic-form-schema'
13
14
  import type { ColumnDefinition, TableMetadata } from '../types'
14
15
 
15
16
  describe('buildRelationFilterParams', () => {
@@ -134,6 +135,36 @@ describe('deriveRelationFormFields', () => {
134
135
  expect(deriveRelationFormFields(undefined, 'invoice_id')).toEqual([])
135
136
  expect(deriveRelationFormFields({ columns: [] }, 'invoice_id')).toEqual([])
136
137
  })
138
+
139
+ // The column→field boundary must carry `ref` (FK target) so a belongs_to
140
+ // column renders the searchable picker instead of a raw uuid text input.
141
+ it('propaga el ref (FK target) de la columna al field', () => {
142
+ const meta: Pick<TableMetadata, 'columns'> = {
143
+ columns: [
144
+ { key: 'product_id', label: 'Producto', type: 'text', sortable: true, filterable: false, ref: 'product' },
145
+ ],
146
+ }
147
+ const fields = deriveRelationFormFields(meta, 'invoice_id')
148
+ const prod = fields.find(f => f.key === 'product_id')
149
+ expect(prod?.ref).toBe('product')
150
+ // …and that ref drives the widget to the async searchable picker.
151
+ expect(resolveWidget(prod!)).toBe('dynamic_select')
152
+ })
153
+
154
+ // Media columns must map to a media-bearing field type so the form renders
155
+ // the upload dropzone (resolveWidget → 'upload'), not a text input.
156
+ it('mapea columnas image/media-gallery a tipos que resuelven a upload', () => {
157
+ const meta: Pick<TableMetadata, 'columns'> = {
158
+ columns: [
159
+ { key: 'logo', label: 'Logo', type: 'image', sortable: false, filterable: false },
160
+ { key: 'gallery', label: 'Galería', type: 'media-gallery', sortable: false, filterable: false },
161
+ ],
162
+ }
163
+ const fields = deriveRelationFormFields(meta, 'invoice_id')
164
+ const byKey = Object.fromEntries(fields.map(f => [f.key, f]))
165
+ expect(resolveWidget(byKey['logo'])).toBe('upload')
166
+ expect(resolveWidget(byKey['gallery'])).toBe('upload')
167
+ })
137
168
  })
138
169
 
139
170
  describe('relationRowKey', () => {
@@ -112,8 +112,17 @@ export function ActionModalDispatcher({
112
112
  return null
113
113
  }
114
114
 
115
- function buildActionUrl(endpoint: string | undefined, model: string, recordId: string, actionKey: string) {
116
- return endpoint ? `${endpoint}/${recordId}/action/${actionKey}` : `/data/${model}/me/${recordId}/action/${actionKey}`
115
+ function buildActionUrl(endpoint: string | undefined, model: string, recordId: string | undefined, actionKey: string) {
116
+ // A create-placement (collection) action has no record yet, so `recordId`
117
+ // is undefined. Omit the `/{id}` segment so the request hits the collection
118
+ // route (`/data/:model/me/action/:action`) instead of the per-record route
119
+ // (`/data/:model/me/:id/action/:action`), which would reject the literal
120
+ // "undefined" as an invalid record ID (ops dynamic.go ExecuteAction → 400).
121
+ const hasRecord = recordId != null && recordId !== '' && recordId !== 'undefined'
122
+ if (endpoint) {
123
+ return hasRecord ? `${endpoint}/${recordId}/action/${actionKey}` : `${endpoint}/action/${actionKey}`
124
+ }
125
+ return hasRecord ? `/data/${model}/me/${recordId}/action/${actionKey}` : `/data/${model}/me/action/${actionKey}`
117
126
  }
118
127
 
119
128
  function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
@@ -40,6 +40,9 @@ import { format, parseISO } from 'date-fns'
40
40
  import { es } from 'date-fns/locale'
41
41
  import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon } from 'lucide-react'
42
42
  import { useApi } from '../api-context'
43
+ import { DynamicSelectField } from '../dynamic-select-field'
44
+ import { getFieldRef } from '../dynamic-form-schema'
45
+ import type { ActionFieldDef } from '../types'
43
46
 
44
47
  interface FieldOption {
45
48
  value: string
@@ -58,6 +61,24 @@ interface FieldDef {
58
61
  hidden?: boolean
59
62
  searchEndpoint?: string
60
63
  filterBy?: string
64
+ /**
65
+ * FK target model the kernel auto-derives for a belongs_to column (>=
66
+ * v0.46.x serves it on modal fields, not just action fields). When present
67
+ * the native form renders an async searchable picker (`DynamicSelectField`)
68
+ * against `/api/options/<ref>?field=id` — with option thumbnails when the
69
+ * remote rows carry an `image` — instead of a raw FK text input. Tolerates
70
+ * the snake_case `source`/`relation` aliases the manifest may serve.
71
+ */
72
+ ref?: string
73
+ source?: string
74
+ relation?: string
75
+ /**
76
+ * Explicit renderer hint. Wins over the `type` switch: `dynamic_select`
77
+ * forces the searchable picker, `upload` forces the file dropzone. Lets the
78
+ * kernel opt a plain text/uuid column into a rich widget without changing
79
+ * its SQL type. Unknown values fall through to the `type`-based default.
80
+ */
81
+ widget?: string
61
82
  }
62
83
 
63
84
  // Permissive shape: the wire payload may omit some fields (e.g. `title` is
@@ -566,10 +587,23 @@ function EditField({ field, value, onChange }: {
566
587
  )
567
588
  }
568
589
 
569
- if (field.type === 'image') {
590
+ // Media widgets: the kernel may serve an explicit `widget: 'upload'` (or the
591
+ // `image` type) for a file/photo column. Both render the themed dropzone
592
+ // that POSTs to the host upload endpoint — same control as the Brand logo.
593
+ if (field.type === 'image' || field.widget === 'upload') {
570
594
  return <ImageUploadField field={field} value={value} onChange={onChange} />
571
595
  }
572
596
 
597
+ // FK columns: a `ref` (kernel-derived belongs_to target) or an explicit
598
+ // `widget: 'dynamic_select'` renders the async searchable picker against
599
+ // /api/options/<ref>?field=id — with option thumbnails when the remote rows
600
+ // carry an `image` — instead of a raw FK uuid text input. Static inline
601
+ // `options` are handled by the enum <Select> branch below; a ref column does
602
+ // not ship inline options, so this never shadows a static enum.
603
+ if ((getFieldRef(field as ActionFieldDef) || field.widget === 'dynamic_select') && !field.options?.length) {
604
+ return <DynamicSelectField field={field as ActionFieldDef} value={value} onChange={onChange} />
605
+ }
606
+
573
607
  if (field.type === 'search' && field.searchEndpoint) {
574
608
  return <SearchField field={field} value={value} onChange={onChange} />
575
609
  }
@@ -82,6 +82,11 @@ export function deriveRelationFormFields(
82
82
  type: columnTypeToFieldType(col),
83
83
  required: false,
84
84
  options: col.options?.map(o => ({ value: String(o.value), label: o.label })),
85
+ // Carry the FK target through so a belongs_to column renders the
86
+ // async searchable picker (resolveWidget → 'dynamic_select') rather
87
+ // than a raw uuid text input. Without this the column lost its `ref`
88
+ // crossing the column→field boundary and degraded to plain text.
89
+ ref: col.ref,
85
90
  })
86
91
  }
87
92
  return out
@@ -93,6 +98,10 @@ function columnTypeToFieldType(col: ColumnDefinition): string {
93
98
  case 'boolean': return 'boolean'
94
99
  case 'date': return 'date'
95
100
  case 'select': return 'select'
101
+ // Media columns map to the upload widget (resolveWidget → 'upload') so an
102
+ // image/photo column gets the file dropzone instead of a text input.
103
+ case 'image': return 'image'
104
+ case 'media-gallery': return 'media'
96
105
  case 'text':
97
106
  default:
98
107
  return 'string'