@dsh-workbench/shared 0.3.3 → 0.3.5
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/dist/api/blocker-table.d.ts +13 -0
- package/dist/api/blocker-table.js +13 -0
- package/dist/api/blocker.d.ts +6 -0
- package/dist/api/context-ref.d.ts +31 -0
- package/dist/api/context-ref.js +28 -0
- package/dist/api/dsh-client.d.ts +99 -0
- package/dist/api/dsh.d.ts +55 -3
- package/dist/api/facts.d.ts +42 -0
- package/dist/api/facts.js +33 -0
- package/dist/api/host-write.d.ts +19 -1
- package/dist/api/host-write.js +12 -0
- package/dist/api/long-task.d.ts +36 -0
- package/dist/api/long-task.js +27 -0
- package/dist/api/unit-board.d.ts +25 -0
- package/dist/api/unit-board.js +16 -0
- package/dist/api/unit-declaration.d.ts +36 -0
- package/dist/api/unit-declaration.js +24 -0
- package/dist/api/unit.d.ts +32 -0
- package/dist/api/unit.js +5 -0
- package/dist/api/workbench-client.d.ts +25 -0
- package/dist/api/workbench-client.js +13 -0
- package/dist/api/workbench-contract.d.ts +12 -0
- package/dist/components/Activity.d.ts +33 -0
- package/dist/components/Activity.js +24 -0
- package/dist/components/ApprovalDetail.d.ts +15 -1
- package/dist/components/ApprovalDetail.js +13 -1
- package/dist/components/ApprovalPlan.d.ts +14 -0
- package/dist/components/ApprovalPlan.js +8 -0
- package/dist/components/Card.d.ts +13 -0
- package/dist/components/Card.js +10 -0
- package/dist/components/CardAnchor.d.ts +14 -0
- package/dist/components/CardAnchor.js +11 -0
- package/dist/components/CardBoundary.d.ts +20 -0
- package/dist/components/CardBoundary.js +14 -0
- package/dist/components/CardHead.d.ts +13 -0
- package/dist/components/CardHead.js +10 -0
- package/dist/components/ChoiceGroup.d.ts +24 -0
- package/dist/components/ChoiceGroup.js +12 -0
- package/dist/components/Field.d.ts +10 -0
- package/dist/components/Field.js +7 -0
- package/dist/components/Form.css +6 -0
- package/dist/components/Form.d.ts +20 -0
- package/dist/components/Form.js +58 -0
- package/dist/components/FormActions.d.ts +14 -0
- package/dist/components/FormActions.js +5 -0
- package/dist/components/FormNodes.d.ts +103 -0
- package/dist/components/FormNodes.js +153 -0
- package/dist/components/FormRows.d.ts +20 -0
- package/dist/components/FormRows.js +14 -0
- package/dist/components/FormTypes.d.ts +59 -0
- package/dist/components/FormTypes.js +1 -0
- package/dist/components/Link.d.ts +12 -0
- package/dist/components/Link.js +9 -0
- package/dist/components/LongTaskRow.d.ts +19 -0
- package/dist/components/LongTaskRow.js +13 -0
- package/dist/components/Note.d.ts +11 -0
- package/dist/components/Note.js +8 -0
- package/dist/components/ResourceStates.d.ts +25 -0
- package/dist/components/ResourceStates.js +22 -0
- package/dist/components/Rows.d.ts +17 -0
- package/dist/components/Rows.js +11 -0
- package/dist/components/SaveBar.d.ts +12 -0
- package/dist/components/SaveBar.js +9 -0
- package/dist/components/SelectField.d.ts +26 -0
- package/dist/components/SelectField.js +22 -0
- package/dist/components/SelectFieldNodes.d.ts +24 -0
- package/dist/components/SelectFieldNodes.js +24 -0
- package/dist/components/SettingsSection.d.ts +11 -0
- package/dist/components/SettingsSection.js +8 -0
- package/dist/components/Spinner.d.ts +9 -0
- package/dist/components/Spinner.js +6 -0
- package/dist/components/Stack.d.ts +8 -0
- package/dist/components/Stack.js +5 -0
- package/dist/components/StateBlock.d.ts +13 -0
- package/dist/components/StateBlock.js +10 -0
- package/dist/components/TextField.d.ts +8 -0
- package/dist/components/TextField.js +5 -0
- package/dist/components/board-module.d.ts +30 -0
- package/dist/components/icons.d.ts +38 -0
- package/dist/components/icons.js +35 -0
- package/dist/config/constants.d.ts +45 -0
- package/dist/config/constants.js +45 -0
- package/dist/config/storage.d.ts +4 -0
- package/dist/config/storage.js +4 -0
- package/dist/host/browser-read.d.ts +1 -0
- package/dist/host/browser-read.js +1 -0
- package/dist/host/http.d.ts +3 -0
- package/dist/host/http.js +10 -1
- package/dist/host/tool-approval.d.ts +5 -0
- package/dist/host/tool-approval.js +11 -5
- package/dist/host/unit-face.d.ts +5 -1
- package/dist/host.d.ts +1 -0
- package/dist/host.js +1 -0
- package/dist/i18n/en.d.ts +1 -0
- package/dist/i18n/en.js +22 -8
- package/dist/i18n/index.d.ts +25 -9
- package/dist/i18n/index.js +1 -0
- package/dist/i18n/merge.d.ts +5 -0
- package/dist/i18n/merge.js +2 -0
- package/dist/i18n/zh.d.ts +23 -8
- package/dist/i18n/zh.js +22 -8
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/lib/blocker-action.d.ts +22 -0
- package/dist/lib/blocker-action.js +13 -0
- package/dist/lib/board-fact.d.ts +27 -0
- package/dist/lib/board-fact.js +18 -0
- package/dist/lib/busy.d.ts +10 -0
- package/dist/lib/busy.js +7 -0
- package/dist/lib/diy-debug.d.ts +42 -2
- package/dist/lib/diy-debug.js +19 -1
- package/dist/lib/diy-record.d.ts +98 -0
- package/dist/lib/diy-record.js +96 -0
- package/dist/lib/form-problem.d.ts +10 -0
- package/dist/lib/form-problem.js +10 -0
- package/dist/lib/panel-state.d.ts +60 -0
- package/dist/lib/panel-state.js +30 -0
- package/dist/lib/react.d.ts +39 -0
- package/dist/lib/react.js +36 -0
- package/dist/lib/text.d.ts +22 -0
- package/dist/lib/text.js +22 -0
- package/dist/lib/ui.d.ts +50 -0
- package/dist/lib/ui.js +41 -0
- package/package.json +1 -1
- package/src/api/blocker-table.ts +13 -0
- package/src/api/blocker.ts +6 -0
- package/src/api/context-ref.ts +31 -0
- package/src/api/dsh-client.ts +99 -0
- package/src/api/dsh.ts +56 -1
- package/src/api/facts.ts +42 -0
- package/src/api/host-write.ts +20 -1
- package/src/api/long-task.ts +36 -0
- package/src/api/unit-board.ts +25 -0
- package/src/api/unit-declaration.ts +36 -0
- package/src/api/unit.ts +32 -0
- package/src/api/workbench-client.ts +25 -0
- package/src/api/workbench-contract.ts +12 -0
- package/src/components/Activity.tsx +33 -0
- package/src/components/ApprovalDetail.tsx +17 -1
- package/src/components/ApprovalPlan.tsx +14 -0
- package/src/components/Card.tsx +13 -0
- package/src/components/CardAnchor.tsx +14 -0
- package/src/components/CardBoundary.tsx +20 -0
- package/src/components/CardHead.tsx +13 -0
- package/src/components/ChoiceGroup.tsx +24 -0
- package/src/components/Field.tsx +10 -0
- package/src/components/Form.css +6 -0
- package/src/components/Form.test.tsx +475 -0
- package/src/components/Form.tsx +78 -0
- package/src/components/FormActions.tsx +14 -0
- package/src/components/FormNodes.tsx +193 -0
- package/src/components/FormRows.tsx +20 -0
- package/src/components/FormTypes.ts +65 -0
- package/src/components/FormWiring.test.tsx +88 -0
- package/src/components/Link.tsx +12 -0
- package/src/components/LongTaskRow.tsx +19 -0
- package/src/components/Note.tsx +11 -0
- package/src/components/ResourceStates.tsx +25 -0
- package/src/components/Rows.tsx +17 -0
- package/src/components/SaveBar.tsx +12 -0
- package/src/components/SelectField.tsx +26 -0
- package/src/components/SelectFieldNodes.tsx +24 -0
- package/src/components/SettingsSection.tsx +11 -0
- package/src/components/Spinner.tsx +9 -0
- package/src/components/Stack.tsx +8 -0
- package/src/components/StateBlock.tsx +13 -0
- package/src/components/TextField.tsx +8 -0
- package/src/components/board-module.ts +30 -0
- package/src/components/icons.tsx +38 -0
- package/src/config/constants.ts +48 -0
- package/src/config/storage.ts +4 -0
- package/src/host/browser-read.ts +1 -0
- package/src/host/http.ts +15 -1
- package/src/host/tool-approval.ts +18 -4
- package/src/host/unit-face.ts +6 -1
- package/src/host.ts +1 -0
- package/src/i18n/en.ts +22 -8
- package/src/i18n/index.ts +3 -0
- package/src/i18n/merge.ts +5 -0
- package/src/i18n/zh.ts +23 -8
- package/src/index.ts +7 -2
- package/src/lib/blocker-action.ts +22 -0
- package/src/lib/board-fact.ts +27 -0
- package/src/lib/busy.ts +10 -0
- package/src/lib/diy-debug.test.ts +2 -1
- package/src/lib/diy-debug.ts +44 -3
- package/src/lib/diy-record.test.ts +171 -0
- package/src/lib/diy-record.ts +185 -0
- package/src/lib/form-problem.ts +10 -0
- package/src/lib/panel-state.ts +60 -0
- package/src/lib/react.ts +39 -0
- package/src/lib/text.ts +22 -0
- package/src/lib/ui.ts +50 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { FormField, FormValue } from './FormTypes.js';
|
|
4
|
+
import {
|
|
5
|
+
duplicateKeysOf,
|
|
6
|
+
formActionsOf,
|
|
7
|
+
formFirstProblemOf,
|
|
8
|
+
formProblemsOf,
|
|
9
|
+
formRowsOf,
|
|
10
|
+
formSubmitOf,
|
|
11
|
+
formValuesOf,
|
|
12
|
+
reportDuplicateKeys,
|
|
13
|
+
visibleFieldsOf,
|
|
14
|
+
} from './FormNodes.js';
|
|
15
|
+
import { FormRow } from './FormRows.js';
|
|
16
|
+
import type { FormAction } from './FormActions.js';
|
|
17
|
+
|
|
18
|
+
function field(key: string, value: FormValue, validate?: FormField['validate']): FormField {
|
|
19
|
+
return {
|
|
20
|
+
key,
|
|
21
|
+
label: key,
|
|
22
|
+
value,
|
|
23
|
+
control: (binding) => `${key}:${binding.id}:${String(binding.invalid)}:${String(binding.disabled)}`,
|
|
24
|
+
...(validate === undefined ? {} : { validate }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function propsOf(node: unknown): Record<string, unknown> {
|
|
29
|
+
return (node as { props: Record<string, unknown> }).props;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function typeOf(node: unknown): unknown {
|
|
33
|
+
return (node as { type: unknown }).type;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function keyOf(node: unknown): unknown {
|
|
37
|
+
return (node as { key: unknown }).key;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('visibleFieldsOf', () => {
|
|
41
|
+
it('★ visible: false 的那格当它不存在(不渲染、不校验、不进 values)', () => {
|
|
42
|
+
const fields = [field('env', 'prod'), { ...field('secret', 'x'), visible: false }, field('app', 'a')];
|
|
43
|
+
|
|
44
|
+
expect(visibleFieldsOf(fields).map((one) => one.key)).toEqual(['env', 'app']);
|
|
45
|
+
expect(formValuesOf(visibleFieldsOf(fields))).toEqual({ env: 'prod', app: 'a' });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('visible 不给或给 true 都留在表里;顺序不变', () => {
|
|
49
|
+
const fields = [field('a', '1'), { ...field('b', '2'), visible: true }, { ...field('c', '3'), visible: true }];
|
|
50
|
+
|
|
51
|
+
expect(visibleFieldsOf(fields).map((one) => one.key)).toEqual(['a', 'b', 'c']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('全不可见时给空数组(当提交时的表就是空的)', () => {
|
|
55
|
+
expect(visibleFieldsOf([{ ...field('a', '1'), visible: false }])).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('duplicateKeysOf / reportDuplicateKeys', () => {
|
|
60
|
+
it('★ 重复的键只报一次、按首次出现顺序;没有重复给空数组', () => {
|
|
61
|
+
expect(duplicateKeysOf([field('a', ''), field('a', ''), field('a', ''), field('b', '')])).toEqual(['a']);
|
|
62
|
+
expect(duplicateKeysOf([field('a', ''), field('b', '')])).toEqual([]);
|
|
63
|
+
expect(duplicateKeysOf([])).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('★ 空 key 也算装配错(它会让 id 撞成同一个)', () => {
|
|
67
|
+
expect(duplicateKeysOf([field('', '')])).toEqual(['']);
|
|
68
|
+
expect(duplicateKeysOf([field('', ''), field('a', '')])).toEqual(['']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('★ 运行期真漏给 key(undefined / null)也算一处,名字印成(空 key)', () => {
|
|
72
|
+
const missing = { ...field('gone', ''), key: undefined } as unknown as FormField;
|
|
73
|
+
const alsoMissing = { ...field('gone2', ''), key: null } as unknown as FormField;
|
|
74
|
+
|
|
75
|
+
expect(duplicateKeysOf([missing])).toEqual(['']);
|
|
76
|
+
expect(duplicateKeysOf([alsoMissing])).toEqual(['']);
|
|
77
|
+
expect(duplicateKeysOf([missing, alsoMissing])).toEqual(['']);
|
|
78
|
+
|
|
79
|
+
const calls: unknown[][] = [];
|
|
80
|
+
const original = console.error;
|
|
81
|
+
console.error = (...args: unknown[]) => void calls.push(args);
|
|
82
|
+
try {
|
|
83
|
+
reportDuplicateKeys([missing]);
|
|
84
|
+
} finally {
|
|
85
|
+
console.error = original;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
expect(calls).toHaveLength(1);
|
|
89
|
+
expect(String(calls[0]?.[0])).toContain('(空 key)');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('★ 装配错了只给 console.error,不 throw(照 FormActions 多一颗 primary 的形式)', () => {
|
|
93
|
+
const calls: unknown[][] = [];
|
|
94
|
+
const original = console.error;
|
|
95
|
+
console.error = (...args: unknown[]) => void calls.push(args);
|
|
96
|
+
try {
|
|
97
|
+
expect(() => reportDuplicateKeys([field('a', ''), field('a', '')])).not.toThrow();
|
|
98
|
+
} finally {
|
|
99
|
+
console.error = original;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(calls).toHaveLength(1);
|
|
103
|
+
const message = String(calls[0]?.[0]);
|
|
104
|
+
expect(message).toContain('[dsh-workbench]');
|
|
105
|
+
expect(message).toContain('a');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('装配没错就一声不响', () => {
|
|
109
|
+
const calls: unknown[][] = [];
|
|
110
|
+
const original = console.error;
|
|
111
|
+
console.error = (...args: unknown[]) => void calls.push(args);
|
|
112
|
+
try {
|
|
113
|
+
reportDuplicateKeys([field('a', ''), field('b', '')]);
|
|
114
|
+
} finally {
|
|
115
|
+
console.error = original;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
expect(calls).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('formValuesOf', () => {
|
|
123
|
+
it('★ 每格的 key 与 value 落成一张表(顺序就是字段顺序)', () => {
|
|
124
|
+
const values = formValuesOf([field('env', 'prod'), field('app', 'uedyhsj')]);
|
|
125
|
+
|
|
126
|
+
expect(values).toEqual({ env: 'prod', app: 'uedyhsj' });
|
|
127
|
+
expect(Object.keys(values)).toEqual(['env', 'app']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('★ 多选那份(一组值)原样带出,不被摊平、不被复制', () => {
|
|
131
|
+
const chosen = ['prod', 'staging'] as const;
|
|
132
|
+
|
|
133
|
+
expect(formValuesOf([field('envs', chosen)])['envs']).toBe(chosen);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('空表单给空表(不是 undefined)', () => {
|
|
137
|
+
expect(formValuesOf([])).toEqual({});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('同键后写覆盖前写(表单里键本就该唯一,重复由 reportDuplicateKeys 报出来)', () => {
|
|
141
|
+
expect(formValuesOf([field('env', 'a'), field('env', 'b')])).toEqual({ env: 'b' });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('formProblemsOf', () => {
|
|
146
|
+
const fields = [
|
|
147
|
+
field('project', '', () => '这个项目不存在'),
|
|
148
|
+
field('env', 'prod', () => undefined),
|
|
149
|
+
field('note', 'x', (value) => (value === 'x' ? '备注不能是 x' : undefined)),
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
it('★ 还没提交过:一格错都不显示(不边打字边骂人)', () => {
|
|
153
|
+
expect(formProblemsOf(fields, false)).toEqual({ project: undefined, env: undefined, note: undefined });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('★ 提交过:逐格重算,有错的给那句文案、没错的给 undefined', () => {
|
|
157
|
+
const problems = formProblemsOf(fields, true);
|
|
158
|
+
|
|
159
|
+
expect(problems['project']).toBe('这个项目不存在');
|
|
160
|
+
expect(problems['env']).toBeUndefined();
|
|
161
|
+
expect(problems['note']).toBe('备注不能是 x');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('没给 validate 的格子永远是 undefined(两层态都不报)', () => {
|
|
165
|
+
const plain = [field('env', 'prod')];
|
|
166
|
+
|
|
167
|
+
expect(formProblemsOf(plain, true)['env']).toBeUndefined();
|
|
168
|
+
expect(formProblemsOf(plain, false)['env']).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('★ 假值都算没错:undefined / null / false / 空串四种一格都不挡', () => {
|
|
172
|
+
const falsy = [undefined, null, false, ''] as const;
|
|
173
|
+
const fields = falsy.map((value, index) => field(`f${String(index)}`, 'x', () => value));
|
|
174
|
+
|
|
175
|
+
const problems = formProblemsOf(fields, true);
|
|
176
|
+
|
|
177
|
+
for (const one of fields) expect(problems[one.key]).toBeUndefined();
|
|
178
|
+
expect(formFirstProblemOf(fields, problems)).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('★ 真值才算错(`&&` 那种最顺手的写法是对的)', () => {
|
|
182
|
+
const required: FormField['validate'] = (value) =>
|
|
183
|
+
typeof value === 'string' && value.trim() === '' && '必填';
|
|
184
|
+
const empty = field('project', '', required);
|
|
185
|
+
const filled = field('note', 'ok', required);
|
|
186
|
+
|
|
187
|
+
expect(formProblemsOf([empty, filled], true)).toEqual({ project: '必填', note: undefined });
|
|
188
|
+
expect(formFirstProblemOf([empty, filled], formProblemsOf([empty, filled], true))?.key).toBe('project');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('validate 读得到整张表(第二格能看见第一格的值)', () => {
|
|
192
|
+
const cross = [
|
|
193
|
+
field('a', '1'),
|
|
194
|
+
field('b', '2', (_value, values) => (values['a'] === '1' ? 'a 是 1 就不行' : undefined)),
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
expect(formProblemsOf(cross, true)['b']).toBe('a 是 1 就不行');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('★ 不可见那格不跑 validate:两条互证 —— 未过滤会被调,过滤后不被调', () => {
|
|
201
|
+
const calls: string[] = [];
|
|
202
|
+
const hidden: FormField = {
|
|
203
|
+
...field('secret', 'x', () => {
|
|
204
|
+
calls.push('secret');
|
|
205
|
+
return '永远不该被算';
|
|
206
|
+
}),
|
|
207
|
+
visible: false,
|
|
208
|
+
};
|
|
209
|
+
const fields = [field('env', 'prod'), hidden];
|
|
210
|
+
|
|
211
|
+
formProblemsOf(fields, true);
|
|
212
|
+
expect(calls).toEqual(['secret']);
|
|
213
|
+
|
|
214
|
+
calls.length = 0;
|
|
215
|
+
const problems = formProblemsOf(visibleFieldsOf(fields), true);
|
|
216
|
+
|
|
217
|
+
expect(calls).toEqual([]);
|
|
218
|
+
expect(problems).toEqual({ env: undefined });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('formFirstProblemOf', () => {
|
|
223
|
+
it('★ 按字段顺序取第一处(不是按表里的键序)', () => {
|
|
224
|
+
const fields = [field('a', ''), field('b', ''), field('c', '')];
|
|
225
|
+
const problems = { c: 'c 错', b: 'b 错', a: 'a 错' };
|
|
226
|
+
|
|
227
|
+
expect(formFirstProblemOf(fields, problems)?.key).toBe('a');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('★ 一格都没错给 undefined(那一拍不滚、不抢焦点)', () => {
|
|
231
|
+
expect(formFirstProblemOf([field('a', 'ok')], { a: undefined })).toBeUndefined();
|
|
232
|
+
expect(formFirstProblemOf([], {})).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('formSubmitOf', () => {
|
|
237
|
+
it('★ 全过 ⇒ blocked: false,交出来的键就是那几个(值现取)', () => {
|
|
238
|
+
const outcome = formSubmitOf([field('env', 'prod'), field('app', 'uedyhsj')]);
|
|
239
|
+
|
|
240
|
+
expect(outcome.blocked).toBe(false);
|
|
241
|
+
expect(outcome.blocked ? {} : outcome.values).toEqual({ env: 'prod', app: 'uedyhsj' });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('★ 任一格有错 ⇒ blocked: true,first 是按字段顺序的第一格(不是随便一格)', () => {
|
|
245
|
+
const fields = [
|
|
246
|
+
field('a', '', () => undefined),
|
|
247
|
+
field('b', '', () => 'b 错了'),
|
|
248
|
+
field('c', '', () => 'c 也错了'),
|
|
249
|
+
];
|
|
250
|
+
const outcome = formSubmitOf(fields);
|
|
251
|
+
|
|
252
|
+
expect(outcome.blocked).toBe(true);
|
|
253
|
+
expect(outcome.blocked ? outcome.first.key : undefined).toBe('b');
|
|
254
|
+
expect(outcome.blocked ? 'values' in outcome : true).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('★ 第一格就错时 first 是它(顺序判据两头都咬得住)', () => {
|
|
258
|
+
const fields = [field('a', '', () => 'a 错了'), field('b', '', () => 'b 也错了')];
|
|
259
|
+
const outcome = formSubmitOf(fields);
|
|
260
|
+
|
|
261
|
+
expect(outcome.blocked ? outcome.first.key : undefined).toBe('a');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('★ validate 看到的是整张 values(跨字段那种校验做得了)', () => {
|
|
265
|
+
const bothFilled: FormField['validate'] = (_value, values) =>
|
|
266
|
+
values['a'] !== '' && values['b'] !== '' ? '两个不能同时填' : undefined;
|
|
267
|
+
const fields = [field('a', 'x'), field('b', 'y', bothFilled)];
|
|
268
|
+
const outcome = formSubmitOf(fields);
|
|
269
|
+
|
|
270
|
+
expect(outcome.blocked).toBe(true);
|
|
271
|
+
expect(outcome.blocked ? outcome.first.key : undefined).toBe('b');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('★ 没给 validate 的格子一律不算错(哪怕值是空串)', () => {
|
|
275
|
+
const outcome = formSubmitOf([field('a', ''), field('b', ['x'])]);
|
|
276
|
+
|
|
277
|
+
expect(outcome.blocked).toBe(false);
|
|
278
|
+
expect(outcome.blocked ? {} : outcome.values).toEqual({ a: '', b: ['x'] });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('空表 ⇒ blocked: false 与空 values(一张没有字段的表没有可挡的)', () => {
|
|
282
|
+
const outcome = formSubmitOf([]);
|
|
283
|
+
|
|
284
|
+
expect(outcome.blocked).toBe(false);
|
|
285
|
+
expect(outcome.blocked ? {} : outcome.values).toEqual({});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('★ 只算给进来的那些格子(不可见那格由调用点先过滤掉,这里看不到它)', () => {
|
|
289
|
+
const fields = [field('env', 'prod'), { ...field('secret', 'x', () => '永远不该被算'), visible: false }];
|
|
290
|
+
const outcome = formSubmitOf(visibleFieldsOf(fields));
|
|
291
|
+
|
|
292
|
+
expect(outcome.blocked).toBe(false);
|
|
293
|
+
expect(outcome.blocked ? {} : outcome.values).toEqual({ env: 'prod' });
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('formActionsOf', () => {
|
|
298
|
+
const submit = { label: '保存', busyLabel: '保存中…' };
|
|
299
|
+
const cancel = { label: '取消', onClick: () => {} };
|
|
300
|
+
|
|
301
|
+
function base(over: Partial<Parameters<typeof formActionsOf>[0]> = {}) {
|
|
302
|
+
return formActionsOf({
|
|
303
|
+
extra: undefined,
|
|
304
|
+
cancel: undefined,
|
|
305
|
+
submit,
|
|
306
|
+
busy: false,
|
|
307
|
+
disabled: false,
|
|
308
|
+
onSubmitClick: () => {},
|
|
309
|
+
...over,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
it('★ 顺序:extra → cancel → submit(primary),提交那颗永远在最右', () => {
|
|
314
|
+
const actions = base({
|
|
315
|
+
extra: [
|
|
316
|
+
{ key: 'retry', label: '重试', onClick: () => {} },
|
|
317
|
+
{ key: 'remove', label: '清除', kind: 'danger', onClick: () => {} },
|
|
318
|
+
],
|
|
319
|
+
cancel,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(actions.map((one) => [one.key, one.kind])).toEqual([
|
|
323
|
+
['retry', undefined],
|
|
324
|
+
['remove', 'danger'],
|
|
325
|
+
['cancel', 'default'],
|
|
326
|
+
['submit', 'primary'],
|
|
327
|
+
]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('没给 extra 也没给 cancel 时只剩提交那一颗(且仍是 primary)', () => {
|
|
331
|
+
expect(base().map((one) => [one.key, one.kind])).toEqual([['submit', 'primary']]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('★ busy:提交钮换 busyLabel、带 busy、禁用;cancel 与 extra 一起禁用', () => {
|
|
335
|
+
const extra: readonly FormAction[] = [{ key: 'retry', label: '重试', onClick: () => {} }];
|
|
336
|
+
const [retry, cancelAction, submitAction] = base({ extra, cancel, busy: true });
|
|
337
|
+
|
|
338
|
+
expect(submitAction?.label).toBe('保存中…');
|
|
339
|
+
expect(submitAction?.busy).toBe(true);
|
|
340
|
+
expect(submitAction?.disabled).toBe(true);
|
|
341
|
+
expect(cancelAction?.disabled).toBe(true);
|
|
342
|
+
expect(retry?.disabled).toBe(true);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('★ busy 不给 busyLabel 时沿用 label', () => {
|
|
346
|
+
const actions = base({ submit: { label: '保存' }, busy: true });
|
|
347
|
+
|
|
348
|
+
expect(actions[0]?.label).toBe('保存');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('★ disabled 只禁提交钮,其余动作不受影响', () => {
|
|
352
|
+
const actions = base({ extra: [{ key: 'retry', label: '重试', onClick: () => {} }], cancel, disabled: true });
|
|
353
|
+
|
|
354
|
+
expect(actions.find((one) => one.key === 'submit')?.disabled).toBe(true);
|
|
355
|
+
expect(actions.find((one) => one.key === 'cancel')?.disabled).toBe(false);
|
|
356
|
+
expect(actions.find((one) => one.key === 'retry')?.disabled).toBeUndefined();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('提交钮的 onClick 就是给进来的那一拍(顺序不在调用点手里,动作的后果在)', () => {
|
|
360
|
+
let clicked = 0;
|
|
361
|
+
const actions = base({ onSubmitClick: () => (clicked += 1) });
|
|
362
|
+
|
|
363
|
+
actions[0]?.onClick();
|
|
364
|
+
expect(clicked).toBe(1);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe('formRowsOf', () => {
|
|
369
|
+
const problems = { env: '选一个环境' };
|
|
370
|
+
|
|
371
|
+
it('★ 每格直接就是一颗 FormRow(中间不许包 div,否则 subgrid 不匹配、控件左沿参差)', () => {
|
|
372
|
+
const rows = formRowsOf({ fields: [field('env', 'prod'), field('app', 'a')], problems, showProblem: true, busy: false });
|
|
373
|
+
|
|
374
|
+
expect(rows).toHaveLength(2);
|
|
375
|
+
for (const row of rows) {
|
|
376
|
+
expect(typeOf(row)).toBe(FormRow);
|
|
377
|
+
expect(propsOf(row)['className']).toBeUndefined();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('★ key 挂在 FormRow 自己身上(React 的列表键在这一层)', () => {
|
|
382
|
+
const rows = formRowsOf({ fields: [field('env', 'prod')], problems, showProblem: true, busy: false });
|
|
383
|
+
|
|
384
|
+
expect(keyOf(rows[0])).toBe('env');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('★ problem 透传给 FormRow:有错那格显示文案并挂 data-wb-problem,没错那格没有', () => {
|
|
388
|
+
const rows = formRowsOf({
|
|
389
|
+
fields: [field('env', 'prod'), field('app', 'a')],
|
|
390
|
+
problems,
|
|
391
|
+
showProblem: true,
|
|
392
|
+
busy: false,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(propsOf(rows[0])['problem']).toBe('选一个环境');
|
|
396
|
+
expect(propsOf(rows[1])['problem']).toBeUndefined();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('★ 控件那格拿到的绑定:invalid 只在这次该显示错时为真', () => {
|
|
400
|
+
const rows = formRowsOf({ fields: [field('env', 'prod')], problems, showProblem: true, busy: false });
|
|
401
|
+
|
|
402
|
+
expect(propsOf(rows[0])['children']).toBe('env:wb-form-env:true:false');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('★ 还没提交过(showProblem: false):文案不画、invalid 也不给(done 之前一律 false)', () => {
|
|
406
|
+
const rows = formRowsOf({ fields: [field('env', 'prod')], problems, showProblem: false, busy: false });
|
|
407
|
+
|
|
408
|
+
expect(propsOf(rows[0])['problem']).toBeUndefined();
|
|
409
|
+
expect(propsOf(rows[0])['children']).toBe('env:wb-form-env:false:false');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('★ busy 让每格的绑定 disabled 为真;layout 与 hint 原样透传', () => {
|
|
413
|
+
const withHint: FormField = { ...field('env', 'prod'), hint: '只列非生产', layout: 'stacked' };
|
|
414
|
+
const rows = formRowsOf({ fields: [withHint], problems: {}, showProblem: false, busy: true });
|
|
415
|
+
|
|
416
|
+
expect(propsOf(rows[0])['children']).toBe('env:wb-form-env:false:true');
|
|
417
|
+
expect(propsOf(rows[0])['hint']).toBe('只列非生产');
|
|
418
|
+
expect(propsOf(rows[0])['layout']).toBe('stacked');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('没有可画的格子时给空数组', () => {
|
|
422
|
+
expect(formRowsOf({ fields: [], problems: {}, showProblem: false, busy: false })).toEqual([]);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const CSS = readFileSync(new URL('./Form.css', import.meta.url), 'utf8');
|
|
427
|
+
|
|
428
|
+
function declarations(selector: string): string[] {
|
|
429
|
+
for (const block of CSS.split('}')) {
|
|
430
|
+
const [head, ...rest] = block.split('{');
|
|
431
|
+
if (rest.length === 0 || head === undefined) continue;
|
|
432
|
+
if (head.trim() !== selector) continue;
|
|
433
|
+
return rest
|
|
434
|
+
.join('{')
|
|
435
|
+
.split(';')
|
|
436
|
+
.map((one) => one.replace(/\s+/g, ' ').trim())
|
|
437
|
+
.filter((one) => one !== '');
|
|
438
|
+
}
|
|
439
|
+
throw new Error(`Form.css 里没有这条规则:${selector}`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
describe('整张表单的版式(spec §2.2 规则三)', () => {
|
|
443
|
+
it('★ `.wb-form` 是那个纵向栈:flex / column / 唯一一档间距', () => {
|
|
444
|
+
const rule = declarations('.wb-form');
|
|
445
|
+
|
|
446
|
+
expect(rule).toContain('display: flex');
|
|
447
|
+
expect(rule).toContain('flex-direction: column');
|
|
448
|
+
expect(rule).toContain('gap: var(--wb-len-12)');
|
|
449
|
+
expect(rule.filter((one) => one.startsWith('gap:'))).toHaveLength(1);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('★ `.wb-form` 占满自己那一格(width: 100%),操作区才贴得到右沿', () => {
|
|
453
|
+
expect(declarations('.wb-form')).toContain('width: 100%');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('★ 位置靠结构不靠扳:`Form.css` 里不许出现 `.wb-formActions`,也不许有宽度 / 外边距去扳它', () => {
|
|
457
|
+
expect(CSS).not.toMatch(/\.wb-formActions\b/);
|
|
458
|
+
expect(CSS).not.toMatch(/justify-content|margin/);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('★ 表单级那句错不自己开类名:`.wb-formProblem` 归 FormRows,这里一个都不留', () => {
|
|
462
|
+
expect(CSS).not.toMatch(/\.wb-formProblem\b/);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('★ 认的是类名,不是 `data-wb-*` 属性选择器(那个命名空间归模块)', () => {
|
|
466
|
+
expect(CSS).not.toMatch(/\[data-wb-/);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('★ 颜色 / 字号 / 长度只走令牌,不写字面值(`width: 100%` 是占比,不是尺寸字面值)', () => {
|
|
470
|
+
const values = CSS.match(/(?:color|font-size|line-height|gap|padding|margin)\s*:\s*([^;]+);/g) ?? [];
|
|
471
|
+
|
|
472
|
+
expect(values.length).toBeGreaterThan(0);
|
|
473
|
+
for (const value of values) expect(value).toMatch(/var\(--(?:dsw|wb)-/);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { h, useEffect, useRef, useState } from '../lib/react.js';
|
|
2
|
+
import type { FormProps } from './FormTypes.js';
|
|
3
|
+
import { FormActions } from './FormActions.js';
|
|
4
|
+
import { FormRows } from './FormRows.js';
|
|
5
|
+
import { Note } from './Note.js';
|
|
6
|
+
import {
|
|
7
|
+
formActionsOf,
|
|
8
|
+
formProblemsOf,
|
|
9
|
+
formRowsOf,
|
|
10
|
+
formSubmitOf,
|
|
11
|
+
reportDuplicateKeys,
|
|
12
|
+
visibleFieldsOf,
|
|
13
|
+
} from './FormNodes.js';
|
|
14
|
+
import { revealFirstProblem } from '../lib/form-problem.js';
|
|
15
|
+
import './Form.css';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 整张表单:`fields` 进,行、错误、操作区、(被挡住的)提交都由它安排 —— 它自己一个值都不存,
|
|
19
|
+
* 值仍归调用点的 model(`value` 只读给校验看)。
|
|
20
|
+
* 还没提交过时一格错都不显示;第一次提交之后每次渲染重算,改好了当场消。
|
|
21
|
+
* 提交那一拍永远先算一遍:有错就不调 `onSubmit`,并把第一个有错的 `FormRow` 带进视口、聚焦它里面的控件。
|
|
22
|
+
* 回车与点提交钮走的是同一条路:真 `<form>` 的隐式提交在**只有一个文本框**时也会 fire,所以 `busy` / `disabled` 时回车同样不提交。
|
|
23
|
+
* 调用点只管数据与语义:行怎么排、错误摆哪、什么时候校验、按钮在哪,这里没有口子。
|
|
24
|
+
* @param props.fields - 每格的键 / 标签 / 值 / 校验 / 控件;`visible: false` 的那格当它不存在(不渲染、不校验、不进 `values`)。
|
|
25
|
+
* @param props.onSubmit - 校验全过的那一拍,回的是从 `fields` 现取的键值表;被挡住时不会被调。
|
|
26
|
+
* @param props.submit - 提交钮文案;`busyLabel` 是提交中换的那句,不给就沿用 `label`。
|
|
27
|
+
* @param props.cancel - 取消钮;给了就排在提交钮左边,`busy` 时一起禁用。
|
|
28
|
+
* @param props.extra - 其余动作(default / danger),排在最左,`busy` 时一起禁用。
|
|
29
|
+
* @param props.busy - 提交中(默认 false):整表控件的绑定 `disabled` 为真,所有动作一起禁用,回车也不提交。
|
|
30
|
+
* @param props.disabled - 只再禁提交钮(如没改动可存),各格控件与其余动作不受影响,回车也不提交。
|
|
31
|
+
* @param props.problem - 整表级那句错(提交失败),落在行与操作区之间;不给就不画。
|
|
32
|
+
* @returns 一个 `<form>`(`noValidate`:报错形状归 `FormRow`,不走浏览器的气泡)。
|
|
33
|
+
*/
|
|
34
|
+
export function Form(props: FormProps) {
|
|
35
|
+
const { fields, onSubmit, submit, cancel, extra, busy = false, disabled = false, problem } = props;
|
|
36
|
+
const [done, setDone] = useState(false);
|
|
37
|
+
const [reveal, setReveal] = useState(0);
|
|
38
|
+
const rootRef = useRef<HTMLFormElement | null>(null);
|
|
39
|
+
const shown = visibleFieldsOf(fields);
|
|
40
|
+
const problems = formProblemsOf(shown, done);
|
|
41
|
+
|
|
42
|
+
reportDuplicateKeys(shown);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (reveal === 0) return;
|
|
46
|
+
const root = rootRef.current;
|
|
47
|
+
if (root !== null) revealFirstProblem(root);
|
|
48
|
+
}, [reveal]);
|
|
49
|
+
|
|
50
|
+
const submitForm = (): void => {
|
|
51
|
+
if (busy || disabled) return;
|
|
52
|
+
const outcome = formSubmitOf(visibleFieldsOf(fields));
|
|
53
|
+
setDone(true);
|
|
54
|
+
if (outcome.blocked) {
|
|
55
|
+
setReveal((count) => count + 1);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
onSubmit(outcome.values);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<form
|
|
63
|
+
className="wb-form"
|
|
64
|
+
ref={rootRef}
|
|
65
|
+
noValidate
|
|
66
|
+
onSubmit={(event) => {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
submitForm();
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<FormRows>{formRowsOf({ fields: shown, problems, showProblem: done, busy })}</FormRows>
|
|
72
|
+
{problem === undefined ? null : h(Note, { tone: 'problem', role: 'alert' }, problem)}
|
|
73
|
+
<FormActions
|
|
74
|
+
actions={formActionsOf({ extra, cancel, submit, busy, disabled, onSubmitClick: submitForm })}
|
|
75
|
+
/>
|
|
76
|
+
</form>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -4,8 +4,14 @@ import { Button } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
|
4
4
|
import { Spinner } from './Spinner.js';
|
|
5
5
|
import './FormActions.css';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* 一颗动作按钮的语气:`default`、`danger`(破坏性,描边危险色,永远不当填充主操作)、`primary`(填充,一个区只许一颗)。
|
|
9
|
+
*/
|
|
7
10
|
export type FormActionKind = 'default' | 'danger' | 'primary';
|
|
8
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 操作区里的一颗动作:键、文案、语气与回调;`busy` 时自带转圈并禁用。
|
|
14
|
+
*/
|
|
9
15
|
export interface FormAction {
|
|
10
16
|
readonly key: string;
|
|
11
17
|
readonly label: ReactNode;
|
|
@@ -15,6 +21,9 @@ export interface FormAction {
|
|
|
15
21
|
readonly busy?: boolean | undefined;
|
|
16
22
|
}
|
|
17
23
|
|
|
24
|
+
/**
|
|
25
|
+
* `FormActions` 的契约:一组动作。
|
|
26
|
+
*/
|
|
18
27
|
export interface FormActionsProps {
|
|
19
28
|
readonly actions: readonly FormAction[];
|
|
20
29
|
}
|
|
@@ -47,6 +56,11 @@ function variantOf(kind: FormActionKind): 'ghost' | 'outline' | 'primary' {
|
|
|
47
56
|
return 'ghost';
|
|
48
57
|
}
|
|
49
58
|
|
|
59
|
+
/**
|
|
60
|
+
* 操作区:把一组动作按"primary 永远在右、danger 紧挨它左侧"排好,窄了自动折行。
|
|
61
|
+
* @param props.actions - 动作清单;调用点给的顺序只在同语气内部生效,空数组时整块不渲染。
|
|
62
|
+
* @returns 一排按钮,或 `null`(没有动作时)。
|
|
63
|
+
*/
|
|
50
64
|
export function FormActions({ actions }: FormActionsProps) {
|
|
51
65
|
if (actions.length === 0) return null;
|
|
52
66
|
reportExtraPrimaries(actions);
|