From 4d148aa80a22617fdff1e6db5ad7e9e87c575fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Wed, 22 Jul 2026 08:21:42 -0700 Subject: [PATCH] fix(app-shell): give inline `lookup` action params a real record picker (#3405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An action param declared inline as `{ name: 'inspector', type: 'lookup', reference: 'sys_user' }` always rendered as a plain text input asking the user to paste a record id (UUID). A QC supervisor assigning an inspector had to go find that person's UUID by hand, while the very same reference field picks records by name in the create/edit dialog. `paramToField()` degrades a picker param to text when it has no `referenceTo` target, and `referenceTo` was only ever populated on the field-backed branch of `resolveActionParams()`. The inline branch dropped the authored `reference` key entirely — as did the spec schema, which stripped it as an unknown key without an error — so an inline picker could never reach `` no matter how it was authored. The user's config was correct and silently discarded. - `resolveActionParam()` maps an inline `reference` onto `referenceTo`: on the inline branch, on the missing-field fallback branch, and as an override on the field-backed branch (matching how every other inline value overrides the resolved field). - The text degradation warns in dev naming the offending param. With `@objectstack/spec` now rejecting a targetless inline picker at parse time, reaching that path means the metadata is broken, not merely partial. - The fallback's placeholder and help text no longer claim "a picker is coming soon" — it shipped long ago, and that copy misreported a dropped config as a missing feature. All 10 locales now say the param has no reference object configured. Verified against the framework showcase (examples/app-showcase) driven in a browser: the inline `p_account` param renders the searchable picker, queries `/api/v1/data/showcase_account`, filters server-side on typed input (incl. the CJK-named account), and resolves the selection by record id. Refs objectstack-ai/objectstack#3405 Co-Authored-By: Claude Opus 4.8 --- .changeset/action-param-inline-lookup.md | 29 +++++++++ .../app-shell/src/utils/paramToField.test.ts | 21 ++++++- packages/app-shell/src/utils/paramToField.ts | 13 ++++ .../src/utils/resolveActionParams.test.ts | 63 +++++++++++++++++++ .../src/utils/resolveActionParams.ts | 18 +++++- packages/i18n/src/locales/ar.ts | 4 +- packages/i18n/src/locales/de.ts | 4 +- packages/i18n/src/locales/en.ts | 4 +- packages/i18n/src/locales/es.ts | 4 +- packages/i18n/src/locales/fr.ts | 4 +- packages/i18n/src/locales/ja.ts | 4 +- packages/i18n/src/locales/ko.ts | 4 +- packages/i18n/src/locales/pt.ts | 4 +- packages/i18n/src/locales/ru.ts | 4 +- packages/i18n/src/locales/zh.ts | 4 +- 15 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 .changeset/action-param-inline-lookup.md diff --git a/.changeset/action-param-inline-lookup.md b/.changeset/action-param-inline-lookup.md new file mode 100644 index 000000000..dc859a130 --- /dev/null +++ b/.changeset/action-param-inline-lookup.md @@ -0,0 +1,29 @@ +--- +"@object-ui/app-shell": patch +"@object-ui/i18n": patch +--- + +fix(app-shell): give inline `lookup` action params a real record picker (#3405) + +An action parameter declared inline as `{ name: 'inspector', type: 'lookup', +reference: 'sys_user' }` always rendered as a plain text input asking the user +to paste a record id (UUID) — a supervisor assigning an inspector had to go +find that person's UUID by hand, while the same reference field picks records +by name in the create/edit dialog. + +`paramToField()` degrades a picker param to text when it has no `referenceTo` +target, and `referenceTo` was only ever populated on the field-backed branch of +`resolveActionParams()`. The inline branch dropped the authored `reference` +key entirely (as did the spec schema, which stripped it as unknown), so an +inline picker could never reach `` no matter how it was authored. + +- `resolveActionParam()` now maps an inline `reference` onto `referenceTo` — on + the inline branch, on the missing-field fallback branch, and as an override + on the field-backed branch (matching how every other inline value overrides + the resolved field). +- The text degradation now warns in dev naming the offending param, since with + `@objectstack/spec` rejecting a targetless inline picker at parse time it + means the metadata is broken, not merely partial. +- The fallback's placeholder and help text no longer claim "a picker is coming + soon" — the picker has shipped, and the message now says the parameter has no + reference object configured. Updated across all 10 locales. diff --git a/packages/app-shell/src/utils/paramToField.test.ts b/packages/app-shell/src/utils/paramToField.test.ts index 64295f195..3cc47eb69 100644 --- a/packages/app-shell/src/utils/paramToField.test.ts +++ b/packages/app-shell/src/utils/paramToField.test.ts @@ -15,7 +15,7 @@ * `FORM_FIELD_TYPES` + this drift test makes that class of bug impossible to * reintroduce silently. */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { FORM_FIELD_TYPES } from '@object-ui/fields'; import type { ActionParamDef } from '@object-ui/core'; import { paramToField, resolveParamWidgetType } from './paramToField'; @@ -130,6 +130,25 @@ describe('paramToField', () => { expect(paramToField(p({ type: 'reference' }))).toMatchObject({ type: 'text' }); }); + // #3405 — the fallback is now a broken-metadata signal, not a normal path, + // so it must be audible in dev instead of silently handing the user a box + // that wants a raw UUID. + it('warns in dev when a picker param degrades for want of a target', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + paramToField(p({ name: 'inspector', type: 'lookup' })); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('inspector'); + expect(warn.mock.calls[0][0]).toContain('reference'); + + warn.mockClear(); + paramToField(p({ name: 'inspector', type: 'lookup', referenceTo: 'sys_user' })); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + it('user params keep their picker without needing referenceTo (implicit sys_user)', () => { expect(paramToField(p({ type: 'user' }))).toMatchObject({ type: 'user' }); }); diff --git a/packages/app-shell/src/utils/paramToField.ts b/packages/app-shell/src/utils/paramToField.ts index 8101abb07..fab8b12fb 100644 --- a/packages/app-shell/src/utils/paramToField.ts +++ b/packages/app-shell/src/utils/paramToField.ts @@ -53,10 +53,23 @@ const LOOKUP_WIDGET_TYPES = new Set(['lookup', 'master_detail']); * `referenceTo` target renders as a plain text input (the picker cannot query * without a target object) — preserving the dialog's long-standing behavior * for partially-resolved metadata. + * + * That fallback is now a last resort, not an expected path (#3405): inline + * params declare `reference` and field-backed ones inherit it, and the spec + * rejects a targetless inline picker at parse time. Reaching it means the + * metadata is broken or partial, so say so in dev instead of silently handing + * the user a box that wants a raw UUID. */ export function paramToField(param: ActionParamDef): Record { let type = resolveParamWidgetType(param.type); if (LOOKUP_WIDGET_TYPES.has(type) && !param.referenceTo) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + `[ActionParamDialog] Param "${param.name}" is type "${param.type}" but has no reference target, ` + + 'so it degrades to a plain record-id text input. Declare `reference: \'\'` on the param, ' + + 'or make it field-backed (`{ field: \'\' }`) to inherit the target.', + ); + } type = 'text'; } diff --git a/packages/app-shell/src/utils/resolveActionParams.test.ts b/packages/app-shell/src/utils/resolveActionParams.test.ts index 098626d38..29cb0ffb9 100644 --- a/packages/app-shell/src/utils/resolveActionParams.test.ts +++ b/packages/app-shell/src/utils/resolveActionParams.test.ts @@ -130,3 +130,66 @@ describe('resolveActionParams — widget config (ADR-0059)', () => { }); }); }); + +/** + * #3405 — an inline `lookup` param carries its own picker target. + * + * Real-world break (PLAT-DEF-005): a QC dispatch action declared + * `{ name: 'inspector', type: 'lookup', reference: 'sys_user' }`. The key was + * unknown to both the spec schema and this resolver, so it was dropped twice + * over and `paramToField()` degraded the param to a "paste the record id + * (UUID)" text box — a supervisor had to go find a user's UUID by hand. + */ +describe('resolveActionParams — inline lookup reference target (#3405)', () => { + const lookupCtx = () => + ctx({ + objects: [ + { + name: 'quality_dispatch', + fields: { + inspector: { type: 'lookup', label: '质检人', reference: 'sys_user' }, + reviewer: { type: 'lookup', label: 'Reviewer', reference_to: 'sys_user', display_field: 'name' }, + }, + }, + ], + objectName: 'quality_dispatch', + }); + + it('maps an inline `reference` onto referenceTo so the picker can query', () => { + const params: RawActionParam[] = [ + { name: 'inspector', label: '质检员', type: 'lookup', reference: 'sys_user', required: true }, + ]; + expect(resolveActionParams(params, lookupCtx())[0]).toMatchObject({ + name: 'inspector', + type: 'lookup', + referenceTo: 'sys_user', + }); + }); + + it('still inherits the target from the referenced field when field-backed', () => { + expect(resolveActionParams([{ field: 'inspector' }], lookupCtx())[0]).toMatchObject({ + type: 'lookup', + referenceTo: 'sys_user', + }); + expect(resolveActionParams([{ field: 'reviewer' }], lookupCtx())[0]).toMatchObject({ + referenceTo: 'sys_user', + displayField: 'name', + }); + }); + + it('lets an inline reference override the field metadata', () => { + const params: RawActionParam[] = [{ field: 'inspector', reference: 'sys_member' }]; + expect(resolveActionParams(params, lookupCtx())[0].referenceTo).toBe('sys_member'); + }); + + it('keeps the inline reference on the missing-field fallback branch', () => { + const params: RawActionParam[] = [ + { field: 'does_not_exist', type: 'lookup', reference: 'sys_user' }, + ]; + expect(resolveActionParams(params, lookupCtx())[0].referenceTo).toBe('sys_user'); + }); + + it('leaves referenceTo undefined for a non-picker inline param', () => { + expect(resolveActionParams([{ name: 'note', type: 'textarea' }], lookupCtx())[0].referenceTo).toBeUndefined(); + }); +}); diff --git a/packages/app-shell/src/utils/resolveActionParams.ts b/packages/app-shell/src/utils/resolveActionParams.ts index 5c77ef4ec..b7668e54c 100644 --- a/packages/app-shell/src/utils/resolveActionParams.ts +++ b/packages/app-shell/src/utils/resolveActionParams.ts @@ -43,6 +43,13 @@ export interface RawActionParam { accept?: string[]; /** Max upload size in bytes for `file`/`image` params. */ maxSize?: number; + /** + * Reference target for an INLINE `lookup`/`master_detail` param (#3405) — + * the object whose records the picker searches. Field-backed params inherit + * it from the referenced field instead (see `lookupExtras` below). Spelled + * `reference` to match `FieldSchema.reference` / `ActionParamSchema.reference`. + */ + reference?: string; /** * Visibility predicate (CEL) — mirrors the spec `ActionParamSchema.visible`. * The server serialises it through `ExpressionInputSchema` as an @@ -172,6 +179,10 @@ export function resolveActionParam( multiple: param.multiple, accept: param.accept, maxSize: param.maxSize, + // Inline picker target (#3405). Without this an inline `lookup` param + // could never reach `` — `paramToField()` degrades a + // targetless picker to a raw record-id text input. + referenceTo: param.reference, }; } @@ -196,6 +207,9 @@ export function resolveActionParam( multiple: param.multiple, accept: param.accept, maxSize: param.maxSize, + // The field is unresolvable, so an inline `reference` is the only picker + // target available — keep it rather than dropping to a text input. + referenceTo: param.reference, }; } @@ -211,7 +225,9 @@ export function resolveActionParam( const isLookupResolvedType = resolvedType === 'lookup' || resolvedType === 'reference'; const lookupExtras: Partial = isLookupResolvedType ? { - referenceTo: field.reference_to ?? field.reference, + // Inline `reference` wins, matching how every other inline value + // overrides the resolved field (#3405). + referenceTo: param.reference ?? field.reference_to ?? field.reference, displayField: field.display_field ?? field.reference_field, idField: field.id_field, descriptionField: field.description_field, diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 75eee2caa..20772df1a 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1496,8 +1496,8 @@ const ar = { uploading: "جارٍ الرفع…", defaultActionTitle: "إجراء", ok: "موافق", - lookupPlaceholder: "لصق معرف السجل (UUID) لـ {{label}}", - lookupHelpText: "أدخل معرف سجل الكائن المرجع. سيتم إضافة أداة اختيار قريباً.", + lookupPlaceholder: "معرف السجل لـ {{label}}", + lookupHelpText: "لم يتم تكوين كائن مرجعي لهذه المعلمة، لذا فإن أداة اختيار السجلات غير متاحة. أدخل معرف السجل، أو اطلب من المسؤول تصحيح معلمة الإجراء.", }, actionConfirm: { title: "تأكيد الإجراء", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f7ef9aef9..f9bda531c 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1498,8 +1498,8 @@ const de = { uploading: "Wird hochgeladen…", defaultActionTitle: "Aktion", ok: "OK", - lookupPlaceholder: "Datensatz-ID (UUID) für {{label}} einfügen", - lookupHelpText: "Geben Sie die Datensatz-ID des referenzierten Objekts ein. Eine Auswahlhilfe kommt bald.", + lookupPlaceholder: "Datensatz-ID für {{label}}", + lookupHelpText: "Für diesen Parameter ist kein Referenzobjekt konfiguriert, daher ist die Datensatzauswahl nicht verfügbar. Geben Sie eine Datensatz-ID ein oder bitten Sie einen Administrator, den Aktionsparameter zu korrigieren.", }, actionConfirm: { title: "Aktion bestätigen", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index c87bcd470..227d8d03c 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2135,8 +2135,8 @@ const en = { description: 'Please provide the required information to continue.', selectPlaceholder: 'Select {{label}}', requiredError: '{{label}} is required', - lookupPlaceholder: 'Paste the {{label}} record id (UUID)', - lookupHelpText: 'Enter the record id of the referenced object. A picker is coming soon.', + lookupPlaceholder: 'Record id for {{label}}', + lookupHelpText: 'No reference object is configured for this parameter, so the record picker is unavailable. Enter a record id, or ask an administrator to fix the action parameter.', cancel: 'Cancel', confirm: 'Confirm', uploading: 'Uploading…', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index f78cf0c8c..78c3ee869 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1498,8 +1498,8 @@ const es = { uploading: "Subiendo…", defaultActionTitle: "Acción", ok: "Aceptar", - lookupPlaceholder: "Pegar ID de registro (UUID) para {{label}}", - lookupHelpText: "Ingrese el ID del registro del objeto referenciado. Pronto se añadirá un selector.", + lookupPlaceholder: "ID de registro para {{label}}", + lookupHelpText: "Este parámetro no tiene un objeto de referencia configurado, por lo que el selector de registros no está disponible. Ingrese un ID de registro o pida a un administrador que corrija el parámetro de la acción.", }, actionConfirm: { title: "Confirmar acción", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 575a447ac..2dc3287f3 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1496,8 +1496,8 @@ const fr = { uploading: "Téléversement…", defaultActionTitle: "Action", ok: "OK", - lookupPlaceholder: "Coller l'ID d'enregistrement (UUID) pour {{label}}", - lookupHelpText: "Entrez l'ID de l'enregistrement de l'objet référencé. Un sélecteur sera bientôt ajouté.", + lookupPlaceholder: "ID d'enregistrement pour {{label}}", + lookupHelpText: "Aucun objet de référence n'est configuré pour ce paramètre, le sélecteur d'enregistrement est donc indisponible. Saisissez un ID d'enregistrement ou demandez à un administrateur de corriger le paramètre d'action.", }, actionConfirm: { title: "Confirmer l’action", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 7961d31f7..68d7568fa 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1498,8 +1498,8 @@ const ja = { uploading: "アップロード中…", defaultActionTitle: "アクション", ok: "OK", - lookupPlaceholder: "{{label}} のレコードID(UUID)を貼り付け", - lookupHelpText: "参照オブジェクトのレコードIDを入力してください。ピッカーは近日公開予定です。", + lookupPlaceholder: "{{label}} のレコードID", + lookupHelpText: "このパラメータには参照オブジェクトが設定されていないため、レコードピッカーを利用できません。レコードIDを直接入力するか、管理者にアクションパラメータの修正を依頼してください。", }, actionConfirm: { title: "操作の確認", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 07be53d57..57c3ba795 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1498,8 +1498,8 @@ const ko = { uploading: "업로드 중…", defaultActionTitle: "작업", ok: "확인", - lookupPlaceholder: "{{label}}의 레코드 ID(UUID) 붙여넣기", - lookupHelpText: "참조된 개체의 레코드 ID를 입력하세요. 선택기가 곧 추가됩니다.", + lookupPlaceholder: "{{label}}의 레코드 ID", + lookupHelpText: "이 매개변수에 참조 개체가 설정되어 있지 않아 레코드 선택기를 사용할 수 없습니다. 레코드 ID를 직접 입력하거나 관리자에게 작업 매개변수 수정을 요청하세요.", }, actionConfirm: { title: "작업 확인", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index ff6e0a9de..17bd33d86 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1496,8 +1496,8 @@ const pt = { uploading: "Enviando…", defaultActionTitle: "Ação", ok: "OK", - lookupPlaceholder: "Colar ID do registro (UUID) para {{label}}", - lookupHelpText: "Insira o ID do registro do objeto referenciado. Um seletor será adicionado em breve.", + lookupPlaceholder: "ID do registro para {{label}}", + lookupHelpText: "Este parâmetro não tem um objeto de referência configurado, portanto o seletor de registros não está disponível. Insira um ID de registro ou peça a um administrador para corrigir o parâmetro da ação.", }, actionConfirm: { title: "Confirmar ação", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 609a928df..8a9930267 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1496,8 +1496,8 @@ const ru = { uploading: "Загрузка…", defaultActionTitle: "Действие", ok: "ОК", - lookupPlaceholder: "Вставить ID записи (UUID) для {{label}}", - lookupHelpText: "Введите ID записи связанного объекта. Выбор записи будет добавлен позже.", + lookupPlaceholder: "ID записи для {{label}}", + lookupHelpText: "Для этого параметра не настроен объект ссылки, поэтому выбор записи недоступен. Введите ID записи или попросите администратора исправить параметр действия.", }, actionConfirm: { title: "Подтвердите действие", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 1d19be070..8bf39060a 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2215,8 +2215,8 @@ const zh = { description: '请填写以下信息以继续操作。', selectPlaceholder: '请选择 {{label}}', requiredError: '{{label}} 为必填项', - lookupPlaceholder: '粘贴 {{label}} 的记录 ID(UUID)', - lookupHelpText: '请输入引用记录的 ID。可视化选择器即将上线。', + lookupPlaceholder: '{{label}} 的记录 ID', + lookupHelpText: '该参数未配置引用对象,无法使用记录选择器。请直接填写记录 ID,或联系管理员修正该动作参数。', cancel: '取消', confirm: '确认', uploading: '上传中…',