@skopon-cool/form-sdk 0.2.4 → 0.3.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.
Files changed (46) hide show
  1. package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
  2. package/dist/catalog/skoponCaseSelect.d.ts.map +1 -1
  3. package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
  4. package/dist/catalog/useSkoponBoundField.d.ts +16 -1
  5. package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
  6. package/dist/client/authStorage.d.ts +14 -1
  7. package/dist/client/authStorage.d.ts.map +1 -1
  8. package/dist/components/AskUserFormCard.d.ts +8 -1
  9. package/dist/components/AskUserFormCard.d.ts.map +1 -1
  10. package/dist/components/CurlSubmitBlock.d.ts +3 -1
  11. package/dist/components/CurlSubmitBlock.d.ts.map +1 -1
  12. package/dist/components/SkoponFormRenderer.d.ts +1 -0
  13. package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1579 -1107
  17. package/dist/runtime/formAutoControl.d.ts +15 -0
  18. package/dist/runtime/formAutoControl.d.ts.map +1 -0
  19. package/dist/runtime/formRuntimeContext.d.ts +17 -0
  20. package/dist/runtime/formRuntimeContext.d.ts.map +1 -0
  21. package/dist/runtime/formRuntimeWs.d.ts +27 -0
  22. package/dist/runtime/formRuntimeWs.d.ts.map +1 -0
  23. package/dist/runtime/types.d.ts +20 -0
  24. package/dist/runtime/types.d.ts.map +1 -0
  25. package/dist/submit/buildCurlStatement.d.ts +4 -3
  26. package/dist/submit/buildCurlStatement.d.ts.map +1 -1
  27. package/dist/submit/submitAnswerAgent.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/catalog/a2uiCustomCatalog.tsx +41 -16
  30. package/src/catalog/skoponCaseSelect.tsx +10 -4
  31. package/src/catalog/skoponResumeSelect.tsx +10 -4
  32. package/src/catalog/useSkoponBoundField.ts +100 -4
  33. package/src/client/authStorage.test.ts +74 -1
  34. package/src/client/authStorage.ts +45 -4
  35. package/src/components/AskUserFormCard.tsx +178 -31
  36. package/src/components/CurlSubmitBlock.tsx +14 -3
  37. package/src/components/SkoponFormRenderer.tsx +12 -0
  38. package/src/index.ts +3 -0
  39. package/src/runtime/formAutoControl.test.ts +71 -0
  40. package/src/runtime/formAutoControl.ts +109 -0
  41. package/src/runtime/formRuntimeContext.tsx +107 -0
  42. package/src/runtime/formRuntimeWs.ts +158 -0
  43. package/src/runtime/types.ts +26 -0
  44. package/src/submit/buildCurlStatement.ts +20 -3
  45. package/src/submit/submit.test.ts +81 -0
  46. package/src/submit/submitAnswerAgent.ts +22 -13
@@ -0,0 +1,107 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useRef,
7
+ type ReactNode,
8
+ } from 'react'
9
+ import type { FormRuntimeWsClient } from './formRuntimeWs'
10
+ import type { FormRuntimeEventData } from './types'
11
+
12
+ export interface FormRuntimeContextValue {
13
+ reportEvent: (event: FormRuntimeEventData) => void
14
+ reportData: (values: Record<string, unknown>) => void
15
+ runAutoControl: <T>(fn: () => T) => T
16
+ isAutoControl: () => boolean
17
+ }
18
+
19
+ const FormRuntimeContext = createContext<FormRuntimeContextValue | null>(null)
20
+
21
+ const noopRuntime: FormRuntimeContextValue = {
22
+ reportEvent: () => {},
23
+ reportData: () => {},
24
+ runAutoControl: (fn) => fn(),
25
+ isAutoControl: () => false,
26
+ }
27
+
28
+ function omitUndefinedValues(
29
+ values: Record<string, unknown>,
30
+ ): Record<string, unknown> {
31
+ const out: Record<string, unknown> = {}
32
+ for (const [key, value] of Object.entries(values)) {
33
+ if (value !== undefined) out[key] = value
34
+ }
35
+ return out
36
+ }
37
+
38
+ export interface FormRuntimeProviderProps {
39
+ client: FormRuntimeWsClient | null
40
+ children: ReactNode
41
+ }
42
+
43
+ export function FormRuntimeProvider({
44
+ client,
45
+ children,
46
+ }: FormRuntimeProviderProps) {
47
+ const autoControlDepthRef = useRef(0)
48
+ const clientRef = useRef(client)
49
+ clientRef.current = client
50
+
51
+ const reportEvent = useCallback((event: FormRuntimeEventData) => {
52
+ const ws = clientRef.current
53
+ if (!ws) return
54
+ const data: Record<string, unknown> = {
55
+ action: event.action,
56
+ component: event.component,
57
+ }
58
+ if (event.field !== undefined) data.field = event.field
59
+ if (event.value !== undefined) data.value = event.value
60
+ const type =
61
+ autoControlDepthRef.current > 0
62
+ ? 'form_auto_control_bac_event'
63
+ : 'form_runtime_event'
64
+ ws.send(type, data)
65
+ }, [])
66
+
67
+ const reportData = useCallback((values: Record<string, unknown>) => {
68
+ const ws = clientRef.current
69
+ if (!ws) return
70
+ ws.send('form_runtime_data', omitUndefinedValues(values))
71
+ }, [])
72
+
73
+ const runAutoControl = useCallback(<T,>(fn: () => T): T => {
74
+ autoControlDepthRef.current += 1
75
+ try {
76
+ return fn()
77
+ } finally {
78
+ autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
79
+ }
80
+ }, [])
81
+
82
+ const isAutoControl = useCallback(
83
+ () => autoControlDepthRef.current > 0,
84
+ [],
85
+ )
86
+
87
+ const value = useMemo(
88
+ (): FormRuntimeContextValue => ({
89
+ reportEvent,
90
+ reportData,
91
+ runAutoControl,
92
+ isAutoControl,
93
+ }),
94
+ [reportEvent, reportData, runAutoControl, isAutoControl],
95
+ )
96
+
97
+ return (
98
+ <FormRuntimeContext.Provider value={value}>
99
+ {children}
100
+ </FormRuntimeContext.Provider>
101
+ )
102
+ }
103
+
104
+ /** 无 Provider 时 no-op(表单管理端预览不受影响)。 */
105
+ export function useFormRuntime(): FormRuntimeContextValue {
106
+ return useContext(FormRuntimeContext) ?? noopRuntime
107
+ }
@@ -0,0 +1,158 @@
1
+ import type {
2
+ FormAutoControlEventData,
3
+ FormRuntimeEnvelope,
4
+ FormRuntimeOutboundType,
5
+ } from './types'
6
+
7
+ const SEND_QUEUE_MAX = 100
8
+ const RECONNECT_MAX_MS = 30_000
9
+
10
+ export type FormAutoControlHandler = (
11
+ data: FormAutoControlEventData,
12
+ envelope: FormRuntimeEnvelope<FormAutoControlEventData>,
13
+ ) => void
14
+
15
+ export interface FormRuntimeWsClientOptions {
16
+ url: string
17
+ askUserMessageId: string
18
+ onAutoControl?: FormAutoControlHandler
19
+ }
20
+
21
+ export class FormRuntimeWsClient {
22
+ private readonly url: string
23
+ private readonly askUserMessageId: string
24
+ private readonly onAutoControl?: FormAutoControlHandler
25
+ private ws: WebSocket | null = null
26
+ private closed = false
27
+ private reconnectAttempt = 0
28
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
29
+ private sendQueue: string[] = []
30
+
31
+ constructor(options: FormRuntimeWsClientOptions) {
32
+ this.url = options.url
33
+ this.askUserMessageId = options.askUserMessageId
34
+ this.onAutoControl = options.onAutoControl
35
+ }
36
+
37
+ connect(): void {
38
+ if (this.closed || typeof WebSocket === 'undefined') return
39
+ this.clearReconnectTimer()
40
+ try {
41
+ const ws = new WebSocket(this.url)
42
+ this.ws = ws
43
+ ws.onopen = () => {
44
+ this.reconnectAttempt = 0
45
+ this.flushQueue()
46
+ }
47
+ ws.onmessage = (event) => {
48
+ this.handleMessage(event.data)
49
+ }
50
+ ws.onclose = () => {
51
+ this.ws = null
52
+ this.scheduleReconnect()
53
+ }
54
+ ws.onerror = () => {
55
+ // onclose will follow; avoid noisy logs
56
+ }
57
+ } catch (error) {
58
+ console.warn('[FormRuntimeWs] connect failed', error)
59
+ this.scheduleReconnect()
60
+ }
61
+ }
62
+
63
+ send(
64
+ type: FormRuntimeOutboundType,
65
+ data: Record<string, unknown>,
66
+ ): void {
67
+ if (this.closed) return
68
+ const payload: FormRuntimeEnvelope = {
69
+ type,
70
+ ask_user_message_id: this.askUserMessageId,
71
+ data,
72
+ }
73
+ let serialized: string
74
+ try {
75
+ serialized = JSON.stringify(payload)
76
+ } catch (error) {
77
+ console.warn('[FormRuntimeWs] serialize failed', error)
78
+ return
79
+ }
80
+ if (this.ws?.readyState === WebSocket.OPEN) {
81
+ this.ws.send(serialized)
82
+ return
83
+ }
84
+ this.enqueue(serialized)
85
+ }
86
+
87
+ close(): void {
88
+ this.closed = true
89
+ this.clearReconnectTimer()
90
+ this.sendQueue = []
91
+ if (this.ws) {
92
+ this.ws.onopen = null
93
+ this.ws.onmessage = null
94
+ this.ws.onclose = null
95
+ this.ws.onerror = null
96
+ try {
97
+ this.ws.close()
98
+ } catch {
99
+ // ignore
100
+ }
101
+ this.ws = null
102
+ }
103
+ }
104
+
105
+ private enqueue(serialized: string): void {
106
+ this.sendQueue.push(serialized)
107
+ while (this.sendQueue.length > SEND_QUEUE_MAX) {
108
+ this.sendQueue.shift()
109
+ }
110
+ }
111
+
112
+ private flushQueue(): void {
113
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
114
+ const pending = this.sendQueue
115
+ this.sendQueue = []
116
+ for (const item of pending) {
117
+ this.ws.send(item)
118
+ }
119
+ }
120
+
121
+ private scheduleReconnect(): void {
122
+ if (this.closed) return
123
+ this.clearReconnectTimer()
124
+ const delay = Math.min(
125
+ RECONNECT_MAX_MS,
126
+ 1000 * 2 ** Math.min(this.reconnectAttempt, 5),
127
+ )
128
+ this.reconnectAttempt += 1
129
+ this.reconnectTimer = setTimeout(() => {
130
+ this.reconnectTimer = null
131
+ this.connect()
132
+ }, delay)
133
+ }
134
+
135
+ private clearReconnectTimer(): void {
136
+ if (this.reconnectTimer != null) {
137
+ clearTimeout(this.reconnectTimer)
138
+ this.reconnectTimer = null
139
+ }
140
+ }
141
+
142
+ private handleMessage(raw: unknown): void {
143
+ if (typeof raw !== 'string') return
144
+ let parsed: unknown
145
+ try {
146
+ parsed = JSON.parse(raw)
147
+ } catch {
148
+ return
149
+ }
150
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return
151
+ const envelope = parsed as FormRuntimeEnvelope<FormAutoControlEventData>
152
+ if (envelope.type !== 'form_auto_control_event') return
153
+ if (String(envelope.ask_user_message_id ?? '') !== this.askUserMessageId) return
154
+ const data = envelope.data
155
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return
156
+ this.onAutoControl?.(data, envelope)
157
+ }
158
+ }
@@ -0,0 +1,26 @@
1
+ export type FormRuntimeAction = 'focus' | 'change' | 'select' | 'click' | 'submit'
2
+
3
+ export interface FormRuntimeEventData {
4
+ action: FormRuntimeAction
5
+ component: string
6
+ field?: string
7
+ value?: unknown
8
+ }
9
+
10
+ export interface FormAutoControlEventData {
11
+ action: FormRuntimeAction
12
+ field?: string
13
+ value?: unknown
14
+ component?: string
15
+ }
16
+
17
+ export type FormRuntimeOutboundType =
18
+ | 'form_runtime_data'
19
+ | 'form_runtime_event'
20
+ | 'form_auto_control_bac_event'
21
+
22
+ export interface FormRuntimeEnvelope<T = Record<string, unknown>> {
23
+ type: string
24
+ ask_user_message_id: string
25
+ data: T
26
+ }
@@ -7,12 +7,25 @@ function escapeSingleQuotes(text: string): string {
7
7
  return text.replace(/'/g, `'\\''`)
8
8
  }
9
9
 
10
- export function buildCurlStatement(payload: unknown, callbackUrl?: string | null): string {
10
+ function resolveAuthorizationCurlHeader(headers?: Record<string, string>): string {
11
+ const authorization = headers?.Authorization?.trim()
12
+ if (authorization) {
13
+ return ` -H 'Authorization: ${escapeSingleQuotes(authorization)}' \\`
14
+ }
15
+ return ` -H 'Authorization: Bearer <token>' \\`
16
+ }
17
+
18
+ export function buildCurlStatement(
19
+ payload: unknown,
20
+ callbackUrl?: string | null,
21
+ headers?: Record<string, string>,
22
+ ): string {
11
23
  const url = (callbackUrl ?? '').trim() || '<callback_url>'
12
24
  const body = JSON.stringify(payload ?? {}, null, 2)
13
25
  return [
14
26
  `curl -X POST '${url}' \\`,
15
27
  ` -H 'Content-Type: application/json' \\`,
28
+ resolveAuthorizationCurlHeader(headers),
16
29
  ` -d '${escapeSingleQuotes(body)}'`,
17
30
  ].join('\n')
18
31
  }
@@ -29,18 +42,21 @@ export interface BuildAskUserCurlStatementOptions {
29
42
  cardValues: Record<string, unknown>
30
43
  extraValues?: Record<string, unknown>
31
44
  callbackUrl?: string | null
45
+ headers?: Record<string, string>
32
46
  }
33
47
 
34
48
  export function buildAskUserCurlStatement({
35
49
  cardValues,
36
50
  extraValues = {},
37
51
  callbackUrl,
52
+ headers,
38
53
  }: BuildAskUserCurlStatementOptions): string {
39
54
  const url = (callbackUrl ?? '').trim() || '<callback_url>'
40
55
  const body = buildAskUserCurlBodyJson(cardValues, extraValues)
41
56
  return [
42
57
  `curl -X POST '${url}' \\`,
43
58
  ` -H 'Content-Type: application/json' \\`,
59
+ resolveAuthorizationCurlHeader(headers),
44
60
  ` -d '${escapeSingleQuotes(body)}'`,
45
61
  ].join('\n')
46
62
  }
@@ -49,9 +65,10 @@ export function buildAskUserCurlStatement({
49
65
  export function buildAskUserSubmitCurlFromPayload(
50
66
  payload: unknown,
51
67
  callbackUrl?: string | null,
68
+ headers?: Record<string, string>,
52
69
  ): string {
53
70
  const def = parsePayloadBlocksJson(payload)
54
- if (!def) return buildCurlStatement(payload, callbackUrl)
71
+ if (!def) return buildCurlStatement(payload, callbackUrl, headers)
55
72
  const cardValues = extractExtraBlockValues(def.blocks)
56
- return buildAskUserCurlStatement({ cardValues, callbackUrl })
73
+ return buildAskUserCurlStatement({ cardValues, callbackUrl, headers })
57
74
  }
@@ -30,6 +30,16 @@ describe('buildCurlStatement', () => {
30
30
  const curl = buildCurlStatement({ name: "O'Brien" }, 'https://example.com/hook')
31
31
  expect(curl).toContain("curl -X POST 'https://example.com/hook'")
32
32
  expect(curl).toContain("O'\\''Brien")
33
+ expect(curl).toContain("Authorization: Bearer <token>")
34
+ })
35
+
36
+ it('includes Authorization header when provided', () => {
37
+ const curl = buildCurlStatement(
38
+ { a: 1 },
39
+ 'https://example.com/hook',
40
+ { Authorization: 'Bearer tok-1' },
41
+ )
42
+ expect(curl).toContain("Authorization: Bearer tok-1")
33
43
  })
34
44
 
35
45
  it('uses placeholder when callback url missing', () => {
@@ -90,6 +100,21 @@ describe('buildAskUserSubmitCurlFromPayload', () => {
90
100
  buildCurlStatement(payload, callbackUrl),
91
101
  )
92
102
  })
103
+
104
+ it('includes Authorization header in submit-fields curl', () => {
105
+ const payload = {
106
+ title: 'Ask',
107
+ blocks: [
108
+ { id: 'p1', type: 'text', name: 'video_title', label: '标题', defaultValue: 'hello' },
109
+ ],
110
+ }
111
+ const curl = buildAskUserSubmitCurlFromPayload(
112
+ payload,
113
+ 'https://example.com/hook',
114
+ { Authorization: 'Bearer tok-1' },
115
+ )
116
+ expect(curl).toContain("Authorization: Bearer tok-1")
117
+ })
93
118
  })
94
119
 
95
120
  describe('parsePayloadBlocksJson', () => {
@@ -409,6 +434,35 @@ describe('submitAnswerAgent', () => {
409
434
  'callback_url 为空,无法提交',
410
435
  )
411
436
  })
437
+
438
+ it('throws when explicit answer agent callback url is empty', async () => {
439
+ await expect(
440
+ submitAnswerAgent(
441
+ {
442
+ mission_id: 1,
443
+ tenant_id: 1001,
444
+ tenant_user_id: 'u1',
445
+ request_id: 'req-1',
446
+ callback_url: 'https://example.com/cb',
447
+ answer: {},
448
+ },
449
+ { answerAgentCallbackUrl: ' ' },
450
+ ),
451
+ ).rejects.toThrow('answer_agent_callback_url 未配置,无法提交')
452
+ })
453
+
454
+ it('throws when answer agent callback url cannot be resolved', async () => {
455
+ await expect(
456
+ submitAnswerAgent({
457
+ mission_id: 1,
458
+ tenant_id: 1001,
459
+ tenant_user_id: 'u1',
460
+ request_id: 'req-1',
461
+ callback_url: 'https://example.com/cb',
462
+ answer: {},
463
+ }),
464
+ ).rejects.toThrow('answer_agent_callback_url 未配置,无法提交')
465
+ })
412
466
  })
413
467
 
414
468
  describe('submitFormJson', () => {
@@ -434,6 +488,33 @@ describe('submitFormJson', () => {
434
488
  }),
435
489
  )
436
490
  })
491
+
492
+ it('POSTs JSON payload with Authorization header', async () => {
493
+ const fetchMock = vi.fn(async () => ({
494
+ ok: true,
495
+ status: 200,
496
+ headers: { get: () => 'application/json' },
497
+ json: async () => ({ ok: true }),
498
+ })) as unknown as typeof fetch
499
+
500
+ await submitFormJson(
501
+ 'https://example.com/hook',
502
+ { a: 1 },
503
+ {
504
+ fetch: fetchMock,
505
+ headers: { Authorization: 'Bearer tok-1' },
506
+ },
507
+ )
508
+ expect(fetchMock).toHaveBeenCalledWith(
509
+ 'https://example.com/hook',
510
+ expect.objectContaining({
511
+ headers: expect.objectContaining({
512
+ Authorization: 'Bearer tok-1',
513
+ 'Content-Type': 'application/json',
514
+ }),
515
+ }),
516
+ )
517
+ })
437
518
  })
438
519
 
439
520
  describe('validateFormSubmitValues', () => {
@@ -1,7 +1,6 @@
1
1
  import {
2
- DEFAULT_ANSWER_AGENT_CALLBACK_URL,
3
2
  resolveAnswerAgentCallbackUrl,
4
- resolveSkoponAuthHeaders,
3
+ resolveSubmitAuthHeaders,
5
4
  type SkoponAuthStorageOptions,
6
5
  } from '../client/authStorage'
7
6
  import { validateAnswerAgentBody } from './buildAnswerAgentBody'
@@ -17,6 +16,25 @@ export interface SubmitAnswerAgentOptions extends SubmitFormJsonOptions {
17
16
  answerAgentCallbackUrl?: string | null
18
17
  }
19
18
 
19
+ const ANSWER_AGENT_CALLBACK_URL_MISSING_ERROR =
20
+ 'answer_agent_callback_url 未配置,无法提交'
21
+
22
+ function resolveSubmitAnswerAgentUrl(options?: SubmitAnswerAgentOptions): string {
23
+ if (options?.answerAgentCallbackUrl != null) {
24
+ const explicit = options.answerAgentCallbackUrl.trim()
25
+ if (!explicit) {
26
+ throw new Error(ANSWER_AGENT_CALLBACK_URL_MISSING_ERROR)
27
+ }
28
+ return explicit
29
+ }
30
+
31
+ const resolved = resolveAnswerAgentCallbackUrl(options?.authStorage).trim()
32
+ if (!resolved) {
33
+ throw new Error(ANSWER_AGENT_CALLBACK_URL_MISSING_ERROR)
34
+ }
35
+ return resolved
36
+ }
37
+
20
38
  interface AnswerAgentResponseData {
21
39
  callback_ok?: boolean
22
40
  callback_status?: number
@@ -79,20 +97,11 @@ export async function submitAnswerAgent(
79
97
  throw new Error(validationError)
80
98
  }
81
99
 
82
- const url = (
83
- options?.answerAgentCallbackUrl?.trim() ||
84
- resolveAnswerAgentCallbackUrl(options?.authStorage) ||
85
- DEFAULT_ANSWER_AGENT_CALLBACK_URL
86
- ).trim()
87
-
88
- const storageHeaders = resolveSkoponAuthHeaders(options?.authStorage)
100
+ const url = resolveSubmitAnswerAgentUrl(options)
89
101
 
90
102
  const result = await submitFormJson(url, body, {
91
103
  ...options,
92
- headers: {
93
- ...storageHeaders,
94
- ...(options?.headers ?? {}),
95
- },
104
+ headers: resolveSubmitAuthHeaders(options?.headers, options?.authStorage),
96
105
  })
97
106
 
98
107
  const data = extractAnswerAgentData(result.body)