@skopon-cool/form-sdk 0.5.1 → 0.6.0
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/README.md +21 -2
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/catalog/fileUploadStatusContext.d.ts +12 -0
- package/dist/catalog/fileUploadStatusContext.d.ts.map +1 -0
- package/dist/client/uploadClient.d.ts +17 -0
- package/dist/client/uploadClient.d.ts.map +1 -0
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
- package/dist/components/SkoponFormRenderer.d.ts +2 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/index.js +1445 -1293
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.test.ts +44 -0
- package/src/adapter/a2uiAdapter.ts +2 -1
- package/src/adapter/formSchema.test.ts +34 -0
- package/src/adapter/formSchema.ts +15 -12
- package/src/catalog/a2uiCustomCatalog.tsx +187 -35
- package/src/catalog/fileUploadPreview.test.tsx +177 -0
- package/src/catalog/fileUploadStatusContext.tsx +64 -0
- package/src/client/uploadClient.test.ts +104 -0
- package/src/client/uploadClient.ts +68 -0
- package/src/components/AskUserFormCard.tsx +8 -1
- package/src/components/AskUserFormCard.upload.test.tsx +91 -0
- package/src/components/SkoponA2uiStreamRenderer.tsx +14 -11
- package/src/components/SkoponFormRenderer.tsx +24 -16
- package/src/submit/submit.test.ts +25 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react'
|
|
10
|
+
|
|
11
|
+
interface FileUploadStatusContextValue {
|
|
12
|
+
beginUpload: () => () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FileUploadStatusContext = createContext<FileUploadStatusContextValue>({
|
|
16
|
+
beginUpload: () => () => {},
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export interface FileUploadStatusProviderProps {
|
|
20
|
+
children: ReactNode
|
|
21
|
+
onUploadingChange?: (uploading: boolean) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function FileUploadStatusProvider({
|
|
25
|
+
children,
|
|
26
|
+
onUploadingChange,
|
|
27
|
+
}: FileUploadStatusProviderProps) {
|
|
28
|
+
const [pendingCount, setPendingCount] = useState(0)
|
|
29
|
+
const mountedRef = useRef(true)
|
|
30
|
+
const onUploadingChangeRef = useRef(onUploadingChange)
|
|
31
|
+
onUploadingChangeRef.current = onUploadingChange
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
mountedRef.current = true
|
|
35
|
+
return () => {
|
|
36
|
+
mountedRef.current = false
|
|
37
|
+
onUploadingChangeRef.current?.(false)
|
|
38
|
+
}
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
onUploadingChange?.(pendingCount > 0)
|
|
43
|
+
}, [pendingCount, onUploadingChange])
|
|
44
|
+
|
|
45
|
+
const beginUpload = useCallback(() => {
|
|
46
|
+
if (mountedRef.current) setPendingCount((count) => count + 1)
|
|
47
|
+
let ended = false
|
|
48
|
+
return () => {
|
|
49
|
+
if (ended) return
|
|
50
|
+
ended = true
|
|
51
|
+
if (mountedRef.current) setPendingCount((count) => Math.max(0, count - 1))
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<FileUploadStatusContext.Provider value={{ beginUpload }}>
|
|
57
|
+
{children}
|
|
58
|
+
</FileUploadStatusContext.Provider>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useFileUploadStatus() {
|
|
63
|
+
return useContext(FileUploadStatusContext)
|
|
64
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
CDN_UPLOAD_PATH,
|
|
5
|
+
CDN_UPLOAD_TIMEOUT_MS,
|
|
6
|
+
UploadCancelledError,
|
|
7
|
+
uploadFileToCdn,
|
|
8
|
+
} from './uploadClient'
|
|
9
|
+
|
|
10
|
+
function response(body: unknown, init: Partial<Response> = {}): Response {
|
|
11
|
+
return {
|
|
12
|
+
ok: init.ok ?? true,
|
|
13
|
+
status: init.status ?? 200,
|
|
14
|
+
json: async () => body,
|
|
15
|
+
} as Response
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('uploadFileToCdn', () => {
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.useRealTimers()
|
|
21
|
+
vi.unstubAllGlobals()
|
|
22
|
+
sessionStorage.clear()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('uploads multipart data to the fixed endpoint with session Bearer auth', async () => {
|
|
26
|
+
sessionStorage.setItem('auth_user', JSON.stringify({ token: 'token-1' }))
|
|
27
|
+
const fetchMock = vi.fn(async () =>
|
|
28
|
+
response({
|
|
29
|
+
success: true,
|
|
30
|
+
data: { url: ' https://cdn.example.com/a.pdf ', file_name: 'a.pdf' },
|
|
31
|
+
}),
|
|
32
|
+
)
|
|
33
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
34
|
+
const file = new File(['pdf'], 'a.pdf', { type: 'application/pdf' })
|
|
35
|
+
|
|
36
|
+
await expect(uploadFileToCdn(file)).resolves.toMatchObject({
|
|
37
|
+
url: 'https://cdn.example.com/a.pdf',
|
|
38
|
+
file_name: 'a.pdf',
|
|
39
|
+
})
|
|
40
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
41
|
+
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
|
42
|
+
expect(url).toBe(CDN_UPLOAD_PATH)
|
|
43
|
+
expect(init.method).toBe('POST')
|
|
44
|
+
expect(init.headers).toEqual({ Authorization: 'Bearer token-1' })
|
|
45
|
+
expect((init.headers as Record<string, string>)['Content-Type']).toBeUndefined()
|
|
46
|
+
expect(init.body).toBeInstanceOf(FormData)
|
|
47
|
+
expect((init.body as FormData).get('file')).toBe(file)
|
|
48
|
+
expect((init.body as FormData).get('purpose')).toBeNull()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it.each([
|
|
52
|
+
[{ success: false, message: '七牛上传失败', data: {} }, '七牛上传失败'],
|
|
53
|
+
[{ success: true, data: { url: ' ' } }, '响应缺少文件地址'],
|
|
54
|
+
])('rejects invalid API responses', async (body, message) => {
|
|
55
|
+
vi.stubGlobal('fetch', vi.fn(async () => response(body)))
|
|
56
|
+
await expect(uploadFileToCdn(new File(['x'], 'x.txt'))).rejects.toThrow(message)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('rejects non-JSON and non-success HTTP responses', async () => {
|
|
60
|
+
vi.stubGlobal(
|
|
61
|
+
'fetch',
|
|
62
|
+
vi.fn(async () => ({
|
|
63
|
+
ok: false,
|
|
64
|
+
status: 502,
|
|
65
|
+
json: async () => {
|
|
66
|
+
throw new SyntaxError('bad json')
|
|
67
|
+
},
|
|
68
|
+
} as Response)),
|
|
69
|
+
)
|
|
70
|
+
await expect(uploadFileToCdn(new File(['x'], 'x.txt'))).rejects.toThrow('响应非 JSON (502)')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('turns a caller abort into UploadCancelledError', async () => {
|
|
74
|
+
vi.stubGlobal(
|
|
75
|
+
'fetch',
|
|
76
|
+
vi.fn((_url: string, init?: RequestInit) =>
|
|
77
|
+
new Promise<Response>((_resolve, reject) => {
|
|
78
|
+
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
const controller = new AbortController()
|
|
83
|
+
const pending = uploadFileToCdn(new File(['x'], 'x.txt'), { signal: controller.signal })
|
|
84
|
+
const assertion = expect(pending).rejects.toBeInstanceOf(UploadCancelledError)
|
|
85
|
+
controller.abort()
|
|
86
|
+
await assertion
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('aborts and reports a timeout after 120 seconds', async () => {
|
|
90
|
+
vi.useFakeTimers()
|
|
91
|
+
vi.stubGlobal(
|
|
92
|
+
'fetch',
|
|
93
|
+
vi.fn((_url: string, init?: RequestInit) =>
|
|
94
|
+
new Promise<Response>((_resolve, reject) => {
|
|
95
|
+
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
const pending = uploadFileToCdn(new File(['x'], 'x.txt'))
|
|
100
|
+
const assertion = expect(pending).rejects.toThrow('上传超时,请稍后重试')
|
|
101
|
+
await vi.advanceTimersByTimeAsync(CDN_UPLOAD_TIMEOUT_MS)
|
|
102
|
+
await assertion
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { parseEnvelope } from './apiEnvelope'
|
|
2
|
+
import { resolveSkoponAuthHeaders } from './authStorage'
|
|
3
|
+
|
|
4
|
+
export const CDN_UPLOAD_PATH = '/api/v1/dev/upload/cdn'
|
|
5
|
+
export const CDN_UPLOAD_TIMEOUT_MS = 120_000
|
|
6
|
+
|
|
7
|
+
export interface CdnUploadResult {
|
|
8
|
+
url: string
|
|
9
|
+
file_name?: string
|
|
10
|
+
mime_type?: string | null
|
|
11
|
+
size?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class UploadCancelledError extends Error {
|
|
15
|
+
constructor(message = '上传已取消') {
|
|
16
|
+
super(message)
|
|
17
|
+
this.name = 'UploadCancelledError'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isUploadCancelledError(error: unknown): error is UploadCancelledError {
|
|
22
|
+
return error instanceof UploadCancelledError
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** SDK 固定 CDN 上传通道:使用宿主默认 session token 调用后端七牛代理。 */
|
|
26
|
+
export async function uploadFileToCdn(
|
|
27
|
+
file: File,
|
|
28
|
+
options: { signal?: AbortSignal } = {},
|
|
29
|
+
): Promise<CdnUploadResult> {
|
|
30
|
+
const controller = new AbortController()
|
|
31
|
+
let timedOut = false
|
|
32
|
+
|
|
33
|
+
const abortFromCaller = () => controller.abort()
|
|
34
|
+
if (options.signal?.aborted) {
|
|
35
|
+
controller.abort()
|
|
36
|
+
} else {
|
|
37
|
+
options.signal?.addEventListener('abort', abortFromCaller, { once: true })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const timer = setTimeout(() => {
|
|
41
|
+
timedOut = true
|
|
42
|
+
controller.abort()
|
|
43
|
+
}, CDN_UPLOAD_TIMEOUT_MS)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const formData = new FormData()
|
|
47
|
+
formData.append('file', file)
|
|
48
|
+
const response = await fetch(CDN_UPLOAD_PATH, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: resolveSkoponAuthHeaders(),
|
|
51
|
+
body: formData,
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
})
|
|
54
|
+
const result = await parseEnvelope<CdnUploadResult>(response)
|
|
55
|
+
const url = typeof result.url === 'string' ? result.url.trim() : ''
|
|
56
|
+
if (!url) throw new Error('上传成功但响应缺少文件地址')
|
|
57
|
+
return { ...result, url }
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (controller.signal.aborted) {
|
|
60
|
+
if (timedOut) throw new Error('上传超时,请稍后重试')
|
|
61
|
+
throw new UploadCancelledError()
|
|
62
|
+
}
|
|
63
|
+
throw error
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
options.signal?.removeEventListener('abort', abortFromCaller)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -103,6 +103,7 @@ function AskUserFormCardBody({
|
|
|
103
103
|
const [formDisabled, setFormDisabled] = useState(false)
|
|
104
104
|
const [loading, setLoading] = useState(true)
|
|
105
105
|
const [submitting, setSubmitting] = useState(false)
|
|
106
|
+
const [uploading, setUploading] = useState(false)
|
|
106
107
|
const rendererRef = useRef<SkoponFormRendererRef>(null)
|
|
107
108
|
const cardRootRef = useRef<HTMLDivElement>(null)
|
|
108
109
|
const mountedRef = useRef(true)
|
|
@@ -322,6 +323,10 @@ function AskUserFormCardBody({
|
|
|
322
323
|
|
|
323
324
|
const handleSubmit = useCallback(async () => {
|
|
324
325
|
if (submitting) return
|
|
326
|
+
if (uploading) {
|
|
327
|
+
onNotifyRef.current?.('error', '文件正在上传,请稍候')
|
|
328
|
+
return
|
|
329
|
+
}
|
|
325
330
|
setSubmitting(true)
|
|
326
331
|
try {
|
|
327
332
|
const cardValues = rendererRef.current?.getValues(fieldNames) ?? {}
|
|
@@ -397,6 +402,7 @@ function AskUserFormCardBody({
|
|
|
397
402
|
}
|
|
398
403
|
}, [
|
|
399
404
|
submitting,
|
|
405
|
+
uploading,
|
|
400
406
|
fieldNames,
|
|
401
407
|
renderBlocks,
|
|
402
408
|
surfaceTitle,
|
|
@@ -486,6 +492,7 @@ function AskUserFormCardBody({
|
|
|
486
492
|
wrapProvider={false}
|
|
487
493
|
resumeSearch={resumeSearch}
|
|
488
494
|
caseSearch={caseSearch}
|
|
495
|
+
onUploadingChange={setUploading}
|
|
489
496
|
/>
|
|
490
497
|
|
|
491
498
|
<div className="ask-user-form-actions">
|
|
@@ -500,7 +507,7 @@ function AskUserFormCardBody({
|
|
|
500
507
|
type="primary"
|
|
501
508
|
size="small"
|
|
502
509
|
loading={submitting}
|
|
503
|
-
disabled={submitting}
|
|
510
|
+
disabled={submitting || uploading}
|
|
504
511
|
onClick={() => void handleSubmit()}
|
|
505
512
|
>
|
|
506
513
|
提交
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const uploadMock = vi.hoisted(() => vi.fn())
|
|
5
|
+
|
|
6
|
+
vi.mock('@a2ui/web_core/v0_9/basic_catalog', async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof import('@a2ui/web_core/v0_9/basic_catalog')>()
|
|
8
|
+
return { ...actual, injectBasicCatalogStyles: vi.fn() }
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
vi.mock('@a2ui/react/styles', () => ({
|
|
12
|
+
injectStyles: vi.fn(),
|
|
13
|
+
removeStyles: vi.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
vi.mock('../client/uploadClient', () => ({
|
|
17
|
+
uploadFileToCdn: uploadMock,
|
|
18
|
+
isUploadCancelledError: () => false,
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
import { createElement } from 'react'
|
|
22
|
+
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
|
23
|
+
import AskUserFormCard from './AskUserFormCard'
|
|
24
|
+
import { normalizeFormDefinition } from '../adapter/formSchema'
|
|
25
|
+
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
class ResizeObserverMock {
|
|
28
|
+
observe() {}
|
|
29
|
+
unobserve() {}
|
|
30
|
+
disconnect() {}
|
|
31
|
+
}
|
|
32
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
uploadMock.mockReset()
|
|
37
|
+
sessionStorage.clear()
|
|
38
|
+
})
|
|
39
|
+
afterEach(cleanup)
|
|
40
|
+
|
|
41
|
+
describe('AskUserFormCard file upload guard', () => {
|
|
42
|
+
it('disables submit until every file upload settles', async () => {
|
|
43
|
+
let resolveUpload!: (value: { url: string }) => void
|
|
44
|
+
uploadMock.mockReturnValue(
|
|
45
|
+
new Promise<{ url: string }>((resolve) => {
|
|
46
|
+
resolveUpload = resolve
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
const fileBlock = {
|
|
50
|
+
id: 'file',
|
|
51
|
+
type: 'file' as const,
|
|
52
|
+
name: 'attachments',
|
|
53
|
+
label: '附件',
|
|
54
|
+
fileMinCount: 0,
|
|
55
|
+
fileMaxCount: 1,
|
|
56
|
+
}
|
|
57
|
+
const formDefinition = normalizeFormDefinition({
|
|
58
|
+
title: '',
|
|
59
|
+
description: '',
|
|
60
|
+
blocks: [fileBlock],
|
|
61
|
+
jsonSchema: {},
|
|
62
|
+
})
|
|
63
|
+
const view = render(
|
|
64
|
+
createElement(AskUserFormCard, {
|
|
65
|
+
payload: { title: '', description: '', blocks: [fileBlock] },
|
|
66
|
+
formUniqueId: 'form-upload',
|
|
67
|
+
callbackUrl: '/callback',
|
|
68
|
+
submitMode: 'post',
|
|
69
|
+
fetchFormDetail: async () => ({
|
|
70
|
+
formUniqueId: 'form-upload',
|
|
71
|
+
disabled: false,
|
|
72
|
+
formDefinition,
|
|
73
|
+
}),
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const input = await waitFor(() => {
|
|
78
|
+
const node = view.container.querySelector('input[type="file"]') as HTMLInputElement
|
|
79
|
+
expect(node).toBeTruthy()
|
|
80
|
+
return node
|
|
81
|
+
})
|
|
82
|
+
const submit = view.getByRole('button', { name: /提\s*交/ }) as HTMLButtonElement
|
|
83
|
+
expect(submit.disabled).toBe(false)
|
|
84
|
+
|
|
85
|
+
fireEvent.change(input, { target: { files: [new File(['x'], 'a.pdf')] } })
|
|
86
|
+
await waitFor(() => expect(submit.disabled).toBe(true))
|
|
87
|
+
|
|
88
|
+
await act(async () => resolveUpload({ url: 'https://cdn.example.com/a.pdf' }))
|
|
89
|
+
await waitFor(() => expect(submit.disabled).toBe(false))
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -5,6 +5,7 @@ import { A2uiSurface, type ReactComponentImplementation } from '@a2ui/react/v0_9
|
|
|
5
5
|
import { injectStyles } from '@a2ui/react/styles'
|
|
6
6
|
import { buildSkoponCatalog } from '../catalog/a2uiCustomCatalog'
|
|
7
7
|
import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
|
|
8
|
+
import { FileUploadStatusProvider } from '../catalog/fileUploadStatusContext'
|
|
8
9
|
import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
|
|
9
10
|
import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
10
11
|
import SkoponFormProvider from './SkoponFormProvider'
|
|
@@ -182,17 +183,19 @@ export default function SkoponA2uiStreamRenderer({
|
|
|
182
183
|
|
|
183
184
|
return (
|
|
184
185
|
<SkoponFormProvider>
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
186
|
+
<FileUploadStatusProvider>
|
|
187
|
+
<ResumeSearchProvider resumeSearch={resumeSearch}>
|
|
188
|
+
<CaseSearchProvider caseSearch={caseSearch}>
|
|
189
|
+
<A2uiPreviewModeProvider interactive={interactive}>
|
|
190
|
+
<div className="a2ui-surface a2ui-container">
|
|
191
|
+
{surfaces.map((surface) => (
|
|
192
|
+
<A2uiSurface key={surface.id} surface={surface} />
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
</A2uiPreviewModeProvider>
|
|
196
|
+
</CaseSearchProvider>
|
|
197
|
+
</ResumeSearchProvider>
|
|
198
|
+
</FileUploadStatusProvider>
|
|
196
199
|
</SkoponFormProvider>
|
|
197
200
|
)
|
|
198
201
|
}
|
|
@@ -15,6 +15,7 @@ import { extractSurfaceValues } from '../adapter/extractSurfaceValues'
|
|
|
15
15
|
import { isA2uiSurfaceEmpty, surfaceDocToMessages } from '../adapter/a2uiAdapter'
|
|
16
16
|
import { SKOPON_CATALOG_ID, buildSkoponCatalog } from '../catalog/a2uiCustomCatalog'
|
|
17
17
|
import { A2uiPreviewModeProvider } from '../catalog/a2uiPreviewContext'
|
|
18
|
+
import { FileUploadStatusProvider } from '../catalog/fileUploadStatusContext'
|
|
18
19
|
import { CaseSearchProvider, type CaseSearchFn } from '../catalog/caseSearchContext'
|
|
19
20
|
import { ResumeSearchProvider, type ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
20
21
|
import SkoponFormProvider from './SkoponFormProvider'
|
|
@@ -35,6 +36,8 @@ export interface SkoponFormRendererProps {
|
|
|
35
36
|
resumeSearch?: ResumeSearchFn | null
|
|
36
37
|
/** 案例单选组件:注入搜索函数 */
|
|
37
38
|
caseSearch?: CaseSearchFn | null
|
|
39
|
+
/** 任意文件上传任务的聚合状态 */
|
|
40
|
+
onUploadingChange?: (uploading: boolean) => void
|
|
38
41
|
/** 外层已包裹 SkoponFormProvider 时可设为 false,避免重复 ConfigProvider */
|
|
39
42
|
wrapProvider?: boolean
|
|
40
43
|
}
|
|
@@ -49,6 +52,7 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
|
|
|
49
52
|
fieldNames = [],
|
|
50
53
|
resumeSearch = null,
|
|
51
54
|
caseSearch = null,
|
|
55
|
+
onUploadingChange,
|
|
52
56
|
wrapProvider = true,
|
|
53
57
|
},
|
|
54
58
|
ref,
|
|
@@ -122,27 +126,31 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
|
|
|
122
126
|
|
|
123
127
|
if (surfaces.length === 0) {
|
|
124
128
|
const empty = (
|
|
125
|
-
<
|
|
126
|
-
<
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
<FileUploadStatusProvider onUploadingChange={onUploadingChange}>
|
|
130
|
+
<ResumeSearchProvider resumeSearch={resumeSearch}>
|
|
131
|
+
<CaseSearchProvider caseSearch={caseSearch}>
|
|
132
|
+
<A2uiPreviewModeProvider interactive={interactive}>{emptyHint}</A2uiPreviewModeProvider>
|
|
133
|
+
</CaseSearchProvider>
|
|
134
|
+
</ResumeSearchProvider>
|
|
135
|
+
</FileUploadStatusProvider>
|
|
130
136
|
)
|
|
131
137
|
return wrapProvider ? <SkoponFormProvider>{empty}</SkoponFormProvider> : empty
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
const content = (
|
|
135
|
-
<
|
|
136
|
-
<
|
|
137
|
-
<
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
<FileUploadStatusProvider onUploadingChange={onUploadingChange}>
|
|
142
|
+
<ResumeSearchProvider resumeSearch={resumeSearch}>
|
|
143
|
+
<CaseSearchProvider caseSearch={caseSearch}>
|
|
144
|
+
<A2uiPreviewModeProvider interactive={interactive}>
|
|
145
|
+
<div className="a2ui-surface a2ui-container">
|
|
146
|
+
{surfaces.map((surface) => (
|
|
147
|
+
<A2uiSurface key={surface.id} surface={surface} />
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
</A2uiPreviewModeProvider>
|
|
151
|
+
</CaseSearchProvider>
|
|
152
|
+
</ResumeSearchProvider>
|
|
153
|
+
</FileUploadStatusProvider>
|
|
146
154
|
)
|
|
147
155
|
|
|
148
156
|
return wrapProvider ? <SkoponFormProvider>{content}</SkoponFormProvider> : content
|
|
@@ -822,4 +822,29 @@ describe('validateFormSubmitValues', () => {
|
|
|
822
822
|
'请填写「简历」',
|
|
823
823
|
)
|
|
824
824
|
})
|
|
825
|
+
|
|
826
|
+
it('enforces file minCount at submit time', () => {
|
|
827
|
+
const blocks = [
|
|
828
|
+
{
|
|
829
|
+
id: 'file-1',
|
|
830
|
+
type: 'file' as const,
|
|
831
|
+
name: 'attachments',
|
|
832
|
+
label: '附件',
|
|
833
|
+
fileMinCount: 2,
|
|
834
|
+
},
|
|
835
|
+
]
|
|
836
|
+
expect(validateFormSubmitValues(blocks, { attachments: [] })).toBeNull()
|
|
837
|
+
expect(
|
|
838
|
+
validateFormSubmitValues(blocks, { attachments: ['https://cdn.example.com/a.pdf'] })
|
|
839
|
+
?.message,
|
|
840
|
+
).toBe('「附件」至少需要 2 个文件')
|
|
841
|
+
expect(
|
|
842
|
+
validateFormSubmitValues(blocks, {
|
|
843
|
+
attachments: [
|
|
844
|
+
'https://cdn.example.com/a.pdf',
|
|
845
|
+
'https://cdn.example.com/b.pdf',
|
|
846
|
+
],
|
|
847
|
+
}),
|
|
848
|
+
).toBeNull()
|
|
849
|
+
})
|
|
825
850
|
})
|