Skip to content

fix(fields): resolve select + multiple to field:multiselect so its host label names the chip group - #4013

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3986-select-multiple-widget-mapping
Aug 10, 2026
Merged

fix(fields): resolve select + multiple to field:multiselect so its host label names the chip group#4013
yinlianghui merged 1 commit into
mainfrom
claude/issue-3986-select-multiple-widget-mapping

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3986

按 PM 已裁的 A 案实施:让生产端决定渲染哪个 widget —— mapFieldTypeToFormTypeselect + multiple: truefield:multiselect。一处决定 widget,声明(ComponentMeta.labelling)与渲染从此不可能错位。

机制回顾(三段各自正确,拼起来漏一格)

  1. mapFieldTypeToFormType 只按 type 字符串映射,select 恒为 field:select,不看 multiple;
  2. 对象表单据此发出 field:select,并把 multiple 随元数据带下去;
  3. SelectField 见到 config.multiple 就委派给 MultiSelectField

于是渲染的是 chip 行(MultiSelectField),但 label 关联的声明是按 select 这个键查的 —— 而 select 必须保持未声明(单选 trigger 是 button[role=combobox],可 label;裸 select 还是内建分支,压根不查注册表)。host 照旧发 for 指向 chip 行的包裹 div,label[for] 指向不可 label 元素是惰性的(HTMLLabelElement.control 返回 null):视觉上有标签,可及性树里这个标签什么都没命名。

波及面测量表(改之前逐个量,正文点名的三项)

① 两个集合的匹配逻辑

集合位置比对方式field:selectfield:multiselect结论
CASCADE_OPTION_FIELD_TYPEScomponents/renderers/form/form.tsx:250先过 normalizeFieldType(剥 field: 前缀)再查集合集合 = {select, radio, multiselect, checkboxes} —— 两个拼写都是成员语义不变;dependentValues / emptyHint 照旧注入
DATA_SOURCE_FIELD_TYPESform.tsx:233同上,normalize 后查集合 = {lookup, master_detail, tree, object-ref, filter-condition, recipient-picker} —— 两个拼写都不是成员语义不变;两者都不拿 dataSource

② 一切按 field:select 字符串判断的调用点(全仓 grep,逐点)

判断点位置结论
BUILTIN_FIELD_TYPESform.tsx:232裸名select,且刻意按 RAW type 比对;两个带前缀拼写都不命中 → 不变(裸 select 走内建分支的行为零触碰)
resolveFieldLabellingform.tsx:348这就是修复生效处:field:multiselect → normalize → multiselectlabelling: 'group' → host 改发 IDREF
级联清值 effectform.tsx:944比的是 RAW type(f.widgetf.type),未 normalize;field:selectfield:multiselect同样都落不进去 → 不变(既有缺陷,与本单无关,见文末「超范围发现」)
PROTOCOL_COMPONENTSrenderers/placeholders.tsx:64field:selectfield:multiselect都已在列 → 不变
resolveActionParams.ts:37app-shell全是注释里的引用,无逻辑 → 不变

消费半径核验:全仓 grep 确认 mapFieldTypeToFormType非注释引用只在 packages/fields(自身 2 处)与 packages/plugin-form(4 处);components / app-shell 里的命中全是注释。

③ 每个调用点是否拿得到 multiple

调用点拿得到?处置
sectionFields.ts:101(fromObjectSchema)field.multiple(整份对象元数据在作用域内)传 pair
sectionFields.ts:148(spec 覆盖分支)fd.multiplebase.multiple 都在合并完覆盖后一次性定 id(见下)
ObjectForm.tsx:567field.multiple传 pair
DrawerForm.tsx:325field.multiple传 pair
ModalForm.tsx:398field.multiple传 pair
fields/src/index.tsx:2160(resolveFormWidgetType)❌ 只有 type 字符串 —— fieldWidgetMap['select'] 命中即早退,select永不到达映射器结构上不受影响,零改动
fields/src/FieldEditWidget.tsx:145(resolveInlineEditType)❌ 同上 —— 'select' in EDIT_WIDGETS 早退,select 永不到达映射器结构上不受影响,零改动

没有任何调用点结构上被卡住(两个只有 type 字符串的调用点都在 select 到达映射器之前早退,且它们本就不传第二参数,行为可证不变),因此按 A 案继续,未升级为契约面裁决。

实施

  • mapFieldTypeToFormType(fieldType, config?) 增可选第二参(结构化,只读 multiple,仿 spec 自己的 ValueShapeFieldDef 写法),所有既有单参调用保持有效
  • override 走(MULTI_VALUE_FORM_TYPES)而非散落的 if,且 select 是唯一成员 —— 这一点是量出来的:spec 的 MULTI_CAPABLE_TYPES 更大(select / lookup / file / image,radio 挂在 select 分支、userlookup 同存储),但 LookupField / FileField / ImageField 各自在同一个 widget 内分支处理 multiple,widget id(以及随之的 labelling 声明)对两种 arity 都已正确。只有 select 的多值形态是另一个组件
  • sectionFields 的 spec 覆盖分支:id 改为在所有覆盖合并之后一次性有效 pair 算出。因为 multiple 本身就是 spec FormField 键 —— 视图只重述 multiple: true(不重述 type)也必须把 widget 移过去,multiple: false 也必须移回来。这是同一缺陷的第二道门。

死码处置:SelectField 的委派分支 —— 保留,并注明

量完确认它不是死码。三个入口仍会带着 multiple 走到它,且都不经过类型解析层:

入口为什么绕过映射器
内联表格编辑器FieldEditWidget 先在自己的 EDIT_WIDGETS 表里查到 select 就早退,并把整份元数据作为 field 转发
ActionParamDialogparamToFieldresolveFormWidgetType,同样先从 fieldWidgetMap 拿到 select 就早退,并带上 multiple: param.multiple
手写 SDUI直接点名 { type: 'field:select' } 且元数据声明 multiple

所以按仓内语义保留,并把 doc comment 里「对象表单也走这里」的已过期说法改成上表(原注释声称覆盖 the object form —— 本 PR 之后不再成立,不留给下一个读者踩)。

反向验证(先预判,再跑变异,未提交)

变异 = 删掉映射器里的 arity override(调用点照旧传 pair),即精确撤掉构成修复的那一处逻辑。

预判方向 —— 普通 Red,不属于「更多诊断」或「倒置」两族:此前这条栈从未产出规范 id,field:multiselect 的 group 声明压根没被查过,所以修复前是规则未施加、修复后才施加。

实测与预判一致:9 红,方向逐条对上

映射钉的实际输出:

AssertionError: expected 'field:select' to be 'field:multiselect' // Object.is equality
Expected: "field:multiselect"
Received: "field:select"

测试

  • packages/fields/src/field-type-alias.multiple.test.ts(新)—— 映射钉:两个方向 + 幂等 + spec 多值可用但不移动 widget 的 6 个类型 + 未知类型兜底。
  • packages/fields/src/__tests__/group-labelling-declaration.test.ts —— 加接合钉:生产端发出的 id 必须携带正确声明。这条只能放在两者之间 —— 映射器的测试不知道自己返回的 id 带什么 labelling,声明测试不知道多值 select 解析到哪个 id,缺陷就活在两个盲区之间。
  • packages/plugin-form/src/__tests__/selectMultipleGroupLabel.test.tsx(新)—— 真 ObjectForm + 真表单渲染器 + 真注册表 + 真 widget 的复现钉,只 stub dataSource;含守方向单选对照与「一张表里三个字段、没有任何 label 留着惰性/悬空 for」的类不变式;另含 normalizeSectionField 的有效-pair 单元钉。
  • 选择器纪律:label 关联用 DOM 查询实际元素并核对 id 引用(aria-labelledby 反查回 label 元素、检查 IDREF 不悬空);getAllByLabelText 因 Set 去重,断言长度而不用 getBy 顺带成立;labelable 白名单区分「resolves 但不可 label」这一本单特有形态。

registerAllFieldsReact.lazy 边界按 AGENTS.md 测试纪律在模块作用域预热(barrel 静态 re-export 即 warm-up),不放 beforeAll

验证命令与结果

  • pnpm exec vitest run packages/fields packages/plugin-form --maxWorkers=2112 files / 1523 tests passed
  • 外围消费面抽查(components 表单渲染器全目录 + 其余带 select+multiple fixture 的文件)→ 37 files / 270 tests passed
  • 仓根 pnpm exec turbo run type-check --concurrency=278/78 successful
  • node scripts/check-control-bytes.mjs → OK(3912 文件);另按 [\x00-\x08\x0b\x0c\x0e-\x1f] 自扫本 PR 全部改动文件,无命中

面切割

超范围发现(仅报告,未在本 PR 修)

  1. 级联清值 effect 对对象表单路径整体落空 —— form.tsx:944resolvedType 取的是 RAW type(f.widgetf.type),与集合里的裸名比对;对象表单发的是带前缀 id,于是任何来自对象 schema 的选项字段都进不了这个 effect,选项集收窄后旧值不会被清掉。与 表单渲染器计算的 emptyHint 被每个选项 widget 丢弃,用户看到的是硬编码英文 #3231 同族:同文件 :1514isOptionField 已经改用 normalizeFieldType 修过一次,这一处漏了。本 PR 前后同样都落不进去,故语义不变。
  2. RadioField 完全不读 multiple —— spec 的 MULTI_CAPABLE_TYPESradio 挂在 select 分支(即 multipleradio 合法),但 widget 里没有任何 multiple 分支,{ type: 'radio', multiple: true } 会静默渲染成单值 radio 组。观察类:radio 本身已声明 labelling: 'group',不存在本单这条 a11y 洞。

Generated by Claude Code

…st label names the chip group
`mapFieldTypeToFormType` keyed the widget id on the field `type` string alone,
so an object-schema `{ type: 'select', multiple: true }` picklist became
`field:select`. `SelectField` then delegated to `MultiSelectField` on
`config.multiple`: the component that RENDERED was the chip picker, while
everything keyed on the widget id still answered for the single-value combobox
-- above all the label-association declaration (`ComponentMeta.labelling`,
objectui#3961), which the form renderer resolves per widget id. The host emitted
`label for` at the chip row's wrapper div, where a `for` is inert
(`HTMLLabelElement.control` is null): a visible label naming nothing.
Declaring `select` itself `labelling: 'group'` was not available -- a
single-value select's trigger is a labelable `button[role=combobox]` whose `for`
works, and a bare `select` is a builtin the renderer resolves without the
registry. So the producer names the widget that renders: the id now carries the
arity, and declaration and render can no longer diverge.
- `mapFieldTypeToFormType(fieldType, config?)` gains an optional second
argument; only `multiple` is read. Single-argument calls are unchanged. The
override is table-driven and `select` is its only member: the spec's
multi-capable set is larger (select / lookup / file / image, `radio` on the
select branch, `user` storing like `lookup`), but LookupField, FileField and
ImageField each render both arities themselves, so their id -- and their
labelling declaration -- is already right either way.
- The four object-form producers pass the pair: ObjectForm, DrawerForm,
ModalForm, sectionFields. In sectionFields the id is computed once from the
EFFECTIVE pair after view-level overrides merge, because `multiple` is itself
a spec FormField key: a view restating only `multiple: true` moves the widget
too, and `multiple: false` moves it back.
- SelectField's delegation is KEPT, not retired. Measured, three entrances
still reach it with `multiple` set, none of which consult the alias map: the
inline grid editor (FieldEditWidget finds `select` in its own table first),
ActionParamDialog (resolveFormWidgetType returns `select` from
fieldWidgetMap first), and hand-written SDUI naming `field:select`.
Read-only rendering is untouched (objectui#4005); so is the built-in `Select`
branch (objectui#3976).
Fixes#3986
Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercelBot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 10, 2026 12:18am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-Bb_8Zq2m.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)483.72KB106.71KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)139.61KB35.99KB
fields (index.js)228.51KB56.69KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.84KB10.80KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)61.49KB17.48KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)118.67KB30.69KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)237.80KB59.48KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)113.37KB27.40KB
plugin-gantt (index.js)162.79KB39.67KB
plugin-grid (index.js)187.97KB49.79KB
plugin-kanban (index.js)48.53KB13.38KB
plugin-list (index.js)109.73KB26.55KB
plugin-map (index.js)17.00KB5.32KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.95KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3986 落地 —— select+multiple 的 widget 由生产端一处决定,#3961 的 labelling 声明从此永远查到实际渲染的组件。

核验记录(实物核验 + CI 亲读):

  1. A 案按裁定实施,测量前置完整:波及面表覆盖 ①两个集合(CASCADE/DATA_SOURCE)迁移语义不变的实证;②逐个 field:select 字符串判断点(修复生效处 / RAW 比对不变处 / 注释);③全部映射器调用点 —— 四个生产者拿得到 multiple 传 pair,两个只有 type 字符串的调用点被证明在 select 到达映射器前早退(行为可证不变,零改动)。停手升级条件未触发,按 A 案走通。
  2. spec 覆盖分支第二道门:multiple 是 spec FormField 键,视图重述它也必须移动 widget —— 合并后一次性从有效 pair 定 id,同一缺陷的两个入口一次收口。
  3. SelectField 委派分支量后确认非死码,保留:三个入口(内联表格编辑器 / ActionParamDialog / 手写 SDUI)各有实测路径且都不经过类型解析层;过期 doc comment 改写为实测入口表。⛔ 无量裁减红线遵守。
  4. 反向验证精确:变异只删映射器 arity override(调用点照旧传 pair),9 红 106 绿,红绿分布与预判逐条一致;守方向对照(单选 for→trigger、multiple:false 回落、BUILTIN 忽略声明、[fields] multiselect 的 host label 也落在不可 label 的包裹 div 上 —— 与 #3961 同一失效类,但不在其审计的六个 widget 内 #3975 multiselect e2e)全绿。
  5. 实物:10 文件与报告一致;[fields] 只读态的 group-labelled 字段:description 的 IDREF 同样没有消费者(#3990 修的是「名」,这条是「描述」) #4005 的只读面(toHostGroupProps/各 widget 只读分支)零触碰(面切割遵守);fable 0;releases 零触碰;base 落后 main 一格已核与本 PR 文件零相交,无需 rebase。
  6. out-of-scope 已落位:级联清值 effect 用 RAW type 与裸名集合比对,对象表单的带前缀 id 整体落空 —— 选项收窄后旧值不清、可脏值提交 #4014(级联清值 RAW type 比对使对象表单路径整体落空,pm:queue)、RadioField 完全不读 multiple —— spec 允许 radio 带 multiple: true,实际静默渲染成单值 radio 组 #4015(RadioField 不读 spec 合法的 multiple,finding)。
  7. CI 亲读终态:20/20 completed、0 失败(Test shard×4 至 00:24:46Z;coverage/dependabot path-filter skipped)。

#4005(只读态 describedby)自本 PR 落 main 后解锁可派 —— 当前额度受限暂停派发,已在队列注记。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 10, 2026 01:22
@yinlianghui
yinlianghui added this pull request to the merge queueAug 10, 2026
Merged via the queue into main with commit 11c1e71Aug 10, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3986-select-multiple-widget-mapping branch August 10, 2026 01:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@yinlianghui@claude