@skopon-cool/form-sdk 0.2.5 → 0.3.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/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/catalog/skoponCaseSelect.d.ts.map +1 -1
- package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
- package/dist/catalog/useSkoponBoundField.d.ts +16 -1
- package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
- package/dist/client/authStorage.d.ts +8 -0
- package/dist/client/authStorage.d.ts.map +1 -1
- package/dist/components/AskUserFormCard.d.ts +6 -1
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/SkoponFormRenderer.d.ts +1 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1520 -1081
- package/dist/runtime/formAutoControl.d.ts +16 -0
- package/dist/runtime/formAutoControl.d.ts.map +1 -0
- package/dist/runtime/formRuntimeContext.d.ts +21 -0
- package/dist/runtime/formRuntimeContext.d.ts.map +1 -0
- package/dist/runtime/formRuntimeWs.d.ts +27 -0
- package/dist/runtime/formRuntimeWs.d.ts.map +1 -0
- package/dist/runtime/types.d.ts +20 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/catalog/a2uiCustomCatalog.tsx +41 -16
- package/src/catalog/skoponCaseSelect.tsx +10 -4
- package/src/catalog/skoponResumeSelect.tsx +10 -4
- package/src/catalog/useSkoponBoundField.ts +100 -4
- package/src/client/authStorage.test.ts +34 -0
- package/src/client/authStorage.ts +27 -0
- package/src/components/AskUserFormCard.tsx +164 -31
- package/src/components/SkoponFormRenderer.tsx +12 -0
- package/src/index.ts +2 -0
- package/src/runtime/formAutoControl.test.ts +127 -0
- package/src/runtime/formAutoControl.ts +110 -0
- package/src/runtime/formRuntimeContext.tsx +129 -0
- package/src/runtime/formRuntimeWs.ts +158 -0
- package/src/runtime/types.ts +26 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { Button, Spin, Tag, Tooltip } from 'antd'
|
|
3
3
|
import type { FormDetailResult, FormSchema, SubmitMode } from '../types/index'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
resolveRealtimeWsUrl,
|
|
6
|
+
resolveSubmitAuthHeaders,
|
|
7
|
+
} from '../client/authStorage'
|
|
5
8
|
import { buildAskUserSurface } from '../adapter/resolveSurface'
|
|
6
9
|
import {
|
|
7
10
|
buildAnswerAgentBody,
|
|
@@ -23,6 +26,10 @@ import {
|
|
|
23
26
|
import { validateFormSubmitValues } from '../submit/validateFormSubmit'
|
|
24
27
|
import type { CaseSearchFn } from '../catalog/caseSearchContext'
|
|
25
28
|
import type { ResumeSearchFn } from '../catalog/resumeSearchContext'
|
|
29
|
+
import { FormRuntimeProvider, useFormRuntime } from '../runtime/formRuntimeContext'
|
|
30
|
+
import { FormRuntimeWsClient } from '../runtime/formRuntimeWs'
|
|
31
|
+
import { executeFormAutoControl } from '../runtime/formAutoControl'
|
|
32
|
+
import type { FormAutoControlEventData } from '../runtime/types'
|
|
26
33
|
import SkoponFormRenderer, { type SkoponFormRendererRef } from './SkoponFormRenderer'
|
|
27
34
|
import CurlSubmitBlock from './CurlSubmitBlock'
|
|
28
35
|
import SkoponFormProvider from './SkoponFormProvider'
|
|
@@ -44,6 +51,11 @@ export interface AskUserFormCardProps {
|
|
|
44
51
|
payload: unknown
|
|
45
52
|
/** 可选;无则跳过拉取 vt_forms,仅用 payload blocks fallback */
|
|
46
53
|
formUniqueId?: string | null
|
|
54
|
+
/**
|
|
55
|
+
* ask_source_unique_id(Flow/QA 业务 id)。
|
|
56
|
+
* 有值且 sessionStorage.auth_user.realtime_ws_url 存在、且渲染出表单控件时按卡建连。
|
|
57
|
+
*/
|
|
58
|
+
askUserMessageId?: string | null
|
|
47
59
|
callbackUrl?: string | null
|
|
48
60
|
submitMode?: SubmitMode
|
|
49
61
|
answerAgentContext?: AskUserAnswerAgentContext
|
|
@@ -58,9 +70,15 @@ export interface AskUserFormCardProps {
|
|
|
58
70
|
caseSearch?: CaseSearchFn | null
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
|
|
73
|
+
interface AskUserFormCardBodyProps extends AskUserFormCardProps {
|
|
74
|
+
onSurfaceReadyChange: (ready: boolean) => void
|
|
75
|
+
onAutoControlReady: (handler: ((data: FormAutoControlEventData) => void) | null) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function AskUserFormCardBody({
|
|
62
79
|
payload,
|
|
63
80
|
formUniqueId,
|
|
81
|
+
askUserMessageId,
|
|
64
82
|
callbackUrl,
|
|
65
83
|
submitMode = 'curl',
|
|
66
84
|
answerAgentContext,
|
|
@@ -69,17 +87,22 @@ export default function AskUserFormCard({
|
|
|
69
87
|
onNotify,
|
|
70
88
|
resumeSearch = null,
|
|
71
89
|
caseSearch = null,
|
|
72
|
-
|
|
90
|
+
onSurfaceReadyChange,
|
|
91
|
+
onAutoControlReady,
|
|
92
|
+
}: AskUserFormCardBodyProps) {
|
|
73
93
|
const [formDef, setFormDef] = useState<FormSchema | null>(null)
|
|
74
94
|
const [formDisabled, setFormDisabled] = useState(false)
|
|
75
95
|
const [loading, setLoading] = useState(true)
|
|
76
96
|
const [submitting, setSubmitting] = useState(false)
|
|
77
97
|
const rendererRef = useRef<SkoponFormRendererRef>(null)
|
|
98
|
+
const cardRootRef = useRef<HTMLDivElement>(null)
|
|
78
99
|
const mountedRef = useRef(true)
|
|
79
100
|
const onNotifyRef = useRef(onNotify)
|
|
80
101
|
onNotifyRef.current = onNotify
|
|
102
|
+
const runtime = useFormRuntime()
|
|
81
103
|
|
|
82
104
|
const resolvedFormUniqueId = formUniqueId?.trim() ?? ''
|
|
105
|
+
const resolvedAskUserMessageId = askUserMessageId?.trim() ?? ''
|
|
83
106
|
|
|
84
107
|
const resolvedSubmitHeaders = useMemo(
|
|
85
108
|
() => resolveSubmitAuthHeaders(submitHeaders),
|
|
@@ -180,32 +203,14 @@ export default function AskUserFormCard({
|
|
|
180
203
|
? `ask-user-${resolvedFormUniqueId}`
|
|
181
204
|
: 'ask-user-payload'
|
|
182
205
|
|
|
183
|
-
|
|
184
|
-
return (
|
|
185
|
-
<SkoponFormProvider>
|
|
186
|
-
<div className="ask-user-form-card">
|
|
187
|
-
<Spin size="small" />
|
|
188
|
-
</div>
|
|
189
|
-
</SkoponFormProvider>
|
|
190
|
-
)
|
|
191
|
-
}
|
|
206
|
+
const hasFormSurface = Boolean(!loading && payloadDef && renderBlocks.length > 0 && surfaceDoc)
|
|
192
207
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
payload={payload}
|
|
198
|
-
callbackUrl={callbackUrl}
|
|
199
|
-
answerAgentContext={resolvedAnswerAgentContext}
|
|
200
|
-
submitHeaders={submitHeaders}
|
|
201
|
-
unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
|
|
202
|
-
onNotify={onNotify}
|
|
203
|
-
/>
|
|
204
|
-
</SkoponFormProvider>
|
|
205
|
-
)
|
|
206
|
-
}
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
onSurfaceReadyChange(hasFormSurface)
|
|
210
|
+
return () => onSurfaceReadyChange(false)
|
|
211
|
+
}, [hasFormSurface, onSurfaceReadyChange])
|
|
207
212
|
|
|
208
|
-
|
|
213
|
+
const handleSubmit = useCallback(async () => {
|
|
209
214
|
if (submitting) return
|
|
210
215
|
setSubmitting(true)
|
|
211
216
|
try {
|
|
@@ -217,6 +222,12 @@ export default function AskUserFormCard({
|
|
|
217
222
|
}
|
|
218
223
|
const answer = { ...cardValues, ...extraValues }
|
|
219
224
|
|
|
225
|
+
runtime.reportData(answer)
|
|
226
|
+
runtime.reportEvent({
|
|
227
|
+
action: 'submit',
|
|
228
|
+
component: 'SubmitButton',
|
|
229
|
+
})
|
|
230
|
+
|
|
220
231
|
if (submitMode === 'post') {
|
|
221
232
|
const url = (callbackUrl ?? '').trim()
|
|
222
233
|
if (!url) {
|
|
@@ -254,11 +265,79 @@ export default function AskUserFormCard({
|
|
|
254
265
|
} finally {
|
|
255
266
|
if (mountedRef.current) setSubmitting(false)
|
|
256
267
|
}
|
|
268
|
+
}, [
|
|
269
|
+
submitting,
|
|
270
|
+
fieldNames,
|
|
271
|
+
renderBlocks,
|
|
272
|
+
extraValues,
|
|
273
|
+
runtime,
|
|
274
|
+
submitMode,
|
|
275
|
+
callbackUrl,
|
|
276
|
+
resolvedSubmitHeaders,
|
|
277
|
+
resolvedAnswerAgentContext,
|
|
278
|
+
payload,
|
|
279
|
+
formDef,
|
|
280
|
+
payloadDef,
|
|
281
|
+
])
|
|
282
|
+
|
|
283
|
+
const handleSubmitRef = useRef(handleSubmit)
|
|
284
|
+
handleSubmitRef.current = handleSubmit
|
|
285
|
+
|
|
286
|
+
const applyAutoControl = useCallback(
|
|
287
|
+
(data: FormAutoControlEventData) => {
|
|
288
|
+
executeFormAutoControl(
|
|
289
|
+
data,
|
|
290
|
+
{
|
|
291
|
+
setValues: (values) => rendererRef.current?.setValues(values),
|
|
292
|
+
getValues: (names) => rendererRef.current?.getValues(names) ?? {},
|
|
293
|
+
submit: () => handleSubmitRef.current(),
|
|
294
|
+
rootElement: cardRootRef.current,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
reportEvent: runtime.reportEvent,
|
|
298
|
+
runAutoControl: runtime.runAutoControl,
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
},
|
|
302
|
+
[runtime],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
if (!hasFormSurface) {
|
|
307
|
+
onAutoControlReady(null)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
onAutoControlReady(applyAutoControl)
|
|
311
|
+
return () => onAutoControlReady(null)
|
|
312
|
+
}, [hasFormSurface, applyAutoControl, onAutoControlReady])
|
|
313
|
+
|
|
314
|
+
if (loading) {
|
|
315
|
+
return (
|
|
316
|
+
<div className="ask-user-form-card">
|
|
317
|
+
<Spin size="small" />
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!payloadDef || renderBlocks.length === 0) {
|
|
323
|
+
return (
|
|
324
|
+
<CurlSubmitBlock
|
|
325
|
+
payload={payload}
|
|
326
|
+
callbackUrl={callbackUrl}
|
|
327
|
+
answerAgentContext={resolvedAnswerAgentContext}
|
|
328
|
+
submitHeaders={submitHeaders}
|
|
329
|
+
unpublishedFormId={formDisabled && resolvedFormUniqueId ? resolvedFormUniqueId : undefined}
|
|
330
|
+
onNotify={onNotify}
|
|
331
|
+
/>
|
|
332
|
+
)
|
|
257
333
|
}
|
|
258
334
|
|
|
259
335
|
return (
|
|
260
|
-
<
|
|
261
|
-
|
|
336
|
+
<div
|
|
337
|
+
className="ask-user-form-card"
|
|
338
|
+
ref={cardRootRef}
|
|
339
|
+
data-ask-user-message-id={resolvedAskUserMessageId || undefined}
|
|
340
|
+
>
|
|
262
341
|
{formDisabled && resolvedFormUniqueId ? (
|
|
263
342
|
<div className="ask-user-form-card-status">
|
|
264
343
|
<Tooltip title={resolvedFormUniqueId}>
|
|
@@ -296,7 +375,61 @@ export default function AskUserFormCard({
|
|
|
296
375
|
</Button>
|
|
297
376
|
</Tooltip>
|
|
298
377
|
</div>
|
|
299
|
-
|
|
378
|
+
</div>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export default function AskUserFormCard(props: AskUserFormCardProps) {
|
|
383
|
+
const resolvedAskUserMessageId = props.askUserMessageId?.trim() ?? ''
|
|
384
|
+
const realtimeWsUrl = useMemo(() => resolveRealtimeWsUrl(), [])
|
|
385
|
+
const [surfaceReady, setSurfaceReady] = useState(false)
|
|
386
|
+
const [wsClient, setWsClient] = useState<FormRuntimeWsClient | null>(null)
|
|
387
|
+
const autoControlHandlerRef = useRef<((data: FormAutoControlEventData) => void) | null>(null)
|
|
388
|
+
|
|
389
|
+
const onSurfaceReadyChange = useCallback((ready: boolean) => {
|
|
390
|
+
setSurfaceReady(ready)
|
|
391
|
+
}, [])
|
|
392
|
+
|
|
393
|
+
const onAutoControlReady = useCallback(
|
|
394
|
+
(handler: ((data: FormAutoControlEventData) => void) | null) => {
|
|
395
|
+
autoControlHandlerRef.current = handler
|
|
396
|
+
},
|
|
397
|
+
[],
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
const shouldConnect = Boolean(
|
|
401
|
+
realtimeWsUrl && resolvedAskUserMessageId && surfaceReady,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (!shouldConnect || !realtimeWsUrl || !resolvedAskUserMessageId) {
|
|
406
|
+
setWsClient(null)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
const client = new FormRuntimeWsClient({
|
|
410
|
+
url: realtimeWsUrl,
|
|
411
|
+
askUserMessageId: resolvedAskUserMessageId,
|
|
412
|
+
onAutoControl: (data) => {
|
|
413
|
+
autoControlHandlerRef.current?.(data)
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
client.connect()
|
|
417
|
+
setWsClient(client)
|
|
418
|
+
return () => {
|
|
419
|
+
client.close()
|
|
420
|
+
setWsClient(null)
|
|
421
|
+
}
|
|
422
|
+
}, [shouldConnect, realtimeWsUrl, resolvedAskUserMessageId])
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<SkoponFormProvider>
|
|
426
|
+
<FormRuntimeProvider client={wsClient}>
|
|
427
|
+
<AskUserFormCardBody
|
|
428
|
+
{...props}
|
|
429
|
+
onSurfaceReadyChange={onSurfaceReadyChange}
|
|
430
|
+
onAutoControlReady={onAutoControlReady}
|
|
431
|
+
/>
|
|
432
|
+
</FormRuntimeProvider>
|
|
300
433
|
</SkoponFormProvider>
|
|
301
434
|
)
|
|
302
435
|
}
|
|
@@ -21,6 +21,7 @@ import SkoponFormProvider from './SkoponFormProvider'
|
|
|
21
21
|
|
|
22
22
|
export interface SkoponFormRendererRef {
|
|
23
23
|
getValues: (fieldNames?: string[]) => Record<string, unknown>
|
|
24
|
+
setValues: (values: Record<string, unknown>) => void
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export interface SkoponFormRendererProps {
|
|
@@ -104,6 +105,17 @@ const SkoponFormRenderer = forwardRef<SkoponFormRendererRef, SkoponFormRendererP
|
|
|
104
105
|
const resolvedNames = (names ?? fieldNamesRef.current).map((n) => n.trim()).filter(Boolean)
|
|
105
106
|
return extractSurfaceValues(processor, surfaceId, resolvedNames)
|
|
106
107
|
},
|
|
108
|
+
setValues(values) {
|
|
109
|
+
const processor = processorRef.current
|
|
110
|
+
if (!processor) return
|
|
111
|
+
const surface = processor.model.surfacesMap.get(surfaceId)
|
|
112
|
+
if (!surface) return
|
|
113
|
+
for (const [name, value] of Object.entries(values)) {
|
|
114
|
+
const trimmed = name.trim()
|
|
115
|
+
if (!trimmed) continue
|
|
116
|
+
surface.dataModel.set(`/${trimmed}`, value)
|
|
117
|
+
}
|
|
118
|
+
},
|
|
107
119
|
}),
|
|
108
120
|
[surfaceId],
|
|
109
121
|
)
|
package/src/index.ts
CHANGED
|
@@ -79,12 +79,14 @@ export {
|
|
|
79
79
|
} from './submit/submitAnswerAgent'
|
|
80
80
|
export {
|
|
81
81
|
resolveAnswerAgentCallbackUrl,
|
|
82
|
+
resolveRealtimeWsUrl,
|
|
82
83
|
resolveSkoponAuthHeaders,
|
|
83
84
|
resolveSubmitAuthHeaders,
|
|
84
85
|
resolveClientHeaders,
|
|
85
86
|
DEFAULT_AUTH_STORAGE_KEY,
|
|
86
87
|
DEFAULT_AUTH_TOKEN_FIELD,
|
|
87
88
|
DEFAULT_AUTH_ANSWER_AGENT_CALLBACK_URL_FIELD,
|
|
89
|
+
DEFAULT_AUTH_REALTIME_WS_URL_FIELD,
|
|
88
90
|
DEFAULT_ANSWER_AGENT_CALLBACK_URL,
|
|
89
91
|
type SkoponAuthStorageOptions,
|
|
90
92
|
type ClientHeadersOptions,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
5
|
+
import { executeFormAutoControl } from './formAutoControl'
|
|
6
|
+
import type { FormAutoControlEventData } from './types'
|
|
7
|
+
|
|
8
|
+
/** 模拟 FormRuntimeProvider.runAutoControl:Promise settle 前保持 depth。 */
|
|
9
|
+
function createDepthTrackingRunAutoControl(depthRef: { current: number }) {
|
|
10
|
+
return <T,>(fn: () => T): T => {
|
|
11
|
+
depthRef.current += 1
|
|
12
|
+
let syncResult: T
|
|
13
|
+
try {
|
|
14
|
+
syncResult = fn()
|
|
15
|
+
} catch (error) {
|
|
16
|
+
depthRef.current = Math.max(0, depthRef.current - 1)
|
|
17
|
+
throw error
|
|
18
|
+
}
|
|
19
|
+
if (
|
|
20
|
+
syncResult != null &&
|
|
21
|
+
(typeof syncResult === 'object' || typeof syncResult === 'function') &&
|
|
22
|
+
typeof (syncResult as PromiseLike<unknown>).then === 'function'
|
|
23
|
+
) {
|
|
24
|
+
const promise = Promise.resolve(syncResult).finally(() => {
|
|
25
|
+
depthRef.current = Math.max(0, depthRef.current - 1)
|
|
26
|
+
})
|
|
27
|
+
return promise as T
|
|
28
|
+
}
|
|
29
|
+
depthRef.current = Math.max(0, depthRef.current - 1)
|
|
30
|
+
return syncResult
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('executeFormAutoControl', () => {
|
|
35
|
+
it('writes values and reports bac change event', () => {
|
|
36
|
+
const setValues = vi.fn()
|
|
37
|
+
const reportEvent = vi.fn()
|
|
38
|
+
const runAutoControl = vi.fn((fn: () => unknown) => fn())
|
|
39
|
+
const data: FormAutoControlEventData = {
|
|
40
|
+
action: 'change',
|
|
41
|
+
field: 'name',
|
|
42
|
+
value: 'Alice',
|
|
43
|
+
component: 'TextField',
|
|
44
|
+
}
|
|
45
|
+
executeFormAutoControl(
|
|
46
|
+
data,
|
|
47
|
+
{
|
|
48
|
+
setValues,
|
|
49
|
+
getValues: () => ({}),
|
|
50
|
+
submit: vi.fn(),
|
|
51
|
+
rootElement: null,
|
|
52
|
+
},
|
|
53
|
+
{ reportEvent, runAutoControl },
|
|
54
|
+
)
|
|
55
|
+
expect(runAutoControl).toHaveBeenCalled()
|
|
56
|
+
expect(setValues).toHaveBeenCalledWith({ name: 'Alice' })
|
|
57
|
+
expect(reportEvent).toHaveBeenCalledWith({
|
|
58
|
+
action: 'change',
|
|
59
|
+
component: 'TextField',
|
|
60
|
+
field: 'name',
|
|
61
|
+
value: 'Alice',
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('ignores change without field', () => {
|
|
66
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
67
|
+
const setValues = vi.fn()
|
|
68
|
+
executeFormAutoControl(
|
|
69
|
+
{ action: 'change', value: 'x' },
|
|
70
|
+
{
|
|
71
|
+
setValues,
|
|
72
|
+
getValues: () => ({}),
|
|
73
|
+
submit: vi.fn(),
|
|
74
|
+
rootElement: null,
|
|
75
|
+
},
|
|
76
|
+
{ reportEvent: vi.fn(), runAutoControl: (fn) => fn() },
|
|
77
|
+
)
|
|
78
|
+
expect(setValues).not.toHaveBeenCalled()
|
|
79
|
+
expect(warn).toHaveBeenCalled()
|
|
80
|
+
warn.mockRestore()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('invokes submit for submit action', () => {
|
|
84
|
+
const submit = vi.fn()
|
|
85
|
+
executeFormAutoControl(
|
|
86
|
+
{ action: 'submit' },
|
|
87
|
+
{
|
|
88
|
+
setValues: vi.fn(),
|
|
89
|
+
getValues: () => ({}),
|
|
90
|
+
submit,
|
|
91
|
+
rootElement: null,
|
|
92
|
+
},
|
|
93
|
+
{ reportEvent: vi.fn(), runAutoControl: (fn) => fn() },
|
|
94
|
+
)
|
|
95
|
+
expect(submit).toHaveBeenCalled()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('keeps autoControl depth until async submit settles', async () => {
|
|
99
|
+
const depthRef = { current: 0 }
|
|
100
|
+
const runAutoControl = createDepthTrackingRunAutoControl(depthRef)
|
|
101
|
+
let resolveSubmit!: () => void
|
|
102
|
+
const submit = vi.fn(
|
|
103
|
+
() =>
|
|
104
|
+
new Promise<void>((resolve) => {
|
|
105
|
+
resolveSubmit = resolve
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
executeFormAutoControl(
|
|
110
|
+
{ action: 'submit' },
|
|
111
|
+
{
|
|
112
|
+
setValues: vi.fn(),
|
|
113
|
+
getValues: () => ({}),
|
|
114
|
+
submit,
|
|
115
|
+
rootElement: null,
|
|
116
|
+
},
|
|
117
|
+
{ reportEvent: vi.fn(), runAutoControl },
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(submit).toHaveBeenCalled()
|
|
121
|
+
expect(depthRef.current).toBe(1)
|
|
122
|
+
resolveSubmit()
|
|
123
|
+
await Promise.resolve()
|
|
124
|
+
await Promise.resolve()
|
|
125
|
+
expect(depthRef.current).toBe(0)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { FormAutoControlEventData, FormRuntimeEventData } from './types'
|
|
2
|
+
|
|
3
|
+
export interface FormAutoControlTargets {
|
|
4
|
+
setValues: (values: Record<string, unknown>) => void
|
|
5
|
+
getValues: (fieldNames?: string[]) => Record<string, unknown>
|
|
6
|
+
submit: () => void | Promise<void>
|
|
7
|
+
/** 卡片根节点,用于按 data-skopon-field 定位 DOM */
|
|
8
|
+
rootElement: HTMLElement | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface FormAutoControlHelpers {
|
|
12
|
+
reportEvent: (event: FormRuntimeEventData) => void
|
|
13
|
+
/** 支持同步或返回 Promise(async submit 期间保持 autoControlDepth) */
|
|
14
|
+
runAutoControl: <T>(fn: () => T) => T
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function escapeAttrSelector(value: string): string {
|
|
18
|
+
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
19
|
+
return CSS.escape(value)
|
|
20
|
+
}
|
|
21
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function focusField(root: HTMLElement | null, field: string): boolean {
|
|
25
|
+
if (!root) return false
|
|
26
|
+
const container = root.querySelector(`[data-skopon-field="${escapeAttrSelector(field)}"]`)
|
|
27
|
+
if (!(container instanceof HTMLElement)) return false
|
|
28
|
+
const focusable =
|
|
29
|
+
container.querySelector<HTMLElement>(
|
|
30
|
+
'input, textarea, select, button, [tabindex]:not([tabindex="-1"])',
|
|
31
|
+
) ?? container
|
|
32
|
+
focusable.focus()
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clickField(root: HTMLElement | null, field: string): boolean {
|
|
37
|
+
if (!root) return false
|
|
38
|
+
const container = root.querySelector(`[data-skopon-field="${escapeAttrSelector(field)}"]`)
|
|
39
|
+
if (!(container instanceof HTMLElement)) return false
|
|
40
|
+
const clickable =
|
|
41
|
+
container.querySelector<HTMLElement>(
|
|
42
|
+
'button, [role="button"], .skopon-resume-select-card, input, label',
|
|
43
|
+
) ?? container
|
|
44
|
+
clickable.click()
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 执行下行 form_auto_control_event;缺参或未知 action 时 warn 并忽略。 */
|
|
49
|
+
export function executeFormAutoControl(
|
|
50
|
+
data: FormAutoControlEventData,
|
|
51
|
+
targets: FormAutoControlTargets,
|
|
52
|
+
helpers: FormAutoControlHelpers,
|
|
53
|
+
): void {
|
|
54
|
+
const action = data.action
|
|
55
|
+
if (
|
|
56
|
+
action !== 'change' &&
|
|
57
|
+
action !== 'select' &&
|
|
58
|
+
action !== 'focus' &&
|
|
59
|
+
action !== 'click' &&
|
|
60
|
+
action !== 'submit'
|
|
61
|
+
) {
|
|
62
|
+
console.warn('[FormAutoControl] unknown action', action)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
helpers.runAutoControl(() => {
|
|
67
|
+
if (action === 'submit') {
|
|
68
|
+
// 返回 Promise,使 runAutoControl 在 settle 前保持 depth
|
|
69
|
+
return targets.submit()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const field = typeof data.field === 'string' ? data.field.trim() : ''
|
|
73
|
+
if (!field) {
|
|
74
|
+
console.warn('[FormAutoControl] missing field for action', action)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const component = data.component?.trim() || 'Unknown'
|
|
79
|
+
|
|
80
|
+
if (action === 'change' || action === 'select') {
|
|
81
|
+
if (data.value === undefined) {
|
|
82
|
+
console.warn('[FormAutoControl] missing value for action', action)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
targets.setValues({ [field]: data.value })
|
|
86
|
+
helpers.reportEvent({
|
|
87
|
+
action,
|
|
88
|
+
component,
|
|
89
|
+
field,
|
|
90
|
+
value: data.value,
|
|
91
|
+
})
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (action === 'focus') {
|
|
96
|
+
focusField(targets.rootElement, field)
|
|
97
|
+
helpers.reportEvent({
|
|
98
|
+
action: 'focus',
|
|
99
|
+
component,
|
|
100
|
+
field,
|
|
101
|
+
})
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// click:由组件 onClick 打点回传(autoControlDepth 生效)
|
|
106
|
+
if (!clickField(targets.rootElement, field)) {
|
|
107
|
+
console.warn('[FormAutoControl] click target not found', field)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
/**
|
|
16
|
+
* 在自动控上下文中执行 fn。若 fn 返回 Promise,depth 保持到 settle,
|
|
17
|
+
* 使异步 submit 内的 reportEvent 仍为 form_auto_control_bac_event。
|
|
18
|
+
*/
|
|
19
|
+
runAutoControl: <T>(fn: () => T) => T
|
|
20
|
+
isAutoControl: () => boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FormRuntimeContext = createContext<FormRuntimeContextValue | null>(null)
|
|
24
|
+
|
|
25
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
26
|
+
return (
|
|
27
|
+
value != null &&
|
|
28
|
+
(typeof value === 'object' || typeof value === 'function') &&
|
|
29
|
+
typeof (value as PromiseLike<unknown>).then === 'function'
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const noopRuntime: FormRuntimeContextValue = {
|
|
34
|
+
reportEvent: () => {},
|
|
35
|
+
reportData: () => {},
|
|
36
|
+
runAutoControl: (fn) => fn(),
|
|
37
|
+
isAutoControl: () => false,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function omitUndefinedValues(
|
|
41
|
+
values: Record<string, unknown>,
|
|
42
|
+
): Record<string, unknown> {
|
|
43
|
+
const out: Record<string, unknown> = {}
|
|
44
|
+
for (const [key, value] of Object.entries(values)) {
|
|
45
|
+
if (value !== undefined) out[key] = value
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FormRuntimeProviderProps {
|
|
51
|
+
client: FormRuntimeWsClient | null
|
|
52
|
+
children: ReactNode
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function FormRuntimeProvider({
|
|
56
|
+
client,
|
|
57
|
+
children,
|
|
58
|
+
}: FormRuntimeProviderProps) {
|
|
59
|
+
const autoControlDepthRef = useRef(0)
|
|
60
|
+
const clientRef = useRef(client)
|
|
61
|
+
clientRef.current = client
|
|
62
|
+
|
|
63
|
+
const reportEvent = useCallback((event: FormRuntimeEventData) => {
|
|
64
|
+
const ws = clientRef.current
|
|
65
|
+
if (!ws) return
|
|
66
|
+
const data: Record<string, unknown> = {
|
|
67
|
+
action: event.action,
|
|
68
|
+
component: event.component,
|
|
69
|
+
}
|
|
70
|
+
if (event.field !== undefined) data.field = event.field
|
|
71
|
+
if (event.value !== undefined) data.value = event.value
|
|
72
|
+
const type =
|
|
73
|
+
autoControlDepthRef.current > 0
|
|
74
|
+
? 'form_auto_control_bac_event'
|
|
75
|
+
: 'form_runtime_event'
|
|
76
|
+
ws.send(type, data)
|
|
77
|
+
}, [])
|
|
78
|
+
|
|
79
|
+
const reportData = useCallback((values: Record<string, unknown>) => {
|
|
80
|
+
const ws = clientRef.current
|
|
81
|
+
if (!ws) return
|
|
82
|
+
ws.send('form_runtime_data', omitUndefinedValues(values))
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
const runAutoControl = useCallback(<T,>(fn: () => T): T => {
|
|
86
|
+
autoControlDepthRef.current += 1
|
|
87
|
+
let syncResult: T
|
|
88
|
+
try {
|
|
89
|
+
syncResult = fn()
|
|
90
|
+
} catch (error) {
|
|
91
|
+
autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
|
|
92
|
+
throw error
|
|
93
|
+
}
|
|
94
|
+
if (isThenable(syncResult)) {
|
|
95
|
+
const promise = Promise.resolve(syncResult).finally(() => {
|
|
96
|
+
autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
|
|
97
|
+
})
|
|
98
|
+
return promise as T
|
|
99
|
+
}
|
|
100
|
+
autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
|
|
101
|
+
return syncResult
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
const isAutoControl = useCallback(
|
|
105
|
+
() => autoControlDepthRef.current > 0,
|
|
106
|
+
[],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const value = useMemo(
|
|
110
|
+
(): FormRuntimeContextValue => ({
|
|
111
|
+
reportEvent,
|
|
112
|
+
reportData,
|
|
113
|
+
runAutoControl,
|
|
114
|
+
isAutoControl,
|
|
115
|
+
}),
|
|
116
|
+
[reportEvent, reportData, runAutoControl, isAutoControl],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<FormRuntimeContext.Provider value={value}>
|
|
121
|
+
{children}
|
|
122
|
+
</FormRuntimeContext.Provider>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 无 Provider 时 no-op(表单管理端预览不受影响)。 */
|
|
127
|
+
export function useFormRuntime(): FormRuntimeContextValue {
|
|
128
|
+
return useContext(FormRuntimeContext) ?? noopRuntime
|
|
129
|
+
}
|