@skopon-cool/form-sdk 0.1.1 → 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 +47 -11
- 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/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 +941 -706
- 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 +11 -6
- package/src/adapter/a2uiAdapter.test.ts +68 -0
- package/src/adapter/a2uiAdapter.ts +7 -3
- 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 +28 -5
- package/src/catalog/textFieldPreview.test.tsx +1 -1
- 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,7 +3,7 @@
|
|
|
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
9
|
"sideEffects": [
|
|
@@ -29,31 +29,36 @@
|
|
|
29
29
|
"test": "vitest run",
|
|
30
30
|
"test:watch": "vitest"
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"dayjs": "^1.11.13",
|
|
34
|
+
"zod": "^3.24.0"
|
|
35
|
+
},
|
|
32
36
|
"peerDependencies": {
|
|
33
|
-
"@ant-design/icons": "^6.0.0",
|
|
34
37
|
"@a2ui/react": "^0.10.1",
|
|
35
38
|
"@a2ui/web_core": "^0.10.2",
|
|
39
|
+
"@ant-design/icons": "^6.0.0",
|
|
36
40
|
"antd": "^6.4.3",
|
|
37
|
-
"dayjs": "^1.11.0",
|
|
38
41
|
"react": "^18.0.0 || ^19.0.0",
|
|
39
42
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
40
43
|
},
|
|
41
44
|
"devDependencies": {
|
|
42
|
-
"@ant-design/icons": "^6.2.3",
|
|
43
45
|
"@a2ui/react": "^0.10.1",
|
|
44
46
|
"@a2ui/web_core": "^0.10.2",
|
|
47
|
+
"@ant-design/icons": "^6.2.3",
|
|
48
|
+
"@testing-library/react": "^16.3.2",
|
|
45
49
|
"@types/react": "^19.1.2",
|
|
46
50
|
"@types/react-dom": "^19.1.2",
|
|
47
51
|
"@vitejs/plugin-react": "^4.5.2",
|
|
48
52
|
"antd": "^6.4.3",
|
|
49
53
|
"dayjs": "^1.11.13",
|
|
54
|
+
"happy-dom": "^17.6.3",
|
|
55
|
+
"jsdom": "^29.1.1",
|
|
50
56
|
"react": "^19.1.0",
|
|
51
57
|
"react-dom": "^19.1.0",
|
|
52
58
|
"typescript": "~5.8.3",
|
|
53
59
|
"vite": "^6.3.5",
|
|
54
60
|
"vite-plugin-dts": "^4.5.4",
|
|
55
61
|
"vitest": "^3.2.4",
|
|
56
|
-
"
|
|
57
|
-
"@testing-library/react": "^16.3.2"
|
|
62
|
+
"zod": "^3.24.0"
|
|
58
63
|
}
|
|
59
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,71 @@ 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
|
+
|
|
123
191
|
it('round-trips textarea placeholder through blocksToA2ui', () => {
|
|
124
192
|
const doc = blocksToA2ui(
|
|
125
193
|
{
|
|
@@ -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 ''
|
|
@@ -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]
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from 'antd'
|
|
21
21
|
import dayjs, { type Dayjs } from 'dayjs'
|
|
22
22
|
import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
|
|
23
|
+
import { coerceToggleValue } from '../adapter/formSchema'
|
|
23
24
|
import {
|
|
24
25
|
buildFileAcceptAttribute,
|
|
25
26
|
formatFileAcceptSummary,
|
|
@@ -30,6 +31,7 @@ import { useA2uiPreviewMode } from './a2uiPreviewContext'
|
|
|
30
31
|
import {
|
|
31
32
|
asOptionalString,
|
|
32
33
|
asStringArray,
|
|
34
|
+
readBoundFieldValue,
|
|
33
35
|
useSkoponBoundField,
|
|
34
36
|
} from './useSkoponBoundField'
|
|
35
37
|
|
|
@@ -359,7 +361,7 @@ function ToggleSwitchPreview({ context }: { context: ComponentContext }) {
|
|
|
359
361
|
<Switch
|
|
360
362
|
disabled={!interactive}
|
|
361
363
|
className="form-block-preview-control"
|
|
362
|
-
checked={value
|
|
364
|
+
checked={coerceToggleValue(value)}
|
|
363
365
|
onChange={(checked) => setValue(checked)}
|
|
364
366
|
/>
|
|
365
367
|
</div>
|
|
@@ -495,14 +497,18 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
|
495
497
|
)
|
|
496
498
|
? (props.filePlaceholderIcon as FormFilePlaceholderIcon)
|
|
497
499
|
: 'document'
|
|
500
|
+
const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
|
|
498
501
|
const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
|
|
502
|
+
const hasMaxLimit = maxCount > 0
|
|
499
503
|
const selectedNames = asStringArray(value)
|
|
504
|
+
const atMaxLimit = hasMaxLimit && selectedNames.length >= maxCount
|
|
505
|
+
const belowMinCount = selectedNames.length < minCount
|
|
500
506
|
|
|
501
507
|
return (
|
|
502
508
|
<div className="form-block-preview">
|
|
503
509
|
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
504
510
|
<Upload.Dragger
|
|
505
|
-
disabled={!interactive}
|
|
511
|
+
disabled={!interactive || atMaxLimit}
|
|
506
512
|
accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
|
|
507
513
|
className="form-file-upload-preview"
|
|
508
514
|
showUploadList={interactive && selectedNames.length > 0}
|
|
@@ -517,12 +523,24 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
|
517
523
|
}
|
|
518
524
|
beforeUpload={(file) => {
|
|
519
525
|
if (!interactive) return Upload.LIST_IGNORE
|
|
520
|
-
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]
|
|
521
531
|
setValue(next)
|
|
522
532
|
return false
|
|
523
533
|
}}
|
|
524
534
|
onRemove={(file) => {
|
|
525
|
-
|
|
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)
|
|
526
544
|
}}
|
|
527
545
|
>
|
|
528
546
|
<p className="form-file-upload-preview-icon">
|
|
@@ -532,6 +550,9 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
|
532
550
|
</Upload.Dragger>
|
|
533
551
|
<Typography.Text type="secondary" className="form-block-preview-help">
|
|
534
552
|
允许:{acceptSummary ?? '全部类型'}
|
|
553
|
+
{minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
|
|
554
|
+
{hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
|
|
555
|
+
{belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
|
|
535
556
|
</Typography.Text>
|
|
536
557
|
</div>
|
|
537
558
|
)
|
|
@@ -567,13 +588,15 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
|
|
|
567
588
|
/>
|
|
568
589
|
)
|
|
569
590
|
} else if (variant === 'number') {
|
|
591
|
+
const numericValue =
|
|
592
|
+
stringValue && Number.isFinite(Number(stringValue)) ? Number(stringValue) : undefined
|
|
570
593
|
control = (
|
|
571
594
|
<InputNumber
|
|
572
595
|
disabled={disabled}
|
|
573
596
|
className={controlClassName}
|
|
574
597
|
style={{ width: '100%' }}
|
|
575
598
|
placeholder={placeholder || '数字'}
|
|
576
|
-
value={
|
|
599
|
+
value={numericValue}
|
|
577
600
|
onChange={(next) => setValue(next == null ? '' : String(next))}
|
|
578
601
|
/>
|
|
579
602
|
)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import type { ComponentContext } from '@a2ui/web_core/v0_9'
|
|
6
|
+
import { asStringArray, readBoundFieldValue } from './useSkoponBoundField'
|
|
7
|
+
|
|
8
|
+
function createMockContext(initial: unknown = []): {
|
|
9
|
+
context: ComponentContext
|
|
10
|
+
getStore: () => unknown
|
|
11
|
+
} {
|
|
12
|
+
const store = new Map<string, unknown>([['/files', initial]])
|
|
13
|
+
const binding = { path: '/files' }
|
|
14
|
+
const context = {
|
|
15
|
+
componentModel: {
|
|
16
|
+
properties: { value: binding },
|
|
17
|
+
},
|
|
18
|
+
dataContext: {
|
|
19
|
+
resolveDynamicValue: (b: typeof binding) => store.get(b.path),
|
|
20
|
+
set: (path: string, value: unknown) => {
|
|
21
|
+
store.set(path, value)
|
|
22
|
+
},
|
|
23
|
+
subscribeDynamicValue: () => ({ unsubscribe: () => {} }),
|
|
24
|
+
},
|
|
25
|
+
} as unknown as ComponentContext
|
|
26
|
+
return {
|
|
27
|
+
context,
|
|
28
|
+
getStore: () => store.get('/files'),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('readBoundFieldValue', () => {
|
|
33
|
+
it('reads current bound value from dataContext', () => {
|
|
34
|
+
const { context } = createMockContext(['a.pdf'])
|
|
35
|
+
expect(asStringArray(readBoundFieldValue(context))).toEqual(['a.pdf'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('supports sequential appends without stale reads', () => {
|
|
39
|
+
const { context, getStore } = createMockContext([])
|
|
40
|
+
const append = (fileName: string) => {
|
|
41
|
+
const current = asStringArray(readBoundFieldValue(context))
|
|
42
|
+
context.dataContext.set('/files', [...current, fileName])
|
|
43
|
+
}
|
|
44
|
+
append('first.pdf')
|
|
45
|
+
append('second.pdf')
|
|
46
|
+
expect(getStore()).toEqual(['first.pdf', 'second.pdf'])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('file upload remove by uid index', () => {
|
|
51
|
+
it('removes only the file at the uid index when names duplicate', () => {
|
|
52
|
+
const currentNames = ['dup.txt', 'dup.txt', 'other.txt']
|
|
53
|
+
const uid = '1-dup.txt'
|
|
54
|
+
const indexMatch = /^(\d+)-/.exec(uid)
|
|
55
|
+
const index = indexMatch ? Number(indexMatch[1]) : -1
|
|
56
|
+
const next =
|
|
57
|
+
index >= 0 && index < currentNames.length
|
|
58
|
+
? currentNames.filter((_, i) => i !== index)
|
|
59
|
+
: currentNames.filter((name) => name !== 'dup.txt')
|
|
60
|
+
expect(next).toEqual(['dup.txt', 'other.txt'])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -11,6 +11,15 @@ function bindingPath(value: unknown): string | null {
|
|
|
11
11
|
return null
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** 从 A2UI 组件 context 读取当前绑定字段值(不订阅更新,适合事件回调内读最新值)。 */
|
|
15
|
+
export function readBoundFieldValue(context: ComponentContext, field = 'value'): unknown {
|
|
16
|
+
const props = context.componentModel.properties as Record<string, unknown>
|
|
17
|
+
const binding = props[field] as DynamicBinding | undefined
|
|
18
|
+
const path = bindingPath(binding)
|
|
19
|
+
if (!path || binding === undefined) return undefined
|
|
20
|
+
return context.dataContext.resolveDynamicValue(binding)
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
/** 将 skopon 预览控件绑定到 A2UI surface dataModel(与 basicCatalog binder 行为一致)。 */
|
|
15
24
|
export function useSkoponBoundField(context: ComponentContext, field = 'value') {
|
|
16
25
|
const props = context.componentModel.properties as Record<string, unknown>
|
|
@@ -31,7 +40,7 @@ export function useSkoponBoundField(context: ComponentContext, field = 'value')
|
|
|
31
40
|
return context.dataContext.resolveDynamicValue(binding)
|
|
32
41
|
}, [context, binding, path])
|
|
33
42
|
|
|
34
|
-
const value =
|
|
43
|
+
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
35
44
|
|
|
36
45
|
const setValue = useCallback(
|
|
37
46
|
(next: unknown) => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
5
|
+
import { createFormClient } from './formClient'
|
|
6
|
+
|
|
7
|
+
function mockResponse(
|
|
8
|
+
init: Partial<Response> & { json?: () => Promise<unknown>; text?: () => Promise<string> },
|
|
9
|
+
): Response {
|
|
10
|
+
return {
|
|
11
|
+
ok: init.ok ?? true,
|
|
12
|
+
status: init.status ?? 200,
|
|
13
|
+
headers: { get: () => 'application/json' },
|
|
14
|
+
json: init.json ?? (async () => ({})),
|
|
15
|
+
text: init.text ?? (async () => ''),
|
|
16
|
+
} as Response
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('createFormClient', () => {
|
|
20
|
+
it('fetchDetail returns mapped form definition on success', async () => {
|
|
21
|
+
const fetchMock = vi.fn(async () =>
|
|
22
|
+
mockResponse({
|
|
23
|
+
json: async () => ({
|
|
24
|
+
success: true,
|
|
25
|
+
data: {
|
|
26
|
+
form_id: 1,
|
|
27
|
+
form_unique_id: 'form-abc',
|
|
28
|
+
name: 'Test',
|
|
29
|
+
disabled: 0,
|
|
30
|
+
form_definition: {
|
|
31
|
+
title: 'T',
|
|
32
|
+
blocks: [{ id: 'b1', type: 'text', name: 'x', label: 'X' }],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
) as unknown as typeof fetch
|
|
38
|
+
|
|
39
|
+
const client = createFormClient({ fetch: fetchMock, baseUrl: '/api/v1', detailPath: '/dev/form/detail' })
|
|
40
|
+
const result = await client.fetchDetail({ formUniqueId: 'form-abc' })
|
|
41
|
+
expect(result.formUniqueId).toBe('form-abc')
|
|
42
|
+
expect(result.formId).toBe(1)
|
|
43
|
+
expect(result.disabled).toBe(false)
|
|
44
|
+
expect(result.formDefinition?.blocks).toHaveLength(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('throws on non-JSON response', async () => {
|
|
48
|
+
const fetchMock = vi.fn(async () =>
|
|
49
|
+
mockResponse({
|
|
50
|
+
json: async () => {
|
|
51
|
+
throw new SyntaxError('Unexpected token')
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
) as unknown as typeof fetch
|
|
55
|
+
|
|
56
|
+
const client = createFormClient({ fetch: fetchMock })
|
|
57
|
+
await expect(client.fetchDetail({ formUniqueId: 'x' })).rejects.toThrow('响应非 JSON')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('throws when envelope data is missing', async () => {
|
|
61
|
+
const fetchMock = vi.fn(async () =>
|
|
62
|
+
mockResponse({
|
|
63
|
+
json: async () => ({ success: true, data: null }),
|
|
64
|
+
}),
|
|
65
|
+
) as unknown as typeof fetch
|
|
66
|
+
|
|
67
|
+
const client = createFormClient({ fetch: fetchMock })
|
|
68
|
+
await expect(client.fetchDetail({ formUniqueId: 'x' })).rejects.toThrow('响应缺少 data')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('throws when API returns success false', async () => {
|
|
72
|
+
const fetchMock = vi.fn(async () =>
|
|
73
|
+
mockResponse({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 404,
|
|
76
|
+
json: async () => ({ success: false, message: '表单不存在' }),
|
|
77
|
+
}),
|
|
78
|
+
) as unknown as typeof fetch
|
|
79
|
+
|
|
80
|
+
const client = createFormClient({ fetch: fetchMock })
|
|
81
|
+
await expect(client.fetchDetail({ formUniqueId: 'missing' })).rejects.toThrow('表单不存在')
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/client/formClient.ts
CHANGED
|
@@ -27,11 +27,19 @@ interface ApiEnvelope<T> {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
async function parseEnvelope<T>(response: Response): Promise<T> {
|
|
30
|
-
|
|
30
|
+
let json: ApiEnvelope<T>
|
|
31
|
+
try {
|
|
32
|
+
json = (await response.json()) as ApiEnvelope<T>
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(`响应非 JSON (${response.status})`)
|
|
35
|
+
}
|
|
31
36
|
if (!response.ok || json.success === false) {
|
|
32
37
|
throw new Error(json.message || `请求失败 (${response.status})`)
|
|
33
38
|
}
|
|
34
|
-
|
|
39
|
+
if (json.data === undefined || json.data === null) {
|
|
40
|
+
throw new Error(json.message || `响应缺少 data (${response.status})`)
|
|
41
|
+
}
|
|
42
|
+
return json.data
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
export function createFormClient(options: FormClientOptions = {}): FormClient {
|