@skopon-cool/form-sdk 0.5.0 → 0.5.1
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/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formOptions.d.ts +6 -0
- package/dist/adapter/formOptions.d.ts.map +1 -0
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/index.js +911 -905
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.test.ts +57 -0
- package/src/adapter/a2uiAdapter.ts +3 -12
- package/src/adapter/formOptions.test.ts +60 -0
- package/src/adapter/formOptions.ts +36 -0
- package/src/adapter/formSchema.test.ts +30 -0
- package/src/adapter/formSchema.ts +3 -5
- package/src/catalog/a2uiCustomCatalog.tsx +6 -10
- package/src/catalog/invalidStaticOptions.test.tsx +62 -0
- package/src/submit/intersectPayloadBlocksWithForm.ts +14 -4
- package/src/submit/submit.test.ts +40 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"intersectPayloadBlocksWithForm.d.ts","sourceRoot":"","sources":["../../src/submit/intersectPayloadBlocksWithForm.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"intersectPayloadBlocksWithForm.d.ts","sourceRoot":"","sources":["../../src/submit/intersectPayloadBlocksWithForm.ts"],"names":[],"mappings":"AAEA,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,GAAG;IAC3F;;;OAGG;IACH,SAAS,CAAC,EAAE,SAAS,EAAE,CAAA;CACxB,CAAA;AAED,MAAM,WAAW,yBAAyB;IACxC,aAAa,EAAE,SAAS,EAAE,CAAA;IAC1B,WAAW,EAAE,SAAS,EAAE,CAAA;IACxB,gEAAgE;IAChE,YAAY,EAAE,SAAS,EAAE,CAAA;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;CACpB;AA+ED,uEAAuE;AACvE,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,uBAAuB,GAAG,IAAI,CAmBvF;AAED;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,UAAU,EAAE,uBAAuB,EACnC,cAAc,EAAE,UAAU,GAAG,SAAS,GACrC,yBAAyB,CA+B3B;AA+BD,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,CAOlF;AAED,yEAAyE;AACzE,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,uBAAuB,GAAG,SAAS,EAAE,CAQ3F;AAED,4DAA4D;AAC5D,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,uBAAuB,GAAG,MAAM,EAAE,CASvF;AAED,6DAA6D;AAC7D,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,uBAAuB,GAClC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB"}
|
package/package.json
CHANGED
|
@@ -80,6 +80,63 @@ describe('a2uiAdapter blocks <-> surface', () => {
|
|
|
80
80
|
expect(img?.mediaSize).toBe('large')
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
+
it('omits empty static option blocks from nodes and dataModel', () => {
|
|
84
|
+
const doc = blocksToA2ui(
|
|
85
|
+
{
|
|
86
|
+
title: '',
|
|
87
|
+
description: '',
|
|
88
|
+
blocks: [
|
|
89
|
+
{ id: 'empty', type: 'select', name: 'empty_choice', label: '空问题', options: [] },
|
|
90
|
+
{ id: 'title', type: 'text', name: 'title', label: '标题' },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
{ includeHeader: false },
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
expect(doc.components.find((component) => component.id === 'empty')).toBeUndefined()
|
|
97
|
+
expect(doc.dataModel).not.toHaveProperty('empty_choice')
|
|
98
|
+
expect(doc.components.find((component) => component.id === 'title')).toBeDefined()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('keeps dynamic option blocks when their initial options are empty', () => {
|
|
102
|
+
const doc = blocksToA2ui(
|
|
103
|
+
{
|
|
104
|
+
title: '',
|
|
105
|
+
description: '',
|
|
106
|
+
blocks: [
|
|
107
|
+
{ id: 'resume', type: 'resume_multiselect', name: 'resumes', options: [] },
|
|
108
|
+
{ id: 'case', type: 'case_multiselect', name: 'cases', options: [] },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{ includeHeader: false },
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expect(doc.components.find((component) => component.id === 'resume')).toBeDefined()
|
|
115
|
+
expect(doc.components.find((component) => component.id === 'case')).toBeDefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('sanitizes options while converting a direct A2UI document to blocks', () => {
|
|
119
|
+
const back = a2uiToBlocks({
|
|
120
|
+
root: 'root',
|
|
121
|
+
components: [
|
|
122
|
+
{ id: 'root', component: 'Column', children: ['choice'] },
|
|
123
|
+
{
|
|
124
|
+
id: 'choice',
|
|
125
|
+
component: 'SkoponSelect',
|
|
126
|
+
label: '选择',
|
|
127
|
+
options: [
|
|
128
|
+
{ value: 0, label: 123 },
|
|
129
|
+
{ value: {}, label: '异常' },
|
|
130
|
+
{ value: 'null', label: '异常' },
|
|
131
|
+
],
|
|
132
|
+
value: { path: '/choice' },
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
expect(back.blocks[0]?.options).toEqual([{ value: '0', label: '123' }])
|
|
138
|
+
})
|
|
139
|
+
|
|
83
140
|
it('blocksToA2ui emits SkoponMedia for image blocks', () => {
|
|
84
141
|
const doc = blocksToA2ui(makeDefinition(), { includeHeader: false })
|
|
85
142
|
const imgNode = doc.components.find((c) => c.id === 'b-img')
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
import { generateId } from './id'
|
|
11
11
|
import { coerceToggleValue, syncFormDefinition } from './formSchema'
|
|
12
12
|
import { getMediaUrls, normalizeMediaSize } from './formMedia'
|
|
13
|
+
import { hasRenderableFormOptions, normalizeFormOptions } from './formOptions'
|
|
13
14
|
import { getBlockTypeAdapter, getComponentAdapter } from '../blocks/registry'
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -325,6 +326,7 @@ export function blocksToA2ui(
|
|
|
325
326
|
}
|
|
326
327
|
|
|
327
328
|
for (const block of definition.blocks ?? []) {
|
|
329
|
+
if (!hasRenderableFormOptions(block)) continue
|
|
328
330
|
const { node, dataKey, dataValue } = blockToComponent(block)
|
|
329
331
|
// hidden 等无节点块仍可写入 dataModel,保证「不展示仍提交」
|
|
330
332
|
if (dataKey) dataModel[dataKey] = dataValue
|
|
@@ -352,18 +354,7 @@ function indexComponents(doc: A2uiSurfaceDoc): Map<string, A2uiComponentNode> {
|
|
|
352
354
|
}
|
|
353
355
|
|
|
354
356
|
function readOptions(node: A2uiComponentNode): FormBlockOption[] {
|
|
355
|
-
|
|
356
|
-
if (!Array.isArray(raw)) return []
|
|
357
|
-
return raw
|
|
358
|
-
.map((o) => {
|
|
359
|
-
if (!o || typeof o !== 'object') return null
|
|
360
|
-
const rec = o as Record<string, unknown>
|
|
361
|
-
return {
|
|
362
|
-
value: String(rec.value ?? rec.label ?? ''),
|
|
363
|
-
label: String(rec.label ?? rec.value ?? ''),
|
|
364
|
-
} as FormBlockOption
|
|
365
|
-
})
|
|
366
|
-
.filter((o): o is FormBlockOption => !!o)
|
|
357
|
+
return normalizeFormOptions(node.options)
|
|
367
358
|
}
|
|
368
359
|
|
|
369
360
|
/** 单个 A2UI 组件节点 -> FormBlock(无法识别返回 null) */
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { hasRenderableFormOptions, normalizeFormOptions } from './formOptions'
|
|
6
|
+
|
|
7
|
+
describe('normalizeFormOptions', () => {
|
|
8
|
+
it('keeps trimmed strings and finite numbers in source order', () => {
|
|
9
|
+
const options = normalizeFormOptions([
|
|
10
|
+
{ value: ' first ', label: ' 第一项 ' },
|
|
11
|
+
{ value: 0, label: 123 },
|
|
12
|
+
{ value: -1.5, label: '负数' },
|
|
13
|
+
{ value: 'undefined', label: '普通文本' },
|
|
14
|
+
{ value: 'first', label: '重复值仍保留' },
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
expect(options).toEqual([
|
|
18
|
+
{ value: 'first', label: '第一项' },
|
|
19
|
+
{ value: '0', label: '123' },
|
|
20
|
+
{ value: '-1.5', label: '负数' },
|
|
21
|
+
{ value: 'undefined', label: '普通文本' },
|
|
22
|
+
{ value: 'first', label: '重复值仍保留' },
|
|
23
|
+
])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('drops malformed options and unsupported value or label types without throwing', () => {
|
|
27
|
+
const malformed = [
|
|
28
|
+
null,
|
|
29
|
+
[],
|
|
30
|
+
'not-an-option',
|
|
31
|
+
{ value: '', label: '空值' },
|
|
32
|
+
{ value: ' ', label: '空白值' },
|
|
33
|
+
{ value: 'null', label: 'null 文本' },
|
|
34
|
+
{ value: ' NULL ', label: '大小写 null 文本' },
|
|
35
|
+
{ value: null, label: '真正 null' },
|
|
36
|
+
{ value: 'ok', label: null },
|
|
37
|
+
{ value: true, label: '布尔值' },
|
|
38
|
+
{ value: 'ok', label: false },
|
|
39
|
+
{ value: {}, label: '对象' },
|
|
40
|
+
{ value: 'ok', label: [] },
|
|
41
|
+
{ value: Number.NaN, label: 'NaN' },
|
|
42
|
+
{ value: Number.POSITIVE_INFINITY, label: 'Infinity' },
|
|
43
|
+
{ label: '缺少 value' },
|
|
44
|
+
{ value: '缺少 label' },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
expect(() => normalizeFormOptions(malformed)).not.toThrow()
|
|
48
|
+
expect(normalizeFormOptions(malformed)).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('hasRenderableFormOptions', () => {
|
|
53
|
+
it('hides empty static option blocks but keeps dynamic option blocks', () => {
|
|
54
|
+
expect(hasRenderableFormOptions({ type: 'select', options: [] })).toBe(false)
|
|
55
|
+
expect(hasRenderableFormOptions({ type: 'radio', options: undefined })).toBe(false)
|
|
56
|
+
expect(hasRenderableFormOptions({ type: 'resume_multiselect', options: [] })).toBe(true)
|
|
57
|
+
expect(hasRenderableFormOptions({ type: 'case_multiselect', options: [] })).toBe(true)
|
|
58
|
+
expect(hasRenderableFormOptions({ type: 'text', options: undefined })).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FormBlock, FormBlockOption } from '../types/index'
|
|
2
|
+
import { isOptionsBlockType } from '../types/index'
|
|
3
|
+
|
|
4
|
+
function normalizeOptionText(raw: unknown): string | undefined {
|
|
5
|
+
if (typeof raw === 'number') {
|
|
6
|
+
return Number.isFinite(raw) ? String(raw) : undefined
|
|
7
|
+
}
|
|
8
|
+
if (typeof raw !== 'string') return undefined
|
|
9
|
+
|
|
10
|
+
const trimmed = raw.trim()
|
|
11
|
+
if (!trimmed || trimmed.toLowerCase() === 'null') return undefined
|
|
12
|
+
return trimmed
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 将不可信 JSON 中的静态选项收敛为 SDK 的字符串选项契约。 */
|
|
16
|
+
export function normalizeFormOptions(raw: unknown): FormBlockOption[] {
|
|
17
|
+
if (!Array.isArray(raw)) return []
|
|
18
|
+
|
|
19
|
+
const options: FormBlockOption[] = []
|
|
20
|
+
for (const item of raw) {
|
|
21
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
|
|
22
|
+
const option = item as Record<string, unknown>
|
|
23
|
+
const value = normalizeOptionText(option.value)
|
|
24
|
+
const label = normalizeOptionText(option.label)
|
|
25
|
+
if (value === undefined || label === undefined) continue
|
|
26
|
+
options.push({ value, label })
|
|
27
|
+
}
|
|
28
|
+
return options
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 动态简历/案例选择不属于静态 options 块,允许初始选项为空。 */
|
|
32
|
+
export function hasRenderableFormOptions(
|
|
33
|
+
block: Pick<FormBlock, 'type' | 'options'>,
|
|
34
|
+
): boolean {
|
|
35
|
+
return !isOptionsBlockType(block.type) || normalizeFormOptions(block.options).length > 0
|
|
36
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* @vitest-environment node
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import type { FormBlock } from '../types/index'
|
|
5
6
|
import {
|
|
6
7
|
coerceToggleValue,
|
|
7
8
|
createEmptyBlock,
|
|
@@ -75,6 +76,35 @@ describe('normalizeFormDefinition toggle default', () => {
|
|
|
75
76
|
})
|
|
76
77
|
})
|
|
77
78
|
|
|
79
|
+
describe('normalizeFormDefinition static options', () => {
|
|
80
|
+
it('keeps an empty static question for editing but omits it from jsonSchema', () => {
|
|
81
|
+
const def = normalizeFormDefinition({
|
|
82
|
+
title: '',
|
|
83
|
+
description: '',
|
|
84
|
+
blocks: [
|
|
85
|
+
{
|
|
86
|
+
id: 'empty-choice',
|
|
87
|
+
type: 'select',
|
|
88
|
+
name: 'empty_choice',
|
|
89
|
+
label: '无有效选项',
|
|
90
|
+
required: true,
|
|
91
|
+
options: [
|
|
92
|
+
{ value: null, label: '空' },
|
|
93
|
+
{ value: 'null', label: 'null' },
|
|
94
|
+
] as unknown as FormBlock['options'],
|
|
95
|
+
},
|
|
96
|
+
{ id: 'title', type: 'text', name: 'title', label: '标题', required: true },
|
|
97
|
+
],
|
|
98
|
+
jsonSchema: {},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(def.blocks).toHaveLength(2)
|
|
102
|
+
expect(def.blocks[0]?.options).toEqual([])
|
|
103
|
+
expect(def.jsonSchema.properties).not.toHaveProperty('empty_choice')
|
|
104
|
+
expect(def.jsonSchema.required).toEqual(['title'])
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
78
108
|
describe('normalizeFormDefinition preserves needLLM and editor fields', () => {
|
|
79
109
|
it('keeps needLLM, hidden, condition, jsonSchemaDescription after normalize', () => {
|
|
80
110
|
const def = normalizeFormDefinition({
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
normalizeMediaUrls,
|
|
24
24
|
} from './formMedia'
|
|
25
25
|
import { FORM_WEB_DEFAULT_HEIGHT, normalizeWebHeight } from './formWeb'
|
|
26
|
+
import { hasRenderableFormOptions, normalizeFormOptions } from './formOptions'
|
|
26
27
|
|
|
27
28
|
const DEFAULT_MEDIA_URL_MAX = 10
|
|
28
29
|
|
|
@@ -183,10 +184,7 @@ function normalizeBlock(raw: FormBlock): FormBlock {
|
|
|
183
184
|
hidden: type === 'text' ? migrated.hidden === true : undefined,
|
|
184
185
|
needLLM: migrated.needLLM === true ? true : undefined,
|
|
185
186
|
options: Array.isArray(migrated.options)
|
|
186
|
-
? migrated.options
|
|
187
|
-
value: opt.value?.trim() || `option_${index + 1}`,
|
|
188
|
-
label: opt.label?.trim() || `选项 ${index + 1}`,
|
|
189
|
-
}))
|
|
187
|
+
? normalizeFormOptions(migrated.options)
|
|
190
188
|
: undefined,
|
|
191
189
|
condition: migrated.condition?.fieldName
|
|
192
190
|
? {
|
|
@@ -359,7 +357,7 @@ export function blocksToJsonSchema(
|
|
|
359
357
|
|
|
360
358
|
for (const block of blocks) {
|
|
361
359
|
const name = block.name?.trim()
|
|
362
|
-
if (!name || !isInputBlockType(block.type)) continue
|
|
360
|
+
if (!name || !isInputBlockType(block.type) || !hasRenderableFormOptions(block)) continue
|
|
363
361
|
|
|
364
362
|
const property = blockToJsonSchemaProperty(block)
|
|
365
363
|
if (!property) continue
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
formatFileAcceptSummary,
|
|
28
28
|
} from '../adapter/formFileAccept'
|
|
29
29
|
import { buildMediaListClassName, normalizeMediaSize } from '../adapter/formMedia'
|
|
30
|
+
import { normalizeFormOptions } from '../adapter/formOptions'
|
|
30
31
|
import {
|
|
31
32
|
getRegisteredCatalogComponentNames,
|
|
32
33
|
getRegisteredCatalogComponents,
|
|
@@ -77,16 +78,7 @@ function readString(value: unknown): string {
|
|
|
77
78
|
function readOptions(
|
|
78
79
|
raw: unknown,
|
|
79
80
|
): { label: string; value: string }[] {
|
|
80
|
-
|
|
81
|
-
return raw
|
|
82
|
-
.map((item) => {
|
|
83
|
-
if (!item || typeof item !== 'object') return null
|
|
84
|
-
const rec = item as Record<string, unknown>
|
|
85
|
-
const value = String(rec.value ?? rec.label ?? '')
|
|
86
|
-
const label = String(rec.label ?? rec.value ?? '')
|
|
87
|
-
return { value, label }
|
|
88
|
-
})
|
|
89
|
-
.filter((item): item is { label: string; value: string } => !!item)
|
|
81
|
+
return normalizeFormOptions(raw)
|
|
90
82
|
}
|
|
91
83
|
|
|
92
84
|
interface SkoponMediaPreviewProps {
|
|
@@ -257,6 +249,8 @@ function SkoponSelectPreview({ context }: { context: ComponentContext }) {
|
|
|
257
249
|
if (normalized !== null) setValueSilent(normalized)
|
|
258
250
|
}, [isMultiple, value, setValueSilent])
|
|
259
251
|
|
|
252
|
+
if (options.length === 0) return null
|
|
253
|
+
|
|
260
254
|
return (
|
|
261
255
|
<div className="form-block-preview" data-skopon-field={fieldName || undefined}>
|
|
262
256
|
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
@@ -310,6 +304,8 @@ function ChoicePickerPreview({ context }: { context: ComponentContext }) {
|
|
|
310
304
|
if (normalized !== null) setValueSilent(normalized)
|
|
311
305
|
}, [isMultiplePicker, value, setValueSilent])
|
|
312
306
|
|
|
307
|
+
if (options.length === 0) return null
|
|
308
|
+
|
|
313
309
|
return (
|
|
314
310
|
<div className="form-block-preview" data-skopon-field={fieldName || undefined}>
|
|
315
311
|
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal<typeof import('@a2ui/web_core/v0_9/basic_catalog')>()
|
|
6
|
+
return { ...actual, injectBasicCatalogStyles: vi.fn() }
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
vi.mock('@a2ui/react/styles', () => ({
|
|
10
|
+
injectStyles: vi.fn(),
|
|
11
|
+
removeStyles: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
import { createElement } from 'react'
|
|
15
|
+
import { cleanup, render } from '@testing-library/react'
|
|
16
|
+
import SkoponFormRenderer from '../components/SkoponFormRenderer'
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
class ResizeObserverMock {
|
|
20
|
+
observe() {}
|
|
21
|
+
unobserve() {}
|
|
22
|
+
disconnect() {}
|
|
23
|
+
}
|
|
24
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(() => cleanup())
|
|
28
|
+
|
|
29
|
+
describe('direct A2UI static options', () => {
|
|
30
|
+
it('does not render an invalid empty question and keeps a numeric option question', () => {
|
|
31
|
+
const doc = {
|
|
32
|
+
root: 'root',
|
|
33
|
+
components: [
|
|
34
|
+
{ id: 'root', component: 'Column', children: ['invalid', 'valid'] },
|
|
35
|
+
{
|
|
36
|
+
id: 'invalid',
|
|
37
|
+
component: 'SkoponSelect',
|
|
38
|
+
label: '不应显示的问题',
|
|
39
|
+
options: [{ value: null, label: '空' }, { value: {}, label: '对象' }],
|
|
40
|
+
value: { path: '/invalid' },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'valid',
|
|
44
|
+
component: 'ChoicePicker',
|
|
45
|
+
label: '应显示的问题',
|
|
46
|
+
options: [{ value: 0, label: 123 }],
|
|
47
|
+
value: { path: '/valid' },
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const view = render(
|
|
53
|
+
createElement(SkoponFormRenderer, {
|
|
54
|
+
doc,
|
|
55
|
+
surfaceId: 'invalid-options-test',
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(view.queryByText('不应显示的问题')).toBeNull()
|
|
60
|
+
expect(view.queryByText('应显示的问题')).toBeTruthy()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeFormDefinition } from '../adapter/formSchema'
|
|
2
|
+
import { hasRenderableFormOptions } from '../adapter/formOptions'
|
|
2
3
|
import type { FormBlock, FormSchema } from '../types/index'
|
|
3
4
|
import { isDisplayBlockType, isInputBlockType } from '../types/index'
|
|
4
5
|
|
|
@@ -58,7 +59,7 @@ function buildRenderBlocks(
|
|
|
58
59
|
if (!matched) continue
|
|
59
60
|
// hidden text 不上屏;提交值由 AskUser 从 matchedBlocks 提取
|
|
60
61
|
if (isHiddenTextBlock(matched)) continue
|
|
61
|
-
renderBlocks.push(matched)
|
|
62
|
+
if (hasRenderableFormOptions(matched)) renderBlocks.push(matched)
|
|
62
63
|
}
|
|
63
64
|
return renderBlocks
|
|
64
65
|
}
|
|
@@ -191,7 +192,7 @@ function blockValueFromDefault(block: FormBlock): unknown {
|
|
|
191
192
|
export function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string, unknown> {
|
|
192
193
|
const values: Record<string, unknown> = {}
|
|
193
194
|
for (const block of extraBlocks) {
|
|
194
|
-
if (!isInputBlockType(block.type)) continue
|
|
195
|
+
if (!isInputBlockType(block.type) || !hasRenderableFormOptions(block)) continue
|
|
195
196
|
const name = block.name?.trim()
|
|
196
197
|
if (!name) continue
|
|
197
198
|
values[name] = blockValueFromDefault(block)
|
|
@@ -202,7 +203,10 @@ export function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string
|
|
|
202
203
|
/** payload 是否含至少一个具 name 的 input 块(可用于卡片 fallback) */
|
|
203
204
|
export function payloadHasInputBlocks(payloadDef: PayloadBlocksDefinition): boolean {
|
|
204
205
|
return payloadDef.blocks.some(
|
|
205
|
-
(block) =>
|
|
206
|
+
(block) =>
|
|
207
|
+
isInputBlockType(block.type) &&
|
|
208
|
+
Boolean(block.name?.trim()) &&
|
|
209
|
+
hasRenderableFormOptions(block),
|
|
206
210
|
)
|
|
207
211
|
}
|
|
208
212
|
|
|
@@ -211,6 +215,7 @@ export function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition):
|
|
|
211
215
|
return payloadDef.blocks.filter((block) => {
|
|
212
216
|
if (isDisplayBlockType(block.type)) return true
|
|
213
217
|
if (!isInputBlockType(block.type) || !block.name?.trim()) return false
|
|
218
|
+
if (!hasRenderableFormOptions(block)) return false
|
|
214
219
|
// hidden text 不上屏;提交值由 getPayloadHiddenBlockValues / dataModel 补齐
|
|
215
220
|
return !isHiddenTextBlock(block)
|
|
216
221
|
})
|
|
@@ -219,7 +224,12 @@ export function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition):
|
|
|
219
224
|
/** payload 中所有 input 块 field name(fallback 提交用,含 hidden) */
|
|
220
225
|
export function getPayloadInputFieldNames(payloadDef: PayloadBlocksDefinition): string[] {
|
|
221
226
|
return payloadDef.blocks
|
|
222
|
-
.filter(
|
|
227
|
+
.filter(
|
|
228
|
+
(block) =>
|
|
229
|
+
isInputBlockType(block.type) &&
|
|
230
|
+
block.name?.trim() &&
|
|
231
|
+
hasRenderableFormOptions(block),
|
|
232
|
+
)
|
|
223
233
|
.map((block) => block.name!.trim())
|
|
224
234
|
}
|
|
225
235
|
|
|
@@ -165,6 +165,32 @@ describe('intersectPayloadBlocksWithForm', () => {
|
|
|
165
165
|
expect(extractExtraBlockValues(result.extraBlocks)).toEqual({ agent_hint: 'hint' })
|
|
166
166
|
})
|
|
167
167
|
|
|
168
|
+
it('excludes empty static questions from rendering, field names, and extra values', () => {
|
|
169
|
+
const invalidOptions = [
|
|
170
|
+
{ value: null, label: '空' },
|
|
171
|
+
{ value: {}, label: '对象' },
|
|
172
|
+
]
|
|
173
|
+
const payload = parsePayloadBlocksJson({
|
|
174
|
+
title: 'Ask',
|
|
175
|
+
blocks: [
|
|
176
|
+
{
|
|
177
|
+
id: 'invalid',
|
|
178
|
+
type: 'select',
|
|
179
|
+
name: 'invalid_choice',
|
|
180
|
+
label: '无效选择',
|
|
181
|
+
options: invalidOptions,
|
|
182
|
+
},
|
|
183
|
+
{ id: 'valid', type: 'text', name: 'title', label: '标题' },
|
|
184
|
+
],
|
|
185
|
+
})!
|
|
186
|
+
|
|
187
|
+
expect(payload.blocks[0]?.options).toEqual([])
|
|
188
|
+
expect(payloadHasInputBlocks(payload)).toBe(true)
|
|
189
|
+
expect(getPayloadRenderableBlocks(payload).map((block) => block.name)).toEqual(['title'])
|
|
190
|
+
expect(getPayloadInputFieldNames(payload)).toEqual(['title'])
|
|
191
|
+
expect(extractExtraBlockValues(payload.blocks)).toEqual({ title: '' })
|
|
192
|
+
})
|
|
193
|
+
|
|
168
194
|
it('defaults resume_multiselect extra blocks to empty array', () => {
|
|
169
195
|
const extraBlocks = [
|
|
170
196
|
{
|
|
@@ -415,7 +441,13 @@ describe('payload fallback helpers', () => {
|
|
|
415
441
|
title: '',
|
|
416
442
|
blocks: [
|
|
417
443
|
{ id: 'title', type: 'heading', label: '问卷调查' },
|
|
418
|
-
{
|
|
444
|
+
{
|
|
445
|
+
id: 'q1',
|
|
446
|
+
type: 'radio',
|
|
447
|
+
name: 'occupation',
|
|
448
|
+
label: '职业',
|
|
449
|
+
options: [{ value: 'operations', label: '运营' }],
|
|
450
|
+
},
|
|
419
451
|
],
|
|
420
452
|
})!
|
|
421
453
|
|
|
@@ -433,7 +465,13 @@ describe('payload fallback helpers', () => {
|
|
|
433
465
|
title: '',
|
|
434
466
|
blocks: [
|
|
435
467
|
{ id: 'title', type: 'heading', label: '问卷调查' },
|
|
436
|
-
{
|
|
468
|
+
{
|
|
469
|
+
id: 'q1',
|
|
470
|
+
type: 'radio',
|
|
471
|
+
name: 'occupation',
|
|
472
|
+
label: '职业',
|
|
473
|
+
options: [{ value: 'operations', label: '运营' }],
|
|
474
|
+
},
|
|
437
475
|
{
|
|
438
476
|
id: 'hint',
|
|
439
477
|
type: 'text',
|