@skopon-cool/form-sdk 0.4.9 → 0.5.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/README.md +26 -1
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/adapter/formOptions.d.ts +6 -0
- package/dist/adapter/formOptions.d.ts.map +1 -0
- package/dist/adapter/formSchema.d.ts.map +1 -1
- package/dist/adapter/formWeb.d.ts +14 -0
- package/dist/adapter/formWeb.d.ts.map +1 -0
- package/dist/blocks/registry.d.ts.map +1 -1
- package/dist/blocks/web/adapter.d.ts +8 -0
- package/dist/blocks/web/adapter.d.ts.map +1 -0
- package/dist/blocks/web/catalog.d.ts +9 -0
- package/dist/blocks/web/catalog.d.ts.map +1 -0
- package/dist/blocks/web/index.d.ts +3 -0
- package/dist/blocks/web/index.d.ts.map +1 -0
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/client/authStorage.d.ts +1 -1
- package/dist/client/authStorage.d.ts.map +1 -1
- package/dist/form-sdk.css +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1408 -1220
- package/dist/submit/intersectPayloadBlocksWithForm.d.ts.map +1 -1
- package/dist/types/index.d.ts +7 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapter/a2uiAdapter.test.ts +96 -0
- package/src/adapter/a2uiAdapter.ts +3 -12
- package/src/adapter/formOptions.test.ts +60 -0
- package/src/adapter/formOptions.ts +36 -0
- package/src/adapter/formSchema.test.ts +68 -0
- package/src/adapter/formSchema.ts +19 -6
- package/src/adapter/formWeb.test.ts +38 -0
- package/src/adapter/formWeb.ts +41 -0
- package/src/blocks/registry.ts +6 -1
- package/src/blocks/web/adapter.ts +36 -0
- package/src/blocks/web/catalog.test.tsx +95 -0
- package/src/blocks/web/catalog.tsx +158 -0
- package/src/blocks/web/index.ts +11 -0
- package/src/catalog/a2uiCustomCatalog.tsx +6 -10
- package/src/catalog/invalidStaticOptions.test.tsx +62 -0
- package/src/client/authStorage.ts +1 -1
- package/src/index.ts +9 -0
- package/src/styles/index.css +87 -0
- package/src/submit/intersectPayloadBlocksWithForm.ts +14 -4
- package/src/submit/submit.test.ts +54 -2
- package/src/submit/validateFormSubmit.ts +2 -2
- package/src/types/index.ts +12 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cleanup, fireEvent, render } from '@testing-library/react'
|
|
4
|
+
import { buildSkoponCatalog } from '../../catalog/a2uiCustomCatalog'
|
|
5
|
+
import { SkoponWebPreview } from './catalog'
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
const getComputedStyle = window.getComputedStyle.bind(window)
|
|
9
|
+
vi.spyOn(window, 'getComputedStyle').mockImplementation((element) =>
|
|
10
|
+
getComputedStyle(element),
|
|
11
|
+
)
|
|
12
|
+
class ResizeObserverMock {
|
|
13
|
+
observe() {}
|
|
14
|
+
unobserve() {}
|
|
15
|
+
disconnect() {}
|
|
16
|
+
}
|
|
17
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
|
18
|
+
vi.stubGlobal(
|
|
19
|
+
'matchMedia',
|
|
20
|
+
vi.fn().mockImplementation((query: string) => ({
|
|
21
|
+
matches: false,
|
|
22
|
+
media: query,
|
|
23
|
+
onchange: null,
|
|
24
|
+
addListener: vi.fn(),
|
|
25
|
+
removeListener: vi.fn(),
|
|
26
|
+
addEventListener: vi.fn(),
|
|
27
|
+
removeEventListener: vi.fn(),
|
|
28
|
+
dispatchEvent: vi.fn(),
|
|
29
|
+
})),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
cleanup()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function activeFrames(url: string): HTMLIFrameElement[] {
|
|
38
|
+
return Array.from(document.querySelectorAll<HTMLIFrameElement>('iframe')).filter(
|
|
39
|
+
(frame) => frame.getAttribute('src') === url,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('SkoponWebPreview', () => {
|
|
44
|
+
it('is registered in the merged Skopon A2UI catalog', () => {
|
|
45
|
+
const catalog = buildSkoponCatalog()
|
|
46
|
+
expect(
|
|
47
|
+
[...catalog.components.values()].some((component) => component.name === 'SkoponWeb'),
|
|
48
|
+
).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders one inline iframe and moves the active URL into the modal', () => {
|
|
52
|
+
const url = 'https://example.com/page'
|
|
53
|
+
const view = render(
|
|
54
|
+
<SkoponWebPreview
|
|
55
|
+
url={url}
|
|
56
|
+
height={480}
|
|
57
|
+
label="官网"
|
|
58
|
+
help="可扩展查看"
|
|
59
|
+
/>,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const inlineFrame = view.getByTitle('官网') as HTMLIFrameElement
|
|
63
|
+
expect(inlineFrame.style.height).toBe('480px')
|
|
64
|
+
expect(inlineFrame.getAttribute('loading')).toBe('lazy')
|
|
65
|
+
expect(activeFrames(url)).toHaveLength(1)
|
|
66
|
+
expect(view.getByText('可扩展查看')).toBeTruthy()
|
|
67
|
+
|
|
68
|
+
fireEvent.click(view.getByRole('button', { name: '扩展显示' }))
|
|
69
|
+
|
|
70
|
+
expect(document.querySelector('.skopon-web-modal')).toBeTruthy()
|
|
71
|
+
expect(view.queryByTitle('官网')).toBeNull()
|
|
72
|
+
expect(document.querySelector('iframe[title="官网(扩展显示)"]')).toBeTruthy()
|
|
73
|
+
expect(activeFrames(url)).toHaveLength(1)
|
|
74
|
+
|
|
75
|
+
const closeButton = document.querySelector<HTMLButtonElement>('.ant-modal-close')
|
|
76
|
+
expect(closeButton).toBeTruthy()
|
|
77
|
+
if (closeButton) fireEvent.click(closeButton)
|
|
78
|
+
|
|
79
|
+
expect(activeFrames(url)).toHaveLength(1)
|
|
80
|
+
expect(view.getByTitle('官网')).toBeTruthy()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('shows distinct placeholders and never passes unsupported URLs to iframe', () => {
|
|
84
|
+
const emptyView = render(<SkoponWebPreview url="" />)
|
|
85
|
+
expect(emptyView.getByText('未设置网页 URL')).toBeTruthy()
|
|
86
|
+
expect(document.querySelector('iframe')).toBeNull()
|
|
87
|
+
expect(emptyView.queryByRole('button', { name: '扩展显示' })).toBeNull()
|
|
88
|
+
emptyView.unmount()
|
|
89
|
+
|
|
90
|
+
const invalidView = render(<SkoponWebPreview url="javascript:alert(1)" />)
|
|
91
|
+
expect(invalidView.getByText('网页 URL 无效')).toBeTruthy()
|
|
92
|
+
expect(document.querySelector('iframe')).toBeNull()
|
|
93
|
+
expect(invalidView.queryByRole('button', { name: '扩展显示' })).toBeNull()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { FullscreenOutlined } from '@ant-design/icons'
|
|
2
|
+
import { Button, Modal, Tooltip, Typography } from 'antd'
|
|
3
|
+
import { useRef, useState, type Ref } from 'react'
|
|
4
|
+
import { createBinderlessComponentImplementation } from '@a2ui/react/v0_9'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { isSupportedWebUrl, normalizeWebHeight } from '../../adapter/formWeb'
|
|
7
|
+
|
|
8
|
+
export interface SkoponWebPreviewProps {
|
|
9
|
+
url?: string
|
|
10
|
+
height?: number
|
|
11
|
+
label?: string
|
|
12
|
+
help?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function WebFrame({
|
|
16
|
+
url,
|
|
17
|
+
title,
|
|
18
|
+
className,
|
|
19
|
+
height,
|
|
20
|
+
frameRef,
|
|
21
|
+
}: {
|
|
22
|
+
url: string
|
|
23
|
+
title: string
|
|
24
|
+
className: string
|
|
25
|
+
height?: number
|
|
26
|
+
frameRef?: Ref<HTMLIFrameElement>
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<iframe
|
|
30
|
+
ref={frameRef}
|
|
31
|
+
src={url}
|
|
32
|
+
title={title}
|
|
33
|
+
className={className}
|
|
34
|
+
style={height === undefined ? undefined : { height }}
|
|
35
|
+
loading="lazy"
|
|
36
|
+
referrerPolicy="strict-origin-when-cross-origin"
|
|
37
|
+
allowFullScreen
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SkoponWebPreview({
|
|
43
|
+
url = '',
|
|
44
|
+
height,
|
|
45
|
+
label,
|
|
46
|
+
help,
|
|
47
|
+
}: SkoponWebPreviewProps) {
|
|
48
|
+
const [expanded, setExpanded] = useState(false)
|
|
49
|
+
const [inlineActive, setInlineActive] = useState(true)
|
|
50
|
+
const modalFrameRef = useRef<HTMLIFrameElement>(null)
|
|
51
|
+
const trimmedUrl = url.trim()
|
|
52
|
+
const supportedUrl = isSupportedWebUrl(trimmedUrl) ? trimmedUrl : null
|
|
53
|
+
const resolvedHeight = normalizeWebHeight(height)
|
|
54
|
+
const title = label?.trim() || '网页展示'
|
|
55
|
+
const helpText = help?.trim()
|
|
56
|
+
const emptyText = trimmedUrl ? '网页 URL 无效' : '未设置网页 URL'
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="form-block-preview skopon-web-preview">
|
|
60
|
+
{label?.trim() ? <div className="form-block-preview-label">{label.trim()}</div> : null}
|
|
61
|
+
|
|
62
|
+
<div
|
|
63
|
+
className={`skopon-web-frame-shell${supportedUrl ? '' : ' skopon-web-frame-shell--empty'}`}
|
|
64
|
+
style={{ height: resolvedHeight }}
|
|
65
|
+
>
|
|
66
|
+
{supportedUrl && inlineActive ? (
|
|
67
|
+
<WebFrame
|
|
68
|
+
url={supportedUrl}
|
|
69
|
+
title={title}
|
|
70
|
+
className="skopon-web-frame skopon-web-frame--inline"
|
|
71
|
+
height={resolvedHeight}
|
|
72
|
+
/>
|
|
73
|
+
) : null}
|
|
74
|
+
|
|
75
|
+
{!supportedUrl ? (
|
|
76
|
+
<div className="skopon-web-empty" role="status">
|
|
77
|
+
{emptyText}
|
|
78
|
+
</div>
|
|
79
|
+
) : null}
|
|
80
|
+
|
|
81
|
+
{supportedUrl ? (
|
|
82
|
+
<Tooltip title="扩展显示">
|
|
83
|
+
<Button
|
|
84
|
+
type="default"
|
|
85
|
+
shape="circle"
|
|
86
|
+
className="skopon-web-expand-button"
|
|
87
|
+
icon={<FullscreenOutlined />}
|
|
88
|
+
aria-label="扩展显示"
|
|
89
|
+
onClick={() => {
|
|
90
|
+
setInlineActive(false)
|
|
91
|
+
setExpanded(true)
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
</Tooltip>
|
|
95
|
+
) : null}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{helpText ? (
|
|
99
|
+
<Typography.Text type="secondary" className="form-block-preview-help">
|
|
100
|
+
{helpText}
|
|
101
|
+
</Typography.Text>
|
|
102
|
+
) : null}
|
|
103
|
+
|
|
104
|
+
<Modal
|
|
105
|
+
open={expanded}
|
|
106
|
+
title={title}
|
|
107
|
+
footer={null}
|
|
108
|
+
width="min(1200px, calc(100vw - 32px))"
|
|
109
|
+
className="skopon-web-modal"
|
|
110
|
+
destroyOnHidden
|
|
111
|
+
mask={{ closable: true }}
|
|
112
|
+
onCancel={() => {
|
|
113
|
+
// rc-dialog 在关闭动画期间会缓存子树;先清掉 src,避免行内 iframe
|
|
114
|
+
// 恢复后与缓存中的弹窗 iframe 同时继续运行。
|
|
115
|
+
modalFrameRef.current?.removeAttribute('src')
|
|
116
|
+
setExpanded(false)
|
|
117
|
+
setInlineActive(true)
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{supportedUrl && !inlineActive ? (
|
|
121
|
+
<WebFrame
|
|
122
|
+
url={supportedUrl}
|
|
123
|
+
title={`${title}(扩展显示)`}
|
|
124
|
+
className="skopon-web-frame skopon-web-frame--modal"
|
|
125
|
+
frameRef={modalFrameRef}
|
|
126
|
+
/>
|
|
127
|
+
) : null}
|
|
128
|
+
</Modal>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const SkoponWebApi = {
|
|
134
|
+
name: 'SkoponWeb',
|
|
135
|
+
schema: z
|
|
136
|
+
.object({
|
|
137
|
+
url: z.any().optional(),
|
|
138
|
+
height: z.any().optional(),
|
|
139
|
+
label: z.any().optional(),
|
|
140
|
+
help: z.any().optional(),
|
|
141
|
+
})
|
|
142
|
+
.passthrough(),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const SkoponWebImpl = createBinderlessComponentImplementation(
|
|
146
|
+
SkoponWebApi,
|
|
147
|
+
({ context }) => {
|
|
148
|
+
const props = context.componentModel.properties as Record<string, unknown>
|
|
149
|
+
return (
|
|
150
|
+
<SkoponWebPreview
|
|
151
|
+
url={typeof props.url === 'string' ? props.url : ''}
|
|
152
|
+
height={normalizeWebHeight(props.height)}
|
|
153
|
+
label={typeof props.label === 'string' ? props.label : ''}
|
|
154
|
+
help={typeof props.help === 'string' ? props.help : ''}
|
|
155
|
+
/>
|
|
156
|
+
)
|
|
157
|
+
},
|
|
158
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BlockAdapterPlugin } from '../types'
|
|
2
|
+
import { webFromComponent, webToComponent } from './adapter'
|
|
3
|
+
import { SkoponWebImpl } from './catalog'
|
|
4
|
+
|
|
5
|
+
export const webAdapter: BlockAdapterPlugin = {
|
|
6
|
+
type: 'web',
|
|
7
|
+
componentName: 'SkoponWeb',
|
|
8
|
+
toComponent: webToComponent,
|
|
9
|
+
fromComponent: webFromComponent,
|
|
10
|
+
catalogComponents: [SkoponWebImpl],
|
|
11
|
+
}
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
formatFileAcceptSummary,
|
|
28
28
|
} from '../adapter/formFileAccept'
|
|
29
29
|
import { buildMediaListClassName, normalizeMediaSize } from '../adapter/formMedia'
|
|
30
|
+
import { normalizeFormOptions } from '../adapter/formOptions'
|
|
30
31
|
import {
|
|
31
32
|
getRegisteredCatalogComponentNames,
|
|
32
33
|
getRegisteredCatalogComponents,
|
|
@@ -77,16 +78,7 @@ function readString(value: unknown): string {
|
|
|
77
78
|
function readOptions(
|
|
78
79
|
raw: unknown,
|
|
79
80
|
): { label: string; value: string }[] {
|
|
80
|
-
|
|
81
|
-
return raw
|
|
82
|
-
.map((item) => {
|
|
83
|
-
if (!item || typeof item !== 'object') return null
|
|
84
|
-
const rec = item as Record<string, unknown>
|
|
85
|
-
const value = String(rec.value ?? rec.label ?? '')
|
|
86
|
-
const label = String(rec.label ?? rec.value ?? '')
|
|
87
|
-
return { value, label }
|
|
88
|
-
})
|
|
89
|
-
.filter((item): item is { label: string; value: string } => !!item)
|
|
81
|
+
return normalizeFormOptions(raw)
|
|
90
82
|
}
|
|
91
83
|
|
|
92
84
|
interface SkoponMediaPreviewProps {
|
|
@@ -257,6 +249,8 @@ function SkoponSelectPreview({ context }: { context: ComponentContext }) {
|
|
|
257
249
|
if (normalized !== null) setValueSilent(normalized)
|
|
258
250
|
}, [isMultiple, value, setValueSilent])
|
|
259
251
|
|
|
252
|
+
if (options.length === 0) return null
|
|
253
|
+
|
|
260
254
|
return (
|
|
261
255
|
<div className="form-block-preview" data-skopon-field={fieldName || undefined}>
|
|
262
256
|
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
@@ -310,6 +304,8 @@ function ChoicePickerPreview({ context }: { context: ComponentContext }) {
|
|
|
310
304
|
if (normalized !== null) setValueSilent(normalized)
|
|
311
305
|
}, [isMultiplePicker, value, setValueSilent])
|
|
312
306
|
|
|
307
|
+
if (options.length === 0) return null
|
|
308
|
+
|
|
313
309
|
return (
|
|
314
310
|
<div className="form-block-preview" data-skopon-field={fieldName || undefined}>
|
|
315
311
|
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, beforeAll, describe, expect, it, vi } 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 { cleanup, render } from '@testing-library/react'
|
|
16
|
+
import SkoponFormRenderer from '../components/SkoponFormRenderer'
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
class ResizeObserverMock {
|
|
20
|
+
observe() {}
|
|
21
|
+
unobserve() {}
|
|
22
|
+
disconnect() {}
|
|
23
|
+
}
|
|
24
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(() => cleanup())
|
|
28
|
+
|
|
29
|
+
describe('direct A2UI static options', () => {
|
|
30
|
+
it('does not render an invalid empty question and keeps a numeric option question', () => {
|
|
31
|
+
const doc = {
|
|
32
|
+
root: 'root',
|
|
33
|
+
components: [
|
|
34
|
+
{ id: 'root', component: 'Column', children: ['invalid', 'valid'] },
|
|
35
|
+
{
|
|
36
|
+
id: 'invalid',
|
|
37
|
+
component: 'SkoponSelect',
|
|
38
|
+
label: '不应显示的问题',
|
|
39
|
+
options: [{ value: null, label: '空' }, { value: {}, label: '对象' }],
|
|
40
|
+
value: { path: '/invalid' },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'valid',
|
|
44
|
+
component: 'ChoicePicker',
|
|
45
|
+
label: '应显示的问题',
|
|
46
|
+
options: [{ value: 0, label: 123 }],
|
|
47
|
+
value: { path: '/valid' },
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const view = render(
|
|
53
|
+
createElement(SkoponFormRenderer, {
|
|
54
|
+
doc,
|
|
55
|
+
surfaceId: 'invalid-options-test',
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(view.queryByText('不应显示的问题')).toBeNull()
|
|
60
|
+
expect(view.queryByText('应显示的问题')).toBeTruthy()
|
|
61
|
+
})
|
|
62
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -27,9 +27,18 @@ export {
|
|
|
27
27
|
isLayoutBlockType,
|
|
28
28
|
isMediaBlockType,
|
|
29
29
|
isOptionsBlockType,
|
|
30
|
+
isWebBlockType,
|
|
30
31
|
supportsDefaultValue,
|
|
31
32
|
} from './types/index'
|
|
32
33
|
|
|
34
|
+
export {
|
|
35
|
+
FORM_WEB_DEFAULT_HEIGHT,
|
|
36
|
+
FORM_WEB_MAX_HEIGHT,
|
|
37
|
+
FORM_WEB_MIN_HEIGHT,
|
|
38
|
+
isSupportedWebUrl,
|
|
39
|
+
normalizeWebHeight,
|
|
40
|
+
} from './adapter/formWeb'
|
|
41
|
+
|
|
33
42
|
export {
|
|
34
43
|
a2uiToBlocks,
|
|
35
44
|
blocksToA2ui,
|
package/src/styles/index.css
CHANGED
|
@@ -105,6 +105,93 @@
|
|
|
105
105
|
text-align: center;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/* 网页展示 */
|
|
109
|
+
.skopon-web-preview {
|
|
110
|
+
min-width: 0;
|
|
111
|
+
max-width: 100%;
|
|
112
|
+
overflow-x: hidden;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.skopon-web-frame-shell {
|
|
116
|
+
position: relative;
|
|
117
|
+
box-sizing: border-box;
|
|
118
|
+
width: 100%;
|
|
119
|
+
max-width: 100%;
|
|
120
|
+
min-width: 0;
|
|
121
|
+
overflow: hidden;
|
|
122
|
+
border: 1px solid var(--color-border-subtle);
|
|
123
|
+
border-radius: var(--radius-md);
|
|
124
|
+
background: var(--color-bg-subtle);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.skopon-web-frame-shell--empty {
|
|
128
|
+
border-style: dashed;
|
|
129
|
+
border-color: var(--color-border);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.skopon-web-frame {
|
|
133
|
+
display: block;
|
|
134
|
+
box-sizing: border-box;
|
|
135
|
+
width: 100%;
|
|
136
|
+
max-width: 100%;
|
|
137
|
+
min-width: 0;
|
|
138
|
+
border: 0;
|
|
139
|
+
background: var(--color-surface);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.skopon-web-empty {
|
|
143
|
+
display: flex;
|
|
144
|
+
width: 100%;
|
|
145
|
+
height: 100%;
|
|
146
|
+
align-items: center;
|
|
147
|
+
justify-content: center;
|
|
148
|
+
padding: var(--space-4);
|
|
149
|
+
color: var(--color-text-muted);
|
|
150
|
+
font-size: var(--font-size-sm);
|
|
151
|
+
text-align: center;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.skopon-web-expand-button.ant-btn {
|
|
155
|
+
position: absolute;
|
|
156
|
+
right: var(--space-3);
|
|
157
|
+
bottom: var(--space-3);
|
|
158
|
+
z-index: 1;
|
|
159
|
+
width: var(--button-height-md);
|
|
160
|
+
min-width: var(--button-height-md);
|
|
161
|
+
height: var(--button-height-md);
|
|
162
|
+
padding: 0;
|
|
163
|
+
color: var(--color-primary);
|
|
164
|
+
border-color: var(--color-border);
|
|
165
|
+
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
|
|
166
|
+
box-shadow: var(--shadow-md);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.skopon-web-expand-button.ant-btn:hover {
|
|
170
|
+
color: var(--color-primary-hover);
|
|
171
|
+
border-color: var(--color-primary-hover);
|
|
172
|
+
background: var(--color-surface);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.skopon-web-expand-button.ant-btn:focus-visible {
|
|
176
|
+
outline: none;
|
|
177
|
+
box-shadow: var(--shadow-focus), var(--shadow-md);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.skopon-web-modal .ant-modal-content {
|
|
181
|
+
overflow: hidden;
|
|
182
|
+
border-radius: var(--radius-lg);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.skopon-web-modal .ant-modal-body {
|
|
186
|
+
padding: 0;
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.skopon-web-frame--modal {
|
|
191
|
+
height: min(80vh, 900px);
|
|
192
|
+
height: min(80dvh, 900px);
|
|
193
|
+
}
|
|
194
|
+
|
|
108
195
|
.form-file-upload-preview {
|
|
109
196
|
margin-top: var(--space-1);
|
|
110
197
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeFormDefinition } from '../adapter/formSchema'
|
|
2
|
+
import { hasRenderableFormOptions } from '../adapter/formOptions'
|
|
2
3
|
import type { FormBlock, FormSchema } from '../types/index'
|
|
3
4
|
import { isDisplayBlockType, isInputBlockType } from '../types/index'
|
|
4
5
|
|
|
@@ -58,7 +59,7 @@ function buildRenderBlocks(
|
|
|
58
59
|
if (!matched) continue
|
|
59
60
|
// hidden text 不上屏;提交值由 AskUser 从 matchedBlocks 提取
|
|
60
61
|
if (isHiddenTextBlock(matched)) continue
|
|
61
|
-
renderBlocks.push(matched)
|
|
62
|
+
if (hasRenderableFormOptions(matched)) renderBlocks.push(matched)
|
|
62
63
|
}
|
|
63
64
|
return renderBlocks
|
|
64
65
|
}
|
|
@@ -191,7 +192,7 @@ function blockValueFromDefault(block: FormBlock): unknown {
|
|
|
191
192
|
export function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string, unknown> {
|
|
192
193
|
const values: Record<string, unknown> = {}
|
|
193
194
|
for (const block of extraBlocks) {
|
|
194
|
-
if (!isInputBlockType(block.type)) continue
|
|
195
|
+
if (!isInputBlockType(block.type) || !hasRenderableFormOptions(block)) continue
|
|
195
196
|
const name = block.name?.trim()
|
|
196
197
|
if (!name) continue
|
|
197
198
|
values[name] = blockValueFromDefault(block)
|
|
@@ -202,7 +203,10 @@ export function extractExtraBlockValues(extraBlocks: FormBlock[]): Record<string
|
|
|
202
203
|
/** payload 是否含至少一个具 name 的 input 块(可用于卡片 fallback) */
|
|
203
204
|
export function payloadHasInputBlocks(payloadDef: PayloadBlocksDefinition): boolean {
|
|
204
205
|
return payloadDef.blocks.some(
|
|
205
|
-
(block) =>
|
|
206
|
+
(block) =>
|
|
207
|
+
isInputBlockType(block.type) &&
|
|
208
|
+
Boolean(block.name?.trim()) &&
|
|
209
|
+
hasRenderableFormOptions(block),
|
|
206
210
|
)
|
|
207
211
|
}
|
|
208
212
|
|
|
@@ -211,6 +215,7 @@ export function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition):
|
|
|
211
215
|
return payloadDef.blocks.filter((block) => {
|
|
212
216
|
if (isDisplayBlockType(block.type)) return true
|
|
213
217
|
if (!isInputBlockType(block.type) || !block.name?.trim()) return false
|
|
218
|
+
if (!hasRenderableFormOptions(block)) return false
|
|
214
219
|
// hidden text 不上屏;提交值由 getPayloadHiddenBlockValues / dataModel 补齐
|
|
215
220
|
return !isHiddenTextBlock(block)
|
|
216
221
|
})
|
|
@@ -219,7 +224,12 @@ export function getPayloadRenderableBlocks(payloadDef: PayloadBlocksDefinition):
|
|
|
219
224
|
/** payload 中所有 input 块 field name(fallback 提交用,含 hidden) */
|
|
220
225
|
export function getPayloadInputFieldNames(payloadDef: PayloadBlocksDefinition): string[] {
|
|
221
226
|
return payloadDef.blocks
|
|
222
|
-
.filter(
|
|
227
|
+
.filter(
|
|
228
|
+
(block) =>
|
|
229
|
+
isInputBlockType(block.type) &&
|
|
230
|
+
block.name?.trim() &&
|
|
231
|
+
hasRenderableFormOptions(block),
|
|
232
|
+
)
|
|
223
233
|
.map((block) => block.name!.trim())
|
|
224
234
|
}
|
|
225
235
|
|
|
@@ -165,6 +165,32 @@ describe('intersectPayloadBlocksWithForm', () => {
|
|
|
165
165
|
expect(extractExtraBlockValues(result.extraBlocks)).toEqual({ agent_hint: 'hint' })
|
|
166
166
|
})
|
|
167
167
|
|
|
168
|
+
it('excludes empty static questions from rendering, field names, and extra values', () => {
|
|
169
|
+
const invalidOptions = [
|
|
170
|
+
{ value: null, label: '空' },
|
|
171
|
+
{ value: {}, label: '对象' },
|
|
172
|
+
]
|
|
173
|
+
const payload = parsePayloadBlocksJson({
|
|
174
|
+
title: 'Ask',
|
|
175
|
+
blocks: [
|
|
176
|
+
{
|
|
177
|
+
id: 'invalid',
|
|
178
|
+
type: 'select',
|
|
179
|
+
name: 'invalid_choice',
|
|
180
|
+
label: '无效选择',
|
|
181
|
+
options: invalidOptions,
|
|
182
|
+
},
|
|
183
|
+
{ id: 'valid', type: 'text', name: 'title', label: '标题' },
|
|
184
|
+
],
|
|
185
|
+
})!
|
|
186
|
+
|
|
187
|
+
expect(payload.blocks[0]?.options).toEqual([])
|
|
188
|
+
expect(payloadHasInputBlocks(payload)).toBe(true)
|
|
189
|
+
expect(getPayloadRenderableBlocks(payload).map((block) => block.name)).toEqual(['title'])
|
|
190
|
+
expect(getPayloadInputFieldNames(payload)).toEqual(['title'])
|
|
191
|
+
expect(extractExtraBlockValues(payload.blocks)).toEqual({ title: '' })
|
|
192
|
+
})
|
|
193
|
+
|
|
168
194
|
it('defaults resume_multiselect extra blocks to empty array', () => {
|
|
169
195
|
const extraBlocks = [
|
|
170
196
|
{
|
|
@@ -415,7 +441,13 @@ describe('payload fallback helpers', () => {
|
|
|
415
441
|
title: '',
|
|
416
442
|
blocks: [
|
|
417
443
|
{ id: 'title', type: 'heading', label: '问卷调查' },
|
|
418
|
-
{
|
|
444
|
+
{
|
|
445
|
+
id: 'q1',
|
|
446
|
+
type: 'radio',
|
|
447
|
+
name: 'occupation',
|
|
448
|
+
label: '职业',
|
|
449
|
+
options: [{ value: 'operations', label: '运营' }],
|
|
450
|
+
},
|
|
419
451
|
],
|
|
420
452
|
})!
|
|
421
453
|
|
|
@@ -433,7 +465,13 @@ describe('payload fallback helpers', () => {
|
|
|
433
465
|
title: '',
|
|
434
466
|
blocks: [
|
|
435
467
|
{ id: 'title', type: 'heading', label: '问卷调查' },
|
|
436
|
-
{
|
|
468
|
+
{
|
|
469
|
+
id: 'q1',
|
|
470
|
+
type: 'radio',
|
|
471
|
+
name: 'occupation',
|
|
472
|
+
label: '职业',
|
|
473
|
+
options: [{ value: 'operations', label: '运营' }],
|
|
474
|
+
},
|
|
437
475
|
{
|
|
438
476
|
id: 'hint',
|
|
439
477
|
type: 'text',
|
|
@@ -734,6 +772,20 @@ describe('submitFormJson', () => {
|
|
|
734
772
|
})
|
|
735
773
|
|
|
736
774
|
describe('validateFormSubmitValues', () => {
|
|
775
|
+
it('ignores display blocks even if they contain input-like metadata', () => {
|
|
776
|
+
const blocks = [
|
|
777
|
+
{
|
|
778
|
+
id: 'web-1',
|
|
779
|
+
type: 'web' as const,
|
|
780
|
+
name: 'embedded_page',
|
|
781
|
+
label: '网页',
|
|
782
|
+
required: true,
|
|
783
|
+
webUrl: 'https://example.com',
|
|
784
|
+
},
|
|
785
|
+
]
|
|
786
|
+
expect(validateFormSubmitValues(blocks, {})).toBeNull()
|
|
787
|
+
})
|
|
788
|
+
|
|
737
789
|
it('rejects empty required text field', () => {
|
|
738
790
|
const blocks = [
|
|
739
791
|
{ id: 'b1', type: 'text' as const, name: 'title', label: '标题', required: true },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FormBlock } from '../types/index'
|
|
2
|
-
import {
|
|
2
|
+
import { isInputBlockType } from '../types/index'
|
|
3
3
|
import { EMAIL_REGEXP } from '../adapter/a2uiAdapter'
|
|
4
4
|
import { coerceToggleValue } from '../adapter/formSchema'
|
|
5
5
|
import { asStringArray } from '../catalog/useSkoponBoundField'
|
|
@@ -52,9 +52,9 @@ export function validateFormSubmitValues(
|
|
|
52
52
|
values: Record<string, unknown>,
|
|
53
53
|
): FormSubmitValidationError | null {
|
|
54
54
|
for (const block of blocks) {
|
|
55
|
+
if (!isInputBlockType(block.type)) continue
|
|
55
56
|
const name = block.name?.trim()
|
|
56
57
|
if (!name) continue
|
|
57
|
-
if (isLayoutBlockType(block.type) || isMediaBlockType(block.type)) continue
|
|
58
58
|
|
|
59
59
|
const raw = values[name]
|
|
60
60
|
const label = block.label?.trim() || name
|