@skopon-cool/form-sdk 0.4.2 → 0.4.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.
@@ -1,5 +1,11 @@
1
1
  import type { FormBlock, FormSchema } from '../types/index';
2
- export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'blocks'>;
2
+ export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'blocks'> & {
3
+ /**
4
+ * ask 原始 blocks(normalize 前)。交集合并按此键集覆盖 form,
5
+ * 避免 normalize 填入的空默认值误盖表单样式。
6
+ */
7
+ rawBlocks?: FormBlock[];
8
+ };
3
9
  export interface PayloadBlocksIntersection {
4
10
  matchedBlocks: FormBlock[];
5
11
  extraBlocks: FormBlock[];
@@ -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,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;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,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
+ {"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,0DAA0D;IAC1D,YAAY,EAAE,SAAS,EAAE,CAAA;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;CACpB;AAuED,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,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"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.2",
6
+ "version": "0.4.3",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -3,7 +3,14 @@
3
3
  */
4
4
  import { describe, it, expect } from 'vitest'
5
5
  import type { ComponentContext } from '@a2ui/web_core/v0_9'
6
- import { asStringArray, clampStringArraySelection, normalizeSelectStoredValue, readBoundFieldValue, toggleStringArraySelection } from './useSkoponBoundField'
6
+ import {
7
+ asStringArray,
8
+ clampStringArraySelection,
9
+ isCardSelectionDisabled,
10
+ normalizeSelectStoredValue,
11
+ readBoundFieldValue,
12
+ toggleStringArraySelection,
13
+ } from './useSkoponBoundField'
7
14
 
8
15
  function createMockContext(initial: unknown = []): {
9
16
  context: ComponentContext
@@ -61,12 +68,16 @@ describe('readBoundFieldValue', () => {
61
68
  })
62
69
 
63
70
  describe('toggleStringArraySelection', () => {
64
- it('blocks append when at maxCount', () => {
65
- expect(toggleStringArraySelection(['a'], 'b', undefined, 1)).toBeNull()
71
+ it('replaces selection when maxCount is 1', () => {
72
+ expect(toggleStringArraySelection(['a'], 'b', 1, 1)).toEqual(['b'])
66
73
  })
67
74
 
68
- it('blocks deselect when at minCount', () => {
69
- expect(toggleStringArraySelection(['a'], 'a', 1, undefined)).toBeNull()
75
+ it('blocks append when at maxCount greater than 1', () => {
76
+ expect(toggleStringArraySelection(['a', 'b'], 'c', undefined, 2)).toBeNull()
77
+ })
78
+
79
+ it('allows deselect even when at minCount', () => {
80
+ expect(toggleStringArraySelection(['a'], 'a', 1, undefined)).toEqual([])
70
81
  })
71
82
 
72
83
  it('allows toggle within bounds', () => {
@@ -75,6 +86,20 @@ describe('toggleStringArraySelection', () => {
75
86
  })
76
87
  })
77
88
 
89
+ describe('isCardSelectionDisabled', () => {
90
+ it('keeps selected cards clickable under minCount', () => {
91
+ expect(isCardSelectionDisabled(['a'], 'a', true, 1, 1)).toBe(false)
92
+ })
93
+
94
+ it('keeps unselected cards clickable when maxCount is 1', () => {
95
+ expect(isCardSelectionDisabled(['a'], 'b', true, 1, 1)).toBe(false)
96
+ })
97
+
98
+ it('disables unselected cards when maxCount greater than 1 is full', () => {
99
+ expect(isCardSelectionDisabled(['a', 'b'], 'c', true, undefined, 2)).toBe(true)
100
+ })
101
+ })
102
+
78
103
  describe('clampStringArraySelection', () => {
79
104
  it('returns null when within maxCount', () => {
80
105
  expect(clampStringArraySelection(['a'], 2)).toBeNull()
@@ -203,33 +203,47 @@ export function readOptionalSelectCount(value: unknown): number | undefined {
203
203
  return Math.floor(num)
204
204
  }
205
205
 
206
+ /**
207
+ * 切换多选选中态。
208
+ * - 已选项:始终允许取消(minCount 仅提交校验)
209
+ * - maxCount === 1:点未选项直接替换为 [itemId]
210
+ * - maxCount > 1 且已满:阻止追加
211
+ */
206
212
  export function toggleStringArraySelection(
207
213
  selectedIds: string[],
208
214
  itemId: string,
209
- minCount: number | undefined,
215
+ _minCount: number | undefined,
210
216
  maxCount: number | undefined,
211
217
  ): string[] | null {
212
218
  const isSelected = selectedIds.includes(itemId)
213
219
  if (isSelected) {
214
- const next = selectedIds.filter((id) => id !== itemId)
215
- if (minCount !== undefined && next.length < minCount) return null
216
- return next
220
+ return selectedIds.filter((id) => id !== itemId)
221
+ }
222
+ if (maxCount === 1) {
223
+ return [itemId]
217
224
  }
218
225
  if (maxCount !== undefined && selectedIds.length >= maxCount) return null
219
226
  return [...selectedIds, itemId]
220
227
  }
221
228
 
229
+ /**
230
+ * 卡片是否禁用点击。
231
+ * - 已选项始终可点(允许取消);minCount 不在此强制
232
+ * - maxCount === 1 时未选项可点(用于替换)
233
+ * - maxCount > 1 且已满时未选项禁用
234
+ */
222
235
  export function isCardSelectionDisabled(
223
236
  selectedIds: string[],
224
237
  itemId: string,
225
238
  interactive: boolean,
226
- minCount: number | undefined,
239
+ _minCount: number | undefined,
227
240
  maxCount: number | undefined,
228
241
  ): boolean {
229
242
  if (!interactive) return true
230
243
  const isSelected = selectedIds.includes(itemId)
231
- if (!isSelected && maxCount !== undefined && selectedIds.length >= maxCount) return true
232
- if (isSelected && minCount !== undefined && selectedIds.length <= minCount) return true
244
+ if (isSelected) return false
245
+ if (maxCount === 1) return false
246
+ if (maxCount !== undefined && selectedIds.length >= maxCount) return true
233
247
  return false
234
248
  }
235
249
 
@@ -2,7 +2,13 @@ import { normalizeFormDefinition } from '../adapter/formSchema'
2
2
  import type { FormBlock, FormSchema } from '../types/index'
3
3
  import { isInputBlockType, isLayoutBlockType } from '../types/index'
4
4
 
5
- export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'blocks'>
5
+ export type PayloadBlocksDefinition = Pick<FormSchema, 'title' | 'description' | 'blocks'> & {
6
+ /**
7
+ * ask 原始 blocks(normalize 前)。交集合并按此键集覆盖 form,
8
+ * 避免 normalize 填入的空默认值误盖表单样式。
9
+ */
10
+ rawBlocks?: FormBlock[]
11
+ }
6
12
 
7
13
  export interface PayloadBlocksIntersection {
8
14
  matchedBlocks: FormBlock[]
@@ -13,6 +19,8 @@ export interface PayloadBlocksIntersection {
13
19
  description: string
14
20
  }
15
21
 
22
+ const FORM_ANCHORED_KEYS = new Set(['type', 'name', 'id'])
23
+
16
24
  function normalizePayloadBlock(block: FormBlock): FormBlock {
17
25
  return normalizeFormDefinition({
18
26
  title: '',
@@ -47,21 +55,37 @@ function buildRenderBlocks(
47
55
  return renderBlocks
48
56
  }
49
57
 
50
- /** 交集块以 form 定义为准,仅从 payload 合并运行时 defaultValue。 */
51
- function mergeBlock(formBlock: FormBlock, payloadBlock: FormBlock): FormBlock {
52
- const merged: FormBlock = {
53
- ...formBlock,
54
- name: formBlock.name?.trim() || payloadBlock.name,
58
+ function resolveRawInputBlock(
59
+ payloadDef: PayloadBlocksDefinition,
60
+ name: string,
61
+ fallback: FormBlock,
62
+ ): FormBlock {
63
+ const rawBlocks = payloadDef.rawBlocks
64
+ if (!rawBlocks?.length) return fallback
65
+ for (const block of rawBlocks) {
66
+ if (!block || typeof block !== 'object') continue
67
+ const blockName = typeof block.name === 'string' ? block.name.trim() : ''
68
+ if (blockName === name) return block
55
69
  }
56
- if (payloadBlock.defaultValue !== undefined) {
57
- merged.defaultValue = payloadBlock.defaultValue
70
+ return fallback
71
+ }
72
+
73
+ /**
74
+ * 交集块:form 锚定 type/name/id;ask 原始 block 上显式出现的其余字段覆盖 form。
75
+ */
76
+ function mergeBlock(formBlock: FormBlock, rawPayloadBlock: FormBlock): FormBlock {
77
+ const merged: FormBlock = { ...formBlock }
78
+ for (const key of Object.keys(rawPayloadBlock) as (keyof FormBlock)[]) {
79
+ if (FORM_ANCHORED_KEYS.has(key)) continue
80
+ if (!Object.prototype.hasOwnProperty.call(rawPayloadBlock, key)) continue
81
+ const value = rawPayloadBlock[key]
82
+ if (value === undefined) continue
83
+ Object.assign(merged, { [key]: value })
58
84
  }
59
- return normalizeFormDefinition({
60
- title: '',
61
- description: '',
62
- blocks: [merged],
63
- jsonSchema: {},
64
- }).blocks[0]!
85
+ merged.type = formBlock.type
86
+ merged.name = formBlock.name?.trim() || rawPayloadBlock.name
87
+ merged.id = formBlock.id
88
+ return normalizePayloadBlock(merged)
65
89
  }
66
90
 
67
91
  /** 解析 ask_user payload 为 blocksJson 形(title / description / blocks) */
@@ -69,12 +93,21 @@ export function parsePayloadBlocksJson(payload: unknown): PayloadBlocksDefinitio
69
93
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
70
94
  const obj = payload as Record<string, unknown>
71
95
  if (!Array.isArray(obj.blocks)) return null
72
- return normalizeFormDefinition({
96
+ const rawBlocks = obj.blocks.filter(
97
+ (block): block is FormBlock => Boolean(block) && typeof block === 'object' && !Array.isArray(block),
98
+ ) as FormBlock[]
99
+ const normalized = normalizeFormDefinition({
73
100
  title: typeof obj.title === 'string' ? obj.title : '',
74
101
  description: typeof obj.description === 'string' ? obj.description : '',
75
- blocks: obj.blocks as FormBlock[],
102
+ blocks: rawBlocks,
76
103
  jsonSchema: {},
77
104
  })
105
+ return {
106
+ title: normalized.title,
107
+ description: normalized.description,
108
+ blocks: normalized.blocks,
109
+ rawBlocks,
110
+ }
78
111
  }
79
112
 
80
113
  /**
@@ -101,16 +134,10 @@ export function intersectPayloadBlocksWithForm(
101
134
  if (!name) continue
102
135
  const formBlock = formByName.get(name)
103
136
  if (formBlock) {
104
- matchedBlocks.push(mergeBlock(formBlock, payloadBlock))
137
+ const rawPayloadBlock = resolveRawInputBlock(payloadDef, name, payloadBlock)
138
+ matchedBlocks.push(mergeBlock(formBlock, rawPayloadBlock))
105
139
  } else {
106
- extraBlocks.push(
107
- normalizeFormDefinition({
108
- title: '',
109
- description: '',
110
- blocks: [payloadBlock],
111
- jsonSchema: {},
112
- }).blocks[0]!,
113
- )
140
+ extraBlocks.push(normalizePayloadBlock(payloadBlock))
114
141
  }
115
142
  }
116
143
 
@@ -176,7 +176,7 @@ describe('intersectPayloadBlocksWithForm', () => {
176
176
  expect(extractExtraBlockValues(extraBlocks)).toEqual({ resumes: [] })
177
177
  })
178
178
 
179
- it('keeps form block type and schema when payload type differs', () => {
179
+ it('keeps form type/name/id and overlays explicit ask fields when payload type differs', () => {
180
180
  const selectForm: FormSchema = {
181
181
  title: 'Form',
182
182
  description: '',
@@ -209,7 +209,8 @@ describe('intersectPayloadBlocksWithForm', () => {
209
209
  const result = intersectPayloadBlocksWithForm(mismatchedPayload, selectForm)
210
210
  expect(result.matchedBlocks).toHaveLength(1)
211
211
  expect(result.matchedBlocks[0]?.type).toBe('select')
212
- expect(result.matchedBlocks[0]?.label).toBe('Form Label')
212
+ expect(result.matchedBlocks[0]?.id).toBe('1')
213
+ expect(result.matchedBlocks[0]?.label).toBe('Payload Label')
213
214
  expect(result.matchedBlocks[0]?.options).toEqual([
214
215
  { label: 'A', value: 'a' },
215
216
  { label: 'B', value: 'b' },
@@ -217,6 +218,130 @@ describe('intersectPayloadBlocksWithForm', () => {
217
218
  expect(result.matchedBlocks[0]?.defaultValue).toBe('a')
218
219
  })
219
220
 
221
+ it('keeps form label when ask only provides name and defaultValue', () => {
222
+ const form: FormSchema = {
223
+ title: 'Form',
224
+ description: '',
225
+ blocks: [{ id: '1', type: 'text', name: 'video_title', label: 'Form Title', help: 'Form help' }],
226
+ jsonSchema: {},
227
+ }
228
+ const payloadDef = parsePayloadBlocksJson({
229
+ title: 'Ask',
230
+ blocks: [{ id: 'p1', type: 'text', name: 'video_title', defaultValue: 'hello' }],
231
+ })!
232
+ const result = intersectPayloadBlocksWithForm(payloadDef, form)
233
+ expect(result.matchedBlocks[0]?.label).toBe('Form Title')
234
+ expect(result.matchedBlocks[0]?.help).toBe('Form help')
235
+ expect(result.matchedBlocks[0]?.defaultValue).toBe('hello')
236
+ })
237
+
238
+ it('overlays ask caseFilter over form caseFilter', () => {
239
+ const form: FormSchema = {
240
+ title: 'Form',
241
+ description: '',
242
+ blocks: [
243
+ {
244
+ id: '1',
245
+ type: 'case_multiselect',
246
+ name: 'selected_cases',
247
+ label: 'Form Cases',
248
+ caseMinCount: 1,
249
+ caseMaxCount: 1,
250
+ caseFilter: {
251
+ agentUniqueId: 'agent-form',
252
+ flowType: 'service',
253
+ category: 'cat-form',
254
+ flowUniqueIds: [],
255
+ pageSize: 10,
256
+ },
257
+ },
258
+ ],
259
+ jsonSchema: {},
260
+ }
261
+ const payloadDef = parsePayloadBlocksJson({
262
+ title: 'Ask',
263
+ blocks: [
264
+ {
265
+ id: 'p1',
266
+ type: 'case_multiselect',
267
+ name: 'selected_cases',
268
+ label: 'Ask Cases',
269
+ caseMinCount: 1,
270
+ caseMaxCount: 3,
271
+ caseFilter: {
272
+ agentKind: 'management',
273
+ agentUniqueId: 'agent-ask',
274
+ flowType: 'service',
275
+ flowUniqueIds: [],
276
+ pageSize: 20,
277
+ },
278
+ },
279
+ ],
280
+ })!
281
+ const result = intersectPayloadBlocksWithForm(payloadDef, form)
282
+ const block = result.matchedBlocks[0]
283
+ expect(block?.type).toBe('case_multiselect')
284
+ expect(block?.label).toBe('Ask Cases')
285
+ expect(block?.caseMaxCount).toBe(3)
286
+ expect(block?.caseFilter).toMatchObject({
287
+ agentUniqueId: 'agent-ask',
288
+ flowType: 'service',
289
+ pageSize: 20,
290
+ })
291
+ expect(block?.caseFilter?.category).toBeUndefined()
292
+ })
293
+
294
+ it('overlays ask resumeFilter over form resumeFilter', () => {
295
+ const form: FormSchema = {
296
+ title: 'Form',
297
+ description: '',
298
+ blocks: [
299
+ {
300
+ id: '1',
301
+ type: 'resume_multiselect',
302
+ name: 'partners',
303
+ label: 'Form Partners',
304
+ resumeFilter: {
305
+ names: ['Alice'],
306
+ agentUniqueIds: ['agent-form'],
307
+ resumeUniqueIds: [],
308
+ pageSize: 10,
309
+ },
310
+ },
311
+ ],
312
+ jsonSchema: {},
313
+ }
314
+ const payloadDef = parsePayloadBlocksJson({
315
+ title: 'Ask',
316
+ blocks: [
317
+ {
318
+ id: 'p1',
319
+ type: 'resume_multiselect',
320
+ name: 'partners',
321
+ label: '选择伙伴',
322
+ help: '请谨慎选择您的伙伴',
323
+ resumeEnableRefresh: true,
324
+ resumeFilter: {
325
+ names: [],
326
+ agentUniqueIds: [],
327
+ resumeUniqueIds: [],
328
+ pageSize: 20,
329
+ },
330
+ },
331
+ ],
332
+ })!
333
+ const result = intersectPayloadBlocksWithForm(payloadDef, form)
334
+ const block = result.matchedBlocks[0]
335
+ expect(block?.label).toBe('选择伙伴')
336
+ expect(block?.help).toBe('请谨慎选择您的伙伴')
337
+ expect(block?.resumeFilter).toEqual({
338
+ names: [],
339
+ agentUniqueIds: [],
340
+ resumeUniqueIds: [],
341
+ pageSize: 20,
342
+ })
343
+ })
344
+
220
345
  it('preserves payload layout blocks in renderBlocks on partial intersection', () => {
221
346
  const partialPayload = parsePayloadBlocksJson({
222
347
  title: 'Ask',
@@ -237,7 +362,7 @@ describe('intersectPayloadBlocksWithForm', () => {
237
362
  ])
238
363
  expect(result.renderBlocks.map((b) => b.label)).toEqual([
239
364
  '请填写以下信息',
240
- 'Title',
365
+ '视频标题',
241
366
  '提交后不可修改',
242
367
  ])
243
368
  expect(result.renderBlocks.some((b) => b.type === 'heading' && b.label === '请填写以下信息')).toBe(