@skopon-cool/form-sdk 0.2.0 → 0.2.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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.0",
6
+ "version": "0.2.1",
7
7
  "description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
8
8
  "type": "module",
9
9
  "sideEffects": [
@@ -8,9 +8,8 @@ import type {
8
8
  FormSchema,
9
9
  } from '../types/index'
10
10
  import { generateId } from './id'
11
- import { coerceToggleValue } from './formSchema'
11
+ import { coerceToggleValue, syncFormDefinition } from './formSchema'
12
12
  import { getMediaUrls, normalizeMediaSize } from './formMedia'
13
- import { syncFormDefinition } from './formSchema'
14
13
  import { getBlockTypeAdapter, getComponentAdapter } from '../blocks/registry'
15
14
 
16
15
  /**
@@ -491,7 +490,7 @@ function applyDataModelDefaults(
491
490
  const raw = dataModel[name]
492
491
  if (raw === undefined) return block
493
492
  if (block.type === 'toggle') {
494
- return { ...block, defaultValue: raw === true || raw === 'true' }
493
+ return { ...block, defaultValue: coerceToggleValue(raw) }
495
494
  }
496
495
  return { ...block, defaultValue: raw as FormBlock['defaultValue'] }
497
496
  })
@@ -5,11 +5,15 @@ import { describe, it, expect } from 'vitest'
5
5
  import { coerceToggleValue, normalizeFormDefinition } from './formSchema'
6
6
 
7
7
  describe('coerceToggleValue', () => {
8
- it('accepts boolean and string true', () => {
8
+ it('accepts boolean, string, and numeric truthy/falsy values', () => {
9
9
  expect(coerceToggleValue(true)).toBe(true)
10
10
  expect(coerceToggleValue('true')).toBe(true)
11
+ expect(coerceToggleValue(1)).toBe(true)
12
+ expect(coerceToggleValue('1')).toBe(true)
11
13
  expect(coerceToggleValue(false)).toBe(false)
12
14
  expect(coerceToggleValue('false')).toBe(false)
15
+ expect(coerceToggleValue(0)).toBe(false)
16
+ expect(coerceToggleValue('0')).toBe(false)
13
17
  expect(coerceToggleValue(undefined)).toBe(false)
14
18
  })
15
19
  })
@@ -14,7 +14,9 @@ import {
14
14
  const DEFAULT_MEDIA_URL_MAX = 10
15
15
 
16
16
  export function coerceToggleValue(raw: unknown): boolean {
17
- return raw === true || raw === 'true'
17
+ if (raw === true || raw === 'true' || raw === 1 || raw === '1') return true
18
+ if (raw === false || raw === 'false' || raw === 0 || raw === '0') return false
19
+ return false
18
20
  }
19
21
 
20
22
  function normalizeFileUploadCount(raw: unknown, fallback = 1): number {
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod'
2
- import type { ReactNode } from 'react'
2
+ import { useEffect, type ReactNode } from 'react'
3
3
  import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
4
4
  import {
5
5
  basicCatalog,
@@ -35,6 +35,7 @@ import { useA2uiPreviewMode } from './a2uiPreviewContext'
35
35
  import {
36
36
  asOptionalString,
37
37
  asStringArray,
38
+ normalizeSelectStoredValue,
38
39
  readBoundFieldValue,
39
40
  useSkoponBoundField,
40
41
  } from './useSkoponBoundField'
@@ -247,6 +248,11 @@ function SkoponSelectPreview({ context }: { context: ComponentContext }) {
247
248
  const options = readOptions(props.options)
248
249
  const isMultiple = readString(props.mode) === 'multiple'
249
250
 
251
+ useEffect(() => {
252
+ const normalized = normalizeSelectStoredValue(value, isMultiple)
253
+ if (normalized !== null) setValue(normalized)
254
+ }, [isMultiple, value, setValue])
255
+
250
256
  return (
251
257
  <div className="form-block-preview">
252
258
  {label ? <div className="form-block-preview-label">{label}</div> : null}
@@ -289,6 +295,13 @@ function ChoicePickerPreview({ context }: { context: ComponentContext }) {
289
295
  : isMultiple
290
296
  ? 'checkbox'
291
297
  : 'radio'
298
+ const isMultiplePicker =
299
+ effectiveType === 'multiselect' || effectiveType === 'checkbox'
300
+
301
+ useEffect(() => {
302
+ const normalized = normalizeSelectStoredValue(value, isMultiplePicker)
303
+ if (normalized !== null) setValue(normalized)
304
+ }, [isMultiplePicker, value, setValue])
292
305
 
293
306
  return (
294
307
  <div className="form-block-preview">
@@ -557,6 +570,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
557
570
  允许:{acceptSummary ?? '全部类型'}
558
571
  {minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
559
572
  {hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
573
+ ;当前仅收集文件名(不含文件内容,需宿主另行上传)
560
574
  {belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
561
575
  </Typography.Text>
562
576
  </div>
@@ -90,7 +90,7 @@ function CaseCard({
90
90
  onClick={onToggle}
91
91
  >
92
92
  <div className="skopon-resume-select-card-avatar">
93
- <FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #1677ff)' }} />
93
+ <FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #208368)' }} />
94
94
  </div>
95
95
  <div className="skopon-resume-select-card-body">
96
96
  <div className="skopon-resume-select-card-name">{item.name}</div>
@@ -152,6 +152,7 @@ function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
152
152
  setItems([])
153
153
  setTotal(0)
154
154
  setFetchError('')
155
+ setLoading(false)
155
156
  return
156
157
  }
157
158
  setLoading(true)
@@ -198,6 +198,7 @@ function SkoponResumeSelectPreview({ context }: { context: ComponentContext }) {
198
198
  setItems([])
199
199
  setTotal(0)
200
200
  setError('未配置简历搜索服务')
201
+ setLoading(false)
201
202
  return
202
203
  }
203
204
  setLoading(true)
@@ -0,0 +1,182 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, it, expect, vi, afterEach, beforeAll } 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 { render, cleanup, waitFor } from '@testing-library/react'
16
+ import { blocksToA2ui } from '../adapter/a2uiAdapter'
17
+ import SkoponFormRenderer from '../components/SkoponFormRenderer'
18
+ import type { CaseSearchFn } from './caseSearchContext'
19
+ import type { ResumeSearchFn } from './resumeSearchContext'
20
+
21
+ beforeAll(() => {
22
+ class ResizeObserverMock {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ }
27
+ vi.stubGlobal('ResizeObserver', ResizeObserverMock)
28
+ })
29
+
30
+ function buildCaseSurface(flowUniqueIds: string[]) {
31
+ return blocksToA2ui(
32
+ {
33
+ title: '',
34
+ description: '',
35
+ blocks: [
36
+ {
37
+ id: 'b-case',
38
+ type: 'case_multiselect',
39
+ name: 'cases',
40
+ label: '案例',
41
+ caseFilter: { flowUniqueIds, pageSize: 20 },
42
+ },
43
+ ],
44
+ },
45
+ { includeHeader: false },
46
+ )
47
+ }
48
+
49
+ function buildResumeSurface() {
50
+ return blocksToA2ui(
51
+ {
52
+ title: '',
53
+ description: '',
54
+ blocks: [
55
+ {
56
+ id: 'b-resume',
57
+ type: 'resume_multiselect',
58
+ name: 'resumes',
59
+ label: '简历',
60
+ resumeFilter: { names: [], agentUniqueIds: [], resumeUniqueIds: [], pageSize: 20 },
61
+ },
62
+ ],
63
+ },
64
+ { includeHeader: false },
65
+ )
66
+ }
67
+
68
+ describe('case/resume select loading state', () => {
69
+ afterEach(() => {
70
+ cleanup()
71
+ })
72
+
73
+ it('clears case loading when caseSearch is removed while fetch is in flight', async () => {
74
+ let resolveSearch!: (value: Awaited<ReturnType<CaseSearchFn>>) => void
75
+ const caseSearch = vi.fn(
76
+ () =>
77
+ new Promise<Awaited<ReturnType<CaseSearchFn>>>((resolve) => {
78
+ resolveSearch = resolve
79
+ }),
80
+ )
81
+
82
+ const doc = buildCaseSurface(['flow-1'])
83
+ const { rerender, queryByText } = render(
84
+ createElement(SkoponFormRenderer, {
85
+ doc,
86
+ surfaceId: 'case-loading-test',
87
+ caseSearch,
88
+ }),
89
+ )
90
+
91
+ expect(queryByText('加载案例…')).toBeTruthy()
92
+
93
+ rerender(
94
+ createElement(SkoponFormRenderer, {
95
+ doc,
96
+ surfaceId: 'case-loading-test',
97
+ caseSearch: null,
98
+ }),
99
+ )
100
+
101
+ await waitFor(() => {
102
+ expect(queryByText('加载案例…')).toBeNull()
103
+ })
104
+ expect(queryByText('未配置案例搜索服务')).toBeTruthy()
105
+
106
+ resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
107
+ })
108
+
109
+ it('clears case loading when filter becomes invalid while fetch is in flight', async () => {
110
+ let resolveSearch!: (value: Awaited<ReturnType<CaseSearchFn>>) => void
111
+ const caseSearch = vi.fn(
112
+ () =>
113
+ new Promise<Awaited<ReturnType<CaseSearchFn>>>((resolve) => {
114
+ resolveSearch = resolve
115
+ }),
116
+ )
117
+
118
+ const validDoc = buildCaseSurface(['flow-1'])
119
+ const invalidDoc = buildCaseSurface([])
120
+
121
+ const { rerender, queryByText } = render(
122
+ createElement(SkoponFormRenderer, {
123
+ doc: validDoc,
124
+ surfaceId: 'case-filter-test',
125
+ caseSearch,
126
+ }),
127
+ )
128
+
129
+ expect(queryByText('加载案例…')).toBeTruthy()
130
+
131
+ rerender(
132
+ createElement(SkoponFormRenderer, {
133
+ doc: invalidDoc,
134
+ surfaceId: 'case-filter-test',
135
+ caseSearch,
136
+ }),
137
+ )
138
+
139
+ await waitFor(() => {
140
+ expect(queryByText('加载案例…')).toBeNull()
141
+ })
142
+ expect(queryByText('未配置案例列表筛选条件')).toBeTruthy()
143
+
144
+ resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
145
+ })
146
+
147
+ it('clears resume loading when resumeSearch is removed while fetch is in flight', async () => {
148
+ let resolveSearch!: (value: Awaited<ReturnType<ResumeSearchFn>>) => void
149
+ const resumeSearch = vi.fn(
150
+ () =>
151
+ new Promise<Awaited<ReturnType<ResumeSearchFn>>>((resolve) => {
152
+ resolveSearch = resolve
153
+ }),
154
+ )
155
+
156
+ const doc = buildResumeSurface()
157
+ const { rerender, queryByText } = render(
158
+ createElement(SkoponFormRenderer, {
159
+ doc,
160
+ surfaceId: 'resume-loading-test',
161
+ resumeSearch,
162
+ }),
163
+ )
164
+
165
+ expect(queryByText('加载简历…')).toBeTruthy()
166
+
167
+ rerender(
168
+ createElement(SkoponFormRenderer, {
169
+ doc,
170
+ surfaceId: 'resume-loading-test',
171
+ resumeSearch: null,
172
+ }),
173
+ )
174
+
175
+ await waitFor(() => {
176
+ expect(queryByText('加载简历…')).toBeNull()
177
+ })
178
+ expect(queryByText('未配置简历搜索服务')).toBeTruthy()
179
+
180
+ resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
181
+ })
182
+ })
@@ -3,7 +3,7 @@
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, readBoundFieldValue, toggleStringArraySelection } from './useSkoponBoundField'
6
+ import { asStringArray, clampStringArraySelection, normalizeSelectStoredValue, readBoundFieldValue, toggleStringArraySelection } from './useSkoponBoundField'
7
7
 
8
8
  function createMockContext(initial: unknown = []): {
9
9
  context: ComponentContext
@@ -29,6 +29,19 @@ function createMockContext(initial: unknown = []): {
29
29
  }
30
30
  }
31
31
 
32
+ describe('normalizeSelectStoredValue', () => {
33
+ it('coerces string to array in multiple mode', () => {
34
+ expect(normalizeSelectStoredValue('a', true)).toEqual(['a'])
35
+ expect(normalizeSelectStoredValue(['a', 'b'], true)).toBeNull()
36
+ })
37
+
38
+ it('coerces array to string in single mode', () => {
39
+ expect(normalizeSelectStoredValue(['a', 'b'], false)).toBe('a')
40
+ expect(normalizeSelectStoredValue([], false)).toBe('')
41
+ expect(normalizeSelectStoredValue('a', false)).toBeNull()
42
+ })
43
+ })
44
+
32
45
  describe('readBoundFieldValue', () => {
33
46
  it('reads current bound value from dataContext', () => {
34
47
  const { context } = createMockContext(['a.pdf'])
@@ -65,6 +65,20 @@ export function asStringArray(value: unknown): string[] {
65
65
  return []
66
66
  }
67
67
 
68
+ /** 单选/多选模式切换时规范化 stored value;无需变更时返回 null。 */
69
+ export function normalizeSelectStoredValue(value: unknown, isMultiple: boolean): unknown | null {
70
+ if (isMultiple) {
71
+ if (Array.isArray(value)) return null
72
+ return asStringArray(value)
73
+ }
74
+ if (Array.isArray(value)) {
75
+ return value.length > 0 ? String(value[0]) : ''
76
+ }
77
+ if (value === undefined || value === null) return null
78
+ if (typeof value === 'string' || typeof value === 'number') return null
79
+ return ''
80
+ }
81
+
68
82
  export function readOptionalSelectCount(value: unknown): number | undefined {
69
83
  if (value === undefined || value === null) return undefined
70
84
  const num = typeof value === 'number' ? value : Number(value)
@@ -230,6 +230,7 @@ export default function AskUserFormCard({
230
230
  surfaceId={surfaceId}
231
231
  fieldNames={fieldNames}
232
232
  interactive
233
+ wrapProvider={false}
233
234
  resumeSearch={resumeSearch}
234
235
  caseSearch={caseSearch}
235
236
  />
@@ -34,6 +34,8 @@ export interface SkoponFormRendererProps {
34
34
  resumeSearch?: ResumeSearchFn | null
35
35
  /** 案例单选组件:注入搜索函数 */
36
36
  caseSearch?: CaseSearchFn | null
37
+ /** 外层已包裹 SkoponFormProvider 时可设为 false,避免重复 ConfigProvider */
38
+ wrapProvider?: boolean
37
39
  }
38
40
 
39
41
  const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererProps>(
@@ -46,6 +48,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
46
48
  fieldNames = [],
47
49
  resumeSearch = null,
48
50
  caseSearch = null,
51
+ wrapProvider = true,
49
52
  },
50
53
  ref,
51
54
  ) {
@@ -105,11 +108,19 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
105
108
  [surfaceId],
106
109
  )
107
110
 
108
- if (surfaces.length === 0) return <SkoponFormProvider>{emptyHint}</SkoponFormProvider>
109
-
110
- return (
111
- <SkoponFormProvider>
111
+ if (surfaces.length === 0) {
112
+ const empty = (
112
113
  <ResumeSearchProvider resumeSearch={resumeSearch}>
114
+ <CaseSearchProvider caseSearch={caseSearch}>
115
+ <A2uiPreviewModeProvider interactive={interactive}>{emptyHint}</A2uiPreviewModeProvider>
116
+ </CaseSearchProvider>
117
+ </ResumeSearchProvider>
118
+ )
119
+ return wrapProvider ? <SkoponFormProvider>{empty}</SkoponFormProvider> : empty
120
+ }
121
+
122
+ const content = (
123
+ <ResumeSearchProvider resumeSearch={resumeSearch}>
113
124
  <CaseSearchProvider caseSearch={caseSearch}>
114
125
  <A2uiPreviewModeProvider interactive={interactive}>
115
126
  <div className="a2ui-surface a2ui-container">
@@ -119,9 +130,10 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
119
130
  </div>
120
131
  </A2uiPreviewModeProvider>
121
132
  </CaseSearchProvider>
122
- </ResumeSearchProvider>
123
- </SkoponFormProvider>
133
+ </ResumeSearchProvider>
124
134
  )
135
+
136
+ return wrapProvider ? <SkoponFormProvider>{content}</SkoponFormProvider> : content
125
137
  },
126
138
  )
127
139
 
@@ -251,11 +251,11 @@
251
251
  min-width: 220px;
252
252
  }
253
253
  .skopon-resume-select-card:hover:not(:disabled) {
254
- border-color: var(--color-primary, #1677ff);
254
+ border-color: var(--color-primary, #208368);
255
255
  }
256
256
  .skopon-resume-select-card--selected {
257
- border-color: var(--color-primary, #1677ff);
258
- background: color-mix(in srgb, var(--color-primary, #1677ff) 8%, transparent);
257
+ border-color: var(--color-primary, #208368);
258
+ background: color-mix(in srgb, var(--color-primary, #208368) 8%, transparent);
259
259
  }
260
260
  .skopon-resume-select-card:disabled {
261
261
  cursor: not-allowed;