@skopon-cool/form-sdk 0.2.1 → 0.2.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.
Files changed (50) hide show
  1. package/README.md +94 -9
  2. package/dist/adapter/a2uiAdapter.d.ts +1 -0
  3. package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
  4. package/dist/adapter/formSchema.d.ts +2 -1
  5. package/dist/adapter/formSchema.d.ts.map +1 -1
  6. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  7. package/dist/client/apiEnvelope.d.ts +7 -0
  8. package/dist/client/apiEnvelope.d.ts.map +1 -0
  9. package/dist/client/authStorage.d.ts +19 -0
  10. package/dist/client/authStorage.d.ts.map +1 -0
  11. package/dist/client/caseSearchClient.d.ts +11 -0
  12. package/dist/client/caseSearchClient.d.ts.map +1 -0
  13. package/dist/client/formClient.d.ts +2 -2
  14. package/dist/client/formClient.d.ts.map +1 -1
  15. package/dist/client/resumeSearchClient.d.ts +9 -0
  16. package/dist/client/resumeSearchClient.d.ts.map +1 -0
  17. package/dist/client/skoponSdkClients.d.ts +22 -0
  18. package/dist/client/skoponSdkClients.d.ts.map +1 -0
  19. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  20. package/dist/index.d.ts +4 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1013 -759
  23. package/dist/submit/validateFormSubmit.d.ts +10 -0
  24. package/dist/submit/validateFormSubmit.d.ts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/adapter/a2uiAdapter.ts +1 -1
  27. package/src/adapter/formSchema.test.ts +24 -0
  28. package/src/adapter/formSchema.ts +9 -2
  29. package/src/adapter/resolveSurface.ts +3 -3
  30. package/src/catalog/a2uiCustomCatalog.tsx +35 -3
  31. package/src/catalog/textFieldPreview.test.tsx +31 -0
  32. package/src/client/apiEnvelope.ts +21 -0
  33. package/src/client/authStorage.test.ts +61 -0
  34. package/src/client/authStorage.ts +70 -0
  35. package/src/client/caseSearchClient.ts +107 -0
  36. package/src/client/formClient.test.ts +25 -0
  37. package/src/client/formClient.ts +7 -25
  38. package/src/client/resumeSearchClient.test.ts +87 -0
  39. package/src/client/resumeSearchClient.ts +108 -0
  40. package/src/client/skoponSdkClients.ts +51 -0
  41. package/src/components/AskUserFormCard.tsx +6 -0
  42. package/src/index.ts +21 -0
  43. package/src/submit/submit.test.ts +40 -0
  44. package/src/submit/validateFormSubmit.ts +111 -0
  45. package/dist/catalog/skoponSelectLoading.test.d.ts +0 -2
  46. package/dist/catalog/skoponSelectLoading.test.d.ts.map +0 -1
  47. package/dist/catalog/textFieldPreview.test.d.ts +0 -2
  48. package/dist/catalog/textFieldPreview.test.d.ts.map +0 -1
  49. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +0 -2
  50. package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +0 -1
@@ -0,0 +1,108 @@
1
+ import type { FormResumeFilter } from '../types/index'
2
+ import type { ResumeSearchFn, ResumeSearchItem } from '../catalog/resumeSearchContext'
3
+ import { parseEnvelope } from './apiEnvelope'
4
+ import {
5
+ type ClientHeadersOptions,
6
+ resolveClientHeaders,
7
+ } from './authStorage'
8
+
9
+ export interface ResumeSearchClientOptions extends ClientHeadersOptions {
10
+ baseUrl?: string
11
+ searchPath?: string
12
+ fetch?: typeof fetch
13
+ }
14
+
15
+ interface ResumeWorkDto {
16
+ id?: string
17
+ title?: string
18
+ link?: string
19
+ cover_url?: string | null
20
+ }
21
+
22
+ interface ResumeListItemDto {
23
+ resume_id: number
24
+ agent_id: number
25
+ name: string
26
+ avatar_url?: string | null
27
+ description?: string | null
28
+ satisfaction: number
29
+ personality_json?: string[]
30
+ specialties_json?: string[]
31
+ traits_json?: string[]
32
+ works_json?: ResumeWorkDto[]
33
+ published: number
34
+ works_count: number
35
+ resume_unique_id: string
36
+ }
37
+
38
+ interface PagedListDto<T> {
39
+ list: T[]
40
+ total: number
41
+ page: number
42
+ page_size: number
43
+ }
44
+
45
+ function mapResumeListItem(dto: ResumeListItemDto): ResumeSearchItem {
46
+ return {
47
+ resumeUniqueId: dto.resume_unique_id,
48
+ resumeId: dto.resume_id,
49
+ agentId: dto.agent_id,
50
+ name: dto.name,
51
+ avatarUrl: dto.avatar_url ?? null,
52
+ description: dto.description ?? undefined,
53
+ satisfaction: dto.satisfaction,
54
+ published: dto.published,
55
+ worksCount: dto.works_count,
56
+ personality: dto.personality_json ?? [],
57
+ specialties: dto.specialties_json ?? [],
58
+ traits: dto.traits_json ?? [],
59
+ works: (dto.works_json ?? []).map((work) => ({
60
+ title: work.title ?? '',
61
+ coverUrl: work.cover_url ?? null,
62
+ link: work.link,
63
+ })),
64
+ }
65
+ }
66
+
67
+ function buildResumeSearchQuery(
68
+ filter: FormResumeFilter,
69
+ page: number,
70
+ ): URLSearchParams {
71
+ const params = new URLSearchParams()
72
+ params.set('page', String(page))
73
+ params.set('page_size', String(filter.pageSize ?? 20))
74
+ for (const name of filter.names ?? []) {
75
+ if (name) params.append('name', name)
76
+ }
77
+ for (const id of filter.agentUniqueIds ?? []) {
78
+ if (id) params.append('agent_unique_id', id)
79
+ }
80
+ for (const id of filter.resumeUniqueIds ?? []) {
81
+ if (id) params.append('resume_unique_id', id)
82
+ }
83
+ return params
84
+ }
85
+
86
+ export function createResumeSearchClient(
87
+ options: ResumeSearchClientOptions = {},
88
+ ): ResumeSearchFn {
89
+ const baseUrl = (options.baseUrl ?? '/api/v1').replace(/\/$/, '')
90
+ const searchPath = options.searchPath ?? '/univ/resume/search'
91
+ const fetchFn = options.fetch ?? fetch
92
+
93
+ return async ({ filter, page }) => {
94
+ const headers = await resolveClientHeaders(options)
95
+ const params = buildResumeSearchQuery(filter, page)
96
+ const response = await fetchFn(`${baseUrl}${searchPath}?${params.toString()}`, {
97
+ method: 'GET',
98
+ headers,
99
+ })
100
+ const data = await parseEnvelope<PagedListDto<ResumeListItemDto>>(response)
101
+ return {
102
+ list: (data.list ?? []).map(mapResumeListItem),
103
+ total: data.total ?? 0,
104
+ page: data.page ?? page,
105
+ pageSize: data.page_size ?? filter.pageSize ?? 20,
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,51 @@
1
+ import { createCaseSearchClient, type CaseSearchClientOptions } from './caseSearchClient'
2
+ import { createFormClient, type FormClient, type FormClientOptions } from './formClient'
3
+ import { createResumeSearchClient, type ResumeSearchClientOptions } from './resumeSearchClient'
4
+ import type { CaseSearchFn } from '../catalog/caseSearchContext'
5
+ import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
6
+ import type { SkoponAuthStorageOptions } from './authStorage'
7
+
8
+ export interface SkoponSdkClientsOptions {
9
+ baseUrl?: string
10
+ fetch?: typeof fetch
11
+ getHeaders?: () => Record<string, string> | Promise<Record<string, string>>
12
+ authStorage?: SkoponAuthStorageOptions
13
+ form?: Pick<FormClientOptions, 'detailPath'>
14
+ resume?: Pick<ResumeSearchClientOptions, 'searchPath'>
15
+ case?: Pick<CaseSearchClientOptions, 'listPath' | 'published'>
16
+ }
17
+
18
+ export interface SkoponSdkClients {
19
+ fetchDetail: FormClient['fetchDetail']
20
+ resumeSearch: ResumeSearchFn
21
+ caseSearch: CaseSearchFn
22
+ }
23
+
24
+ export function createSkoponSdkClients(
25
+ options: SkoponSdkClientsOptions = {},
26
+ ): SkoponSdkClients {
27
+ const shared = {
28
+ baseUrl: options.baseUrl,
29
+ fetch: options.fetch,
30
+ getHeaders: options.getHeaders,
31
+ authStorage: options.authStorage,
32
+ }
33
+
34
+ const formClient = createFormClient({
35
+ ...shared,
36
+ detailPath: options.form?.detailPath,
37
+ })
38
+
39
+ return {
40
+ fetchDetail: formClient.fetchDetail,
41
+ resumeSearch: createResumeSearchClient({
42
+ ...shared,
43
+ searchPath: options.resume?.searchPath,
44
+ }),
45
+ caseSearch: createCaseSearchClient({
46
+ ...shared,
47
+ listPath: options.case?.listPath,
48
+ published: options.case?.published,
49
+ }),
50
+ }
51
+ }
@@ -12,6 +12,7 @@ import {
12
12
  payloadHasInputBlocks,
13
13
  } from '../submit/intersectPayloadBlocksWithForm'
14
14
  import { copyTextToClipboard, submitFormJson } from '../submit/submitFormJson'
15
+ import { validateFormSubmitValues } from '../submit/validateFormSubmit'
15
16
  import type { CaseSearchFn } from '../catalog/caseSearchContext'
16
17
  import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
17
18
  import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
@@ -182,6 +183,11 @@ export default function AskUserFormCard({
182
183
  setSubmitting(true)
183
184
  try {
184
185
  const cardValues = rendererRef.current?.getValues(fieldNames) ?? {}
186
+ const validationError = validateFormSubmitValues(renderBlocks, cardValues)
187
+ if (validationError) {
188
+ onNotifyRef.current?.('error', validationError.message)
189
+ return
190
+ }
185
191
  const submitBody = { ...cardValues, ...extraValues }
186
192
 
187
193
  if (submitMode === 'post') {
package/src/index.ts CHANGED
@@ -65,6 +65,27 @@ export {
65
65
  } from './submit/submitFormJson'
66
66
 
67
67
  export { createFormClient, type FormClient, type FormClientOptions } from './client/formClient'
68
+ export {
69
+ resolveSkoponAuthHeaders,
70
+ resolveClientHeaders,
71
+ DEFAULT_AUTH_STORAGE_KEY,
72
+ DEFAULT_AUTH_TOKEN_FIELD,
73
+ type SkoponAuthStorageOptions,
74
+ type ClientHeadersOptions,
75
+ } from './client/authStorage'
76
+ export {
77
+ createResumeSearchClient,
78
+ type ResumeSearchClientOptions,
79
+ } from './client/resumeSearchClient'
80
+ export {
81
+ createCaseSearchClient,
82
+ type CaseSearchClientOptions,
83
+ } from './client/caseSearchClient'
84
+ export {
85
+ createSkoponSdkClients,
86
+ type SkoponSdkClients,
87
+ type SkoponSdkClientsOptions,
88
+ } from './client/skoponSdkClients'
68
89
 
69
90
  export { default as SkoponFormRenderer } from './components/SkoponFormRenderer'
70
91
  export type { SkoponFormRendererProps, SkoponFormRendererRef } from './components/SkoponFormRenderer'
@@ -17,6 +17,7 @@ import {
17
17
  payloadHasInputBlocks,
18
18
  } from '../submit/intersectPayloadBlocksWithForm'
19
19
  import { submitFormJson } from '../submit/submitFormJson'
20
+ import { validateFormSubmitValues } from '../submit/validateFormSubmit'
20
21
  import type { FormSchema } from '../types/index'
21
22
 
22
23
  describe('buildCurlStatement', () => {
@@ -284,3 +285,42 @@ describe('submitFormJson', () => {
284
285
  )
285
286
  })
286
287
  })
288
+
289
+ describe('validateFormSubmitValues', () => {
290
+ it('rejects empty required text field', () => {
291
+ const blocks = [
292
+ { id: 'b1', type: 'text' as const, name: 'title', label: '标题', required: true },
293
+ ]
294
+ expect(validateFormSubmitValues(blocks, { title: '' })?.message).toBe('请填写「标题」')
295
+ expect(validateFormSubmitValues(blocks, { title: 'ok' })).toBeNull()
296
+ })
297
+
298
+ it('rejects invalid email format', () => {
299
+ const blocks = [
300
+ { id: 'b1', type: 'email' as const, name: 'email', label: '邮箱' },
301
+ ]
302
+ expect(validateFormSubmitValues(blocks, { email: 'bad' })?.message).toBe('「邮箱」格式不正确')
303
+ expect(validateFormSubmitValues(blocks, { email: 'a@b.co' })).toBeNull()
304
+ })
305
+
306
+ it('enforces resume and case minCount when required or partially filled', () => {
307
+ const resumeBlocks = [
308
+ {
309
+ id: 'b1',
310
+ type: 'resume_multiselect' as const,
311
+ name: 'resumes',
312
+ label: '简历',
313
+ resumeMinCount: 2,
314
+ },
315
+ ]
316
+ expect(validateFormSubmitValues(resumeBlocks, { resumes: [] })).toBeNull()
317
+ expect(validateFormSubmitValues(resumeBlocks, { resumes: ['r1'] })?.message).toBe(
318
+ '「简历」至少选择 2 项',
319
+ )
320
+
321
+ const requiredResume = [{ ...resumeBlocks[0]!, required: true }]
322
+ expect(validateFormSubmitValues(requiredResume, { resumes: [] })?.message).toBe(
323
+ '请填写「简历」',
324
+ )
325
+ })
326
+ })
@@ -0,0 +1,111 @@
1
+ import type { FormBlock } from '../types/index'
2
+ import { isLayoutBlockType, isMediaBlockType } from '../types/index'
3
+ import { EMAIL_REGEXP } from '../adapter/a2uiAdapter'
4
+ import { coerceToggleValue } from '../adapter/formSchema'
5
+ import { asStringArray } from '../catalog/useSkoponBoundField'
6
+
7
+ export interface FormSubmitValidationError {
8
+ fieldName: string
9
+ label: string
10
+ message: string
11
+ }
12
+
13
+ export function matchesValidationRegexp(value: string, pattern: string): boolean {
14
+ if (!pattern.trim()) return true
15
+ try {
16
+ return new RegExp(pattern).test(value)
17
+ } catch {
18
+ return true
19
+ }
20
+ }
21
+
22
+ function isBlankString(raw: unknown): boolean {
23
+ if (raw === undefined || raw === null) return true
24
+ if (typeof raw === 'string') return !raw.trim()
25
+ return false
26
+ }
27
+
28
+ function isEmptySubmitValue(block: FormBlock, raw: unknown): boolean {
29
+ switch (block.type) {
30
+ case 'multiselect':
31
+ case 'checkbox':
32
+ case 'file':
33
+ case 'resume_multiselect':
34
+ case 'case_multiselect':
35
+ return asStringArray(raw).length === 0
36
+ case 'toggle':
37
+ return !coerceToggleValue(raw)
38
+ case 'number':
39
+ return raw === undefined || raw === null || raw === ''
40
+ default:
41
+ return isBlankString(raw)
42
+ }
43
+ }
44
+
45
+ function shouldEnforceMinCount(block: FormBlock, count: number): boolean {
46
+ return block.required === true || count > 0
47
+ }
48
+
49
+ /** 提交前校验 renderBlocks 对应字段值;首个错误即返回。 */
50
+ export function validateFormSubmitValues(
51
+ blocks: FormBlock[],
52
+ values: Record<string, unknown>,
53
+ ): FormSubmitValidationError | null {
54
+ for (const block of blocks) {
55
+ const name = block.name?.trim()
56
+ if (!name) continue
57
+ if (isLayoutBlockType(block.type) || isMediaBlockType(block.type)) continue
58
+
59
+ const raw = values[name]
60
+ const label = block.label?.trim() || name
61
+
62
+ if (block.required && isEmptySubmitValue(block, raw)) {
63
+ return { fieldName: name, label, message: `请填写「${label}」` }
64
+ }
65
+
66
+ if (block.type === 'email' && !isBlankString(raw)) {
67
+ const text = String(raw).trim()
68
+ if (!matchesValidationRegexp(text, EMAIL_REGEXP)) {
69
+ return { fieldName: name, label, message: `「${label}」格式不正确` }
70
+ }
71
+ }
72
+
73
+ if (block.type === 'file') {
74
+ const min = block.fileMinCount ?? 0
75
+ const count = asStringArray(raw).length
76
+ if (min > 0 && count < min && shouldEnforceMinCount(block, count)) {
77
+ return {
78
+ fieldName: name,
79
+ label,
80
+ message: `「${label}」至少需要 ${min} 个文件`,
81
+ }
82
+ }
83
+ }
84
+
85
+ if (block.type === 'resume_multiselect') {
86
+ const min = block.resumeMinCount
87
+ const count = asStringArray(raw).length
88
+ if (min !== undefined && count < min && shouldEnforceMinCount(block, count)) {
89
+ return {
90
+ fieldName: name,
91
+ label,
92
+ message: `「${label}」至少选择 ${min} 项`,
93
+ }
94
+ }
95
+ }
96
+
97
+ if (block.type === 'case_multiselect') {
98
+ const min = block.caseMinCount
99
+ const count = asStringArray(raw).length
100
+ if (min !== undefined && count < min && shouldEnforceMinCount(block, count)) {
101
+ return {
102
+ fieldName: name,
103
+ label,
104
+ message: `「${label}」至少选择 ${min} 项`,
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ return null
111
+ }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=skoponSelectLoading.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"skoponSelectLoading.test.d.ts","sourceRoot":"","sources":["../../src/catalog/skoponSelectLoading.test.tsx"],"names":[],"mappings":""}
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=textFieldPreview.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"textFieldPreview.test.d.ts","sourceRoot":"","sources":["../../src/catalog/textFieldPreview.test.tsx"],"names":[],"mappings":""}
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=SkoponA2uiStreamRenderer.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"SkoponA2uiStreamRenderer.test.d.ts","sourceRoot":"","sources":["../../src/components/SkoponA2uiStreamRenderer.test.tsx"],"names":[],"mappings":""}