Archived
feat(interaction): data-options —— 下拉的选项从台账现读
芯片类型是人在件上现造的,写死在模版里等于「只能选出包那天就有的那几种」。 与 data-rows 同一层机制:只取值不判断,选中哪一个仍由 selectCurrent 按行内的值定。 空选项一律留着 ——「还没选」得说得出来。 brief 给的实现有两处会让测试落空,已按 TDD 修正并留痕在代码注释里: ①产出的 <select> 若留着 data-slot,renderTemplate 的 SLOT 规则会把整段 <option> 当文本槽冲掉(<select>2</select>)—— fillOptions 需要把 data-slot 一并摘掉; ②selectCurrent 只在 renderRows 的逐行流程里被调用,不在任何 data-rows 里的下拉 永远等不到人给它标 selected —— renderView 收尾再兜底调一遍(用外层作用域), 已经被行内定过的整块原样放过(靠判断块内是否已有 selected,不靠字符串层面 分不清的「是不是在行里」)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKMZDwMbyKzjw6Hembwvet
This commit is contained in:
+1
-1
@@ -7,6 +7,6 @@ export type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStor
|
|||||||
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
|
export type { InteractionServiceDeps } from './internal/usecase/interaction-service.ts';
|
||||||
export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts';
|
export { createInteractionService, visibleTo } from './internal/usecase/interaction-service.ts';
|
||||||
export { CONTROL_HOLDERS, checkTemplate, handoverHappens, inputControl, INTERACTION_PHASES, outputControl, templateIsSafe, } from './internal/domain/control-handover.ts';
|
export { CONTROL_HOLDERS, checkTemplate, handoverHappens, inputControl, INTERACTION_PHASES, outputControl, templateIsSafe, } from './internal/domain/control-handover.ts';
|
||||||
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||||
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
||||||
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
||||||
|
|||||||
@@ -435,3 +435,46 @@ describe('嵌套行模版与 data-by —— 一个候选一张表', () => {
|
|||||||
expect(all.split('使能逻辑').length - 1).toBe(2);
|
expect(all.split('使能逻辑').length - 1).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('data-options:下拉的选项从台账现读', () => {
|
||||||
|
const scope = {
|
||||||
|
'parts.chip_type': [
|
||||||
|
{ type_id: 1, type_name: 'DC-DC 降压稳压器' },
|
||||||
|
{ type_id: 2, type_name: 'LDO 线性稳压器' },
|
||||||
|
],
|
||||||
|
chip_type_id: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('把选项铺进去,并留一个空选项(「还没选」要说得出来)', () => {
|
||||||
|
const html = '<select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select>';
|
||||||
|
const out = renderView(html, scope);
|
||||||
|
expect(out).toContain('<option value=""></option>');
|
||||||
|
expect(out).toContain('<option value="1">DC-DC 降压稳压器</option>');
|
||||||
|
expect(out).toContain('<option value="2" selected>LDO 线性稳压器</option>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('选项文字要转义 —— 台账里的值是人填的', () => {
|
||||||
|
const out = renderView(
|
||||||
|
'<select data-col="c" data-slot="c" data-options="t.t" data-option-value="v" data-option-label="l"></select>',
|
||||||
|
{ 't.t': [{ v: 'x', l: '<b>坏</b>' }], c: '' },
|
||||||
|
);
|
||||||
|
expect(out).toContain('<b>坏</b>');
|
||||||
|
expect(out).not.toContain('<b>坏</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('地址取不到 → 只留那个空选项,不塞假数据(界面不撒谎)', () => {
|
||||||
|
const out = renderView('<select data-col="c" data-options="nope.nope" data-option-value="v" data-option-label="l"></select>', {});
|
||||||
|
expect(out).toContain('<option value=""></option>');
|
||||||
|
expect(out).not.toContain('undefined');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行内的下拉:每一行都铺同一份选项,选中的按各行自己的值', () => {
|
||||||
|
const html =
|
||||||
|
'<tbody data-rows="mine:a.b" data-edit="a.b">' +
|
||||||
|
'<tr><select data-col="chip_type_id" data-slot="chip_type_id" data-options="parts.chip_type" data-option-value="type_id" data-option-label="type_name"></select></tr>' +
|
||||||
|
'</tbody>';
|
||||||
|
const out = renderView(html, { ...scope, 'mine:a.b': [{ chip_type_id: 1 }, { chip_type_id: 2 }] });
|
||||||
|
expect(out.match(/<option value="1">DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
|
||||||
|
expect(out.match(/<option value="1" selected>DC-DC 降压稳压器<\/option>/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export {
|
|||||||
outputControl,
|
outputControl,
|
||||||
templateIsSafe,
|
templateIsSafe,
|
||||||
} from './internal/domain/control-handover.ts';
|
} from './internal/domain/control-handover.ts';
|
||||||
export { renderView, renderTemplate, renderRows, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
export { renderView, renderTemplate, renderRows, fillOptions, escapeHtml, displayValue } from './internal/domain/view-render.ts';
|
||||||
|
|
||||||
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
export { createInteractionStore } from './internal/adapter/interaction-store.ts';
|
||||||
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
export { chatMessageTable, taskViewTable } from './internal/adapter/interaction-schema.ts';
|
||||||
|
|||||||
@@ -283,9 +283,17 @@ const SELECT_CELL = /<select\b[^>]*\bdata-col\s*=\s*"([^"]+)"[^>]*>[\s\S]*?<\/se
|
|||||||
* 找到 `value` 与当下取值相等的那个 `<option>`,补一个 `selected`。**没有值就照旧留白**
|
* 找到 `value` 与当下取值相等的那个 `<option>`,补一个 `selected`。**没有值就照旧留白**
|
||||||
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
|
* (原样交给「没答=不改」那条规矩——这里只补「有值就该看得见」,不改「没答不算话」的语义:
|
||||||
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
|
* 人如果确实没碰这一格,交出去的还是当下这个值,原样写回等于没变)。
|
||||||
|
*
|
||||||
|
* **已经标过 `selected` 的整块原样放过**(2026-08-29,`data-options` 逼出来的):
|
||||||
|
* `renderRows` 逐行展开时会用**这一行自己的作用域**先调一遍这个函数;`renderView`
|
||||||
|
* 收尾时还会再拿**外层作用域**兜底调一遍,专为处理那些根本不在任何 `data-rows`
|
||||||
|
* 里的下拉(选项从台账现读但压根没有行可展开)。行内那一遍已经按这一行自己的值
|
||||||
|
* 定过了,收尾那一遍不该拿外层作用域覆盖它 —— 两次调用谁先谁后靠这一条区分,
|
||||||
|
* 不靠「是不是在行里」这种字符串层面分不清的信息。
|
||||||
*/
|
*/
|
||||||
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
|
function selectCurrent(inner: string, scope: Readonly<Record<string, unknown>>): string {
|
||||||
return inner.replace(SELECT_CELL, (block, col: string) => {
|
return inner.replace(SELECT_CELL, (block, col: string) => {
|
||||||
|
if (/\bselected\b/.test(block)) return block;
|
||||||
const raw = resolveSlot(scope, col);
|
const raw = resolveSlot(scope, col);
|
||||||
if (raw === undefined || raw === null || raw === '') return block;
|
if (raw === undefined || raw === null || raw === '') return block;
|
||||||
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const value = String(raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
@@ -312,7 +320,58 @@ function textareaCurrent(inner: string, scope: Readonly<Record<string, unknown>>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 先展开行,再填标量槽 —— 顺序不能反,否则行内槽位会被外层作用域填掉 */
|
/** `<select … data-options="域.对象" data-option-value="列" data-option-label="列">` */
|
||||||
|
const OPTIONS_SELECT = /<select\b([^>]*?)\bdata-options\s*=\s*"([^"]+)"([^>]*)>([\s\S]*?)<\/select>/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **下拉的选项从台账现读**(2026-08-29,spec §5.3)。
|
||||||
|
*
|
||||||
|
* 与 `data-rows` 同一层机制:**只取值,不判断**。地址写法也一致
|
||||||
|
* (`域.对象` = 任务视图,`mine:域.对象` = 本单草稿)。
|
||||||
|
*
|
||||||
|
* 为什么必须是现读而不是写死在模版里:芯片类型是**人在件上现造**的,
|
||||||
|
* 写死等于「只能选我出包那天就有的那几种」。
|
||||||
|
*
|
||||||
|
* **在行展开之前跑一遍**(`renderView` 的最内层):选项与行无关,一次铺好,
|
||||||
|
* 行展开时整段复制进每一行;哪一个 `selected` 由 `selectCurrent` 按各行自己的值定。
|
||||||
|
* 空选项一律留着 ——「还没选」得说得出来。
|
||||||
|
*
|
||||||
|
* `data-slot` 若与 `data-options` 同时出现在同一个 `<select>` 上,这里要把它摘掉:
|
||||||
|
* `renderTemplate` 的 `SLOT` 规则认标签名不认标签种类,凡带 `data-slot` 的元素一律
|
||||||
|
* 把**整段内容**换成取值的文本 —— `<select>` 的内容是一串 `<option>`,真被当成文本槽
|
||||||
|
* 处理,选项会被冲成一行数字/文字(踩过一次:`<select>2</select>`)。
|
||||||
|
*/
|
||||||
|
export function fillOptions(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||||
|
return html.replace(OPTIONS_SELECT, (_all, before: string, address: string, after: string) => {
|
||||||
|
const raw = `${before}${after}`;
|
||||||
|
const valueCol = /\bdata-option-value\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? 'value';
|
||||||
|
const labelCol = /\bdata-option-label\s*=\s*"([^"]+)"/.exec(raw)?.[1] ?? valueCol;
|
||||||
|
const attrs = raw
|
||||||
|
.replace(/\s*\bdata-option-value\s*=\s*"[^"]*"/, '')
|
||||||
|
.replace(/\s*\bdata-option-label\s*=\s*"[^"]*"/, '')
|
||||||
|
.replace(/\s*\bdata-slot\s*=\s*"[^"]*"/, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
const rows = resolveSlot(scope, address);
|
||||||
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
|
const options = list
|
||||||
|
.map((row) => {
|
||||||
|
const one = (typeof row === 'object' && row !== null ? row : {}) as Record<string, unknown>;
|
||||||
|
const value = escapeHtml(displayValue(one[valueCol]));
|
||||||
|
const label = escapeHtml(displayValue(one[labelCol]));
|
||||||
|
return `<option value="${value}">${label}</option>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
return `<select${attrs === '' ? '' : ` ${attrs}`}><option value=""></option>${options}</select>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 先铺下拉选项(行展开之前,一次铺好)→ 展开行(行内的下拉照各行自己的值定 `selected`)
|
||||||
|
* → **收尾再兜底调一遍 `selectCurrent`**(那些压根不在任何 `data-rows` 里的下拉,
|
||||||
|
* 没有「行」这一步替它调,靠这里用外层作用域补上;已经被行内那一遍定过的原样放过,
|
||||||
|
* 见 `selectCurrent` 内部的判重)→ 最后填标量槽。顺序不能反。
|
||||||
|
*/
|
||||||
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
export function renderView(html: string, scope: Readonly<Record<string, unknown>>): string {
|
||||||
return renderTemplate(renderRows(html, scope), scope);
|
return renderTemplate(selectCurrent(renderRows(fillOptions(html, scope), scope), scope), scope);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
| `data-blank` | 与 `data-slot` 同元素 | 空值**真空**(不写「无」)—— 空格子的胶囊、占位交给 CSS `:empty::after` |
|
| `data-blank` | 与 `data-slot` 同元素 | 空值**真空**(不写「无」)—— 空格子的胶囊、占位交给 CSS `:empty::after` |
|
||||||
| `data-class-of="键"` | 任意元素 | 把值消毒成 `v-<小写值>` 追加进 class(数值就是 `v-0`/`v-1`/…)。**判断留在样式表** |
|
| `data-class-of="键"` | 任意元素 | 把值消毒成 `v-<小写值>` 追加进 class(数值就是 `v-0`/`v-1`/…)。**判断留在样式表** |
|
||||||
| `data-col="台账列名"` | 可改格子(与 `data-slot` 同元素) | 渲染时多渲一个孪生 `<input class="jc-cell">`;写的是**台账列名**不是表头文字(表头改说法不该让写回错位) |
|
| `data-col="台账列名"` | 可改格子(与 `data-slot` 同元素) | 渲染时多渲一个孪生 `<input class="jc-cell">`;写的是**台账列名**不是表头文字(表头改说法不该让写回错位) |
|
||||||
|
| `data-options="域.对象"`(配 `data-option-value` / `data-option-label`) | `<select>` | **选项从台账现读**:一行一个 `<option>`,`value` 取值列、显示取名列,前面固定留一个空选项(「还没选」得说得出来)。地址写法与 `data-rows` 一致。选中哪一个仍由 `selectCurrent` 按这一行的值定 —— 模版这一侧照旧只取值不判断 |
|
||||||
| `data-edit="域.对象"` | 可改表的行容器 | 这组行可就地改;保存按它认地址 |
|
| `data-edit="域.对象"` | 可改表的行容器 | 这组行可就地改;保存按它认地址 |
|
||||||
| `data-value` | `<input>` | 值进 value 属性(孪生件用,模版一般不手写) |
|
| `data-value` | `<input>` | 值进 value 属性(孪生件用,模版一般不手写) |
|
||||||
| `data-file-of="主键列"` | 行内任意元素 | 这一格**点开是一份资料文件**:渲成 `data-file="域.对象/主键值"`,外层接住就跳到查看面。那一列空着就整个属性去掉 —— 没有文件的行不该长得像能点开 |
|
| `data-file-of="主键列"` | 行内任意元素 | 这一格**点开是一份资料文件**:渲成 `data-file="域.对象/主键值"`,外层接住就跳到查看面。那一列空着就整个属性去掉 —— 没有文件的行不该长得像能点开 |
|
||||||
|
|||||||
Reference in New Issue
Block a user