Repository navigation
fix(OptionEditor): render nested options under multi-select (checkbox) options #398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,7 +26,7 @@ import { | |
| ChevronRight, | ||
| } from 'lucide-react'; | ||
| import { getInterfaceLangKey } from '@/i18n'; | ||
| import { findSwitchCase } from '@/utils/optionHelpers'; | ||
| import { getSelectedCasesWithOptions } from '@/utils/optionHelpers'; | ||
| import { getCheckboxMaxCount, getCheckboxMinCount } from '@/utils/checkboxOptionValidation'; | ||
| import { stripInlineRichText } from '@/utils/richText'; | ||
| import { SwitchButton, TextInput, FileInput, TimeInput, HotkeyInput } from './FormControls'; | ||
|
|
@@ -40,6 +40,13 @@ export function switchHasNestedOptions(optionDef: OptionDefinition): boolean { | |
| return optionDef.cases.some((c: CaseItem) => c.option && c.option.length > 0); | ||
| } | ||
|
|
||
| /** 嵌套子选项分组:checkbox 多选时,不同 case 的子项需要分开渲染才能区分归属 */ | ||
| interface NestedOptionGroup { | ||
| caseName: string; | ||
| label: string; | ||
| optionKeys: string[]; | ||
| } | ||
|
|
||
| /** 子选项折叠箭头:复用任务标题的 ChevronRight 样式,位于开关/下拉框左侧 */ | ||
| function OptionCollapseArrow({ | ||
| collapsed, | ||
|
|
@@ -476,24 +483,18 @@ export function OptionEditor({ | |
| : undefined; | ||
| const effectiveDisabled = disabled || isOptionIncompatible; | ||
|
|
||
| // 获取当前选中的 case(用于渲染嵌套选项) | ||
| const getSelectedCase = (): CaseItem | undefined => { | ||
| if (optionDef.type === 'switch') { | ||
| const isChecked = effectiveValue?.type === 'switch' ? effectiveValue.value : false; | ||
| return findSwitchCase(optionDef.cases, isChecked); | ||
| } | ||
| if (optionDef.type === 'select' || !optionDef.type) { | ||
| const caseName = | ||
| effectiveValue?.type === 'select' | ||
| ? effectiveValue.caseName | ||
| : optionDef.default_case || optionDef.cases?.[0]?.name; | ||
| return optionDef.cases?.find((c) => c.name === caseName); | ||
| } | ||
| return undefined; | ||
| }; | ||
| // 嵌套子选项分组:switch/select 单选取当前 case;checkbox 为多选, | ||
| // 协议允许任意 case 挂 option,故按被选中的 case 逐组汇总(每组保留标签用于区分来源) | ||
| const toNestedGroup = (caseItem: CaseItem): NestedOptionGroup => ({ | ||
| caseName: caseItem.name, | ||
| label: isMxuOption | ||
| ? t(caseItem.label || caseItem.name) | ||
| : resolveI18nText(caseItem.label, langKey) || caseItem.name, | ||
| optionKeys: caseItem.option ?? [], | ||
| }); | ||
|
|
||
| const selectedCase = getSelectedCase(); | ||
| const nestedOptionKeys = selectedCase?.option || []; | ||
| const nestedGroups = getSelectedCasesWithOptions(optionDef, effectiveValue).map(toNestedGroup); | ||
| const nestedOptionKeys = nestedGroups.flatMap((g) => g.optionKeys); | ||
|
|
||
| // 当前选项的子选项是否处于折叠状态(缺省展开) | ||
| const isCollapsed = globalScope ? localCollapsed : !!collapsedOptions?.[optionKey]; | ||
|
|
@@ -628,17 +629,28 @@ export function OptionEditor({ | |
| isOptionIncompatible && 'opacity-60', | ||
| )} | ||
| > | ||
| <OptionLabelWithIncompatible | ||
| label={optionLabel} | ||
| icon={optionDef.icon} | ||
| basePath={basePath} | ||
| incompatibleReason={incompatibleReason} | ||
| /> | ||
| <OptionDescription | ||
| description={optionDescription} | ||
| basePath={basePath} | ||
| translations={translations} | ||
| /> | ||
| <div className="flex items-start gap-3"> | ||
| <div className="min-w-0 flex-1 max-w-[60%]"> | ||
| <OptionLabelWithIncompatible | ||
| label={optionLabel} | ||
| icon={optionDef.icon} | ||
| basePath={basePath} | ||
| incompatibleReason={incompatibleReason} | ||
| /> | ||
| <OptionDescription | ||
| description={optionDescription} | ||
| basePath={basePath} | ||
| translations={translations} | ||
| /> | ||
| </div> | ||
| {nestedOptionKeys.length > 0 && ( | ||
| <OptionCollapseArrow | ||
| collapsed={isCollapsed} | ||
| onToggle={handleToggleCollapsed} | ||
| disabled={effectiveDisabled} | ||
| /> | ||
| )} | ||
| </div> | ||
| <div className="grid grid-cols-4 gap-1"> | ||
| {optionDef.cases.map((caseItem) => { | ||
| const caseLabel = isMxuOption | ||
|
|
@@ -710,6 +722,44 @@ export function OptionEditor({ | |
| </span> | ||
| </div> | ||
| )} | ||
| {/* 渲染嵌套选项(可折叠,复用任务标题的 grid 展开动画)。 | ||
| checkbox 多选时同一层可能挂多组子项,按 case 分组并标注归属,避免来源混淆 */} | ||
| {nestedOptionKeys.length > 0 && ( | ||
| <div | ||
| className="grid transition-[grid-template-rows] duration-150 ease-out" | ||
| style={{ gridTemplateRows: isCollapsed ? '0fr' : '1fr' }} | ||
| > | ||
| <div className={clsx('min-h-0', isCollapsed ? 'overflow-hidden' : 'overflow-visible')}> | ||
| <div className="space-y-3"> | ||
| {nestedGroups.map((group) => ( | ||
| <div key={group.caseName} className="space-y-3"> | ||
| {nestedGroups.length > 1 && ( | ||
| <div className="text-xs text-text-muted"> | ||
| <span className="inline-block max-w-full truncate rounded bg-bg-hover px-1.5 py-0.5"> | ||
| <RichLabel text={group.label} /> | ||
| </span> | ||
| </div> | ||
| )} | ||
| {group.optionKeys.map((nestedKey) => ( | ||
| <OptionEditor | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 中 · 无效的嵌套选择未通过校验 当选中的复选框分支包含一个嵌套复选框,而其选择违反了配置的数量限制时, 请让复选框校验递归遍历所选分支的选项键。 面向 AI 代理的提示在 `src/components/OptionEditor.tsx` 第 744 行:
**无效的嵌套选择未通过校验**
当选中的复选框分支包含一个嵌套复选框,而其选择违反了配置的数量限制时,`validateOptionKeys` 在检查外层复选框后就会返回,不会遍历所选分支的选项键。因此,`validateInstanceCheckboxCounts` 会漏掉嵌套选项的数量限制违规,程序启动时仍会采用无效选择。
请让复选框校验递归遍历所选分支的选项键。Original comment in English🟡 Medium · Invalid nested selections pass validation When a selected checkbox case contains a nested checkbox whose selection violates its configured count limits, Make checkbox validation recurse into the option keys of selected cases. Prompt for AI agentsIn `src/components/OptionEditor.tsx` at line 744:
**Invalid nested selections pass validation**
When a selected checkbox case contains a nested checkbox whose selection violates its configured count limits, `validateOptionKeys` returns after checking the outer checkbox without visiting its selected cases’ option keys, so `validateInstanceCheckboxCounts` misses the nested count violation and startup proceeds with an invalid selection.
Make checkbox validation recurse into the option keys of selected cases.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 中 · 循环引用的选项会导致渲染崩溃 当选中复选框分支中的某个嵌套选项引用了祖先路径上已有的选项时, 请在递归渲染期间跟踪祖先选项键,并在渲染重复选项之前停止递归或报告循环引用。 面向 AI 代理的提示在 `src/components/OptionEditor.tsx` 第 744 行:
**循环引用的选项会导致渲染崩溃**
当选中复选框分支中的某个嵌套选项引用了祖先路径上已有的选项时,`OptionEditor` 会递归渲染被引用的子项,却不跟踪已访问的选项键。如果某个选中的复选框分支直接或通过另一个复选框引用自身,就会导致组件不断递归渲染,直到界面发生堆栈溢出,而不是安全地显示该选项。
请在递归渲染期间跟踪祖先选项键,并在渲染重复选项之前停止递归或报告循环引用。Original comment in English🟡 Medium · Cyclic options crash rendering When a selected checkbox case’s nested option references an option already on its ancestor path, Track ancestor option keys during recursive rendering and stop or report a cycle before rendering the repeated option. Prompt for AI agentsIn `src/components/OptionEditor.tsx` at line 744:
**Cyclic options crash rendering**
When a selected checkbox case’s nested option references an option already on its ancestor path, `OptionEditor` recursively renders the referenced child without tracking visited option keys. A selected checkbox case that refers back to itself (directly or through another checkbox) causes recursive component rendering until the UI hits a stack overflow instead of displaying the option safely.
Track ancestor option keys during recursive rendering and stop or report a cycle before rendering the repeated option.
Comment on lines
+743
to
+744
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 中 · 共享设置会重复显示 当两个同时选中的复选框分支引用同一个嵌套选项键时, 请为每个唯一的嵌套选项键只渲染一个编辑器。 面向 AI 代理的提示在 `src/components/OptionEditor.tsx` 第 743–744 行:
**共享设置会重复显示**
当两个同时选中的复选框分支引用同一个嵌套选项键时,`nestedGroups.map` 会为每个引用都渲染一个编辑器,而每个编辑器都会读取和写入同一个 `allOptionValues[nestedKey]`;用户会看到同一设置的多个控件,修改其中任意一个都会同时影响其他控件。
请为每个唯一的嵌套选项键只渲染一个编辑器。Original comment in English🟡 Medium · Shared settings appear twice When two simultaneously selected checkbox cases reference the same nested option key, Render only one editor for each unique nested option key. Prompt for AI agentsIn `src/components/OptionEditor.tsx` at lines 743-744:
**Shared settings appear twice**
When two simultaneously selected checkbox cases reference the same nested option key, `nestedGroups.map` renders an editor for each reference, and each editor reads and writes the same `allOptionValues[nestedKey]`; callers see duplicate controls for one setting, and changing either changes both.
Render only one editor for each unique nested option key. |
||
| key={`${group.caseName}:${nestedKey}`} | ||
| instanceId={instanceId} | ||
| taskId={taskId} | ||
| optionKey={nestedKey} | ||
| value={allOptionValues[nestedKey]} | ||
| depth={depth + 1} | ||
| disabled={effectiveDisabled} | ||
| globalScope={globalScope} | ||
| controllerIncompatible={isOptionIncompatible} | ||
| parentIncompatibilityReason={incompatibleReasonType} | ||
| /> | ||
| ))} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| /** | ||
| * 嵌套子选项推导的单测。 | ||
| * | ||
| * 背景:ProjectInterface 协议允许 case.option 挂子配置项(最大嵌套无限层)。 | ||
| * switch / select 是单选,取当前选中的 case 即可;checkbox 是多选, | ||
| * 任意被勾选的 case 都可能带子项,必须逐个汇总 —— 早期实现按单选处理, | ||
| * 导致 checkbox 下的子配置项永远推导不出来。 | ||
| * | ||
| * 被测模块只有 type-only import,运行时无需 DOM / Tauri 环境。 | ||
| */ | ||
|
|
||
| import { test } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
|
|
||
| import { getSelectedCasesWithOptions, findSwitchCase } from '../src/utils/optionHelpers.ts'; | ||
|
|
||
| type CaseItem = { | ||
| name: string; | ||
| label?: string; | ||
| option?: string[]; | ||
| }; | ||
|
|
||
| function checkbox(cases: CaseItem[], default_case?: string[]) { | ||
| return { type: 'checkbox' as const, cases, default_case }; | ||
| } | ||
|
|
||
| test('checkbox:勾选一个带子项的 case 时返回它', () => { | ||
| const optionDef = checkbox([ | ||
| { name: '节点2舰种1', option: ['节点2检测目标名', '节点2反向检测目标'] }, | ||
| { name: '节点2舰种2', option: ['节点2检测目标名2'] }, | ||
| ]); | ||
| const result = getSelectedCasesWithOptions(optionDef, { | ||
| type: 'checkbox', | ||
| caseNames: ['节点2舰种1'], | ||
| }); | ||
| assert.deepEqual( | ||
| result.map((c) => c.name), | ||
| ['节点2舰种1'], | ||
| ); | ||
| }); | ||
|
|
||
| test('checkbox:同时勾选多个带子项的 case 时全部返回,且保持定义顺序', () => { | ||
| const optionDef = checkbox([ | ||
| { name: 'A', option: ['a1'] }, | ||
| { name: 'B', option: ['b1'] }, | ||
| { name: 'C', option: ['c1'] }, | ||
| ]); | ||
| // 故意让勾选顺序与定义顺序相反,返回值仍应按定义顺序 | ||
| const result = getSelectedCasesWithOptions(optionDef, { | ||
| type: 'checkbox', | ||
| caseNames: ['C', 'A'], | ||
| }); | ||
| assert.deepEqual( | ||
| result.map((c) => c.name), | ||
| ['A', 'C'], | ||
| ); | ||
| }); | ||
|
|
||
| test('checkbox:勾选了不带子项的 case 时返回空数组', () => { | ||
| const optionDef = checkbox([{ name: '纯开关项' }, { name: '带子项', option: ['x'] }]); | ||
| const result = getSelectedCasesWithOptions(optionDef, { | ||
| type: 'checkbox', | ||
| caseNames: ['纯开关项'], | ||
| }); | ||
| assert.deepEqual(result, []); | ||
| }); | ||
|
|
||
| test('checkbox:value 缺失时回退到 default_case', () => { | ||
| const optionDef = checkbox( | ||
| [ | ||
| { name: 'A', option: ['a1'] }, | ||
| { name: 'B', option: ['b1'] }, | ||
| ], | ||
| ['B'], | ||
| ); | ||
| const result = getSelectedCasesWithOptions(optionDef, undefined); | ||
| assert.deepEqual( | ||
| result.map((c) => c.name), | ||
| ['B'], | ||
| ); | ||
| }); | ||
|
|
||
| test('checkbox:既无 value 也无 default_case 时返回空数组,不抛错', () => { | ||
| const optionDef = checkbox([{ name: 'A', option: ['a1'] }]); | ||
| assert.deepEqual(getSelectedCasesWithOptions(optionDef, undefined), []); | ||
| }); | ||
|
|
||
| test('switch:按布尔值取对应分支,两个分支都可挂子项', () => { | ||
| const optionDef = { | ||
| type: 'switch' as const, | ||
| cases: [ | ||
| { name: 'Yes', option: ['on1'] }, | ||
| { name: 'No', option: ['off1'] }, | ||
| ] as [CaseItem, CaseItem], | ||
| }; | ||
| const on = getSelectedCasesWithOptions(optionDef, { type: 'switch', value: true }); | ||
| const off = getSelectedCasesWithOptions(optionDef, { type: 'switch', value: false }); | ||
| assert.deepEqual( | ||
| on.map((c) => c.name), | ||
| ['Yes'], | ||
| ); | ||
| assert.deepEqual( | ||
| off.map((c) => c.name), | ||
| ['No'], | ||
| ); | ||
| }); | ||
|
|
||
| test('switch:value 缺失时按未选中处理', () => { | ||
| const optionDef = { | ||
| type: 'switch' as const, | ||
| cases: [{ name: 'Yes', option: ['on1'] }, { name: 'No' }] as [CaseItem, CaseItem], | ||
| }; | ||
| assert.deepEqual(getSelectedCasesWithOptions(optionDef, undefined), []); | ||
| }); | ||
|
|
||
| test('select:按 caseName 取对应 case', () => { | ||
| const optionDef = { | ||
| type: 'select' as const, | ||
| cases: [{ name: '简单' }, { name: '完整', option: ['子项1', '子项2'] }], | ||
| }; | ||
| const result = getSelectedCasesWithOptions(optionDef, { type: 'select', caseName: '完整' }); | ||
| assert.deepEqual( | ||
| result.map((c) => c.name), | ||
| ['完整'], | ||
| ); | ||
| }); | ||
|
|
||
| test('select:无 value 时依次回退 default_case、首个 case', () => { | ||
| const withDefault = { | ||
| type: 'select' as const, | ||
| cases: [{ name: 'A' }, { name: 'B', option: ['b1'] }], | ||
| default_case: 'B', | ||
| }; | ||
| assert.deepEqual( | ||
| getSelectedCasesWithOptions(withDefault, undefined).map((c) => c.name), | ||
| ['B'], | ||
| ); | ||
|
|
||
| const withoutDefault = { type: 'select' as const, cases: [{ name: 'A', option: ['a1'] }] }; | ||
| assert.deepEqual( | ||
| getSelectedCasesWithOptions(withoutDefault, undefined).map((c) => c.name), | ||
| ['A'], | ||
| ); | ||
| }); | ||
|
|
||
| test('select:省略 type 时按 select 处理(协议允许)', () => { | ||
| const optionDef = { cases: [{ name: 'A', option: ['a1'] }] }; | ||
| assert.deepEqual( | ||
| getSelectedCasesWithOptions(optionDef, undefined).map((c) => c.name), | ||
| ['A'], | ||
| ); | ||
| }); | ||
|
|
||
| test('input / hotkey:没有 case,返回空数组', () => { | ||
| const optionDef = { | ||
| type: 'input' as const, | ||
| inputs: [{ name: 'field' }], | ||
| }; | ||
| assert.deepEqual(getSelectedCasesWithOptions(optionDef, undefined), []); | ||
| }); | ||
|
|
||
| test('回归:checkbox 不得按单选逻辑只返回第一个分支', () => { | ||
| // 真实场景:战斗检测目标舰种 12 个 case,每个挂 3 个子项 | ||
| const cases: CaseItem[] = []; | ||
| for (let node = 1; node <= 2; node += 1) { | ||
| for (let ship = 1; ship <= 2; ship += 1) { | ||
| cases.push({ | ||
| name: `节点${node}舰种${ship}`, | ||
| option: [`节点${node}检测目标名`, `节点${node}反向检测目标`], | ||
| }); | ||
| } | ||
| } | ||
| const optionDef = checkbox(cases); | ||
| const result = getSelectedCasesWithOptions(optionDef, { | ||
| type: 'checkbox', | ||
| caseNames: ['节点2舰种1', '节点1舰种2'], | ||
| }); | ||
| assert.equal(result.length, 2, '两个勾选项都应被推导出来'); | ||
| assert.deepEqual( | ||
| result.flatMap((c) => c.option ?? []), | ||
| ['节点1检测目标名', '节点1反向检测目标', '节点2检测目标名', '节点2反向检测目标'], | ||
| ); | ||
| }); | ||
|
|
||
| test('findSwitchCase:仍按 Yes/No 别名匹配', () => { | ||
| const cases: CaseItem[] = [{ name: 'Yes' }, { name: 'No' }]; | ||
| assert.equal(findSwitchCase(cases, true)?.name, 'Yes'); | ||
| assert.equal(findSwitchCase(cases, false)?.name, 'No'); | ||
| assert.equal(findSwitchCase(undefined, true), undefined); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 高 · 嵌套编辑不会影响输出
当选中的复选框分支包含嵌套选项,且这些选项的值应影响任务流水线或前置任务时,
collectOptionOverrides和generateTaskPipelineOverride只添加所选分支自身的覆盖值,而collectPretaskOptionValues会在复选框处返回;子选项的值因此未包含在任务和前置任务的输出中。请让流水线覆盖值和前置任务收集器递归遍历所选分支的选项键。
另请查看
src/components/OptionEditor.tsx:743。面向 AI 代理的提示
Original comment in English
🟠 High · Nested edits never reach outputs
When a selected checkbox case has nested options whose values should affect a task pipeline or pretask,
collectOptionOverridesandgenerateTaskPipelineOverrideadd only the selected case’s own override, whilecollectPretaskOptionValuesreturns at the checkbox; child values are omitted from task and pretask output.Make the pipeline override and pretask collectors recurse into the selected cases’ option keys.
Also at
src/components/OptionEditor.tsx:743.Prompt for AI agents