@dsh-workbench/shared 0.3.5 → 0.3.7
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/context-ref.d.ts +0 -12
- package/dist/api/context-ref.js +0 -40
- package/dist/api/facts.d.ts +0 -6
- package/dist/api/facts.js +0 -8
- package/dist/api/long-task.d.ts +33 -4
- package/dist/api/long-task.js +34 -2
- package/dist/components/ConfirmBar.css +26 -0
- package/dist/components/ConfirmBar.d.ts +36 -0
- package/dist/components/ConfirmBar.js +26 -0
- package/dist/components/Form.d.ts +7 -5
- package/dist/components/Form.js +5 -3
- package/dist/components/FormNodes.d.ts +17 -13
- package/dist/components/FormNodes.js +13 -5
- package/dist/components/FormRows.css +31 -0
- package/dist/components/FormTypes.d.ts +39 -14
- package/dist/components/LongTaskRow.css +52 -0
- package/dist/components/LongTaskRow.d.ts +18 -2
- package/dist/components/LongTaskRow.js +33 -5
- package/dist/components/MockStrip.css +11 -1
- package/dist/components/StateDot.d.ts +5 -0
- package/dist/components/StateDot.js +10 -0
- package/dist/config/constants.d.ts +2 -0
- package/dist/config/constants.js +2 -0
- package/dist/host/__tests__/fakes.d.ts +41 -0
- package/dist/host/__tests__/fakes.js +76 -0
- package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
- package/dist/host/__tests__/preflight-fixtures.js +47 -0
- package/dist/host/board-fact.d.ts +8 -0
- package/dist/host/board-fact.js +16 -0
- package/dist/host/browser-read.js +2 -1
- package/dist/host/context-inject.d.ts +24 -0
- package/dist/host/context-inject.js +74 -0
- package/dist/host/context-ref.d.ts +13 -0
- package/dist/host/context-ref.js +41 -0
- package/dist/host/diy-debug.d.ts +80 -0
- package/dist/host/diy-debug.js +88 -0
- package/dist/host/diy-record.d.ts +135 -0
- package/dist/host/diy-record.js +162 -0
- package/dist/host/events.d.ts +9 -0
- package/dist/host/events.js +70 -0
- package/dist/host/facts.d.ts +6 -0
- package/dist/host/facts.js +9 -0
- package/dist/host/git.js +1 -1
- package/dist/host/http.d.ts +2 -2
- package/dist/host/http.js +3 -3
- package/dist/host/long-task.d.ts +12 -0
- package/dist/host/long-task.js +34 -5
- package/dist/host/mount-units.d.ts +52 -0
- package/dist/host/mount-units.js +186 -0
- package/dist/host/opencli-runtime.js +1 -1
- package/dist/host/opencli-window.js +1 -1
- package/dist/host/opencli.js +2 -1
- package/dist/host/panel-state.d.ts +120 -0
- package/dist/host/panel-state.js +176 -0
- package/dist/host/plugin-source.js +1 -1
- package/dist/host/preflight.d.ts +58 -0
- package/dist/host/preflight.js +233 -0
- package/dist/host/probe-cache.d.ts +6 -0
- package/dist/host/probe-cache.js +19 -0
- package/dist/host/probe-libs.d.ts +45 -0
- package/dist/host/probe-libs.js +61 -0
- package/dist/host/probe-units.d.ts +17 -0
- package/dist/host/probe-units.js +102 -0
- package/dist/host/probe-verdict.d.ts +7 -0
- package/dist/host/probe-verdict.js +143 -0
- package/dist/host/shell-text.d.ts +13 -0
- package/dist/host/shell-text.js +20 -0
- package/dist/host/tool-approval.d.ts +1 -0
- package/dist/host/tool-scope.d.ts +32 -0
- package/dist/host/tool-scope.js +84 -0
- package/dist/host/unit-declaration.d.ts +65 -0
- package/dist/host/unit-declaration.js +213 -0
- package/dist/host/unit-face.d.ts +1 -0
- package/dist/host/unit-readiness.d.ts +7 -0
- package/dist/host/unit-readiness.js +5 -0
- package/dist/host/unit-registry.d.ts +9 -0
- package/dist/host/unit-registry.js +33 -0
- package/dist/host/version-range.d.ts +2 -0
- package/dist/host/version-range.js +73 -0
- package/dist/host.d.ts +7 -4
- package/dist/host.js +7 -4
- package/dist/i18n/en.js +6 -0
- package/dist/i18n/index.d.ts +7 -1
- package/dist/i18n/zh.d.ts +6 -0
- package/dist/i18n/zh.js +6 -0
- package/dist/index.d.ts +1 -4
- package/dist/index.js +1 -4
- package/dist/lib/board-fact.d.ts +0 -7
- package/dist/lib/board-fact.js +0 -15
- package/dist/lib/diy-record.d.ts +38 -1
- package/dist/lib/diy-record.js +66 -0
- package/dist/lib/text.d.ts +0 -13
- package/dist/lib/text.js +0 -20
- package/dist/testing/declaration.js +1 -1
- package/package.json +1 -1
- package/src/api/__tests__/facts.test.ts +0 -13
- package/src/api/__tests__/long-task.test.ts +41 -1
- package/src/api/context-ref.ts +0 -39
- package/src/api/facts.ts +0 -9
- package/src/api/long-task.ts +46 -4
- package/src/components/ConfirmBar.css +26 -0
- package/src/components/ConfirmBar.test.tsx +130 -0
- package/src/components/ConfirmBar.tsx +63 -0
- package/src/components/Form.test.tsx +15 -0
- package/src/components/Form.tsx +7 -5
- package/src/components/FormNodes.tsx +50 -26
- package/src/components/FormRows.css +31 -0
- package/src/components/FormRows.test.tsx +71 -1
- package/src/components/FormTypes.test.ts +31 -0
- package/src/components/FormTypes.ts +46 -15
- package/src/components/FormWiring.test.tsx +1 -0
- package/src/components/LongTaskRow.css +52 -0
- package/src/components/LongTaskRow.tsx +59 -4
- package/src/components/__tests__/long-task-row.test.ts +115 -0
- package/src/config/constants.ts +3 -0
- package/src/host/__tests__/board-fact.test.ts +47 -0
- package/src/{lib → host/__tests__}/diy-debug.test.ts +1 -1
- package/src/{lib → host/__tests__}/diy-record.test.ts +137 -1
- package/src/host/__tests__/facts.test.ts +13 -0
- package/src/host/__tests__/long-task.test.ts +306 -0
- package/src/{lib → host/__tests__}/panel-state.test.ts +1 -1
- package/src/host/__tests__/preflight-route.test.ts +33 -0
- package/src/host/__tests__/shell-text.test.ts +44 -0
- package/src/host/board-fact.ts +14 -0
- package/src/host/browser-read.ts +2 -1
- package/src/host/context-ref.ts +40 -0
- package/src/{lib → host}/diy-record.ts +95 -1
- package/src/host/facts.ts +10 -0
- package/src/host/git.ts +1 -1
- package/src/host/http.ts +4 -4
- package/src/host/long-task.ts +52 -7
- package/src/host/opencli-runtime.ts +1 -1
- package/src/host/opencli-window.ts +1 -1
- package/src/host/opencli.ts +2 -1
- package/src/host/plugin-source.ts +1 -1
- package/src/host/shell-text.ts +21 -0
- package/src/host/tool-approval.ts +1 -0
- package/src/{api → host}/unit-declaration.ts +1 -1
- package/src/host/unit-face.ts +1 -0
- package/src/host.ts +7 -4
- package/src/i18n/en.ts +7 -0
- package/src/i18n/zh.ts +6 -0
- package/src/index.ts +1 -4
- package/src/lib/board-fact.test.ts +1 -33
- package/src/lib/board-fact.ts +0 -13
- package/src/lib/text.test.ts +1 -43
- package/src/lib/text.ts +0 -22
- package/src/testing/declaration.ts +1 -1
- package/dist/lib/lint-probe.d.ts +0 -1
- package/dist/lib/lint-probe.js +0 -6
- /package/src/{api → host}/__tests__/unit-declaration.test.ts +0 -0
- /package/src/{lib → host}/diy-debug.ts +0 -0
- /package/src/{lib → host}/panel-state.ts +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from '../lib/react.js';
|
|
2
2
|
import { h } from '../lib/react.js';
|
|
3
3
|
import type { FormAction } from './FormActions.js';
|
|
4
|
-
import type { FormField,
|
|
4
|
+
import type { FormField, FormFieldOf, FormValues } from './FormTypes.js';
|
|
5
5
|
import { FormRow } from './FormRows.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -9,7 +9,9 @@ import { FormRow } from './FormRows.js';
|
|
|
9
9
|
* @param fields - 表单的字段清单。
|
|
10
10
|
* @returns 保序的新数组,`visible` 不给或为 `true` 的都在;全不可见时给空数组。
|
|
11
11
|
*/
|
|
12
|
-
export function visibleFieldsOf
|
|
12
|
+
export function visibleFieldsOf<Values extends object>(
|
|
13
|
+
fields: readonly FormFieldOf<Values>[],
|
|
14
|
+
): readonly FormFieldOf<Values>[] {
|
|
13
15
|
return fields.filter((field) => field.visible !== false);
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -19,7 +21,7 @@ export function visibleFieldsOf(fields: readonly FormField[]): readonly FormFiel
|
|
|
19
21
|
* @param fields - 表单的字段清单(通常是已经过滤过可见性的那份)。
|
|
20
22
|
* @returns 出问题的键的清单、按首次出现顺序去重;缺失的键一律记成空串(报的时候印成 `(空 key)`),空表给空数组。
|
|
21
23
|
*/
|
|
22
|
-
export function duplicateKeysOf(fields: readonly
|
|
24
|
+
export function duplicateKeysOf(fields: readonly { readonly key: string }[]): readonly string[] {
|
|
23
25
|
const seen = new Set<string>();
|
|
24
26
|
const duplicated = new Set<string>();
|
|
25
27
|
for (const field of fields) {
|
|
@@ -39,7 +41,7 @@ export function duplicateKeysOf(fields: readonly FormField[]): readonly string[]
|
|
|
39
41
|
* 形式与理由同 `FormActions` 里那颗多出来的 `primary`:装配的小错不该让 `CardBoundary` 把用户的整张卡变空白。
|
|
40
42
|
* @param fields - 表单的字段清单(通常是已经过滤过可见性的那份)。
|
|
41
43
|
*/
|
|
42
|
-
export function reportDuplicateKeys(fields: readonly
|
|
44
|
+
export function reportDuplicateKeys(fields: readonly { readonly key: string }[]): void {
|
|
43
45
|
const duplicated = duplicateKeysOf(fields);
|
|
44
46
|
if (duplicated.length === 0) return;
|
|
45
47
|
const named = duplicated.map((key) => (key === '' ? '(空 key)' : key));
|
|
@@ -54,10 +56,12 @@ export function reportDuplicateKeys(fields: readonly FormField[]): void {
|
|
|
54
56
|
* @param fields - 表单的字段清单;取的就是它们的 `value`,`value` 是数组的那格(多选)原样带出、不复制。
|
|
55
57
|
* @returns 键为字段 `key`、值为该格 `value` 的表;`fields` 为空时给空表,不是 `undefined`。
|
|
56
58
|
*/
|
|
57
|
-
export function formValuesOf
|
|
58
|
-
|
|
59
|
+
export function formValuesOf<Values extends object>(
|
|
60
|
+
fields: readonly FormFieldOf<Values>[],
|
|
61
|
+
): Readonly<Values> {
|
|
62
|
+
const values: Record<string, unknown> = {};
|
|
59
63
|
for (const field of fields) values[field.key] = field.value;
|
|
60
|
-
return values
|
|
64
|
+
return values as Readonly<Values>;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/**
|
|
@@ -68,19 +72,26 @@ export function formValuesOf(fields: readonly FormField[]): Readonly<Record<stri
|
|
|
68
72
|
* @param show - 提交过没有;false 时全给 `undefined`,true 时逐格调 `field.validate` 并按真值收。
|
|
69
73
|
* @returns 键为 `key`、值为那句文案或 `undefined` 的表;没给 `validate` 的格子也是 `undefined`。
|
|
70
74
|
*/
|
|
71
|
-
export function formProblemsOf(
|
|
72
|
-
fields: readonly
|
|
75
|
+
export function formProblemsOf<Values extends object>(
|
|
76
|
+
fields: readonly FormFieldOf<Values>[],
|
|
73
77
|
show: boolean,
|
|
74
78
|
): Readonly<Record<string, ReactNode | undefined>> {
|
|
75
79
|
const values = formValuesOf(fields);
|
|
76
80
|
const problems: Record<string, ReactNode | undefined> = {};
|
|
77
81
|
for (const field of fields) {
|
|
78
|
-
const found = show ?
|
|
82
|
+
const found = show ? problemOf(field, values) : undefined;
|
|
79
83
|
problems[field.key] = found ? found : undefined;
|
|
80
84
|
}
|
|
81
85
|
return problems;
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
function problemOf<K extends string, V, Vals extends object>(
|
|
89
|
+
field: FormField<K, V, Vals>,
|
|
90
|
+
values: Readonly<Vals>,
|
|
91
|
+
): ReactNode | undefined {
|
|
92
|
+
return field.validate?.(field.value, values);
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
/**
|
|
85
96
|
* 按字段顺序取第一处错误 —— 提交被挡住时要带进视口的就是这一格。
|
|
86
97
|
* 判的是**真值**(与 `formProblemsOf` 同一口径):假值那种"错误文案"渲染出来本来就是空的,不算错。
|
|
@@ -88,19 +99,19 @@ export function formProblemsOf(
|
|
|
88
99
|
* @param problems - `formProblemsOf` 那张表,也可以是调用点自己拼的(同样按真值判)。
|
|
89
100
|
* @returns 第一格有错的字段;一格都没错时给 `undefined`。
|
|
90
101
|
*/
|
|
91
|
-
export function formFirstProblemOf(
|
|
92
|
-
fields: readonly
|
|
102
|
+
export function formFirstProblemOf<Values extends object>(
|
|
103
|
+
fields: readonly FormFieldOf<Values>[],
|
|
93
104
|
problems: Readonly<Record<string, ReactNode | undefined>>,
|
|
94
|
-
):
|
|
105
|
+
): FormFieldOf<Values> | undefined {
|
|
95
106
|
return fields.find((field) => Boolean(problems[field.key]));
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
/**
|
|
99
110
|
* 提交那一拍的结果:有错就挡住(并点出第一处错在哪一格),没错就把键值表交出去。
|
|
100
111
|
*/
|
|
101
|
-
export type FormSubmitOutcome =
|
|
102
|
-
| { readonly blocked: true; readonly first:
|
|
103
|
-
| { readonly blocked: false; readonly values: Readonly<
|
|
112
|
+
export type FormSubmitOutcome<Values extends object = FormValues> =
|
|
113
|
+
| { readonly blocked: true; readonly first: FormFieldOf<Values> }
|
|
114
|
+
| { readonly blocked: false; readonly values: Readonly<Values> };
|
|
104
115
|
|
|
105
116
|
/**
|
|
106
117
|
* 算提交那一拍该干什么 —— `Form` 的提交钮点下去、以及 `<form>` 的 Enter 提交,走的都是它。
|
|
@@ -109,7 +120,9 @@ export type FormSubmitOutcome =
|
|
|
109
120
|
* @returns `blocked: true` 时带上是按字段顺序数第一格有错的字段(拿它去 `revealFirstProblem`);
|
|
110
121
|
* `blocked: false` 时带上从 `fields` 现取的键值表。空表一定给 `blocked: false` 与空 values。
|
|
111
122
|
*/
|
|
112
|
-
export function formSubmitOf
|
|
123
|
+
export function formSubmitOf<Values extends object>(
|
|
124
|
+
fields: readonly FormFieldOf<Values>[],
|
|
125
|
+
): FormSubmitOutcome<Values> {
|
|
113
126
|
const problems = formProblemsOf(fields, true);
|
|
114
127
|
const first = formFirstProblemOf(fields, problems);
|
|
115
128
|
if (first !== undefined) return { blocked: true, first };
|
|
@@ -170,8 +183,8 @@ export function formActionsOf(args: {
|
|
|
170
183
|
* @param args.busy - 提交中:每格的绑定 `disabled` 为真。
|
|
171
184
|
* @returns `FormRow` 组成的数组;没有可画的格子时给空数组。
|
|
172
185
|
*/
|
|
173
|
-
export function formRowsOf(args: {
|
|
174
|
-
readonly fields: readonly
|
|
186
|
+
export function formRowsOf<Values extends object>(args: {
|
|
187
|
+
readonly fields: readonly FormFieldOf<Values>[];
|
|
175
188
|
readonly problems: Readonly<Record<string, ReactNode | undefined>>;
|
|
176
189
|
readonly showProblem: boolean;
|
|
177
190
|
readonly busy: boolean;
|
|
@@ -183,11 +196,22 @@ export function formRowsOf(args: {
|
|
|
183
196
|
hint: field.hint,
|
|
184
197
|
problem: args.showProblem ? args.problems[field.key] : undefined,
|
|
185
198
|
layout: field.layout,
|
|
186
|
-
}, field.
|
|
187
|
-
id: `wb-form-${field.key}`,
|
|
188
|
-
name: field.key,
|
|
189
|
-
invalid: args.showProblem && Boolean(args.problems[field.key]),
|
|
190
|
-
disabled: args.busy || field.disabled === true,
|
|
191
|
-
})),
|
|
199
|
+
}, controlOf(field, args.problems, args.showProblem, args.busy)),
|
|
192
200
|
);
|
|
193
|
-
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function controlOf<K extends string, V, Vals extends object>(
|
|
204
|
+
field: FormField<K, V, Vals>,
|
|
205
|
+
problems: Readonly<Record<string, ReactNode | undefined>>,
|
|
206
|
+
showProblem: boolean,
|
|
207
|
+
busy: boolean,
|
|
208
|
+
): ReactNode {
|
|
209
|
+
return field.control({
|
|
210
|
+
id: `wb-form-${field.key}`,
|
|
211
|
+
name: field.key,
|
|
212
|
+
invalid: showProblem && Boolean(problems[field.key]),
|
|
213
|
+
disabled: busy || field.disabled === true,
|
|
214
|
+
value: field.value,
|
|
215
|
+
onChange: field.onChange,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
gap: var(--wb-len-6);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
.wb-formRows > .wb-formRow[data-layout='stacked'] {
|
|
28
|
+
grid-template-columns: minmax(0, 1fr);
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
.wb-formLabel {
|
|
28
32
|
min-width: 0;
|
|
29
33
|
font-size: var(--dsw-font-xs-13-font-size);
|
|
@@ -36,12 +40,39 @@
|
|
|
36
40
|
white-space: normal;
|
|
37
41
|
}
|
|
38
42
|
|
|
43
|
+
.wb-formRow[data-layout='stacked'] .wb-formHint,
|
|
44
|
+
.wb-formRow[data-layout='stacked'] .wb-formProblem {
|
|
45
|
+
grid-column: 1 / -1;
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
.wb-formControl {
|
|
40
49
|
display: flex;
|
|
41
50
|
align-items: center;
|
|
42
51
|
min-width: 0;
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
.wb-formControl
|
|
55
|
+
> :where(
|
|
56
|
+
.wb-selectAnchor,
|
|
57
|
+
.wb-choiceMenu,
|
|
58
|
+
input:not(
|
|
59
|
+
[type='checkbox'],
|
|
60
|
+
[type='radio'],
|
|
61
|
+
[type='button'],
|
|
62
|
+
[type='submit'],
|
|
63
|
+
[type='reset'],
|
|
64
|
+
[type='image'],
|
|
65
|
+
[type='color'],
|
|
66
|
+
[type='range']
|
|
67
|
+
),
|
|
68
|
+
select,
|
|
69
|
+
textarea
|
|
70
|
+
) {
|
|
71
|
+
box-sizing: border-box;
|
|
72
|
+
width: 100%;
|
|
73
|
+
min-width: 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
45
76
|
.wb-formHint {
|
|
46
77
|
grid-column: 2;
|
|
47
78
|
font-size: var(--dsw-font-xxs-12-font-size);
|
|
@@ -84,11 +84,37 @@ describe('FormRow', () => {
|
|
|
84
84
|
|
|
85
85
|
const FORM_CSS = readFileSync(new URL('./FormRows.css', import.meta.url), 'utf8');
|
|
86
86
|
|
|
87
|
+
function normalize(selector: string): string {
|
|
88
|
+
return selector
|
|
89
|
+
.replace(/\s+/g, ' ')
|
|
90
|
+
.replace(/\(\s+/g, '(')
|
|
91
|
+
.replace(/\s+\)/g, ')')
|
|
92
|
+
.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function selectorParts(head: string): string[] {
|
|
96
|
+
const parts: string[] = [];
|
|
97
|
+
let depth = 0;
|
|
98
|
+
let current = '';
|
|
99
|
+
for (const char of head) {
|
|
100
|
+
if (char === '(') depth += 1;
|
|
101
|
+
if (char === ')') depth -= 1;
|
|
102
|
+
if (char === ',' && depth === 0) {
|
|
103
|
+
parts.push(normalize(current));
|
|
104
|
+
current = '';
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
current += char;
|
|
108
|
+
}
|
|
109
|
+
parts.push(normalize(current));
|
|
110
|
+
return parts;
|
|
111
|
+
}
|
|
112
|
+
|
|
87
113
|
function declarations(selector: string): string[] {
|
|
88
114
|
for (const block of FORM_CSS.split('}')) {
|
|
89
115
|
const [head, ...rest] = block.split('{');
|
|
90
116
|
if (rest.length === 0 || head === undefined) continue;
|
|
91
|
-
if (head.
|
|
117
|
+
if (!selectorParts(head).includes(normalize(selector))) continue;
|
|
92
118
|
return rest
|
|
93
119
|
.join('{')
|
|
94
120
|
.split(';')
|
|
@@ -132,7 +158,51 @@ describe('标签列:同一个 FormRows 共用一列(ADR-0052 §11)', () =>
|
|
|
132
158
|
);
|
|
133
159
|
});
|
|
134
160
|
|
|
161
|
+
it('★ stacked 行在 FormRows 里也压得过 subgrid 与那条回退(同权重、靠后那条赢过的老坑)', () => {
|
|
162
|
+
expect(declarations(".wb-formRows > .wb-formRow[data-layout='stacked']")).toContain(
|
|
163
|
+
'grid-template-columns: minmax(0, 1fr)',
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('★ stacked 行里 hint / problem 通栏(它们的 grid-column: 2 会造出隐式第二列)', () => {
|
|
168
|
+
expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formHint")).toContain(
|
|
169
|
+
'grid-column: 1 / -1',
|
|
170
|
+
);
|
|
171
|
+
expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formProblem")).toContain(
|
|
172
|
+
'grid-column: 1 / -1',
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
135
176
|
it('★ 不是 FormRow 的子项(分组标题那种)通栏,别被塞进标签那一列', () => {
|
|
136
177
|
expect(declarations('.wb-formRows > :not(.wb-formRow)')).toContain('grid-column: 1 / -1');
|
|
137
178
|
});
|
|
138
179
|
});
|
|
180
|
+
|
|
181
|
+
const EXCLUDED_INPUT_TYPES = [
|
|
182
|
+
'checkbox',
|
|
183
|
+
'radio',
|
|
184
|
+
'button',
|
|
185
|
+
'submit',
|
|
186
|
+
'reset',
|
|
187
|
+
'image',
|
|
188
|
+
'color',
|
|
189
|
+
'range',
|
|
190
|
+
];
|
|
191
|
+
const CONTROL_FILL_SELECTOR = `.wb-formControl > :where(.wb-selectAnchor, .wb-choiceMenu, input:not(${EXCLUDED_INPUT_TYPES.map((one) => `[type='${one}']`).join(', ')}), select, textarea)`;
|
|
192
|
+
|
|
193
|
+
describe('控件槽那一格:字段控件铺满,宽度不归调用点写(ADR-0052 §13)', () => {
|
|
194
|
+
it('★ 那一格里的字段控件吃满控件槽:border-box + width 100% + min-width 0', () => {
|
|
195
|
+
const fill = declarations(CONTROL_FILL_SELECTOR);
|
|
196
|
+
|
|
197
|
+
expect(fill).toContain('box-sizing: border-box');
|
|
198
|
+
expect(fill).toContain('width: 100%');
|
|
199
|
+
expect(fill).toContain('min-width: 0');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('★ 开关与按钮那一族不在名单里(拉宽一颗 switch、一颗 <input type=submit> 就是另一回事)', () => {
|
|
203
|
+
for (const type of EXCLUDED_INPUT_TYPES) {
|
|
204
|
+
expect(CONTROL_FILL_SELECTOR).toContain(`[type='${type}']`);
|
|
205
|
+
}
|
|
206
|
+
expect(FORM_CSS).not.toMatch(/\.wb-formControl\s*>\s*\*/);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
2
|
+
import type { FormFieldOf, FormProps } from './FormTypes.js';
|
|
3
|
+
|
|
4
|
+
type Draft = { readonly env: string; readonly branch?: string };
|
|
5
|
+
|
|
6
|
+
type Submitted = Parameters<FormProps<Draft>['onSubmit']>[0];
|
|
7
|
+
type EnvField = Extract<FormFieldOf<Draft>, { readonly key: 'env' }>;
|
|
8
|
+
type BranchField = Extract<FormFieldOf<Draft>, { readonly key: 'branch' }>;
|
|
9
|
+
type AcceptedKeys = Pick<FormFieldOf<Draft>, 'key'>['key'];
|
|
10
|
+
type StrangerKeyAccepted = { readonly key: 'nope' } extends Pick<FormFieldOf<Draft>, 'key'> ? true : false;
|
|
11
|
+
|
|
12
|
+
const draft: FormFieldOf<Draft> = {
|
|
13
|
+
key: 'env',
|
|
14
|
+
label: '环境',
|
|
15
|
+
value: 'stage',
|
|
16
|
+
onChange: (next) => void next.trim(),
|
|
17
|
+
control: (binding) => binding.value,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('Form<Values> 把每一格的键与值钉住(这几条由 tsc 核,`vitest run` 跑的是最后那条 expect)', () => {
|
|
21
|
+
it('★ 键只取 Values 的键、值只取那一键的类型;会隐藏的键写成可选键,提交表跟着带上 undefined', () => {
|
|
22
|
+
expectTypeOf<AcceptedKeys>().toEqualTypeOf<'env' | 'branch'>();
|
|
23
|
+
expectTypeOf<StrangerKeyAccepted>().toEqualTypeOf<false>();
|
|
24
|
+
expectTypeOf<EnvField['value']>().toEqualTypeOf<string>();
|
|
25
|
+
expectTypeOf<EnvField['onChange']>().toEqualTypeOf<(next: string) => void>();
|
|
26
|
+
expectTypeOf<BranchField['value']>().toEqualTypeOf<string | undefined>();
|
|
27
|
+
expectTypeOf<Submitted>().toEqualTypeOf<Readonly<Draft>>();
|
|
28
|
+
expectTypeOf<Submitted['branch']>().toEqualTypeOf<string | undefined>();
|
|
29
|
+
expect(draft.key).toBe('env');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -2,38 +2,65 @@ import type { ReactNode } from '../lib/react.js';
|
|
|
2
2
|
import type { FormAction } from './FormActions.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
5
|
+
* 默认档一格控件的值:单值(`TextField` / `SelectField` / `ChoiceGroup` 单选),或一组值(`ChoiceGroup` 多选)。
|
|
6
|
+
* 写了 `Form<Values>` 的类型参数之后,每一格的值类型由 `Values[那一格的键]` 定 —— 这个联合只是**没写类型参数**时的默认档。
|
|
6
7
|
*/
|
|
7
8
|
export type FormValue = string | readonly string[];
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
+
* 默认档的 `Values`:键是任意 `string`、值是 `FormValue` —— 不写 `Form<Values>` 的类型参数时就是它,
|
|
12
|
+
* 于是 `fields` 的键与值都不钉(老写法照旧能编),代价是每格拿到的都是那个联合、要自己收窄。
|
|
11
13
|
*/
|
|
12
|
-
export
|
|
14
|
+
export type FormValues = Readonly<Record<string, FormValue>>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `Form` 递给控件那一格的绑定:`id` / `name` / `invalid` / `disabled` 由 `Form` 定;
|
|
18
|
+
* `value` / `onChange` 就是这一格自己那份配置里的那两个**原样转手**(`Form` 不持有值)——
|
|
19
|
+
* 控件接上即可,别再自己接 model:值只写在字段配置里那一次。
|
|
20
|
+
*/
|
|
21
|
+
export interface FormFieldBinding<K extends string = string, V = FormValue> {
|
|
13
22
|
readonly id: string;
|
|
14
|
-
readonly name:
|
|
23
|
+
readonly name: K;
|
|
15
24
|
readonly invalid: boolean;
|
|
16
25
|
readonly disabled: boolean;
|
|
26
|
+
readonly value: V;
|
|
27
|
+
readonly onChange: (next: V) => void;
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
/**
|
|
20
|
-
*
|
|
31
|
+
* 一格字段的配置:键、标签、值、值怎么改(`onChange`)、怎么算错(`validate`)、控件本身 ——
|
|
32
|
+
* 排布、时机、阻断都不写在这里,那些归 `Form`。`value` 与 `onChange` 就在同一格里,`control` 从绑定直接取
|
|
33
|
+
* (`(binding) => …({ value: binding.value, onChange: binding.onChange })`),值不再有第二处。
|
|
21
34
|
* `validate` 是纯函数、同步、只读 `values`:返回**真值**才算这一格错,假值(`undefined` / `null` / `false` / 空串)
|
|
22
35
|
* 一律当"没错"—— 所以 `(value) => value.trim() === '' && '必填'` 这种用 `&&` 算文案的写法是对的,
|
|
23
36
|
* 它的返回值就是错误行那句话。
|
|
24
37
|
*/
|
|
25
|
-
export interface FormField
|
|
26
|
-
|
|
38
|
+
export interface FormField<
|
|
39
|
+
K extends string = string,
|
|
40
|
+
V = FormValue,
|
|
41
|
+
Vals extends object = FormValues,
|
|
42
|
+
> {
|
|
43
|
+
readonly key: K;
|
|
27
44
|
readonly label: ReactNode;
|
|
28
45
|
readonly hint?: ReactNode | undefined;
|
|
29
46
|
readonly layout?: 'inline' | 'stacked' | undefined;
|
|
30
|
-
readonly value:
|
|
47
|
+
readonly value: V;
|
|
48
|
+
readonly onChange: (next: V) => void;
|
|
31
49
|
readonly visible?: boolean | undefined;
|
|
32
50
|
readonly disabled?: boolean | undefined;
|
|
33
|
-
readonly validate?: (value:
|
|
34
|
-
readonly control: (binding: FormFieldBinding) => ReactNode;
|
|
51
|
+
readonly validate?: (value: V, values: Readonly<Vals>) => ReactNode | undefined;
|
|
52
|
+
readonly control: (binding: FormFieldBinding<K, V>) => ReactNode;
|
|
35
53
|
}
|
|
36
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 把 `Values` 的每一键映射成一格字段 —— 写 `Form<Values>` 时 `fields` 就是这个联合的数组:
|
|
57
|
+
* `{ key: 'env', … }` 那一格的 `value` / `onChange` / `validate` 只认 `Values['env']`,
|
|
58
|
+
* `control` 拿到的绑定也只带它。要"键值都不钉"的松档就干脆别写类型参数(默认档 `FormValues`),别手写这个类型。
|
|
59
|
+
*/
|
|
60
|
+
export type FormFieldOf<Values extends object> = {
|
|
61
|
+
[K in keyof Values & string]: FormField<K, Values[K], Values>;
|
|
62
|
+
}[keyof Values & string];
|
|
63
|
+
|
|
37
64
|
/**
|
|
38
65
|
* 提交钮:文案归调用点,位置、禁用与忙态归 `Form`。
|
|
39
66
|
*/
|
|
@@ -51,15 +78,19 @@ export interface FormCancel {
|
|
|
51
78
|
}
|
|
52
79
|
|
|
53
80
|
/**
|
|
54
|
-
* `Form`
|
|
81
|
+
* `Form` 的契约:`fields` 配置进,排布、校验时机、提交阻断、操作区全归组件。
|
|
82
|
+
* `Values` 是**那张值表**的形状(不写就是默认档 `FormValues`):`fields` 的键只能取它的键,
|
|
83
|
+
* 每格的 `value` / `onChange` / `validate` 只认它那一键的值类型,`onSubmit` 与 `validate` 第二参收到的也是它。
|
|
84
|
+
* **会 `visible: false` 的键写成可选键**(`branch?: string`)—— 那一格不渲染、不校验、不进 `values`(规则五),
|
|
85
|
+
* 可选键让"它可能不在表里"这件事在类型上也成立;永远可见的键照常必填。
|
|
55
86
|
*/
|
|
56
|
-
export interface FormProps {
|
|
57
|
-
readonly fields: readonly
|
|
58
|
-
readonly onSubmit: (values: Readonly<
|
|
87
|
+
export interface FormProps<Values extends object = FormValues> {
|
|
88
|
+
readonly fields: readonly FormFieldOf<Values>[];
|
|
89
|
+
readonly onSubmit: (values: Readonly<Values>) => void;
|
|
59
90
|
readonly submit: FormSubmit;
|
|
60
91
|
readonly cancel?: FormCancel | undefined;
|
|
61
92
|
readonly extra?: readonly FormAction[] | undefined;
|
|
62
93
|
readonly busy?: boolean | undefined;
|
|
63
94
|
readonly disabled?: boolean | undefined;
|
|
64
95
|
readonly problem?: ReactNode | undefined;
|
|
65
|
-
}
|
|
96
|
+
}
|
|
@@ -7,6 +7,7 @@ function field(key: string, value: FormValue, validate?: FormField['validate']):
|
|
|
7
7
|
key,
|
|
8
8
|
label: key,
|
|
9
9
|
value,
|
|
10
|
+
onChange: () => {},
|
|
10
11
|
control: (binding) => `${key}:${binding.id}:${String(binding.invalid)}:${String(binding.disabled)}`,
|
|
11
12
|
...(validate === undefined ? {} : { validate }),
|
|
12
13
|
};
|
|
@@ -45,4 +45,56 @@
|
|
|
45
45
|
margin: var(--wb-len-4) 0 0;
|
|
46
46
|
max-height: var(--wb-len-220);
|
|
47
47
|
overflow: auto;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.wb-longTaskStop {
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
flex: none;
|
|
53
|
+
align-items: center;
|
|
54
|
+
align-self: center;
|
|
55
|
+
justify-content: center;
|
|
56
|
+
width: var(--wb-len-20);
|
|
57
|
+
height: var(--wb-len-20);
|
|
58
|
+
padding: 0;
|
|
59
|
+
margin-left: auto;
|
|
60
|
+
border: 0.5px solid var(--dsw-alias-border-l2);
|
|
61
|
+
border-radius: var(--dsw-radius-sm);
|
|
62
|
+
background: var(--dsw-specific-menu);
|
|
63
|
+
color: var(--dsw-alias-label-secondary);
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.wb-longTaskStop:hover:not(:disabled),
|
|
68
|
+
.wb-longTaskStop:focus-visible {
|
|
69
|
+
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
|
|
70
|
+
color: var(--dsw-alias-state-error-primary);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.wb-longTaskStop:disabled {
|
|
74
|
+
cursor: default;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.wb-longTaskStopArmed,
|
|
78
|
+
.wb-longTaskStopArmed:hover:not(:disabled),
|
|
79
|
+
.wb-longTaskStopArmed:focus-visible {
|
|
80
|
+
gap: var(--wb-len-4);
|
|
81
|
+
width: auto;
|
|
82
|
+
padding: 0 var(--wb-len-7);
|
|
83
|
+
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
|
|
84
|
+
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
|
|
85
|
+
color: var(--dsw-alias-state-error-primary);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.wb-longTaskStopLabel {
|
|
89
|
+
white-space: nowrap;
|
|
90
|
+
font-size: var(--dsw-font-xxxs-11-font-size);
|
|
91
|
+
line-height: var(--wb-line-18);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.wb-longTaskStopWhy {
|
|
95
|
+
margin: 0;
|
|
96
|
+
font-size: var(--dsw-font-xxxs-11-font-size);
|
|
97
|
+
line-height: var(--wb-line-16);
|
|
98
|
+
color: var(--dsw-alias-state-error-primary);
|
|
99
|
+
overflow-wrap: anywhere;
|
|
48
100
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { longTaskDot, type LongTaskStatus } from '../api/long-task.js';
|
|
1
|
+
import { longTaskDot, longTaskStopPress, longTaskStopStage, LONG_TASK_STOP_ARM_MS, type LongTaskStatus } from '../api/long-task.js';
|
|
2
2
|
import { Link } from './Link.js';
|
|
3
|
-
import { Fragment, h, useState } from '../lib/react.js';
|
|
3
|
+
import { Fragment, h, useEffect, useState } from '../lib/react.js';
|
|
4
4
|
import {
|
|
5
5
|
CodeBlock,
|
|
6
6
|
DisclosureRow,
|
|
7
7
|
IconCodeOutlineRegular,
|
|
8
8
|
IconFlatListOutlineRegular,
|
|
9
|
+
IconStopFillRegular,
|
|
9
10
|
StateDot,
|
|
10
11
|
} from '@deepseek-ai/dsh-client-ui-primitives';
|
|
11
12
|
import './LongTaskRow.css';
|
|
@@ -19,7 +20,22 @@ export interface LongTaskRowLink {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
* `LongTaskRow`
|
|
23
|
+
* `LongTaskRow` 那颗两按停止钮的契约:确认之后真发的那一下、三句词、在飞与失败两态。
|
|
24
|
+
* `label` 是没上膛时那颗钮的 aria-label(dsh 的词:`停止任务 {label}`);`askLabel` 是上膛时那颗钮上显示的字(`确认停止`);
|
|
25
|
+
* `armedLabel` 是上膛时的 aria-label,不给就用 `askLabel`(`再次点击确认停止`);`busy` = 这一趟正在被停、钮禁用;
|
|
26
|
+
* `failed` 给了才在问题那行后面再画一行。
|
|
27
|
+
*/
|
|
28
|
+
export interface LongTaskRowStop {
|
|
29
|
+
readonly onStop: () => void;
|
|
30
|
+
readonly label: string;
|
|
31
|
+
readonly askLabel: string;
|
|
32
|
+
readonly armedLabel?: string | undefined;
|
|
33
|
+
readonly busy?: boolean | undefined;
|
|
34
|
+
readonly failed?: string | null | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `LongTaskRow` 的契约:状态、标题、问题、链接,两处可展开的原始输出,与最右边那颗可选的停止钮。
|
|
23
39
|
*/
|
|
24
40
|
export interface LongTaskRowProps {
|
|
25
41
|
readonly status: LongTaskStatus;
|
|
@@ -32,6 +48,7 @@ export interface LongTaskRowProps {
|
|
|
32
48
|
readonly rawTitle?: string | undefined;
|
|
33
49
|
readonly copyLabel?: string | undefined;
|
|
34
50
|
readonly copiedLabel?: string | undefined;
|
|
51
|
+
readonly stop?: LongTaskRowStop | undefined;
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
function RowLink({ href, text }: LongTaskRowLink) {
|
|
@@ -45,7 +62,7 @@ function RowLink({ href, text }: LongTaskRowLink) {
|
|
|
45
62
|
/**
|
|
46
63
|
* 长任务那一行:状态点 + 标题 + 链接,跑失败时把步骤与原始输出收在两处可展开里。
|
|
47
64
|
* 发布、扫描、迁移这类跑得久的事都用它,别自己画进度。
|
|
48
|
-
* @param props.status - `longTaskDot`
|
|
65
|
+
* @param props.status - `longTaskDot` 认的五态之一,决定状态点颜色与要不要画"失败"那两块。
|
|
49
66
|
* @param props.headline - 这一行在干什么(一句话)。
|
|
50
67
|
* @param props.problem - 失败原因;不给不画。
|
|
51
68
|
* @param props.links - 行尾的链接,通常指向产物或日志。
|
|
@@ -53,15 +70,38 @@ function RowLink({ href, text }: LongTaskRowLink) {
|
|
|
53
70
|
* @param props.raw - 原始输出;只在失败时画,且要同时给 `rawTitle` 才成一块可展开。
|
|
54
71
|
* @param props.stepsTitle - 步骤那块展开行的标题;`rawTitle` 同理是原始输出那块。
|
|
55
72
|
* @param props.copyLabel - 复制钮文案:给了才画复制钮;`copiedLabel` 是复制成功后那句。
|
|
73
|
+
* @param props.stop - 给了才在最右边画那颗两按停止钮;**不给就一个像素都不变**。只在生产方认 `signal` 时给。
|
|
56
74
|
* @returns 一行长任务。
|
|
57
75
|
*/
|
|
58
76
|
export function LongTaskRow(props: LongTaskRowProps) {
|
|
59
77
|
const [stepsOpen, setStepsOpen] = useState(false);
|
|
60
78
|
const [rawOpen, setRawOpen] = useState(false);
|
|
79
|
+
const [armed, setArmed] = useState(false);
|
|
61
80
|
const failed = props.status === 'failed';
|
|
62
81
|
const problem = props.problem ?? '';
|
|
63
82
|
const raw = (props.raw ?? '').trim();
|
|
64
83
|
const steps = props.steps ?? [];
|
|
84
|
+
const stop = props.stop;
|
|
85
|
+
const busy = stop?.busy === true;
|
|
86
|
+
const stopWhy = stop?.failed ?? '';
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!armed) return undefined;
|
|
90
|
+
const timer = setTimeout(() => setArmed(false), LONG_TASK_STOP_ARM_MS);
|
|
91
|
+
return () => clearTimeout(timer);
|
|
92
|
+
}, [armed]);
|
|
93
|
+
|
|
94
|
+
const onStopPress = (): void => {
|
|
95
|
+
if (stop === undefined) return;
|
|
96
|
+
const press = longTaskStopPress(longTaskStopStage({ armed, busy }));
|
|
97
|
+
if (press === 'ignore') return;
|
|
98
|
+
if (press === 'arm') {
|
|
99
|
+
setArmed(true);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setArmed(false);
|
|
103
|
+
stop.onStop();
|
|
104
|
+
};
|
|
65
105
|
|
|
66
106
|
return (
|
|
67
107
|
<div className="wb-longTask">
|
|
@@ -71,8 +111,23 @@ export function LongTaskRow(props: LongTaskRowProps) {
|
|
|
71
111
|
{(props.links ?? []).map((link) => (
|
|
72
112
|
<RowLink href={link.href} key={`${link.text}@${link.href}`} text={link.text} />
|
|
73
113
|
))}
|
|
114
|
+
{stop === undefined ? null : (
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
className={`wb-longTaskStop${armed ? ' wb-longTaskStopArmed' : ''}`}
|
|
118
|
+
data-stop-state={longTaskStopStage({ armed, busy })}
|
|
119
|
+
aria-label={armed ? stop.armedLabel ?? stop.askLabel : stop.label}
|
|
120
|
+
disabled={busy}
|
|
121
|
+
onClick={onStopPress}
|
|
122
|
+
onBlur={() => setArmed(false)}
|
|
123
|
+
>
|
|
124
|
+
<IconStopFillRegular size={12} />
|
|
125
|
+
{armed ? <span className="wb-longTaskStopLabel">{stop.askLabel}</span> : null}
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
74
128
|
</p>
|
|
75
129
|
{problem === '' ? null : <p className="wb-longTaskProblem">{problem}</p>}
|
|
130
|
+
{stopWhy === '' ? null : <p className="wb-longTaskStopWhy">{stopWhy}</p>}
|
|
76
131
|
{!failed ? null : (
|
|
77
132
|
<Fragment>
|
|
78
133
|
{steps.length === 0 || props.stepsTitle === undefined ? null : (
|