@skopon-cool/form-sdk 0.5.0 → 0.5.2

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.
@@ -1 +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,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,CAIlF;AAED,yEAAyE;AACzE,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,uBAAuB,GAAG,SAAS,EAAE,CAO3F;AAED,4DAA4D;AAC5D,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,uBAAuB,GAAG,MAAM,EAAE,CAIvF;AAED,6DAA6D;AAC7D,wBAAgB,2BAA2B,CACzC,UAAU,EAAE,uBAAuB,GAClC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CASzB"}
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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.5.0",
6
+ "version": "0.5.2",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -80,6 +80,88 @@ 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
+
140
+ it('sanitizes untrusted FormBlock options while building an A2UI document', () => {
141
+ const doc = blocksToA2ui(
142
+ {
143
+ title: '',
144
+ description: '',
145
+ blocks: [
146
+ {
147
+ id: 'choice',
148
+ type: 'select',
149
+ name: 'choice',
150
+ options: [
151
+ { value: 0, label: 123 },
152
+ { value: {}, label: '异常' },
153
+ ] as never,
154
+ },
155
+ ],
156
+ },
157
+ { includeHeader: false },
158
+ )
159
+
160
+ expect(doc.components.find((component) => component.id === 'choice')?.options).toEqual([
161
+ { value: '0', label: '123' },
162
+ ])
163
+ })
164
+
83
165
  it('blocksToA2ui emits SkoponMedia for image blocks', () => {
84
166
  const doc = blocksToA2ui(makeDefinition(), { includeHeader: false })
85
167
  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
  /**
@@ -126,7 +127,7 @@ function blockToComponent(block: FormBlock): {
126
127
  const label = block.label ?? ''
127
128
  const name = block.name?.trim() || ''
128
129
  const path = name ? pathFor(name) : undefined
129
- const options = (block.options ?? []).map((o) => ({ label: o.label, value: o.value }))
130
+ const options = normalizeFormOptions(block.options)
130
131
 
131
132
  const withData = (node: A2uiComponentNode): {
132
133
  node: A2uiComponentNode
@@ -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
- const raw = node.options
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.map((opt, index) => ({
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
  ? {
@@ -223,7 +221,7 @@ function normalizeBlock(raw: FormBlock): FormBlock {
223
221
  }
224
222
 
225
223
  function getOptionValueSet(block: FormBlock): Set<string> {
226
- return new Set((block.options ?? []).map((opt) => opt.value))
224
+ return new Set(normalizeFormOptions(block.options).map((opt) => opt.value))
227
225
  }
228
226
 
229
227
  function buildMultiselectCountJsonSchema(
@@ -294,13 +292,13 @@ function blockToJsonSchemaProperty(block: FormBlock): Record<string, unknown> |
294
292
  case 'select':
295
293
  case 'radio': {
296
294
  property = { type: 'string' }
297
- const enumValues = (block.options ?? []).map((opt) => opt.value).filter(Boolean)
295
+ const enumValues = normalizeFormOptions(block.options).map((opt) => opt.value)
298
296
  if (enumValues.length > 0) property.enum = enumValues
299
297
  break
300
298
  }
301
299
  case 'multiselect':
302
300
  case 'checkbox': {
303
- const enumValues = (block.options ?? []).map((opt) => opt.value).filter(Boolean)
301
+ const enumValues = normalizeFormOptions(block.options).map((opt) => opt.value)
304
302
  property = {
305
303
  type: 'array',
306
304
  items:
@@ -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
- if (!Array.isArray(raw)) return []
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) => isInputBlockType(block.type) && Boolean(block.name?.trim()),
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((block) => isInputBlockType(block.type) && block.name?.trim())
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
- { id: 'q1', type: 'radio', name: 'occupation', label: '职业', options: [] },
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
- { id: 'q1', type: 'radio', name: 'occupation', label: '职业', options: [] },
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',