@skopon-cool/form-sdk 0.1.0 → 0.1.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.
- package/README.md +52 -12
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formFileAccept.d.ts.map +1 -1
- package/dist/adapter/formSchema.d.ts +1 -0
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/catalog/textFieldPreview.test.d.ts +2 -0
- package/dist/catalog/textFieldPreview.test.d.ts.map +1 -0
- package/dist/catalog/useSkoponBoundField.d.ts +2 -0
- package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
- package/dist/client/formClient.d.ts.map +1 -1
- package/dist/components/AskUserFormCard.d.ts +3 -1
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/SkoponA2uiStreamRenderer.d.ts.map +1 -1
- package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +2 -0
- package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +1 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +998 -667
- package/dist/submit/buildCurlStatement.d.ts +8 -0
- package/dist/submit/buildCurlStatement.d.ts.map +1 -1
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts +26 -0
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -0
- package/dist/submit/submitFormJson.d.ts.map +1 -1
- package/package.json +16 -5
- package/src/adapter/a2uiAdapter.test.ts +91 -0
- package/src/adapter/a2uiAdapter.ts +36 -7
- package/src/adapter/formFileAccept.test.ts +53 -0
- package/src/adapter/formFileAccept.ts +11 -2
- package/src/adapter/formSchema.test.ts +35 -0
- package/src/adapter/formSchema.ts +5 -1
- package/src/catalog/a2uiCustomCatalog.tsx +154 -5
- package/src/catalog/textFieldPreview.test.tsx +88 -0
- package/src/catalog/useSkoponBoundField.test.ts +62 -0
- package/src/catalog/useSkoponBoundField.ts +10 -1
- package/src/client/formClient.test.ts +83 -0
- package/src/client/formClient.ts +10 -2
- package/src/components/AskUserFormCard.tsx +136 -58
- package/src/components/SkoponA2uiStreamRenderer.test.ts +62 -0
- package/src/components/SkoponA2uiStreamRenderer.test.tsx +79 -0
- package/src/components/SkoponA2uiStreamRenderer.tsx +96 -15
- package/src/components/SkoponFormRenderer.tsx +10 -7
- package/src/index.ts +11 -2
- package/src/styles/index.css +5 -0
- package/src/submit/buildCurlStatement.ts +49 -0
- package/src/submit/intersectPayloadBlocksWithForm.ts +175 -0
- package/src/submit/submit.test.ts +170 -10
- package/src/submit/submitFormJson.ts +20 -1
- package/dist/submit/intersectPayloadWithForm.d.ts +0 -17
- package/dist/submit/intersectPayloadWithForm.d.ts.map +0 -1
- package/src/submit/intersectPayloadWithForm.ts +0 -54
|
@@ -0,0 +1,88 @@
|
|
|
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, createRef } from 'react'
|
|
15
|
+
import { render, cleanup, fireEvent } from '@testing-library/react'
|
|
16
|
+
import { blocksToA2ui } from '../adapter/a2uiAdapter'
|
|
17
|
+
import SkoponFormRenderer, { type SkoponFormRendererRef } from '../components/SkoponFormRenderer'
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
class ResizeObserverMock {
|
|
21
|
+
observe() {}
|
|
22
|
+
unobserve() {}
|
|
23
|
+
disconnect() {}
|
|
24
|
+
}
|
|
25
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function renderTextareaSurface() {
|
|
29
|
+
const doc = blocksToA2ui(
|
|
30
|
+
{
|
|
31
|
+
title: '',
|
|
32
|
+
description: '',
|
|
33
|
+
blocks: [
|
|
34
|
+
{
|
|
35
|
+
id: 'b-bio',
|
|
36
|
+
type: 'textarea',
|
|
37
|
+
name: 'bio',
|
|
38
|
+
label: '简介',
|
|
39
|
+
placeholder: '请填写简介',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'b-desc',
|
|
43
|
+
type: 'paragraph',
|
|
44
|
+
label: '这是一段说明文字',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{ includeHeader: false },
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const ref = createRef<SkoponFormRendererRef>()
|
|
52
|
+
const view = render(
|
|
53
|
+
createElement(SkoponFormRenderer, {
|
|
54
|
+
ref,
|
|
55
|
+
doc,
|
|
56
|
+
surfaceId: 'test-form',
|
|
57
|
+
fieldNames: ['bio'],
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
return { ref, ...view }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('SkoponFormRenderer TextField longText', () => {
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
cleanup()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('renders Ant Design TextArea for textarea blocks', () => {
|
|
69
|
+
const { container, getByText } = renderTextareaSurface()
|
|
70
|
+
expect(getByText('简介')).toBeTruthy()
|
|
71
|
+
const textarea = container.querySelector('textarea.ant-input')
|
|
72
|
+
expect(textarea).toBeTruthy()
|
|
73
|
+
expect(textarea?.getAttribute('placeholder')).toBe('请填写简介')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('reads textarea values via getValues()', () => {
|
|
77
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
78
|
+
const { container, ref } = renderTextareaSurface()
|
|
79
|
+
const textarea = container.querySelector('textarea.ant-input')
|
|
80
|
+
expect(textarea).toBeTruthy()
|
|
81
|
+
if (textarea) {
|
|
82
|
+
fireEvent.change(textarea, { target: { value: 'hello world' } })
|
|
83
|
+
}
|
|
84
|
+
expect(ref.current?.getValues(['bio'])).toEqual({ bio: 'hello world' })
|
|
85
|
+
expect(warnSpy.mock.calls.some((call) => String(call[0]).includes('[useMarkdown]'))).toBe(false)
|
|
86
|
+
warnSpy.mockRestore()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import type { ComponentContext } from '@a2ui/web_core/v0_9'
|
|
6
|
+
import { asStringArray, readBoundFieldValue } from './useSkoponBoundField'
|
|
7
|
+
|
|
8
|
+
function createMockContext(initial: unknown = []): {
|
|
9
|
+
context: ComponentContext
|
|
10
|
+
getStore: () => unknown
|
|
11
|
+
} {
|
|
12
|
+
const store = new Map<string, unknown>([['/files', initial]])
|
|
13
|
+
const binding = { path: '/files' }
|
|
14
|
+
const context = {
|
|
15
|
+
componentModel: {
|
|
16
|
+
properties: { value: binding },
|
|
17
|
+
},
|
|
18
|
+
dataContext: {
|
|
19
|
+
resolveDynamicValue: (b: typeof binding) => store.get(b.path),
|
|
20
|
+
set: (path: string, value: unknown) => {
|
|
21
|
+
store.set(path, value)
|
|
22
|
+
},
|
|
23
|
+
subscribeDynamicValue: () => ({ unsubscribe: () => {} }),
|
|
24
|
+
},
|
|
25
|
+
} as unknown as ComponentContext
|
|
26
|
+
return {
|
|
27
|
+
context,
|
|
28
|
+
getStore: () => store.get('/files'),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('readBoundFieldValue', () => {
|
|
33
|
+
it('reads current bound value from dataContext', () => {
|
|
34
|
+
const { context } = createMockContext(['a.pdf'])
|
|
35
|
+
expect(asStringArray(readBoundFieldValue(context))).toEqual(['a.pdf'])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('supports sequential appends without stale reads', () => {
|
|
39
|
+
const { context, getStore } = createMockContext([])
|
|
40
|
+
const append = (fileName: string) => {
|
|
41
|
+
const current = asStringArray(readBoundFieldValue(context))
|
|
42
|
+
context.dataContext.set('/files', [...current, fileName])
|
|
43
|
+
}
|
|
44
|
+
append('first.pdf')
|
|
45
|
+
append('second.pdf')
|
|
46
|
+
expect(getStore()).toEqual(['first.pdf', 'second.pdf'])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('file upload remove by uid index', () => {
|
|
51
|
+
it('removes only the file at the uid index when names duplicate', () => {
|
|
52
|
+
const currentNames = ['dup.txt', 'dup.txt', 'other.txt']
|
|
53
|
+
const uid = '1-dup.txt'
|
|
54
|
+
const indexMatch = /^(\d+)-/.exec(uid)
|
|
55
|
+
const index = indexMatch ? Number(indexMatch[1]) : -1
|
|
56
|
+
const next =
|
|
57
|
+
index >= 0 && index < currentNames.length
|
|
58
|
+
? currentNames.filter((_, i) => i !== index)
|
|
59
|
+
: currentNames.filter((name) => name !== 'dup.txt')
|
|
60
|
+
expect(next).toEqual(['dup.txt', 'other.txt'])
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -11,6 +11,15 @@ function bindingPath(value: unknown): string | null {
|
|
|
11
11
|
return null
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/** 从 A2UI 组件 context 读取当前绑定字段值(不订阅更新,适合事件回调内读最新值)。 */
|
|
15
|
+
export function readBoundFieldValue(context: ComponentContext, field = 'value'): unknown {
|
|
16
|
+
const props = context.componentModel.properties as Record<string, unknown>
|
|
17
|
+
const binding = props[field] as DynamicBinding | undefined
|
|
18
|
+
const path = bindingPath(binding)
|
|
19
|
+
if (!path || binding === undefined) return undefined
|
|
20
|
+
return context.dataContext.resolveDynamicValue(binding)
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
/** 将 skopon 预览控件绑定到 A2UI surface dataModel(与 basicCatalog binder 行为一致)。 */
|
|
15
24
|
export function useSkoponBoundField(context: ComponentContext, field = 'value') {
|
|
16
25
|
const props = context.componentModel.properties as Record<string, unknown>
|
|
@@ -31,7 +40,7 @@ export function useSkoponBoundField(context: ComponentContext, field = 'value')
|
|
|
31
40
|
return context.dataContext.resolveDynamicValue(binding)
|
|
32
41
|
}, [context, binding, path])
|
|
33
42
|
|
|
34
|
-
const value =
|
|
43
|
+
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
35
44
|
|
|
36
45
|
const setValue = useCallback(
|
|
37
46
|
(next: unknown) => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
5
|
+
import { createFormClient } from './formClient'
|
|
6
|
+
|
|
7
|
+
function mockResponse(
|
|
8
|
+
init: Partial<Response> & { json?: () => Promise<unknown>; text?: () => Promise<string> },
|
|
9
|
+
): Response {
|
|
10
|
+
return {
|
|
11
|
+
ok: init.ok ?? true,
|
|
12
|
+
status: init.status ?? 200,
|
|
13
|
+
headers: { get: () => 'application/json' },
|
|
14
|
+
json: init.json ?? (async () => ({})),
|
|
15
|
+
text: init.text ?? (async () => ''),
|
|
16
|
+
} as Response
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('createFormClient', () => {
|
|
20
|
+
it('fetchDetail returns mapped form definition on success', async () => {
|
|
21
|
+
const fetchMock = vi.fn(async () =>
|
|
22
|
+
mockResponse({
|
|
23
|
+
json: async () => ({
|
|
24
|
+
success: true,
|
|
25
|
+
data: {
|
|
26
|
+
form_id: 1,
|
|
27
|
+
form_unique_id: 'form-abc',
|
|
28
|
+
name: 'Test',
|
|
29
|
+
disabled: 0,
|
|
30
|
+
form_definition: {
|
|
31
|
+
title: 'T',
|
|
32
|
+
blocks: [{ id: 'b1', type: 'text', name: 'x', label: 'X' }],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
) as unknown as typeof fetch
|
|
38
|
+
|
|
39
|
+
const client = createFormClient({ fetch: fetchMock, baseUrl: '/api/v1', detailPath: '/dev/form/detail' })
|
|
40
|
+
const result = await client.fetchDetail({ formUniqueId: 'form-abc' })
|
|
41
|
+
expect(result.formUniqueId).toBe('form-abc')
|
|
42
|
+
expect(result.formId).toBe(1)
|
|
43
|
+
expect(result.disabled).toBe(false)
|
|
44
|
+
expect(result.formDefinition?.blocks).toHaveLength(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('throws on non-JSON response', async () => {
|
|
48
|
+
const fetchMock = vi.fn(async () =>
|
|
49
|
+
mockResponse({
|
|
50
|
+
json: async () => {
|
|
51
|
+
throw new SyntaxError('Unexpected token')
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
) as unknown as typeof fetch
|
|
55
|
+
|
|
56
|
+
const client = createFormClient({ fetch: fetchMock })
|
|
57
|
+
await expect(client.fetchDetail({ formUniqueId: 'x' })).rejects.toThrow('响应非 JSON')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('throws when envelope data is missing', async () => {
|
|
61
|
+
const fetchMock = vi.fn(async () =>
|
|
62
|
+
mockResponse({
|
|
63
|
+
json: async () => ({ success: true, data: null }),
|
|
64
|
+
}),
|
|
65
|
+
) as unknown as typeof fetch
|
|
66
|
+
|
|
67
|
+
const client = createFormClient({ fetch: fetchMock })
|
|
68
|
+
await expect(client.fetchDetail({ formUniqueId: 'x' })).rejects.toThrow('响应缺少 data')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('throws when API returns success false', async () => {
|
|
72
|
+
const fetchMock = vi.fn(async () =>
|
|
73
|
+
mockResponse({
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 404,
|
|
76
|
+
json: async () => ({ success: false, message: '表单不存在' }),
|
|
77
|
+
}),
|
|
78
|
+
) as unknown as typeof fetch
|
|
79
|
+
|
|
80
|
+
const client = createFormClient({ fetch: fetchMock })
|
|
81
|
+
await expect(client.fetchDetail({ formUniqueId: 'missing' })).rejects.toThrow('表单不存在')
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/client/formClient.ts
CHANGED
|
@@ -27,11 +27,19 @@ interface ApiEnvelope<T> {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
async function parseEnvelope<T>(response: Response): Promise<T> {
|
|
30
|
-
|
|
30
|
+
let json: ApiEnvelope<T>
|
|
31
|
+
try {
|
|
32
|
+
json = (await response.json()) as ApiEnvelope<T>
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(`响应非 JSON (${response.status})`)
|
|
35
|
+
}
|
|
31
36
|
if (!response.ok || json.success === false) {
|
|
32
37
|
throw new Error(json.message || `请求失败 (${response.status})`)
|
|
33
38
|
}
|
|
34
|
-
|
|
39
|
+
if (json.data === undefined || json.data === null) {
|
|
40
|
+
throw new Error(json.message || `响应缺少 data (${response.status})`)
|
|
41
|
+
}
|
|
42
|
+
return json.data
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
export function createFormClient(options: FormClientOptions = {}): FormClient {
|
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import { Button, Spin, Tooltip } from 'antd'
|
|
2
|
+
import { Button, Spin, Tag, Tooltip } from 'antd'
|
|
3
3
|
import type { FormDetailResult, FormSchema, SubmitMode } from '../types/index'
|
|
4
4
|
import { buildAskUserSurface } from '../adapter/resolveSurface'
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { buildAskUserCurlStatement } from '../submit/buildCurlStatement'
|
|
6
|
+
import {
|
|
7
|
+
extractExtraBlockValues,
|
|
8
|
+
getPayloadInputFieldNames,
|
|
9
|
+
getPayloadRenderableBlocks,
|
|
10
|
+
intersectPayloadBlocksWithForm,
|
|
11
|
+
parsePayloadBlocksJson,
|
|
12
|
+
payloadHasInputBlocks,
|
|
13
|
+
} from '../submit/intersectPayloadBlocksWithForm'
|
|
7
14
|
import { copyTextToClipboard, submitFormJson } from '../submit/submitFormJson'
|
|
8
15
|
import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
|
|
9
16
|
import CurlSubmitBlock from './CurlSubmitBlock'
|
|
10
17
|
|
|
18
|
+
function formatSubmitErrorMessage(status: number, body: unknown): string {
|
|
19
|
+
if (body && typeof body === 'object' && body !== null && 'message' in body) {
|
|
20
|
+
const msg = (body as { message: unknown }).message
|
|
21
|
+
if (typeof msg === 'string' && msg.trim()) {
|
|
22
|
+
return `提交失败 (${status}): ${msg.trim()}`
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (typeof body === 'string' && body.trim()) {
|
|
26
|
+
return `提交失败 (${status}): ${body.trim()}`
|
|
27
|
+
}
|
|
28
|
+
return `提交失败 (${status})`
|
|
29
|
+
}
|
|
30
|
+
|
|
11
31
|
export interface AskUserFormCardProps {
|
|
12
32
|
payload: unknown
|
|
13
|
-
|
|
33
|
+
/** 可选;无则跳过拉取 vt_forms,仅用 payload blocks fallback */
|
|
34
|
+
formUniqueId?: string | null
|
|
14
35
|
callbackUrl?: string | null
|
|
15
36
|
submitMode?: SubmitMode
|
|
37
|
+
/** 需 useCallback 稳定引用,避免重复拉取 */
|
|
16
38
|
fetchFormDetail: (ref: { formUniqueId: string }) => Promise<FormDetailResult>
|
|
17
39
|
onNotify?: (type: 'success' | 'error', message: string) => void
|
|
18
40
|
}
|
|
@@ -28,22 +50,46 @@ export default function AskUserFormCard({
|
|
|
28
50
|
const [formDef, setFormDef] = useState<FormSchema | null>(null)
|
|
29
51
|
const [formDisabled, setFormDisabled] = useState(false)
|
|
30
52
|
const [loading, setLoading] = useState(true)
|
|
53
|
+
const [submitting, setSubmitting] = useState(false)
|
|
31
54
|
const rendererRef = useRef<SkoponFormRendererRef>(null)
|
|
55
|
+
const mountedRef = useRef(true)
|
|
56
|
+
const onNotifyRef = useRef(onNotify)
|
|
57
|
+
onNotifyRef.current = onNotify
|
|
58
|
+
|
|
59
|
+
const resolvedFormUniqueId = formUniqueId?.trim() ?? ''
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
mountedRef.current = true
|
|
63
|
+
return () => {
|
|
64
|
+
mountedRef.current = false
|
|
65
|
+
}
|
|
66
|
+
}, [])
|
|
32
67
|
|
|
33
68
|
useEffect(() => {
|
|
34
69
|
let cancelled = false
|
|
70
|
+
if (!resolvedFormUniqueId) {
|
|
71
|
+
setLoading(false)
|
|
72
|
+
setFormDisabled(false)
|
|
73
|
+
setFormDef(null)
|
|
74
|
+
return () => {
|
|
75
|
+
cancelled = true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
35
79
|
setLoading(true)
|
|
36
80
|
setFormDisabled(false)
|
|
37
|
-
fetchFormDetail({ formUniqueId })
|
|
81
|
+
fetchFormDetail({ formUniqueId: resolvedFormUniqueId })
|
|
38
82
|
.then((detail) => {
|
|
39
83
|
if (cancelled) return
|
|
40
84
|
setFormDisabled(Boolean(detail.disabled))
|
|
41
|
-
setFormDef(detail.
|
|
85
|
+
setFormDef(detail.formDefinition ?? null)
|
|
42
86
|
})
|
|
43
|
-
.catch(() => {
|
|
87
|
+
.catch((error) => {
|
|
44
88
|
if (!cancelled) {
|
|
45
89
|
setFormDisabled(false)
|
|
46
90
|
setFormDef(null)
|
|
91
|
+
const detail = error instanceof Error ? error.message : '获取表单定义失败'
|
|
92
|
+
onNotifyRef.current?.('error', detail)
|
|
47
93
|
}
|
|
48
94
|
})
|
|
49
95
|
.finally(() => {
|
|
@@ -52,25 +98,49 @@ export default function AskUserFormCard({
|
|
|
52
98
|
return () => {
|
|
53
99
|
cancelled = true
|
|
54
100
|
}
|
|
55
|
-
}, [
|
|
101
|
+
}, [resolvedFormUniqueId, fetchFormDetail])
|
|
56
102
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
103
|
+
const payloadDef = useMemo(() => parsePayloadBlocksJson(payload), [payload])
|
|
104
|
+
|
|
105
|
+
const intersection = useMemo(() => {
|
|
106
|
+
if (!payloadDef) return null
|
|
107
|
+
return intersectPayloadBlocksWithForm(payloadDef, formDef ?? undefined)
|
|
108
|
+
}, [payloadDef, formDef])
|
|
109
|
+
|
|
110
|
+
const matchedBlocks = intersection?.matchedBlocks ?? []
|
|
111
|
+
const usePayloadFallback =
|
|
112
|
+
Boolean(payloadDef && payloadHasInputBlocks(payloadDef) && matchedBlocks.length === 0)
|
|
61
113
|
|
|
62
|
-
const
|
|
63
|
-
()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
[
|
|
114
|
+
const renderBlocks = useMemo(() => {
|
|
115
|
+
if (intersection && intersection.renderBlocks.length > 0) {
|
|
116
|
+
return intersection.renderBlocks
|
|
117
|
+
}
|
|
118
|
+
if (usePayloadFallback && payloadDef) return getPayloadRenderableBlocks(payloadDef)
|
|
119
|
+
return []
|
|
120
|
+
}, [intersection, usePayloadFallback, payloadDef])
|
|
121
|
+
|
|
122
|
+
const extraValues = useMemo(
|
|
123
|
+
() => (usePayloadFallback ? {} : extractExtraBlockValues(intersection?.extraBlocks ?? [])),
|
|
124
|
+
[usePayloadFallback, intersection],
|
|
68
125
|
)
|
|
69
126
|
|
|
127
|
+
const fieldNames = useMemo(() => {
|
|
128
|
+
if (usePayloadFallback && payloadDef) return getPayloadInputFieldNames(payloadDef)
|
|
129
|
+
return renderBlocks
|
|
130
|
+
.filter((block) => block.name?.trim())
|
|
131
|
+
.map((block) => block.name!.trim())
|
|
132
|
+
}, [usePayloadFallback, payloadDef, renderBlocks])
|
|
133
|
+
|
|
70
134
|
const surfaceDoc = useMemo(() => {
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
135
|
+
if (renderBlocks.length === 0) return null
|
|
136
|
+
const title = intersection?.title ?? payloadDef?.title ?? ''
|
|
137
|
+
const description = intersection?.description ?? payloadDef?.description ?? ''
|
|
138
|
+
return buildAskUserSurface({ title, description }, renderBlocks)
|
|
139
|
+
}, [intersection, payloadDef, renderBlocks])
|
|
140
|
+
|
|
141
|
+
const surfaceId = resolvedFormUniqueId
|
|
142
|
+
? `ask-user-${resolvedFormUniqueId}`
|
|
143
|
+
: 'ask-user-payload'
|
|
74
144
|
|
|
75
145
|
if (loading) {
|
|
76
146
|
return (
|
|
@@ -80,76 +150,84 @@ export default function AskUserFormCard({
|
|
|
80
150
|
)
|
|
81
151
|
}
|
|
82
152
|
|
|
83
|
-
if (
|
|
153
|
+
if (!payloadDef || renderBlocks.length === 0) {
|
|
84
154
|
return (
|
|
85
155
|
<CurlSubmitBlock
|
|
86
156
|
payload={payload}
|
|
87
157
|
callbackUrl={callbackUrl}
|
|
88
|
-
unpublishedFormId={formDisabled ?
|
|
158
|
+
unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
|
|
89
159
|
onNotify={onNotify}
|
|
90
160
|
/>
|
|
91
161
|
)
|
|
92
162
|
}
|
|
93
163
|
|
|
94
|
-
const hasRemainder = Object.keys(remainderPayload).length > 0
|
|
95
|
-
|
|
96
164
|
async function handleSubmit() {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
165
|
+
if (submitting) return
|
|
166
|
+
setSubmitting(true)
|
|
167
|
+
try {
|
|
168
|
+
const cardValues = rendererRef.current?.getValues(fieldNames) ?? {}
|
|
169
|
+
const submitBody = { ...cardValues, ...extraValues }
|
|
170
|
+
|
|
171
|
+
if (submitMode === 'post') {
|
|
172
|
+
const url = (callbackUrl ?? '').trim()
|
|
173
|
+
if (!url) {
|
|
174
|
+
onNotifyRef.current?.('error', 'callback_url 为空,无法提交')
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
const result = await submitFormJson(url, submitBody)
|
|
178
|
+
if (!mountedRef.current) return
|
|
106
179
|
if (result.ok) {
|
|
107
|
-
|
|
180
|
+
onNotifyRef.current?.('success', '提交成功')
|
|
108
181
|
} else {
|
|
109
|
-
|
|
182
|
+
onNotifyRef.current?.('error', formatSubmitErrorMessage(result.status, result.body))
|
|
110
183
|
}
|
|
111
|
-
|
|
112
|
-
const detail = error instanceof Error ? error.message : '提交失败'
|
|
113
|
-
onNotify?.('error', detail)
|
|
184
|
+
return
|
|
114
185
|
}
|
|
115
|
-
return
|
|
116
|
-
}
|
|
117
186
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
187
|
+
await copyTextToClipboard(
|
|
188
|
+
buildAskUserCurlStatement({ cardValues, extraValues, callbackUrl }),
|
|
189
|
+
)
|
|
190
|
+
if (!mountedRef.current) return
|
|
191
|
+
onNotifyRef.current?.('success', '已复制 curl 到剪贴板')
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (!mountedRef.current) return
|
|
194
|
+
const detail = error instanceof Error ? error.message : '复制失败'
|
|
195
|
+
onNotifyRef.current?.('error', detail)
|
|
196
|
+
} finally {
|
|
197
|
+
if (mountedRef.current) setSubmitting(false)
|
|
123
198
|
}
|
|
124
199
|
}
|
|
125
200
|
|
|
126
201
|
return (
|
|
127
202
|
<div className="ask-user-form-card">
|
|
203
|
+
{formDisabled && resolvedFormUniqueId ? (
|
|
204
|
+
<div className="ask-user-form-card-status">
|
|
205
|
+
<Tooltip title={resolvedFormUniqueId}>
|
|
206
|
+
<Tag className="ask-user-curl-unpublished-tag">卡片 ID 未发布</Tag>
|
|
207
|
+
</Tooltip>
|
|
208
|
+
</div>
|
|
209
|
+
) : null}
|
|
128
210
|
<SkoponFormRenderer
|
|
129
211
|
ref={rendererRef}
|
|
130
212
|
doc={surfaceDoc}
|
|
131
|
-
surfaceId={
|
|
213
|
+
surfaceId={surfaceId}
|
|
132
214
|
fieldNames={fieldNames}
|
|
133
215
|
interactive
|
|
134
216
|
/>
|
|
135
217
|
|
|
136
218
|
<div className="ask-user-form-actions">
|
|
137
219
|
<Tooltip title={submitMode === 'curl' ? '点击复制 curl' : '提交 JSON 到 callback_url'}>
|
|
138
|
-
<Button
|
|
220
|
+
<Button
|
|
221
|
+
type="primary"
|
|
222
|
+
size="small"
|
|
223
|
+
loading={submitting}
|
|
224
|
+
disabled={submitting}
|
|
225
|
+
onClick={() => void handleSubmit()}
|
|
226
|
+
>
|
|
139
227
|
提交
|
|
140
228
|
</Button>
|
|
141
229
|
</Tooltip>
|
|
142
230
|
</div>
|
|
143
|
-
|
|
144
|
-
{hasRemainder ? (
|
|
145
|
-
<CurlSubmitBlock
|
|
146
|
-
payload={remainderPayload}
|
|
147
|
-
callbackUrl={callbackUrl}
|
|
148
|
-
title="以下字段不在表单中,请使用 curl 提交"
|
|
149
|
-
incompleteFormId={formUniqueId}
|
|
150
|
-
onNotify={onNotify}
|
|
151
|
-
/>
|
|
152
|
-
) : null}
|
|
153
231
|
</div>
|
|
154
232
|
)
|
|
155
233
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { MessageProcessor, type A2uiMessage } from '@a2ui/web_core/v0_9'
|
|
6
|
+
import type { ReactComponentImplementation } from '@a2ui/react/v0_9'
|
|
7
|
+
import { blocksToA2ui, surfaceDocToMessages } from '../adapter/a2uiAdapter'
|
|
8
|
+
import { buildSkoponCatalog, SKOPON_CATALOG_ID } from '../catalog/a2uiCustomCatalog'
|
|
9
|
+
|
|
10
|
+
function createProcessor(): MessageProcessor<ReactComponentImplementation> {
|
|
11
|
+
return new MessageProcessor([buildSkoponCatalog()])
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildStreamMessages(surfaceId: string): A2uiMessage[] {
|
|
15
|
+
const doc = blocksToA2ui(
|
|
16
|
+
{
|
|
17
|
+
title: '',
|
|
18
|
+
description: '',
|
|
19
|
+
blocks: [{ id: 'b-name', type: 'text', name: 'name', label: '姓名' }],
|
|
20
|
+
},
|
|
21
|
+
{ includeHeader: false },
|
|
22
|
+
)
|
|
23
|
+
return surfaceDocToMessages(doc, { surfaceId, catalogId: SKOPON_CATALOG_ID }) as unknown as A2uiMessage[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 与 SkoponA2uiStreamRenderer 在 messages 清空时的 reset 行为一致。 */
|
|
27
|
+
function resetStreamProcessor(): MessageProcessor<ReactComponentImplementation> {
|
|
28
|
+
return createProcessor()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('SkoponA2uiStreamRenderer stream lifecycle', () => {
|
|
32
|
+
it('processes incremental messages into surfaces', () => {
|
|
33
|
+
const surfaceId = 'stream-test'
|
|
34
|
+
const messages = buildStreamMessages(surfaceId)
|
|
35
|
+
const processor = createProcessor()
|
|
36
|
+
processor.processMessages(messages)
|
|
37
|
+
expect(processor.model.surfacesMap.size).toBeGreaterThan(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('clears surfaces when stream messages are reset to empty', () => {
|
|
41
|
+
const surfaceId = 'stream-clear'
|
|
42
|
+
const messages = buildStreamMessages(surfaceId)
|
|
43
|
+
let processor = createProcessor()
|
|
44
|
+
processor.processMessages(messages)
|
|
45
|
+
expect(processor.model.surfacesMap.size).toBeGreaterThan(0)
|
|
46
|
+
|
|
47
|
+
processor = resetStreamProcessor()
|
|
48
|
+
expect(processor.model.surfacesMap.size).toBe(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('rebuilds processor when message count shrinks', () => {
|
|
52
|
+
const surfaceId = 'stream-shrink'
|
|
53
|
+
const messages = buildStreamMessages(surfaceId)
|
|
54
|
+
const processor = createProcessor()
|
|
55
|
+
processor.processMessages(messages)
|
|
56
|
+
const countAfterFull = processor.model.surfacesMap.size
|
|
57
|
+
|
|
58
|
+
const rebuilt = createProcessor()
|
|
59
|
+
rebuilt.processMessages(messages.slice(0, 1))
|
|
60
|
+
expect(rebuilt.model.surfacesMap.size).toBeLessThanOrEqual(countAfterFull)
|
|
61
|
+
})
|
|
62
|
+
})
|