@skopon-cool/form-sdk 0.2.0 → 0.2.2
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 +96 -9
- package/dist/adapter/a2uiAdapter.d.ts +1 -0
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formSchema.d.ts +2 -1
- package/dist/adapter/formSchema.d.ts.map +1 -1
- 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 +2 -0
- package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
- package/dist/client/apiEnvelope.d.ts +7 -0
- package/dist/client/apiEnvelope.d.ts.map +1 -0
- package/dist/client/authStorage.d.ts +19 -0
- package/dist/client/authStorage.d.ts.map +1 -0
- package/dist/client/caseSearchClient.d.ts +11 -0
- package/dist/client/caseSearchClient.d.ts.map +1 -0
- package/dist/client/formClient.d.ts +2 -2
- package/dist/client/formClient.d.ts.map +1 -1
- package/dist/client/resumeSearchClient.d.ts +9 -0
- package/dist/client/resumeSearchClient.d.ts.map +1 -0
- package/dist/client/skoponSdkClients.d.ts +22 -0
- package/dist/client/skoponSdkClients.d.ts.map +1 -0
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/SkoponFormRenderer.d.ts +2 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1097 -826
- package/dist/submit/validateFormSubmit.d.ts +10 -0
- package/dist/submit/validateFormSubmit.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.ts +3 -4
- package/src/adapter/formSchema.test.ts +29 -1
- package/src/adapter/formSchema.ts +12 -3
- package/src/adapter/resolveSurface.ts +3 -3
- package/src/catalog/a2uiCustomCatalog.tsx +49 -3
- package/src/catalog/skoponCaseSelect.tsx +2 -1
- package/src/catalog/skoponResumeSelect.tsx +1 -0
- package/src/catalog/skoponSelectLoading.test.tsx +182 -0
- package/src/catalog/textFieldPreview.test.tsx +31 -0
- package/src/catalog/useSkoponBoundField.test.ts +14 -1
- package/src/catalog/useSkoponBoundField.ts +14 -0
- package/src/client/apiEnvelope.ts +21 -0
- package/src/client/authStorage.test.ts +61 -0
- package/src/client/authStorage.ts +70 -0
- package/src/client/caseSearchClient.ts +107 -0
- package/src/client/formClient.test.ts +25 -0
- package/src/client/formClient.ts +7 -25
- package/src/client/resumeSearchClient.test.ts +87 -0
- package/src/client/resumeSearchClient.ts +108 -0
- package/src/client/skoponSdkClients.ts +51 -0
- package/src/components/AskUserFormCard.tsx +7 -0
- package/src/components/SkoponFormRenderer.tsx +18 -6
- package/src/index.ts +21 -0
- package/src/styles/index.css +3 -3
- package/src/submit/submit.test.ts +40 -0
- package/src/submit/validateFormSubmit.ts +111 -0
- package/dist/catalog/textFieldPreview.test.d.ts +0 -2
- package/dist/catalog/textFieldPreview.test.d.ts.map +0 -1
- package/dist/components/SkoponA2uiStreamRenderer.test.d.ts +0 -2
- package/dist/components/SkoponA2uiStreamRenderer.test.d.ts.map +0 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { FormBlock } from '../types/index';
|
|
2
|
+
export interface FormSubmitValidationError {
|
|
3
|
+
fieldName: string;
|
|
4
|
+
label: string;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function matchesValidationRegexp(value: string, pattern: string): boolean;
|
|
8
|
+
/** 提交前校验 renderBlocks 对应字段值;首个错误即返回。 */
|
|
9
|
+
export declare function validateFormSubmitValues(blocks: FormBlock[], values: Record<string, unknown>): FormSubmitValidationError | null;
|
|
10
|
+
//# sourceMappingURL=validateFormSubmit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validateFormSubmit.d.ts","sourceRoot":"","sources":["../../src/submit/validateFormSubmit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAM/C,MAAM,WAAW,yBAAyB;IACxC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAO/E;AA6BD,wCAAwC;AACxC,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,SAAS,EAAE,EACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC9B,yBAAyB,GAAG,IAAI,CA0DlC"}
|
package/package.json
CHANGED
|
@@ -8,9 +8,8 @@ import type {
|
|
|
8
8
|
FormSchema,
|
|
9
9
|
} from '../types/index'
|
|
10
10
|
import { generateId } from './id'
|
|
11
|
-
import { coerceToggleValue } from './formSchema'
|
|
11
|
+
import { coerceToggleValue, syncFormDefinition } from './formSchema'
|
|
12
12
|
import { getMediaUrls, normalizeMediaSize } from './formMedia'
|
|
13
|
-
import { syncFormDefinition } from './formSchema'
|
|
14
13
|
import { getBlockTypeAdapter, getComponentAdapter } from '../blocks/registry'
|
|
15
14
|
|
|
16
15
|
/**
|
|
@@ -28,7 +27,7 @@ import { getBlockTypeAdapter, getComponentAdapter } from '../blocks/registry'
|
|
|
28
27
|
const ROOT_ID = 'root'
|
|
29
28
|
const TITLE_ID = '__title__'
|
|
30
29
|
const DESC_ID = '__desc__'
|
|
31
|
-
const EMAIL_REGEXP = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'
|
|
30
|
+
export const EMAIL_REGEXP = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$'
|
|
32
31
|
|
|
33
32
|
function pathFor(name: string): string {
|
|
34
33
|
return `/${name}`
|
|
@@ -491,7 +490,7 @@ function applyDataModelDefaults(
|
|
|
491
490
|
const raw = dataModel[name]
|
|
492
491
|
if (raw === undefined) return block
|
|
493
492
|
if (block.type === 'toggle') {
|
|
494
|
-
return { ...block, defaultValue: raw
|
|
493
|
+
return { ...block, defaultValue: coerceToggleValue(raw) }
|
|
495
494
|
}
|
|
496
495
|
return { ...block, defaultValue: raw as FormBlock['defaultValue'] }
|
|
497
496
|
})
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect } from 'vitest'
|
|
5
5
|
import { coerceToggleValue, normalizeFormDefinition } from './formSchema'
|
|
6
|
+
import { mapFormDefinitionFromDto } from './resolveSurface'
|
|
6
7
|
|
|
7
8
|
describe('coerceToggleValue', () => {
|
|
8
|
-
it('accepts boolean and
|
|
9
|
+
it('accepts boolean, string, and numeric truthy/falsy values', () => {
|
|
9
10
|
expect(coerceToggleValue(true)).toBe(true)
|
|
10
11
|
expect(coerceToggleValue('true')).toBe(true)
|
|
12
|
+
expect(coerceToggleValue(1)).toBe(true)
|
|
13
|
+
expect(coerceToggleValue('1')).toBe(true)
|
|
11
14
|
expect(coerceToggleValue(false)).toBe(false)
|
|
12
15
|
expect(coerceToggleValue('false')).toBe(false)
|
|
16
|
+
expect(coerceToggleValue(0)).toBe(false)
|
|
17
|
+
expect(coerceToggleValue('0')).toBe(false)
|
|
13
18
|
expect(coerceToggleValue(undefined)).toBe(false)
|
|
14
19
|
})
|
|
15
20
|
})
|
|
@@ -32,4 +37,27 @@ describe('normalizeFormDefinition toggle default', () => {
|
|
|
32
37
|
})
|
|
33
38
|
expect(def.blocks[0]?.defaultValue).toBe(true)
|
|
34
39
|
})
|
|
40
|
+
|
|
41
|
+
it('preserves jsonSchema from raw definition', () => {
|
|
42
|
+
const schema = { type: 'object', properties: { title: { type: 'string' } } }
|
|
43
|
+
const def = normalizeFormDefinition({
|
|
44
|
+
title: '',
|
|
45
|
+
description: '',
|
|
46
|
+
blocks: [],
|
|
47
|
+
jsonSchema: schema,
|
|
48
|
+
})
|
|
49
|
+
expect(def.jsonSchema).toEqual(schema)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('mapFormDefinitionFromDto jsonSchema', () => {
|
|
54
|
+
it('reads json_schema snake_case from DTO', () => {
|
|
55
|
+
const schema = { type: 'object' }
|
|
56
|
+
const def = mapFormDefinitionFromDto({
|
|
57
|
+
title: 'T',
|
|
58
|
+
blocks: [{ id: 'b1', type: 'text', name: 'x', label: 'X' }],
|
|
59
|
+
json_schema: schema,
|
|
60
|
+
})
|
|
61
|
+
expect(def?.jsonSchema).toEqual(schema)
|
|
62
|
+
})
|
|
35
63
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FormBlock, FormBlockType, FormCaseFilter, FormResumeFilter, FormSchema } from '../types/index'
|
|
1
|
+
import type { FormBlock, FormBlockType, FormCaseFilter, FormResumeFilter, FormJsonSchema, FormSchema } from '../types/index'
|
|
2
2
|
import { isInputBlockType, isMediaBlockType } from '../types/index'
|
|
3
3
|
import { generateId } from './id'
|
|
4
4
|
import {
|
|
@@ -13,8 +13,17 @@ import {
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_MEDIA_URL_MAX = 10
|
|
15
15
|
|
|
16
|
+
export function normalizeJsonSchema(raw: unknown): FormJsonSchema {
|
|
17
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
18
|
+
return raw as FormJsonSchema
|
|
19
|
+
}
|
|
20
|
+
return {}
|
|
21
|
+
}
|
|
22
|
+
|
|
16
23
|
export function coerceToggleValue(raw: unknown): boolean {
|
|
17
|
-
|
|
24
|
+
if (raw === true || raw === 'true' || raw === 1 || raw === '1') return true
|
|
25
|
+
if (raw === false || raw === 'false' || raw === 0 || raw === '0') return false
|
|
26
|
+
return false
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
function normalizeFileUploadCount(raw: unknown, fallback = 1): number {
|
|
@@ -178,7 +187,7 @@ export function normalizeFormDefinition(raw: FormSchema | undefined): FormSchema
|
|
|
178
187
|
title: typeof raw.title === 'string' ? raw.title : '',
|
|
179
188
|
description: typeof raw.description === 'string' ? raw.description : '',
|
|
180
189
|
blocks,
|
|
181
|
-
jsonSchema:
|
|
190
|
+
jsonSchema: normalizeJsonSchema(raw.jsonSchema),
|
|
182
191
|
})
|
|
183
192
|
}
|
|
184
193
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { A2uiSurfaceDoc, FormBlock, FormDefinitionPayload, FormSchema } from '../types/index'
|
|
2
2
|
import { a2uiToBlocks, blocksToA2ui, isA2uiSurfaceEmpty, type BlocksToA2uiOptions } from './a2uiAdapter'
|
|
3
|
-
import { normalizeFormDefinition } from './formSchema'
|
|
3
|
+
import { normalizeFormDefinition, normalizeJsonSchema } from './formSchema'
|
|
4
4
|
|
|
5
5
|
function isA2uiSurfaceDoc(raw: unknown): raw is A2uiSurfaceDoc {
|
|
6
6
|
return (
|
|
@@ -27,7 +27,7 @@ export function resolveSurfaceFromFormDefinition(
|
|
|
27
27
|
title: typeof payload.title === 'string' ? payload.title : '',
|
|
28
28
|
description: typeof payload.description === 'string' ? payload.description : '',
|
|
29
29
|
blocks,
|
|
30
|
-
jsonSchema:
|
|
30
|
+
jsonSchema: normalizeJsonSchema(payload.jsonSchema ?? payload.json_schema),
|
|
31
31
|
})
|
|
32
32
|
const doc = blocksToA2ui(normalized, options)
|
|
33
33
|
return isA2uiSurfaceEmpty(doc) ? null : doc
|
|
@@ -61,6 +61,6 @@ export function mapFormDefinitionFromDto(raw: unknown): FormSchema | undefined {
|
|
|
61
61
|
title: typeof obj.title === 'string' ? obj.title : '',
|
|
62
62
|
description: typeof obj.description === 'string' ? obj.description : '',
|
|
63
63
|
blocks: Array.isArray(obj.blocks) ? (obj.blocks as FormSchema['blocks']) : [],
|
|
64
|
-
jsonSchema:
|
|
64
|
+
jsonSchema: normalizeJsonSchema(obj.jsonSchema ?? obj.json_schema),
|
|
65
65
|
})
|
|
66
66
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import type
|
|
2
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
3
3
|
import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
|
|
4
4
|
import {
|
|
5
5
|
basicCatalog,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import dayjs, { type Dayjs } from 'dayjs'
|
|
22
22
|
import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
|
|
23
23
|
import { coerceToggleValue } from '../adapter/formSchema'
|
|
24
|
+
import { matchesValidationRegexp } from '../submit/validateFormSubmit'
|
|
24
25
|
import {
|
|
25
26
|
buildFileAcceptAttribute,
|
|
26
27
|
formatFileAcceptSummary,
|
|
@@ -35,6 +36,7 @@ import { useA2uiPreviewMode } from './a2uiPreviewContext'
|
|
|
35
36
|
import {
|
|
36
37
|
asOptionalString,
|
|
37
38
|
asStringArray,
|
|
39
|
+
normalizeSelectStoredValue,
|
|
38
40
|
readBoundFieldValue,
|
|
39
41
|
useSkoponBoundField,
|
|
40
42
|
} from './useSkoponBoundField'
|
|
@@ -247,6 +249,11 @@ function SkoponSelectPreview({ context }: { context: ComponentContext }) {
|
|
|
247
249
|
const options = readOptions(props.options)
|
|
248
250
|
const isMultiple = readString(props.mode) === 'multiple'
|
|
249
251
|
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
const normalized = normalizeSelectStoredValue(value, isMultiple)
|
|
254
|
+
if (normalized !== null) setValue(normalized)
|
|
255
|
+
}, [isMultiple, value, setValue])
|
|
256
|
+
|
|
250
257
|
return (
|
|
251
258
|
<div className="form-block-preview">
|
|
252
259
|
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
@@ -289,6 +296,13 @@ function ChoicePickerPreview({ context }: { context: ComponentContext }) {
|
|
|
289
296
|
: isMultiple
|
|
290
297
|
? 'checkbox'
|
|
291
298
|
: 'radio'
|
|
299
|
+
const isMultiplePicker =
|
|
300
|
+
effectiveType === 'multiselect' || effectiveType === 'checkbox'
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
const normalized = normalizeSelectStoredValue(value, isMultiplePicker)
|
|
304
|
+
if (normalized !== null) setValue(normalized)
|
|
305
|
+
}, [isMultiplePicker, value, setValue])
|
|
292
306
|
|
|
293
307
|
return (
|
|
294
308
|
<div className="form-block-preview">
|
|
@@ -557,6 +571,7 @@ function FileUploadPreview({ context }: { context: ComponentContext }) {
|
|
|
557
571
|
允许:{acceptSummary ?? '全部类型'}
|
|
558
572
|
{minCount > 0 ? `;至少 ${minCount} 个文件` : ''}
|
|
559
573
|
{hasMaxLimit ? `;最多 ${maxCount} 个文件` : ''}
|
|
574
|
+
;当前仅收集文件名(不含文件内容,需宿主另行上传)
|
|
560
575
|
{belowMinCount && interactive ? `(当前 ${selectedNames.length} 个,未达下限)` : ''}
|
|
561
576
|
</Typography.Text>
|
|
562
577
|
</div>
|
|
@@ -575,8 +590,22 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
|
|
|
575
590
|
const label = readString(props.label)
|
|
576
591
|
const placeholder = readString(props.placeholder)
|
|
577
592
|
const variant = readString(props.variant) || 'shortText'
|
|
593
|
+
const validationRegexp = readString(props.validationRegexp)
|
|
578
594
|
const stringValue = asOptionalString(value) ?? ''
|
|
579
595
|
const disabled = !interactive
|
|
596
|
+
const [formatError, setFormatError] = useState('')
|
|
597
|
+
|
|
598
|
+
const validateFormat = (text: string) => {
|
|
599
|
+
if (!validationRegexp || !text.trim()) {
|
|
600
|
+
setFormatError('')
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
setFormatError(matchesValidationRegexp(text, validationRegexp) ? '' : '格式不正确')
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
useEffect(() => {
|
|
607
|
+
validateFormat(stringValue)
|
|
608
|
+
}, [validationRegexp, stringValue])
|
|
580
609
|
|
|
581
610
|
const controlClassName = 'form-block-preview-control'
|
|
582
611
|
|
|
@@ -589,7 +618,13 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
|
|
|
589
618
|
placeholder={placeholder || '长文本回答...'}
|
|
590
619
|
autoSize={{ minRows: 2, maxRows: 6 }}
|
|
591
620
|
value={stringValue}
|
|
592
|
-
|
|
621
|
+
status={formatError ? 'error' : undefined}
|
|
622
|
+
onChange={(e) => {
|
|
623
|
+
const next = e.target.value
|
|
624
|
+
setValue(next)
|
|
625
|
+
validateFormat(next)
|
|
626
|
+
}}
|
|
627
|
+
onBlur={(e) => validateFormat(e.target.value)}
|
|
593
628
|
/>
|
|
594
629
|
)
|
|
595
630
|
} else if (variant === 'number') {
|
|
@@ -622,7 +657,13 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
|
|
|
622
657
|
className={controlClassName}
|
|
623
658
|
placeholder={placeholder}
|
|
624
659
|
value={stringValue}
|
|
625
|
-
|
|
660
|
+
status={formatError ? 'error' : undefined}
|
|
661
|
+
onChange={(e) => {
|
|
662
|
+
const next = e.target.value
|
|
663
|
+
setValue(next)
|
|
664
|
+
validateFormat(next)
|
|
665
|
+
}}
|
|
666
|
+
onBlur={(e) => validateFormat(e.target.value)}
|
|
626
667
|
/>
|
|
627
668
|
)
|
|
628
669
|
}
|
|
@@ -631,6 +672,11 @@ function TextFieldPreview({ context }: { context: ComponentContext }) {
|
|
|
631
672
|
<div className="form-block-preview">
|
|
632
673
|
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
633
674
|
{control}
|
|
675
|
+
{formatError ? (
|
|
676
|
+
<Typography.Text type="danger" className="form-block-preview-help">
|
|
677
|
+
{formatError}
|
|
678
|
+
</Typography.Text>
|
|
679
|
+
) : null}
|
|
634
680
|
</div>
|
|
635
681
|
)
|
|
636
682
|
}
|
|
@@ -90,7 +90,7 @@ function CaseCard({
|
|
|
90
90
|
onClick={onToggle}
|
|
91
91
|
>
|
|
92
92
|
<div className="skopon-resume-select-card-avatar">
|
|
93
|
-
<FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #
|
|
93
|
+
<FileTextOutlined style={{ fontSize: 24, color: 'var(--color-primary, #208368)' }} />
|
|
94
94
|
</div>
|
|
95
95
|
<div className="skopon-resume-select-card-body">
|
|
96
96
|
<div className="skopon-resume-select-card-name">{item.name}</div>
|
|
@@ -152,6 +152,7 @@ function SkoponCaseSelectPreview({ context }: { context: ComponentContext }) {
|
|
|
152
152
|
setItems([])
|
|
153
153
|
setTotal(0)
|
|
154
154
|
setFetchError('')
|
|
155
|
+
setLoading(false)
|
|
155
156
|
return
|
|
156
157
|
}
|
|
157
158
|
setLoading(true)
|
|
@@ -0,0 +1,182 @@
|
|
|
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 } from 'react'
|
|
15
|
+
import { render, cleanup, waitFor } from '@testing-library/react'
|
|
16
|
+
import { blocksToA2ui } from '../adapter/a2uiAdapter'
|
|
17
|
+
import SkoponFormRenderer from '../components/SkoponFormRenderer'
|
|
18
|
+
import type { CaseSearchFn } from './caseSearchContext'
|
|
19
|
+
import type { ResumeSearchFn } from './resumeSearchContext'
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
class ResizeObserverMock {
|
|
23
|
+
observe() {}
|
|
24
|
+
unobserve() {}
|
|
25
|
+
disconnect() {}
|
|
26
|
+
}
|
|
27
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
function buildCaseSurface(flowUniqueIds: string[]) {
|
|
31
|
+
return blocksToA2ui(
|
|
32
|
+
{
|
|
33
|
+
title: '',
|
|
34
|
+
description: '',
|
|
35
|
+
blocks: [
|
|
36
|
+
{
|
|
37
|
+
id: 'b-case',
|
|
38
|
+
type: 'case_multiselect',
|
|
39
|
+
name: 'cases',
|
|
40
|
+
label: '案例',
|
|
41
|
+
caseFilter: { flowUniqueIds, pageSize: 20 },
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{ includeHeader: false },
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildResumeSurface() {
|
|
50
|
+
return blocksToA2ui(
|
|
51
|
+
{
|
|
52
|
+
title: '',
|
|
53
|
+
description: '',
|
|
54
|
+
blocks: [
|
|
55
|
+
{
|
|
56
|
+
id: 'b-resume',
|
|
57
|
+
type: 'resume_multiselect',
|
|
58
|
+
name: 'resumes',
|
|
59
|
+
label: '简历',
|
|
60
|
+
resumeFilter: { names: [], agentUniqueIds: [], resumeUniqueIds: [], pageSize: 20 },
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
{ includeHeader: false },
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('case/resume select loading state', () => {
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
cleanup()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('clears case loading when caseSearch is removed while fetch is in flight', async () => {
|
|
74
|
+
let resolveSearch!: (value: Awaited<ReturnType<CaseSearchFn>>) => void
|
|
75
|
+
const caseSearch = vi.fn(
|
|
76
|
+
() =>
|
|
77
|
+
new Promise<Awaited<ReturnType<CaseSearchFn>>>((resolve) => {
|
|
78
|
+
resolveSearch = resolve
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const doc = buildCaseSurface(['flow-1'])
|
|
83
|
+
const { rerender, queryByText } = render(
|
|
84
|
+
createElement(SkoponFormRenderer, {
|
|
85
|
+
doc,
|
|
86
|
+
surfaceId: 'case-loading-test',
|
|
87
|
+
caseSearch,
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
expect(queryByText('加载案例…')).toBeTruthy()
|
|
92
|
+
|
|
93
|
+
rerender(
|
|
94
|
+
createElement(SkoponFormRenderer, {
|
|
95
|
+
doc,
|
|
96
|
+
surfaceId: 'case-loading-test',
|
|
97
|
+
caseSearch: null,
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(queryByText('加载案例…')).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
expect(queryByText('未配置案例搜索服务')).toBeTruthy()
|
|
105
|
+
|
|
106
|
+
resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('clears case loading when filter becomes invalid while fetch is in flight', async () => {
|
|
110
|
+
let resolveSearch!: (value: Awaited<ReturnType<CaseSearchFn>>) => void
|
|
111
|
+
const caseSearch = vi.fn(
|
|
112
|
+
() =>
|
|
113
|
+
new Promise<Awaited<ReturnType<CaseSearchFn>>>((resolve) => {
|
|
114
|
+
resolveSearch = resolve
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const validDoc = buildCaseSurface(['flow-1'])
|
|
119
|
+
const invalidDoc = buildCaseSurface([])
|
|
120
|
+
|
|
121
|
+
const { rerender, queryByText } = render(
|
|
122
|
+
createElement(SkoponFormRenderer, {
|
|
123
|
+
doc: validDoc,
|
|
124
|
+
surfaceId: 'case-filter-test',
|
|
125
|
+
caseSearch,
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
expect(queryByText('加载案例…')).toBeTruthy()
|
|
130
|
+
|
|
131
|
+
rerender(
|
|
132
|
+
createElement(SkoponFormRenderer, {
|
|
133
|
+
doc: invalidDoc,
|
|
134
|
+
surfaceId: 'case-filter-test',
|
|
135
|
+
caseSearch,
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(queryByText('加载案例…')).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
expect(queryByText('未配置案例列表筛选条件')).toBeTruthy()
|
|
143
|
+
|
|
144
|
+
resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('clears resume loading when resumeSearch is removed while fetch is in flight', async () => {
|
|
148
|
+
let resolveSearch!: (value: Awaited<ReturnType<ResumeSearchFn>>) => void
|
|
149
|
+
const resumeSearch = vi.fn(
|
|
150
|
+
() =>
|
|
151
|
+
new Promise<Awaited<ReturnType<ResumeSearchFn>>>((resolve) => {
|
|
152
|
+
resolveSearch = resolve
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const doc = buildResumeSurface()
|
|
157
|
+
const { rerender, queryByText } = render(
|
|
158
|
+
createElement(SkoponFormRenderer, {
|
|
159
|
+
doc,
|
|
160
|
+
surfaceId: 'resume-loading-test',
|
|
161
|
+
resumeSearch,
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
expect(queryByText('加载简历…')).toBeTruthy()
|
|
166
|
+
|
|
167
|
+
rerender(
|
|
168
|
+
createElement(SkoponFormRenderer, {
|
|
169
|
+
doc,
|
|
170
|
+
surfaceId: 'resume-loading-test',
|
|
171
|
+
resumeSearch: null,
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
expect(queryByText('加载简历…')).toBeNull()
|
|
177
|
+
})
|
|
178
|
+
expect(queryByText('未配置简历搜索服务')).toBeTruthy()
|
|
179
|
+
|
|
180
|
+
resolveSearch({ list: [], total: 0, page: 1, pageSize: 20 })
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -85,4 +85,35 @@ describe('SkoponFormRenderer TextField longText', () => {
|
|
|
85
85
|
expect(warnSpy.mock.calls.some((call) => String(call[0]).includes('[useMarkdown]'))).toBe(false)
|
|
86
86
|
warnSpy.mockRestore()
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
it('shows format error for invalid email TextField', () => {
|
|
90
|
+
const doc = blocksToA2ui(
|
|
91
|
+
{
|
|
92
|
+
title: '',
|
|
93
|
+
description: '',
|
|
94
|
+
blocks: [
|
|
95
|
+
{
|
|
96
|
+
id: 'b-email',
|
|
97
|
+
type: 'email',
|
|
98
|
+
name: 'contact',
|
|
99
|
+
label: '邮箱',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{ includeHeader: false },
|
|
104
|
+
)
|
|
105
|
+
const { container, getByText } = render(
|
|
106
|
+
createElement(SkoponFormRenderer, {
|
|
107
|
+
doc,
|
|
108
|
+
surfaceId: 'test-email',
|
|
109
|
+
fieldNames: ['contact'],
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
const input = container.querySelector('input.ant-input')
|
|
113
|
+
expect(input).toBeTruthy()
|
|
114
|
+
if (input) {
|
|
115
|
+
fireEvent.change(input, { target: { value: 'not-an-email' } })
|
|
116
|
+
}
|
|
117
|
+
expect(getByText('格式不正确')).toBeTruthy()
|
|
118
|
+
})
|
|
88
119
|
})
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { describe, it, expect } from 'vitest'
|
|
5
5
|
import type { ComponentContext } from '@a2ui/web_core/v0_9'
|
|
6
|
-
import { asStringArray, clampStringArraySelection, readBoundFieldValue, toggleStringArraySelection } from './useSkoponBoundField'
|
|
6
|
+
import { asStringArray, clampStringArraySelection, normalizeSelectStoredValue, readBoundFieldValue, toggleStringArraySelection } from './useSkoponBoundField'
|
|
7
7
|
|
|
8
8
|
function createMockContext(initial: unknown = []): {
|
|
9
9
|
context: ComponentContext
|
|
@@ -29,6 +29,19 @@ function createMockContext(initial: unknown = []): {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
describe('normalizeSelectStoredValue', () => {
|
|
33
|
+
it('coerces string to array in multiple mode', () => {
|
|
34
|
+
expect(normalizeSelectStoredValue('a', true)).toEqual(['a'])
|
|
35
|
+
expect(normalizeSelectStoredValue(['a', 'b'], true)).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('coerces array to string in single mode', () => {
|
|
39
|
+
expect(normalizeSelectStoredValue(['a', 'b'], false)).toBe('a')
|
|
40
|
+
expect(normalizeSelectStoredValue([], false)).toBe('')
|
|
41
|
+
expect(normalizeSelectStoredValue('a', false)).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
32
45
|
describe('readBoundFieldValue', () => {
|
|
33
46
|
it('reads current bound value from dataContext', () => {
|
|
34
47
|
const { context } = createMockContext(['a.pdf'])
|
|
@@ -65,6 +65,20 @@ export function asStringArray(value: unknown): string[] {
|
|
|
65
65
|
return []
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** 单选/多选模式切换时规范化 stored value;无需变更时返回 null。 */
|
|
69
|
+
export function normalizeSelectStoredValue(value: unknown, isMultiple: boolean): unknown | null {
|
|
70
|
+
if (isMultiple) {
|
|
71
|
+
if (Array.isArray(value)) return null
|
|
72
|
+
return asStringArray(value)
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
return value.length > 0 ? String(value[0]) : ''
|
|
76
|
+
}
|
|
77
|
+
if (value === undefined || value === null) return null
|
|
78
|
+
if (typeof value === 'string' || typeof value === 'number') return null
|
|
79
|
+
return ''
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
export function readOptionalSelectCount(value: unknown): number | undefined {
|
|
69
83
|
if (value === undefined || value === null) return undefined
|
|
70
84
|
const num = typeof value === 'number' ? value : Number(value)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ApiEnvelope<T> {
|
|
2
|
+
success?: boolean
|
|
3
|
+
data?: T
|
|
4
|
+
message?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function parseEnvelope<T>(response: Response): Promise<T> {
|
|
8
|
+
let json: ApiEnvelope<T>
|
|
9
|
+
try {
|
|
10
|
+
json = (await response.json()) as ApiEnvelope<T>
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error(`响应非 JSON (${response.status})`)
|
|
13
|
+
}
|
|
14
|
+
if (!response.ok || json.success === false) {
|
|
15
|
+
throw new Error(json.message || `请求失败 (${response.status})`)
|
|
16
|
+
}
|
|
17
|
+
if (json.data === undefined || json.data === null) {
|
|
18
|
+
throw new Error(json.message || `响应缺少 data (${response.status})`)
|
|
19
|
+
}
|
|
20
|
+
return json.data
|
|
21
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_AUTH_STORAGE_KEY,
|
|
7
|
+
resolveSkoponAuthHeaders,
|
|
8
|
+
} from './authStorage'
|
|
9
|
+
|
|
10
|
+
describe('resolveSkoponAuthHeaders', () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
sessionStorage.clear()
|
|
13
|
+
localStorage.clear()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('reads Bearer token from sessionStorage auth_user by default', () => {
|
|
17
|
+
sessionStorage.setItem(
|
|
18
|
+
DEFAULT_AUTH_STORAGE_KEY,
|
|
19
|
+
JSON.stringify({ username: 'u', displayName: 'U', token: 'abc123' }),
|
|
20
|
+
)
|
|
21
|
+
expect(resolveSkoponAuthHeaders()).toEqual({
|
|
22
|
+
Authorization: 'Bearer abc123',
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('strips existing Bearer prefix from stored token', () => {
|
|
27
|
+
sessionStorage.setItem(
|
|
28
|
+
DEFAULT_AUTH_STORAGE_KEY,
|
|
29
|
+
JSON.stringify({ token: 'Bearer xyz' }),
|
|
30
|
+
)
|
|
31
|
+
expect(resolveSkoponAuthHeaders()).toEqual({
|
|
32
|
+
Authorization: 'Bearer xyz',
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns empty object when session is missing', () => {
|
|
37
|
+
expect(resolveSkoponAuthHeaders()).toEqual({})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns empty object when token field is empty', () => {
|
|
41
|
+
sessionStorage.setItem(DEFAULT_AUTH_STORAGE_KEY, JSON.stringify({ token: '' }))
|
|
42
|
+
expect(resolveSkoponAuthHeaders()).toEqual({})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('supports custom storageKey and tokenField', () => {
|
|
46
|
+
sessionStorage.setItem('my_session', JSON.stringify({ accessToken: 'tok-1' }))
|
|
47
|
+
expect(
|
|
48
|
+
resolveSkoponAuthHeaders({ storageKey: 'my_session', tokenField: 'accessToken' }),
|
|
49
|
+
).toEqual({ Authorization: 'Bearer tok-1' })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('supports localStorage', () => {
|
|
53
|
+
localStorage.setItem(
|
|
54
|
+
DEFAULT_AUTH_STORAGE_KEY,
|
|
55
|
+
JSON.stringify({ token: 'local-tok' }),
|
|
56
|
+
)
|
|
57
|
+
expect(resolveSkoponAuthHeaders({ storage: 'localStorage' })).toEqual({
|
|
58
|
+
Authorization: 'Bearer local-tok',
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
})
|