@skopon-cool/form-sdk 0.1.0 → 0.1.3
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/README.md +52 -12
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formFileAccept.d.ts.map +1 -1
- package/dist/adapter/formSchema.d.ts +1 -0
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/catalog/textFieldPreview.test.d.ts +2 -0
- package/dist/catalog/textFieldPreview.test.d.ts.map +1 -0
- package/dist/catalog/useSkoponBoundField.d.ts +2 -0
- package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
- package/dist/client/formClient.d.ts.map +1 -1
- package/dist/components/AskUserFormCard.d.ts +3 -1
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
- package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +2 -0
- package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +998 -667
- package/dist/submit/buildCurlStatement.d.ts +8 -0
- package/dist/submit/buildCurlStatement.d.ts.map +1 -1
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
- package/dist/submit/submitFormJson.d.ts.map +1 -1
- package/package.json +16 -5
- package/src/adapter/a2uiAdapter.test.ts +91 -0
- package/src/adapter/a2uiAdapter.ts +36 -7
- package/src/adapter/formFileAccept.test.ts +53 -0
- package/src/adapter/formFileAccept.ts +11 -2
- package/src/adapter/formSchema.test.ts +35 -0
- package/src/adapter/formSchema.ts +5 -1
- package/src/catalog/a2uiCustomCatalog.tsx +154 -5
- package/src/catalog/textFieldPreview.test.tsx +88 -0
- package/src/catalog/useSkoponBoundField.test.ts +62 -0
- package/src/catalog/useSkoponBoundField.ts +10 -1
- package/src/client/formClient.test.ts +83 -0
- package/src/client/formClient.ts +10 -2
- package/src/components/AskUserFormCard.tsx +136 -58
- package/src/components/SkoponA2uiStreamRenderer.test.ts +62 -0
- package/src/components/SkoponA2uiStreamRenderer.test.tsx +79 -0
- package/src/components/SkoponA2uiStreamRenderer.tsx +96 -15
- package/src/components/SkoponFormRenderer.tsx +10 -7
- package/src/index.ts +11 -2
- package/src/styles/index.css +5 -0
- package/src/submit/buildCurlStatement.ts +49 -0
- package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
- package/src/submit/submit.test.ts +170 -10
- package/src/submit/submitFormJson.ts +20 -1
- package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
- package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
- package/src/submit/intersectPayloadWithForm.ts +0 -54
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
export declare function buildCurlStatement(payload: unknown, callbackUrl?: string | null): string;
|
|
2
|
+
/** 构建含行注释的 JSON body:extra 段标注「额外字段(未在卡片展示)」 */
|
|
3
|
+
export declare function buildAskUserCurlBodyJson(cardValues: Record<string, unknown>, extraValues: Record<string, unknown>): string;
|
|
4
|
+
export interface BuildAskUserCurlStatementOptions {
|
|
5
|
+
cardValues: Record<string, unknown>;
|
|
6
|
+
extraValues?: Record<string, unknown>;
|
|
7
|
+
callbackUrl?: string | null;
|
|
8
|
+
}
|
|
9
|
+
export declare function buildAskUserCurlStatement({ cardValues, extraValues, callbackUrl, }: BuildAskUserCurlStatementOptions): string;
|
|
2
10
|
//# sourceMappingURL=buildCurlStatement.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"buildCurlStatement.d.ts","sourceRoot":"","sources":["../../src/submit/buildCurlStatement.ts"],"names":[],"mappings":"AAIA,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQxF"}
|
|
1
|
+
{"version":3,"file":"buildCurlStatement.d.ts","sourceRoot":"","sources":["../../src/submit/buildCurlStatement.ts"],"names":[],"mappings":"AAIA,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAQxF;AAMD,gDAAgD;AAChD,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAmBR;AAED,MAAM,WAAW,gCAAgC;IAC/C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACnC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACrC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC5B;AAED,wBAAgB,yBAAyB,CAAC,EACxC,UAAU,EACV,WAAgB,EAChB,WAAW,GACZ,EAAE,gCAAgC,GAAG,MAAM,CAQ3C"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FormBlock, FormSchema } from '../types/index';
|
|
2
|
+
export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'blocks'>;
|
|
3
|
+
export interface PayloadBlocksIntersection {
|
|
4
|
+
matchedBlocks: FormBlock[];
|
|
5
|
+
extraBlocks: FormBlock[];
|
|
6
|
+
/** 按 payload 顺序:layout + matched input(不含 extra input) */
|
|
7
|
+
renderBlocks: FormBlock[];
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
}
|
|
11
|
+
/** 解析 ask_user payload 为 blocksJson 形(title / description / blocks) */
|
|
12
|
+
export declare function parsePayloadBlocksJson(payload: unknown): PayloadBlocksDefinition | null;
|
|
13
|
+
/**
|
|
14
|
+
* payload.blocks 与 vt_forms 按 input 块 name 取交集用于渲染;
|
|
15
|
+
* payload 中 form 不存在的块归入 extraBlocks(不渲染,提交时写入 curl)。
|
|
16
|
+
*/
|
|
17
|
+
export declare function intersectPayloadBlocksWithForm(payloadDef: PayloadBlocksDefinition, formDefinition: FormSchema | undefined): PayloadBlocksIntersection;
|
|
18
|
+
/** 从 extraBlocks 提取提交值(优先 defaultValue) */
|
|
19
|
+
export declare function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string, unknown>;
|
|
20
|
+
/** payload 是否含至少一个具 name 的 input 块(可用于卡片 fallback) */
|
|
21
|
+
export declare function payloadHasInputBlocks(payloadDef: PayloadBlocksDefinition): boolean;
|
|
22
|
+
/** 交集为空时 fallback:payload 中可上屏的 blocks(layout + input) */
|
|
23
|
+
export declare function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition): FormBlock[];
|
|
24
|
+
/** payload 中所有 input 块 field name(fallback 提交用) */
|
|
25
|
+
export declare function getPayloadInputFieldNames(payloadDef: PayloadBlocksDefinition): string[];
|
|
26
|
+
//# sourceMappingURL=intersectPayloadBlocksWithForm.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"intersectPayloadBlocksWithForm.d.ts","sourceRoot":"","sources":["../../src/submit/intersectPayloadBlocksWithForm.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAA;AAG3D,MAAM,MAAM,uBAAuB,GAAG,IAAI,CAAC,UAAU,EAAE,OAAO,GAAG,aAAa,GAAG,QAAQ,CAAC,CAAA;AAE1F,MAAM,WAAW,yBAAyB;IACxC,aAAa,EAAE,SAAS,EAAE,CAAA;IAC1B,WAAW,EAAE,SAAS,EAAE,CAAA;IACxB,0DAA0D;IAC1D,YAAY,EAAE,SAAS,EAAE,CAAA;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;CACpB;AAqDD,uEAAuE;AACvE,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,uBAAuB,GAAG,IAAI,CAUvF;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,uBAAuB,EACnC,cAAc,EAAE,UAAU,GAAG,SAAS,GACrC,yBAAyB,CAqC3B;AAmBD,2CAA2C;AAC3C,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,SAAS,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzF;AAED,sDAAsD;AACtD,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,uBAAuB,GAAG,OAAO,CAIlF;AAED,0DAA0D;AAC1D,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,uBAAuB,GAAG,SAAS,EAAE,CAK3F;AAED,mDAAmD;AACnD,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,uBAAuB,GAAG,MAAM,EAAE,CAIvF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"submitFormJson.d.ts","sourceRoot":"","sources":["../../src/submit/submitFormJson.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,oBAAoB,CAAC,CA8B/B;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"submitFormJson.d.ts","sourceRoot":"","sources":["../../src/submit/submitFormJson.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,qBAAqB;IACpC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,OAAO,CAAA;IACX,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;CACf;AAED,wBAAsB,cAAc,CAClC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,oBAAoB,CAAC,CA8B/B;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBrE"}
|
package/package.json
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.3",
|
|
7
7
|
"description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
|
|
8
8
|
"type": "module",
|
|
9
|
+
"sideEffects": [
|
|
10
|
+
"*.css",
|
|
11
|
+
"dist/form-sdk.css"
|
|
12
|
+
],
|
|
9
13
|
"main": "./dist/index.js",
|
|
10
14
|
"module": "./dist/index.js",
|
|
11
15
|
"types": "./dist/index.d.ts",
|
|
@@ -25,29 +29,36 @@
|
|
|
25
29
|
"test": "vitest run",
|
|
26
30
|
"test:watch": "vitest"
|
|
27
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"dayjs": "^1.11.13",
|
|
34
|
+
"zod": "^3.24.0"
|
|
35
|
+
},
|
|
28
36
|
"peerDependencies": {
|
|
29
|
-
"@ant-design/icons": "^6.0.0",
|
|
30
37
|
"@a2ui/react": "^0.10.1",
|
|
31
38
|
"@a2ui/web_core": "^0.10.2",
|
|
39
|
+
"@ant-design/icons": "^6.0.0",
|
|
32
40
|
"antd": "^6.4.3",
|
|
33
|
-
"dayjs": "^1.11.0",
|
|
34
41
|
"react": "^18.0.0 || ^19.0.0",
|
|
35
42
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
36
43
|
},
|
|
37
44
|
"devDependencies": {
|
|
38
|
-
"@ant-design/icons": "^6.2.3",
|
|
39
45
|
"@a2ui/react": "^0.10.1",
|
|
40
46
|
"@a2ui/web_core": "^0.10.2",
|
|
47
|
+
"@ant-design/icons": "^6.2.3",
|
|
48
|
+
"@testing-library/react": "^16.3.2",
|
|
41
49
|
"@types/react": "^19.1.2",
|
|
42
50
|
"@types/react-dom": "^19.1.2",
|
|
43
51
|
"@vitejs/plugin-react": "^4.5.2",
|
|
44
52
|
"antd": "^6.4.3",
|
|
45
53
|
"dayjs": "^1.11.13",
|
|
54
|
+
"happy-dom": "^17.6.3",
|
|
55
|
+
"jsdom": "^29.1.1",
|
|
46
56
|
"react": "^19.1.0",
|
|
47
57
|
"react-dom": "^19.1.0",
|
|
48
58
|
"typescript": "~5.8.3",
|
|
49
59
|
"vite": "^6.3.5",
|
|
50
60
|
"vite-plugin-dts": "^4.5.4",
|
|
51
|
-
"vitest": "^3.2.4"
|
|
61
|
+
"vitest": "^3.2.4",
|
|
62
|
+
"zod": "^3.24.0"
|
|
52
63
|
}
|
|
53
64
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
1
4
|
import { describe, it, expect } from 'vitest'
|
|
2
5
|
import type { FormSchema } from '../types/index'
|
|
3
6
|
import { a2uiToBlocks, blocksToA2ui, isA2uiSurfaceEmpty, surfaceDocToMessages } from './a2uiAdapter'
|
|
@@ -120,6 +123,94 @@ describe('a2uiAdapter blocks <-> surface', () => {
|
|
|
120
123
|
expect(back.blocks.find((b) => b.name === 'tags')?.type).toBe('multiselect')
|
|
121
124
|
})
|
|
122
125
|
|
|
126
|
+
it('blocksToA2ui stores single-select and radio defaults as strings in dataModel', () => {
|
|
127
|
+
const doc = blocksToA2ui(
|
|
128
|
+
{
|
|
129
|
+
title: '',
|
|
130
|
+
description: '',
|
|
131
|
+
blocks: [
|
|
132
|
+
{
|
|
133
|
+
id: 'b-city',
|
|
134
|
+
type: 'select',
|
|
135
|
+
name: 'city',
|
|
136
|
+
label: '城市',
|
|
137
|
+
defaultValue: 'bj',
|
|
138
|
+
options: [{ label: '北京', value: 'bj' }],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'b-gender',
|
|
142
|
+
type: 'radio',
|
|
143
|
+
name: 'gender',
|
|
144
|
+
label: '性别',
|
|
145
|
+
defaultValue: 'm',
|
|
146
|
+
options: [
|
|
147
|
+
{ label: '男', value: 'm' },
|
|
148
|
+
{ label: '女', value: 'f' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'b-tags',
|
|
153
|
+
type: 'multiselect',
|
|
154
|
+
name: 'tags',
|
|
155
|
+
label: '标签',
|
|
156
|
+
defaultValue: ['a', 'b'],
|
|
157
|
+
options: [
|
|
158
|
+
{ label: 'A', value: 'a' },
|
|
159
|
+
{ label: 'B', value: 'b' },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
{ includeHeader: false },
|
|
165
|
+
)
|
|
166
|
+
expect(doc.dataModel?.city).toBe('bj')
|
|
167
|
+
expect(doc.dataModel?.gender).toBe('m')
|
|
168
|
+
expect(doc.dataModel?.tags).toEqual(['a', 'b'])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('blocksToA2ui stores toggle string default "true" as true in dataModel', () => {
|
|
172
|
+
const doc = blocksToA2ui(
|
|
173
|
+
{
|
|
174
|
+
title: '',
|
|
175
|
+
description: '',
|
|
176
|
+
blocks: [
|
|
177
|
+
{
|
|
178
|
+
id: 'b-sub',
|
|
179
|
+
type: 'toggle',
|
|
180
|
+
name: 'subscribe',
|
|
181
|
+
label: '订阅',
|
|
182
|
+
defaultValue: 'true',
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
{ includeHeader: false },
|
|
187
|
+
)
|
|
188
|
+
expect(doc.dataModel?.subscribe).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('round-trips textarea placeholder through blocksToA2ui', () => {
|
|
192
|
+
const doc = blocksToA2ui(
|
|
193
|
+
{
|
|
194
|
+
title: '',
|
|
195
|
+
description: '',
|
|
196
|
+
blocks: [
|
|
197
|
+
{
|
|
198
|
+
id: 'b-bio',
|
|
199
|
+
type: 'textarea',
|
|
200
|
+
name: 'bio',
|
|
201
|
+
label: '简介',
|
|
202
|
+
placeholder: '请填写简介',
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
{ includeHeader: false },
|
|
207
|
+
)
|
|
208
|
+
const bioNode = doc.components.find((c) => c.id === 'b-bio')
|
|
209
|
+
expect(bioNode?.placeholder).toBe('请填写简介')
|
|
210
|
+
const back = a2uiToBlocks(doc)
|
|
211
|
+
expect(back.blocks.find((b) => b.name === 'bio')?.placeholder).toBe('请填写简介')
|
|
212
|
+
})
|
|
213
|
+
|
|
123
214
|
it('surfaceDocToMessages emits createSurface + updateComponents + updateDataModel', () => {
|
|
124
215
|
const doc = blocksToA2ui(makeDefinition())
|
|
125
216
|
const messages = surfaceDocToMessages(doc, { surfaceId: 's1', catalogId: 'c1' })
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
FormSchema,
|
|
9
9
|
} from '../types/index'
|
|
10
10
|
import { generateId } from './id'
|
|
11
|
+
import { coerceToggleValue } from './formSchema'
|
|
11
12
|
import { getMediaUrls, normalizeMediaSize } from './formMedia'
|
|
12
13
|
import { syncFormDefinition } from './formSchema'
|
|
13
14
|
|
|
@@ -95,14 +96,17 @@ function readMediaBlock(
|
|
|
95
96
|
function defaultValueForBlock(block: FormBlock): unknown {
|
|
96
97
|
switch (block.type) {
|
|
97
98
|
case 'toggle':
|
|
98
|
-
return
|
|
99
|
+
return coerceToggleValue(block.defaultValue)
|
|
99
100
|
case 'multiselect':
|
|
100
101
|
case 'checkbox':
|
|
101
|
-
case 'select':
|
|
102
|
-
case 'radio':
|
|
103
102
|
if (Array.isArray(block.defaultValue)) return block.defaultValue
|
|
104
103
|
if (typeof block.defaultValue === 'string' && block.defaultValue) return [block.defaultValue]
|
|
105
104
|
return []
|
|
105
|
+
case 'select':
|
|
106
|
+
case 'radio':
|
|
107
|
+
if (Array.isArray(block.defaultValue)) return block.defaultValue[0] ?? ''
|
|
108
|
+
if (typeof block.defaultValue === 'string') return block.defaultValue
|
|
109
|
+
return ''
|
|
106
110
|
default:
|
|
107
111
|
if (typeof block.defaultValue === 'string') return block.defaultValue
|
|
108
112
|
return ''
|
|
@@ -136,7 +140,14 @@ function blockToComponent(block: FormBlock): {
|
|
|
136
140
|
case 'text':
|
|
137
141
|
case 'tel':
|
|
138
142
|
case 'url':
|
|
139
|
-
return withData({
|
|
143
|
+
return withData({
|
|
144
|
+
id,
|
|
145
|
+
component: 'TextField',
|
|
146
|
+
label,
|
|
147
|
+
variant: 'shortText',
|
|
148
|
+
...(block.placeholder ? { placeholder: block.placeholder } : {}),
|
|
149
|
+
value: { path },
|
|
150
|
+
})
|
|
140
151
|
case 'email':
|
|
141
152
|
return withData({
|
|
142
153
|
id,
|
|
@@ -144,12 +155,27 @@ function blockToComponent(block: FormBlock): {
|
|
|
144
155
|
label,
|
|
145
156
|
variant: 'shortText',
|
|
146
157
|
validationRegexp: EMAIL_REGEXP,
|
|
158
|
+
...(block.placeholder ? { placeholder: block.placeholder } : {}),
|
|
147
159
|
value: { path },
|
|
148
160
|
})
|
|
149
161
|
case 'textarea':
|
|
150
|
-
return withData({
|
|
162
|
+
return withData({
|
|
163
|
+
id,
|
|
164
|
+
component: 'TextField',
|
|
165
|
+
label,
|
|
166
|
+
variant: 'longText',
|
|
167
|
+
...(block.placeholder ? { placeholder: block.placeholder } : {}),
|
|
168
|
+
value: { path },
|
|
169
|
+
})
|
|
151
170
|
case 'number':
|
|
152
|
-
return withData({
|
|
171
|
+
return withData({
|
|
172
|
+
id,
|
|
173
|
+
component: 'TextField',
|
|
174
|
+
label,
|
|
175
|
+
variant: 'number',
|
|
176
|
+
...(block.placeholder ? { placeholder: block.placeholder } : {}),
|
|
177
|
+
value: { path },
|
|
178
|
+
})
|
|
153
179
|
|
|
154
180
|
case 'select':
|
|
155
181
|
return withData({
|
|
@@ -337,7 +363,10 @@ function componentToBlock(node: A2uiComponentNode): FormBlock | null {
|
|
|
337
363
|
if (variant === 'longText') type = 'textarea'
|
|
338
364
|
else if (variant === 'number') type = 'number'
|
|
339
365
|
else if (asLiteral(node.validationRegexp) === EMAIL_REGEXP) type = 'email'
|
|
340
|
-
|
|
366
|
+
const block: FormBlock = { ...base(type), name }
|
|
367
|
+
const placeholder = asLiteral(node.placeholder)
|
|
368
|
+
if (placeholder) block.placeholder = placeholder
|
|
369
|
+
return block
|
|
341
370
|
}
|
|
342
371
|
case 'CheckBox':
|
|
343
372
|
return { ...base('toggle'), name }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import {
|
|
6
|
+
buildFileAcceptAttribute,
|
|
7
|
+
filterFileAcceptExtensionsForTypes,
|
|
8
|
+
formatFileAcceptSummary,
|
|
9
|
+
} from './formFileAccept'
|
|
10
|
+
import { normalizeFormDefinition } from './formSchema'
|
|
11
|
+
|
|
12
|
+
describe('filterFileAcceptExtensionsForTypes without types', () => {
|
|
13
|
+
it('keeps whitelisted extensions when fileAcceptTypes is empty', () => {
|
|
14
|
+
expect(filterFileAcceptExtensionsForTypes(undefined, ['.pdf', '.docx'])).toEqual([
|
|
15
|
+
'.pdf',
|
|
16
|
+
'.docx',
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('drops unknown extensions when fileAcceptTypes is empty', () => {
|
|
21
|
+
expect(filterFileAcceptExtensionsForTypes([], ['.pdf', '.exe'])).toEqual(['.pdf'])
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('buildFileAcceptAttribute without types', () => {
|
|
26
|
+
it('preserves extension-only accept attribute', () => {
|
|
27
|
+
expect(buildFileAcceptAttribute(undefined, ['.pdf', '.docx'])).toBe('.pdf,.docx')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('formatFileAcceptSummary shows extension labels without types', () => {
|
|
31
|
+
expect(formatFileAcceptSummary(undefined, ['.pdf'])).toBe('.pdf')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('normalizeFormDefinition file extensions without types', () => {
|
|
36
|
+
it('retains whitelisted extensions on file blocks', () => {
|
|
37
|
+
const def = normalizeFormDefinition({
|
|
38
|
+
title: '',
|
|
39
|
+
description: '',
|
|
40
|
+
blocks: [
|
|
41
|
+
{
|
|
42
|
+
id: 'b1',
|
|
43
|
+
type: 'file',
|
|
44
|
+
name: 'attach',
|
|
45
|
+
label: '附件',
|
|
46
|
+
fileAcceptExtensions: ['.pdf', '.docx'],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
jsonSchema: {},
|
|
50
|
+
})
|
|
51
|
+
expect(def.blocks[0]?.fileAcceptExtensions).toEqual(['.pdf', '.docx'])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -103,6 +103,10 @@ const FILE_EXTENSION_LABELS = Object.fromEntries(
|
|
|
103
103
|
|
|
104
104
|
const FILE_TYPE_VALUE_SET = new Set(FORM_FILE_TYPE_OPTIONS.map((item) => item.value))
|
|
105
105
|
|
|
106
|
+
const KNOWN_FILE_EXTENSION_SET = new Set(
|
|
107
|
+
FORM_FILE_EXTENSION_OPTIONS.map((item) => item.value),
|
|
108
|
+
)
|
|
109
|
+
|
|
106
110
|
function resolveFileAcceptTypeValue(value: string): string | null {
|
|
107
111
|
const trimmed = value.trim()
|
|
108
112
|
if (!trimmed) return null
|
|
@@ -164,10 +168,15 @@ export function filterFileAcceptExtensionsForTypes(
|
|
|
164
168
|
types?: string[],
|
|
165
169
|
extensions?: string[],
|
|
166
170
|
): string[] {
|
|
171
|
+
const normalizedExtensions = normalizeFileAcceptExtensions(extensions)
|
|
172
|
+
const normalizedTypes = normalizeFileAcceptTypes(types)
|
|
173
|
+
if (normalizedTypes.length === 0) {
|
|
174
|
+
return normalizedExtensions.filter((item) => KNOWN_FILE_EXTENSION_SET.has(item))
|
|
175
|
+
}
|
|
167
176
|
const allowed = new Set(
|
|
168
|
-
getFileExtensionOptionsForTypes(
|
|
177
|
+
getFileExtensionOptionsForTypes(normalizedTypes).map((item) => item.value),
|
|
169
178
|
)
|
|
170
|
-
return
|
|
179
|
+
return normalizedExtensions.filter((item) => allowed.has(item))
|
|
171
180
|
}
|
|
172
181
|
|
|
173
182
|
export function buildFileAcceptAttribute(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { coerceToggleValue, normalizeFormDefinition } from './formSchema'
|
|
6
|
+
|
|
7
|
+
describe('coerceToggleValue', () => {
|
|
8
|
+
it('accepts boolean and string true', () => {
|
|
9
|
+
expect(coerceToggleValue(true)).toBe(true)
|
|
10
|
+
expect(coerceToggleValue('true')).toBe(true)
|
|
11
|
+
expect(coerceToggleValue(false)).toBe(false)
|
|
12
|
+
expect(coerceToggleValue('false')).toBe(false)
|
|
13
|
+
expect(coerceToggleValue(undefined)).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('normalizeFormDefinition toggle default', () => {
|
|
18
|
+
it('normalizes string "true" default to boolean true', () => {
|
|
19
|
+
const def = normalizeFormDefinition({
|
|
20
|
+
title: '',
|
|
21
|
+
description: '',
|
|
22
|
+
blocks: [
|
|
23
|
+
{
|
|
24
|
+
id: 'b1',
|
|
25
|
+
type: 'toggle',
|
|
26
|
+
name: 'subscribe',
|
|
27
|
+
label: '订阅',
|
|
28
|
+
defaultValue: 'true',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
jsonSchema: {},
|
|
32
|
+
})
|
|
33
|
+
expect(def.blocks[0]?.defaultValue).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_MEDIA_URL_MAX = 10
|
|
15
15
|
|
|
16
|
+
export function coerceToggleValue(raw: unknown): boolean {
|
|
17
|
+
return raw === true || raw === 'true'
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
function normalizeFileUploadCount(raw: unknown, fallback = 1): number {
|
|
17
21
|
const num = typeof raw === 'number' ? raw : Number(raw)
|
|
18
22
|
if (!Number.isFinite(num)) return fallback
|
|
@@ -24,7 +28,7 @@ function normalizeDefaultValue(
|
|
|
24
28
|
raw: FormBlock['defaultValue'],
|
|
25
29
|
): FormBlock['defaultValue'] {
|
|
26
30
|
if (raw === undefined) return undefined
|
|
27
|
-
if (type === 'toggle') return raw
|
|
31
|
+
if (type === 'toggle') return coerceToggleValue(raw)
|
|
28
32
|
if (type === 'multiselect' || type === 'checkbox') {
|
|
29
33
|
if (Array.isArray(raw)) return raw.map(String)
|
|
30
34
|
if (typeof raw === 'string' && raw) return [raw]
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
2
3
|
import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
|
|
3
4
|
import {
|
|
4
5
|
basicCatalog,
|
|
5
6
|
createBinderlessComponentImplementation,
|
|
6
7
|
type ReactComponentImplementation,
|
|
7
8
|
} from '@a2ui/react/v0_9'
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
Checkbox,
|
|
11
|
+
DatePicker,
|
|
12
|
+
Input,
|
|
13
|
+
InputNumber,
|
|
14
|
+
Radio,
|
|
15
|
+
Select,
|
|
16
|
+
Switch,
|
|
17
|
+
TimePicker,
|
|
18
|
+
Typography,
|
|
19
|
+
Upload,
|
|
20
|
+
} from 'antd'
|
|
9
21
|
import dayjs, { type Dayjs } from 'dayjs'
|
|
10
22
|
import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
|
|
23
|
+
import { coerceToggleValue } from '../adapter/formSchema'
|
|
11
24
|
import {
|
|
12
25
|
buildFileAcceptAttribute,
|
|
13
26
|
formatFileAcceptSummary,
|
|
@@ -18,6 +31,7 @@ import { useA2uiPreviewMode } from './a2uiPreviewContext'
|
|
|
18
31
|
import {
|
|
19
32
|
asOptionalString,
|
|
20
33
|
asStringArray,
|
|
34
|
+
readBoundFieldValue,
|
|
21
35
|
useSkoponBoundField,
|
|
22
36
|
} from './useSkoponBoundField'
|
|
23
37
|
|
|
@@ -37,8 +51,12 @@ const SKOPON_COMPONENT_NAMES = new Set([
|
|
|
37
51
|
'CheckBox',
|
|
38
52
|
'DateTimeInput',
|
|
39
53
|
'FileUpload',
|
|
54
|
+
'TextField',
|
|
55
|
+
'Text',
|
|
40
56
|
])
|
|
41
57
|
|
|
58
|
+
const NON_MARKDOWN_TEXT_VARIANTS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'caption'])
|
|
59
|
+
|
|
42
60
|
function getBasicSchema(name: string): z.ZodTypeAny {
|
|
43
61
|
const impl = [...basicCatalog.components.values()].find((c) => c.name === name)
|
|
44
62
|
if (!impl) throw new Error(`Missing basic catalog component: ${name}`)
|
|
@@ -343,7 +361,7 @@ function ToggleSwitchPreview({ context }: { context: ComponentContext }) {
|
|
|
343
361
|
<Switch
|
|
344
362
|
disabled={!interactive}
|
|
345
363
|
className="form-block-preview-control"
|
|
346
|
-
checked={value
|
|
364
|
+
checked={coerceToggleValue(value)}
|
|
347
365
|
onChange={(checked) => setValue(checked)}
|
|
348
366
|
/>
|
|
349
367
|
</div>
|
|
@@ -479,14 +497,18 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
|
479
497
|
)
|
|
480
498
|
? (props.filePlaceholderIcon as FormFilePlaceholderIcon)
|
|
481
499
|
: 'document'
|
|
500
|
+
const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
|
|
482
501
|
const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
|
|
502
|
+
const hasMaxLimit = maxCount > 0
|
|
483
503
|
const selectedNames = asStringArray(value)
|
|
504
|
+
const atMaxLimit = hasMaxLimit && selectedNames.length >= maxCount
|
|
505
|
+
const belowMinCount = selectedNames.length < minCount
|
|
484
506
|
|
|
485
507
|
return (
|
|
486
508
|
<div className="form-block-preview">
|
|
487
509
|
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
488
510
|
<Upload.Dragger
|
|
489
|
-
disabled={!interactive}
|
|
511
|
+
disabled={!interactive || atMaxLimit}
|
|
490
512
|
accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
|
|
491
513
|
className="form-file-upload-preview"
|
|
492
514
|
showUploadList={interactive && selectedNames.length > 0}
|
|
@@ -501,12 +523,24 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
|
501
523
|
}
|
|
502
524
|
beforeUpload={(file) => {
|
|
503
525
|
if (!interactive) return Upload.LIST_IGNORE
|
|
504
|
-
const
|
|
526
|
+
const currentNames = asStringArray(readBoundFieldValue(context))
|
|
527
|
+
if (hasMaxLimit && currentNames.length >= maxCount) return Upload.LIST_IGNORE
|
|
528
|
+
const next = hasMaxLimit
|
|
529
|
+
? [...currentNames, file.name].slice(0, maxCount)
|
|
530
|
+
: [...currentNames, file.name]
|
|
505
531
|
setValue(next)
|
|
506
532
|
return false
|
|
507
533
|
}}
|
|
508
534
|
onRemove={(file) => {
|
|
509
|
-
|
|
535
|
+
const currentNames = asStringArray(readBoundFieldValue(context))
|
|
536
|
+
const indexMatch = /^(\d+)-/.exec(file.uid ?? '')
|
|
537
|
+
const index = indexMatch ? Number(indexMatch[1]) : -1
|
|
538
|
+
const next =
|
|
539
|
+
index >= 0 && index < currentNames.length
|
|
540
|
+
? currentNames.filter((_, i) => i !== index)
|
|
541
|
+
: currentNames.filter((name) => name !== file.name)
|
|
542
|
+
if (next.length < minCount) return false
|
|
543
|
+
setValue(next)
|
|
510
544
|
}}
|
|
511
545
|
>
|
|
512
546
|
<p className="form-file-upload-preview-icon">
|
|
@@ -516,6 +550,9 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
|
516
550
|
</Upload.Dragger>
|
|
517
551
|
<Typography.Text type="secondary" className="form-block-preview-help">
|
|
518
552
|
允许:{acceptSummary ?? '全部类型'}
|
|
553
|
+
{minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
|
|
554
|
+
{hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
|
|
555
|
+
{belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
|
|
519
556
|
</Typography.Text>
|
|
520
557
|
</div>
|
|
521
558
|
)
|
|
@@ -526,6 +563,116 @@ const FileUploadImpl = createBinderlessComponentImplementation(
|
|
|
526
563
|
FileUploadPreview,
|
|
527
564
|
)
|
|
528
565
|
|
|
566
|
+
function TextFieldPreview({ context }: { context: ComponentContext }) {
|
|
567
|
+
const { interactive } = useA2uiPreviewMode()
|
|
568
|
+
const { value, setValue } = useSkoponBoundField(context)
|
|
569
|
+
const props = context.componentModel.properties as Record<string, unknown>
|
|
570
|
+
const label = readString(props.label)
|
|
571
|
+
const placeholder = readString(props.placeholder)
|
|
572
|
+
const variant = readString(props.variant) || 'shortText'
|
|
573
|
+
const stringValue = asOptionalString(value) ?? ''
|
|
574
|
+
const disabled = !interactive
|
|
575
|
+
|
|
576
|
+
const controlClassName = 'form-block-preview-control'
|
|
577
|
+
|
|
578
|
+
let control: ReactNode
|
|
579
|
+
if (variant === 'longText') {
|
|
580
|
+
control = (
|
|
581
|
+
<Input.TextArea
|
|
582
|
+
disabled={disabled}
|
|
583
|
+
className={controlClassName}
|
|
584
|
+
placeholder={placeholder || '长文本回答...'}
|
|
585
|
+
autoSize={{ minRows: 2, maxRows: 6 }}
|
|
586
|
+
value={stringValue}
|
|
587
|
+
onChange={(e) => setValue(e.target.value)}
|
|
588
|
+
/>
|
|
589
|
+
)
|
|
590
|
+
} else if (variant === 'number') {
|
|
591
|
+
const numericValue =
|
|
592
|
+
stringValue && Number.isFinite(Number(stringValue)) ? Number(stringValue) : undefined
|
|
593
|
+
control = (
|
|
594
|
+
<InputNumber
|
|
595
|
+
disabled={disabled}
|
|
596
|
+
className={controlClassName}
|
|
597
|
+
style={{ width: '100%' }}
|
|
598
|
+
placeholder={placeholder || '数字'}
|
|
599
|
+
value={numericValue}
|
|
600
|
+
onChange={(next) => setValue(next == null ? '' : String(next))}
|
|
601
|
+
/>
|
|
602
|
+
)
|
|
603
|
+
} else if (variant === 'obscured') {
|
|
604
|
+
control = (
|
|
605
|
+
<Input.Password
|
|
606
|
+
disabled={disabled}
|
|
607
|
+
className={controlClassName}
|
|
608
|
+
placeholder={placeholder}
|
|
609
|
+
value={stringValue}
|
|
610
|
+
onChange={(e) => setValue(e.target.value)}
|
|
611
|
+
/>
|
|
612
|
+
)
|
|
613
|
+
} else {
|
|
614
|
+
control = (
|
|
615
|
+
<Input
|
|
616
|
+
disabled={disabled}
|
|
617
|
+
className={controlClassName}
|
|
618
|
+
placeholder={placeholder}
|
|
619
|
+
value={stringValue}
|
|
620
|
+
onChange={(e) => setValue(e.target.value)}
|
|
621
|
+
/>
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return (
|
|
626
|
+
<div className="form-block-preview">
|
|
627
|
+
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
628
|
+
{control}
|
|
629
|
+
</div>
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const TextFieldImpl = createBinderlessComponentImplementation(
|
|
634
|
+
{
|
|
635
|
+
name: 'TextField',
|
|
636
|
+
schema: z
|
|
637
|
+
.object({
|
|
638
|
+
label: z.any().optional(),
|
|
639
|
+
variant: z.any().optional(),
|
|
640
|
+
placeholder: z.any().optional(),
|
|
641
|
+
validationRegexp: z.any().optional(),
|
|
642
|
+
value: z.any().optional(),
|
|
643
|
+
})
|
|
644
|
+
.passthrough(),
|
|
645
|
+
},
|
|
646
|
+
TextFieldPreview,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
function TextPreview({ context }: { context: ComponentContext }) {
|
|
650
|
+
const props = context.componentModel.properties as Record<string, unknown>
|
|
651
|
+
const text = readString(props.text)
|
|
652
|
+
const variant = readString(props.variant) || 'body'
|
|
653
|
+
|
|
654
|
+
if (NON_MARKDOWN_TEXT_VARIANTS.has(variant)) {
|
|
655
|
+
if (variant === 'caption') {
|
|
656
|
+
return (
|
|
657
|
+
<Typography.Text type="secondary" className="form-block-preview-paragraph">
|
|
658
|
+
<em>{text}</em>
|
|
659
|
+
</Typography.Text>
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
const Tag = variant as 'h1' | 'h2' | 'h3' | 'h4' | 'h5'
|
|
663
|
+
return <Tag className="form-block-preview-heading">{text}</Tag>
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return (
|
|
667
|
+
<Typography.Paragraph className="form-block-preview-paragraph">{text}</Typography.Paragraph>
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const TextImpl = createBinderlessComponentImplementation(
|
|
672
|
+
{ name: 'Text', schema: getBasicSchema('Text') },
|
|
673
|
+
TextPreview,
|
|
674
|
+
)
|
|
675
|
+
|
|
529
676
|
/** 构建合并后的 skopon catalog(预览每次重建,避免 HMR 缓存旧组件)。 */
|
|
530
677
|
export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
|
|
531
678
|
const baseComponents = [...basicCatalog.components.values()].filter(
|
|
@@ -542,6 +689,8 @@ export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
|
|
|
542
689
|
CheckBoxImpl,
|
|
543
690
|
DateTimeInputImpl,
|
|
544
691
|
FileUploadImpl,
|
|
692
|
+
TextFieldImpl,
|
|
693
|
+
TextImpl,
|
|
545
694
|
]
|
|
546
695
|
const functions = [...basicCatalog.functions.values()]
|
|
547
696
|
return new Catalog(SKOPON_CATALOG_ID, components, functions, basicCatalog.themeSchema)
|