@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
package/package.json
CHANGED
|
@@ -59,6 +59,25 @@ describe('a2uiAdapter blocks <-> surface', () => {
|
|
|
59
59
|
}
|
|
60
60
|
})
|
|
61
61
|
|
|
62
|
+
it('stores file defaults as URL arrays in the A2UI data model', () => {
|
|
63
|
+
const doc = blocksToA2ui(
|
|
64
|
+
{
|
|
65
|
+
title: '',
|
|
66
|
+
description: '',
|
|
67
|
+
blocks: [
|
|
68
|
+
{
|
|
69
|
+
id: 'file',
|
|
70
|
+
type: 'file',
|
|
71
|
+
name: 'attachments',
|
|
72
|
+
defaultValue: 'https://cdn.example.com/a.pdf',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{ includeHeader: false },
|
|
77
|
+
)
|
|
78
|
+
expect(doc.dataModel?.attachments).toEqual(['https://cdn.example.com/a.pdf'])
|
|
79
|
+
})
|
|
80
|
+
|
|
62
81
|
it('round-trips block types through a2uiToBlocks', () => {
|
|
63
82
|
const doc = blocksToA2ui(makeDefinition())
|
|
64
83
|
const back = a2uiToBlocks(doc)
|
|
@@ -137,6 +156,31 @@ describe('a2uiAdapter blocks <-> surface', () => {
|
|
|
137
156
|
expect(back.blocks[0]?.options).toEqual([{ value: '0', label: '123' }])
|
|
138
157
|
})
|
|
139
158
|
|
|
159
|
+
it('sanitizes untrusted FormBlock options while building an A2UI document', () => {
|
|
160
|
+
const doc = blocksToA2ui(
|
|
161
|
+
{
|
|
162
|
+
title: '',
|
|
163
|
+
description: '',
|
|
164
|
+
blocks: [
|
|
165
|
+
{
|
|
166
|
+
id: 'choice',
|
|
167
|
+
type: 'select',
|
|
168
|
+
name: 'choice',
|
|
169
|
+
options: [
|
|
170
|
+
{ value: 0, label: 123 },
|
|
171
|
+
{ value: {}, label: '异常' },
|
|
172
|
+
] as never,
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
{ includeHeader: false },
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(doc.components.find((component) => component.id === 'choice')?.options).toEqual([
|
|
180
|
+
{ value: '0', label: '123' },
|
|
181
|
+
])
|
|
182
|
+
})
|
|
183
|
+
|
|
140
184
|
it('blocksToA2ui emits SkoponMedia for image blocks', () => {
|
|
141
185
|
const doc = blocksToA2ui(makeDefinition(), { includeHeader: false })
|
|
142
186
|
const imgNode = doc.components.find((c) => c.id === 'b-img')
|
|
@@ -100,6 +100,7 @@ function defaultValueForBlock(block: FormBlock): unknown {
|
|
|
100
100
|
return coerceToggleValue(block.defaultValue)
|
|
101
101
|
case 'multiselect':
|
|
102
102
|
case 'checkbox':
|
|
103
|
+
case 'file':
|
|
103
104
|
case 'resume_multiselect':
|
|
104
105
|
case 'case_multiselect':
|
|
105
106
|
if (Array.isArray(block.defaultValue)) return block.defaultValue
|
|
@@ -127,7 +128,7 @@ function blockToComponent(block: FormBlock): {
|
|
|
127
128
|
const label = block.label ?? ''
|
|
128
129
|
const name = block.name?.trim() || ''
|
|
129
130
|
const path = name ? pathFor(name) : undefined
|
|
130
|
-
const options = (block.options
|
|
131
|
+
const options = normalizeFormOptions(block.options)
|
|
131
132
|
|
|
132
133
|
const withData = (node: A2uiComponentNode): {
|
|
133
134
|
node: A2uiComponentNode
|
|
@@ -76,6 +76,40 @@ describe('normalizeFormDefinition toggle default', () => {
|
|
|
76
76
|
})
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
+
describe('file value schema', () => {
|
|
80
|
+
it('uses a URL array schema even when maxCount is one', () => {
|
|
81
|
+
const schema = blocksToJsonSchema([
|
|
82
|
+
{
|
|
83
|
+
id: 'file-1',
|
|
84
|
+
type: 'file',
|
|
85
|
+
name: 'attachment',
|
|
86
|
+
fileMinCount: 1,
|
|
87
|
+
fileMaxCount: 1,
|
|
88
|
+
defaultValue: 'https://cdn.example.com/legacy.pdf',
|
|
89
|
+
},
|
|
90
|
+
])
|
|
91
|
+
expect(schema.properties).toMatchObject({
|
|
92
|
+
attachment: {
|
|
93
|
+
type: 'array',
|
|
94
|
+
items: { type: 'string' },
|
|
95
|
+
minItems: 1,
|
|
96
|
+
maxItems: 1,
|
|
97
|
+
default: ['https://cdn.example.com/legacy.pdf'],
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('omits maxItems when maxCount is unlimited', () => {
|
|
103
|
+
const schema = blocksToJsonSchema([
|
|
104
|
+
{ id: 'file-1', type: 'file', name: 'attachments', fileMinCount: 0, fileMaxCount: 0 },
|
|
105
|
+
])
|
|
106
|
+
expect(schema.properties).toMatchObject({
|
|
107
|
+
attachments: { type: 'array', items: { type: 'string' }, minItems: 0 },
|
|
108
|
+
})
|
|
109
|
+
expect((schema.properties as Record<string, Record<string, unknown>>).attachments).not.toHaveProperty('maxItems')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
79
113
|
describe('normalizeFormDefinition static options', () => {
|
|
80
114
|
it('keeps an empty static question for editing but omits it from jsonSchema', () => {
|
|
81
115
|
const def = normalizeFormDefinition({
|
|
@@ -63,6 +63,7 @@ function normalizeDefaultValue(
|
|
|
63
63
|
if (
|
|
64
64
|
type === 'multiselect' ||
|
|
65
65
|
type === 'checkbox' ||
|
|
66
|
+
type === 'file' ||
|
|
66
67
|
type === 'resume_multiselect' ||
|
|
67
68
|
type === 'case_multiselect'
|
|
68
69
|
) {
|
|
@@ -221,7 +222,7 @@ function normalizeBlock(raw: FormBlock): FormBlock {
|
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
function getOptionValueSet(block: FormBlock): Set<string> {
|
|
224
|
-
return new Set((block.options
|
|
225
|
+
return new Set(normalizeFormOptions(block.options).map((opt) => opt.value))
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
function buildMultiselectCountJsonSchema(
|
|
@@ -271,6 +272,12 @@ export function resolveSchemaDefaultValue(block: FormBlock): unknown | undefined
|
|
|
271
272
|
: []
|
|
272
273
|
return values.length > 0 ? values : undefined
|
|
273
274
|
}
|
|
275
|
+
case 'file': {
|
|
276
|
+
const values = Array.isArray(normalized)
|
|
277
|
+
? (normalized as string[]).map(String).map((item) => item.trim()).filter(Boolean)
|
|
278
|
+
: []
|
|
279
|
+
return values.length > 0 ? values : undefined
|
|
280
|
+
}
|
|
274
281
|
default:
|
|
275
282
|
return normalized
|
|
276
283
|
}
|
|
@@ -292,13 +299,13 @@ function blockToJsonSchemaProperty(block: FormBlock): Record<string, unknown> |
|
|
|
292
299
|
case 'select':
|
|
293
300
|
case 'radio': {
|
|
294
301
|
property = { type: 'string' }
|
|
295
|
-
const enumValues = (block.options
|
|
302
|
+
const enumValues = normalizeFormOptions(block.options).map((opt) => opt.value)
|
|
296
303
|
if (enumValues.length > 0) property.enum = enumValues
|
|
297
304
|
break
|
|
298
305
|
}
|
|
299
306
|
case 'multiselect':
|
|
300
307
|
case 'checkbox': {
|
|
301
|
-
const enumValues = (block.options
|
|
308
|
+
const enumValues = normalizeFormOptions(block.options).map((opt) => opt.value)
|
|
302
309
|
property = {
|
|
303
310
|
type: 'array',
|
|
304
311
|
items:
|
|
@@ -311,15 +318,11 @@ function blockToJsonSchemaProperty(block: FormBlock): Record<string, unknown> |
|
|
|
311
318
|
case 'file': {
|
|
312
319
|
const minCount = normalizeFileUploadCount(block.fileMinCount, 1)
|
|
313
320
|
const maxCount = normalizeFileUploadCount(block.fileMaxCount, 1)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
items: { type: 'string' },
|
|
320
|
-
minItems: minCount,
|
|
321
|
-
maxItems: maxCount,
|
|
322
|
-
}
|
|
321
|
+
property = {
|
|
322
|
+
type: 'array',
|
|
323
|
+
items: { type: 'string' },
|
|
324
|
+
minItems: minCount,
|
|
325
|
+
...(maxCount > 0 ? { maxItems: maxCount } : {}),
|
|
323
326
|
}
|
|
324
327
|
break
|
|
325
328
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import { useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
3
3
|
import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
|
|
4
4
|
import {
|
|
5
5
|
basicCatalog,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
TimePicker,
|
|
18
18
|
Typography,
|
|
19
19
|
Upload,
|
|
20
|
+
type UploadFile,
|
|
20
21
|
} from 'antd'
|
|
21
22
|
import dayjs, { type Dayjs } from 'dayjs'
|
|
22
23
|
import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
|
|
@@ -33,12 +34,16 @@ import {
|
|
|
33
34
|
getRegisteredCatalogComponents,
|
|
34
35
|
} from '../blocks/registry'
|
|
35
36
|
import FilePlaceholderIcon from '../icons/FilePlaceholderIcon'
|
|
37
|
+
import {
|
|
38
|
+
isUploadCancelledError,
|
|
39
|
+
uploadFileToCdn,
|
|
40
|
+
} from '../client/uploadClient'
|
|
36
41
|
import { useA2uiPreviewMode } from './a2uiPreviewContext'
|
|
42
|
+
import { useFileUploadStatus } from './fileUploadStatusContext'
|
|
37
43
|
import {
|
|
38
44
|
asOptionalString,
|
|
39
45
|
asStringArray,
|
|
40
46
|
normalizeSelectStoredValue,
|
|
41
|
-
readBoundFieldValue,
|
|
42
47
|
useSkoponBoundField,
|
|
43
48
|
} from './useSkoponBoundField'
|
|
44
49
|
|
|
@@ -506,9 +511,36 @@ const FileUploadApi = {
|
|
|
506
511
|
.passthrough(),
|
|
507
512
|
}
|
|
508
513
|
|
|
514
|
+
interface FileUploadListItem {
|
|
515
|
+
uid: string
|
|
516
|
+
name: string
|
|
517
|
+
status: 'uploading' | 'done' | 'error'
|
|
518
|
+
url?: string
|
|
519
|
+
error?: string
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function normalizeFileUrls(value: unknown): string[] {
|
|
523
|
+
return asStringArray(value).map((url) => url.trim()).filter(Boolean)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function fileNameFromUrl(url: string): string {
|
|
527
|
+
const withoutQuery = url.split(/[?#]/, 1)[0]
|
|
528
|
+
const tail = withoutQuery.split('/').filter(Boolean).pop() || url
|
|
529
|
+
try {
|
|
530
|
+
return decodeURIComponent(tail)
|
|
531
|
+
} catch {
|
|
532
|
+
return tail
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function fileItemFromUrl(url: string, uid: string): FileUploadListItem {
|
|
537
|
+
return { uid, name: fileNameFromUrl(url), status: 'done', url }
|
|
538
|
+
}
|
|
539
|
+
|
|
509
540
|
function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
510
541
|
const { interactive } = useA2uiPreviewMode()
|
|
511
|
-
const {
|
|
542
|
+
const { beginUpload } = useFileUploadStatus()
|
|
543
|
+
const { value, setValue, setValueSilent, fieldName } = useSkoponBoundField(context, 'value', {
|
|
512
544
|
component: 'FileUpload',
|
|
513
545
|
changeAction: 'change',
|
|
514
546
|
})
|
|
@@ -532,47 +564,162 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
|
532
564
|
const minCount = typeof props.minCount === 'number' ? Math.max(0, props.minCount) : 0
|
|
533
565
|
const maxCount = typeof props.maxCount === 'number' ? props.maxCount : 1
|
|
534
566
|
const hasMaxLimit = maxCount > 0
|
|
535
|
-
const
|
|
536
|
-
const
|
|
537
|
-
const
|
|
567
|
+
const initialUrls = normalizeFileUrls(value)
|
|
568
|
+
const externalUidRef = useRef(0)
|
|
569
|
+
const mountedRef = useRef(true)
|
|
570
|
+
const controllersRef = useRef(new Map<string, AbortController>())
|
|
571
|
+
const setValueRef = useRef(setValue)
|
|
572
|
+
setValueRef.current = setValue
|
|
573
|
+
const [items, setItems] = useState<FileUploadListItem[]>(() =>
|
|
574
|
+
initialUrls.map((url) => fileItemFromUrl(url, `external-${externalUidRef.current++}`)),
|
|
575
|
+
)
|
|
576
|
+
const itemsRef = useRef(items)
|
|
577
|
+
itemsRef.current = items
|
|
578
|
+
|
|
579
|
+
const commitItems = useCallback(
|
|
580
|
+
(
|
|
581
|
+
updater: (current: FileUploadListItem[]) => FileUploadListItem[],
|
|
582
|
+
writeUrls: boolean,
|
|
583
|
+
) => {
|
|
584
|
+
const next = updater(itemsRef.current)
|
|
585
|
+
itemsRef.current = next
|
|
586
|
+
if (mountedRef.current) setItems(next)
|
|
587
|
+
if (writeUrls) {
|
|
588
|
+
setValueRef.current(
|
|
589
|
+
next
|
|
590
|
+
.filter((item) => item.status === 'done' && item.url)
|
|
591
|
+
.map((item) => item.url as string),
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
return next
|
|
595
|
+
},
|
|
596
|
+
[],
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
useEffect(() => {
|
|
600
|
+
mountedRef.current = true
|
|
601
|
+
return () => {
|
|
602
|
+
mountedRef.current = false
|
|
603
|
+
for (const controller of controllersRef.current.values()) controller.abort()
|
|
604
|
+
controllersRef.current.clear()
|
|
605
|
+
}
|
|
606
|
+
}, [])
|
|
607
|
+
|
|
608
|
+
useEffect(() => {
|
|
609
|
+
const urls = normalizeFileUrls(value)
|
|
610
|
+
const rawIsNormalizedArray =
|
|
611
|
+
Array.isArray(value) &&
|
|
612
|
+
value.length === urls.length &&
|
|
613
|
+
value.every((entry, index) => typeof entry === 'string' && entry === urls[index])
|
|
614
|
+
if (!rawIsNormalizedArray) setValueSilent(urls)
|
|
615
|
+
|
|
616
|
+
const consumed = new Set<number>()
|
|
617
|
+
commitItems(
|
|
618
|
+
(current) => {
|
|
619
|
+
const next = current.filter((item) => {
|
|
620
|
+
if (item.status !== 'done' || !item.url) return true
|
|
621
|
+
const index = urls.findIndex((url, i) => !consumed.has(i) && url === item.url)
|
|
622
|
+
if (index < 0) return false
|
|
623
|
+
consumed.add(index)
|
|
624
|
+
return true
|
|
625
|
+
})
|
|
626
|
+
urls.forEach((url, index) => {
|
|
627
|
+
if (!consumed.has(index)) {
|
|
628
|
+
next.push(fileItemFromUrl(url, `external-${externalUidRef.current++}`))
|
|
629
|
+
}
|
|
630
|
+
})
|
|
631
|
+
return next
|
|
632
|
+
},
|
|
633
|
+
false,
|
|
634
|
+
)
|
|
635
|
+
}, [value, setValueSilent, commitItems])
|
|
636
|
+
|
|
637
|
+
const startUpload = useCallback(
|
|
638
|
+
(file: File & { uid?: string }) => {
|
|
639
|
+
if (!interactive) return
|
|
640
|
+
if (hasMaxLimit && itemsRef.current.length >= maxCount) return
|
|
641
|
+
|
|
642
|
+
const uid = `${file.uid ?? 'file'}-${externalUidRef.current++}`
|
|
643
|
+
const controller = new AbortController()
|
|
644
|
+
controllersRef.current.set(uid, controller)
|
|
645
|
+
commitItems(
|
|
646
|
+
(current) => [...current, { uid, name: file.name, status: 'uploading' }],
|
|
647
|
+
false,
|
|
648
|
+
)
|
|
649
|
+
const endUpload = beginUpload()
|
|
650
|
+
|
|
651
|
+
void uploadFileToCdn(file, { signal: controller.signal })
|
|
652
|
+
.then((result) => {
|
|
653
|
+
if (!mountedRef.current || controller.signal.aborted) return
|
|
654
|
+
commitItems(
|
|
655
|
+
(current) =>
|
|
656
|
+
current.map((item) =>
|
|
657
|
+
item.uid === uid
|
|
658
|
+
? { ...item, status: 'done', url: result.url, error: undefined }
|
|
659
|
+
: item,
|
|
660
|
+
),
|
|
661
|
+
true,
|
|
662
|
+
)
|
|
663
|
+
})
|
|
664
|
+
.catch((error: unknown) => {
|
|
665
|
+
if (!mountedRef.current || isUploadCancelledError(error)) return
|
|
666
|
+
const detail = error instanceof Error ? error.message : '上传失败'
|
|
667
|
+
commitItems(
|
|
668
|
+
(current) =>
|
|
669
|
+
current.map((item) =>
|
|
670
|
+
item.uid === uid ? { ...item, status: 'error', error: detail } : item,
|
|
671
|
+
),
|
|
672
|
+
false,
|
|
673
|
+
)
|
|
674
|
+
})
|
|
675
|
+
.finally(() => {
|
|
676
|
+
controllersRef.current.delete(uid)
|
|
677
|
+
endUpload()
|
|
678
|
+
})
|
|
679
|
+
},
|
|
680
|
+
[beginUpload, commitItems, hasMaxLimit, interactive, maxCount],
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
const handleRemove = useCallback(
|
|
684
|
+
(uid: string) => {
|
|
685
|
+
controllersRef.current.get(uid)?.abort()
|
|
686
|
+
controllersRef.current.delete(uid)
|
|
687
|
+
commitItems((current) => current.filter((item) => item.uid !== uid), true)
|
|
688
|
+
},
|
|
689
|
+
[commitItems],
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
const uploadedCount = items.filter((item) => item.status === 'done').length
|
|
693
|
+
const atMaxLimit = hasMaxLimit && items.length >= maxCount
|
|
694
|
+
const belowMinCount = uploadedCount < minCount
|
|
695
|
+
const latestError = [...items].reverse().find((item) => item.error)?.error
|
|
696
|
+
const fileList: UploadFile[] = items.map((item) => ({
|
|
697
|
+
uid: item.uid,
|
|
698
|
+
name: item.name,
|
|
699
|
+
status: item.status,
|
|
700
|
+
url: item.url,
|
|
701
|
+
response: item.error,
|
|
702
|
+
}))
|
|
538
703
|
|
|
539
704
|
return (
|
|
540
705
|
<div className="form-block-preview" data-skopon-field={fieldName || undefined}>
|
|
541
706
|
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
542
707
|
<Upload.Dragger
|
|
543
|
-
disabled={!interactive
|
|
708
|
+
disabled={!interactive}
|
|
709
|
+
openFileDialogOnClick={!atMaxLimit}
|
|
544
710
|
accept={buildFileAcceptAttribute(fileAcceptTypes, fileAcceptExtensions)}
|
|
545
711
|
className="form-file-upload-preview"
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
? selectedNames.map((name, index) => ({
|
|
550
|
-
uid: `${index}-${name}`,
|
|
551
|
-
name,
|
|
552
|
-
status: 'done' as const,
|
|
553
|
-
}))
|
|
554
|
-
: []
|
|
555
|
-
}
|
|
712
|
+
multiple={!hasMaxLimit || maxCount > 1}
|
|
713
|
+
showUploadList={interactive && items.length > 0}
|
|
714
|
+
fileList={interactive ? fileList : []}
|
|
556
715
|
beforeUpload={(file) => {
|
|
557
716
|
if (!interactive) return Upload.LIST_IGNORE
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const next = hasMaxLimit
|
|
561
|
-
? [...currentNames, file.name].slice(0, maxCount)
|
|
562
|
-
: [...currentNames, file.name]
|
|
563
|
-
setValue(next)
|
|
564
|
-
return false
|
|
717
|
+
startUpload(file)
|
|
718
|
+
return Upload.LIST_IGNORE
|
|
565
719
|
}}
|
|
566
720
|
onRemove={(file) => {
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const index = indexMatch ? Number(indexMatch[1]) : -1
|
|
570
|
-
const next =
|
|
571
|
-
index >= 0 && index < currentNames.length
|
|
572
|
-
? currentNames.filter((_, i) => i !== index)
|
|
573
|
-
: currentNames.filter((name) => name !== file.name)
|
|
574
|
-
if (next.length < minCount) return false
|
|
575
|
-
setValue(next)
|
|
721
|
+
handleRemove(file.uid)
|
|
722
|
+
return true
|
|
576
723
|
}}
|
|
577
724
|
>
|
|
578
725
|
<p className="form-file-upload-preview-icon">
|
|
@@ -584,9 +731,14 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
|
584
731
|
允许:{acceptSummary ?? '全部类型'}
|
|
585
732
|
{minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
|
|
586
733
|
{hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
|
|
587
|
-
|
|
588
|
-
{belowMinCount && interactive ? `(当前 ${
|
|
734
|
+
;选择后自动上传至 CDN
|
|
735
|
+
{belowMinCount && interactive ? `(当前 ${uploadedCount} 个,未达下限)` : ''}
|
|
589
736
|
</Typography.Text>
|
|
737
|
+
{latestError ? (
|
|
738
|
+
<Typography.Text type="danger" className="form-block-preview-help">
|
|
739
|
+
{latestError}
|
|
740
|
+
</Typography.Text>
|
|
741
|
+
) : null}
|
|
590
742
|
</div>
|
|
591
743
|
)
|
|
592
744
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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: (error: unknown) =>
|
|
19
|
+
error instanceof Error && error.name === 'UploadCancelledError',
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
import { createElement, createRef } from 'react'
|
|
23
|
+
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
|
24
|
+
import { blocksToA2ui } from '../adapter/a2uiAdapter'
|
|
25
|
+
import SkoponFormRenderer, {
|
|
26
|
+
type SkoponFormRendererRef,
|
|
27
|
+
} from '../components/SkoponFormRenderer'
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
class ResizeObserverMock {
|
|
31
|
+
observe() {}
|
|
32
|
+
unobserve() {}
|
|
33
|
+
disconnect() {}
|
|
34
|
+
}
|
|
35
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
uploadMock.mockReset()
|
|
40
|
+
})
|
|
41
|
+
afterEach(cleanup)
|
|
42
|
+
|
|
43
|
+
function deferred<T>() {
|
|
44
|
+
let resolve!: (value: T) => void
|
|
45
|
+
let reject!: (reason?: unknown) => void
|
|
46
|
+
const promise = new Promise<T>((res, rej) => {
|
|
47
|
+
resolve = res
|
|
48
|
+
reject = rej
|
|
49
|
+
})
|
|
50
|
+
return { promise, resolve, reject }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderFileSurface(options: {
|
|
54
|
+
maxCount?: number
|
|
55
|
+
minCount?: number
|
|
56
|
+
defaultValue?: string | string[]
|
|
57
|
+
legacyRawValue?: string
|
|
58
|
+
onUploadingChange?: (uploading: boolean) => void
|
|
59
|
+
} = {}) {
|
|
60
|
+
const doc = blocksToA2ui(
|
|
61
|
+
{
|
|
62
|
+
title: '',
|
|
63
|
+
description: '',
|
|
64
|
+
blocks: [
|
|
65
|
+
{
|
|
66
|
+
id: 'file',
|
|
67
|
+
type: 'file',
|
|
68
|
+
name: 'attachments',
|
|
69
|
+
label: '附件',
|
|
70
|
+
fileMinCount: options.minCount ?? 0,
|
|
71
|
+
fileMaxCount: options.maxCount ?? 1,
|
|
72
|
+
defaultValue: options.defaultValue,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{ includeHeader: false },
|
|
77
|
+
)
|
|
78
|
+
if (options.legacyRawValue !== undefined && doc.dataModel) {
|
|
79
|
+
doc.dataModel.attachments = options.legacyRawValue
|
|
80
|
+
}
|
|
81
|
+
const ref = createRef<SkoponFormRendererRef>()
|
|
82
|
+
const view = render(
|
|
83
|
+
createElement(SkoponFormRenderer, {
|
|
84
|
+
ref,
|
|
85
|
+
doc,
|
|
86
|
+
surfaceId: 'upload-test',
|
|
87
|
+
fieldNames: ['attachments'],
|
|
88
|
+
onUploadingChange: options.onUploadingChange,
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
const input = view.container.querySelector('input[type="file"]') as HTMLInputElement
|
|
92
|
+
expect(input).toBeTruthy()
|
|
93
|
+
return { ref, input, ...view }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('FileUploadPreview', () => {
|
|
97
|
+
it('stores successful CDN URLs in user selection order', async () => {
|
|
98
|
+
const first = deferred<{ url: string }>()
|
|
99
|
+
const second = deferred<{ url: string }>()
|
|
100
|
+
uploadMock
|
|
101
|
+
.mockImplementationOnce(() => first.promise)
|
|
102
|
+
.mockImplementationOnce(() => second.promise)
|
|
103
|
+
const uploadingChanges: boolean[] = []
|
|
104
|
+
const { input, ref, container } = renderFileSurface({
|
|
105
|
+
maxCount: 2,
|
|
106
|
+
onUploadingChange: (uploading) => uploadingChanges.push(uploading),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
fireEvent.change(input, { target: { files: [new File(['1'], 'first.pdf')] } })
|
|
110
|
+
await waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(1))
|
|
111
|
+
const nextInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
|
112
|
+
fireEvent.change(nextInput, { target: { files: [new File(['2'], 'second.pdf')] } })
|
|
113
|
+
await waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(2))
|
|
114
|
+
expect(uploadingChanges).toContain(true)
|
|
115
|
+
|
|
116
|
+
await act(async () => second.resolve({ url: 'https://cdn.example.com/second.pdf' }))
|
|
117
|
+
expect(ref.current?.getValues()).toEqual({
|
|
118
|
+
attachments: ['https://cdn.example.com/second.pdf'],
|
|
119
|
+
})
|
|
120
|
+
await act(async () => first.resolve({ url: 'https://cdn.example.com/first.pdf' }))
|
|
121
|
+
await waitFor(() =>
|
|
122
|
+
expect(ref.current?.getValues()).toEqual({
|
|
123
|
+
attachments: [
|
|
124
|
+
'https://cdn.example.com/first.pdf',
|
|
125
|
+
'https://cdn.example.com/second.pdf',
|
|
126
|
+
],
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
await waitFor(() => expect(uploadingChanges.at(-1)).toBe(false))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('keeps failed uploads out of the bound value and shows the error', async () => {
|
|
133
|
+
uploadMock.mockRejectedValueOnce(new Error('七牛上传失败'))
|
|
134
|
+
const { input, ref, getByText } = renderFileSurface()
|
|
135
|
+
fireEvent.change(input, { target: { files: [new File(['x'], 'bad.pdf')] } })
|
|
136
|
+
await waitFor(() => expect(getByText('七牛上传失败')).toBeTruthy())
|
|
137
|
+
expect(ref.current?.getValues()).toEqual({ attachments: [] })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('allows removing below minCount and aborts an in-flight request', async () => {
|
|
141
|
+
let signal: AbortSignal | undefined
|
|
142
|
+
uploadMock.mockImplementation(
|
|
143
|
+
(_file: File, options?: { signal?: AbortSignal }) => {
|
|
144
|
+
signal = options?.signal
|
|
145
|
+
return new Promise<{ url: string }>((resolve) => {
|
|
146
|
+
signal?.addEventListener('abort', () => {
|
|
147
|
+
resolve({ url: 'https://cdn.example.com/ignored.pdf' })
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
const { input, container, ref } = renderFileSurface({ minCount: 1, maxCount: 1 })
|
|
153
|
+
fireEvent.change(input, { target: { files: [new File(['x'], 'pending.pdf')] } })
|
|
154
|
+
await waitFor(() => expect(uploadMock).toHaveBeenCalledTimes(1))
|
|
155
|
+
const remove = await waitFor(() => {
|
|
156
|
+
const button = container.querySelector('.ant-upload-list-item-actions button') as HTMLElement
|
|
157
|
+
expect(button).toBeTruthy()
|
|
158
|
+
return button
|
|
159
|
+
})
|
|
160
|
+
fireEvent.click(remove)
|
|
161
|
+
await waitFor(() => expect(signal?.aborted).toBe(true))
|
|
162
|
+
expect(ref.current?.getValues()).toEqual({ attachments: [] })
|
|
163
|
+
expect(container.textContent).toContain('当前 0 个,未达下限')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('normalizes a legacy string value to a URL array', async () => {
|
|
167
|
+
const { ref, getByText } = renderFileSurface({
|
|
168
|
+
legacyRawValue: 'https://cdn.example.com/legacy%20file.pdf',
|
|
169
|
+
})
|
|
170
|
+
await waitFor(() =>
|
|
171
|
+
expect(ref.current?.getValues()).toEqual({
|
|
172
|
+
attachments: ['https://cdn.example.com/legacy%20file.pdf'],
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
expect(getByText('legacy file.pdf')).toBeTruthy()
|
|
176
|
+
})
|
|
177
|
+
})
|