@skopon-cool/form-sdk 0.1.0 → 0.1.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 +6 -2
- package/dist/adapter/a2uiAdapter.d.ts.map +1 -1
- package/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/catalog/textFieldPreview.test.d.ts +2 -0
- package/dist/catalog/textFieldPreview.test.d.ts.map +1 -0
- package/dist/index.js +586 -490
- package/package.json +8 -2
- package/src/adapter/a2uiAdapter.test.ts +23 -0
- package/src/adapter/a2uiAdapter.ts +29 -4
- package/src/catalog/a2uiCustomCatalog.tsx +127 -1
- package/src/catalog/textFieldPreview.test.tsx +88 -0
package/package.json
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.1.
|
|
6
|
+
"version": "0.1.1",
|
|
7
7
|
"description": "Skopon form rendering SDK (A2UI + form_definition) with submit helpers",
|
|
8
8
|
"type": "module",
|
|
9
|
+
"sideEffects": [
|
|
10
|
+
"*.css",
|
|
11
|
+
"dist/form-sdk.css"
|
|
12
|
+
],
|
|
9
13
|
"main": "./dist/index.js",
|
|
10
14
|
"module": "./dist/index.js",
|
|
11
15
|
"types": "./dist/index.d.ts",
|
|
@@ -48,6 +52,8 @@
|
|
|
48
52
|
"typescript": "~5.8.3",
|
|
49
53
|
"vite": "^6.3.5",
|
|
50
54
|
"vite-plugin-dts": "^4.5.4",
|
|
51
|
-
"vitest": "^3.2.4"
|
|
55
|
+
"vitest": "^3.2.4",
|
|
56
|
+
"jsdom": "^29.1.1",
|
|
57
|
+
"@testing-library/react": "^16.3.2"
|
|
52
58
|
}
|
|
53
59
|
}
|
|
@@ -120,6 +120,29 @@ describe('a2uiAdapter blocks <-> surface', () => {
|
|
|
120
120
|
expect(back.blocks.find((b) => b.name === 'tags')?.type).toBe('multiselect')
|
|
121
121
|
})
|
|
122
122
|
|
|
123
|
+
it('round-trips textarea placeholder through blocksToA2ui', () => {
|
|
124
|
+
const doc = blocksToA2ui(
|
|
125
|
+
{
|
|
126
|
+
title: '',
|
|
127
|
+
description: '',
|
|
128
|
+
blocks: [
|
|
129
|
+
{
|
|
130
|
+
id: 'b-bio',
|
|
131
|
+
type: 'textarea',
|
|
132
|
+
name: 'bio',
|
|
133
|
+
label: '简介',
|
|
134
|
+
placeholder: '请填写简介',
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{ includeHeader: false },
|
|
139
|
+
)
|
|
140
|
+
const bioNode = doc.components.find((c) => c.id === 'b-bio')
|
|
141
|
+
expect(bioNode?.placeholder).toBe('请填写简介')
|
|
142
|
+
const back = a2uiToBlocks(doc)
|
|
143
|
+
expect(back.blocks.find((b) => b.name === 'bio')?.placeholder).toBe('请填写简介')
|
|
144
|
+
})
|
|
145
|
+
|
|
123
146
|
it('surfaceDocToMessages emits createSurface + updateComponents + updateDataModel', () => {
|
|
124
147
|
const doc = blocksToA2ui(makeDefinition())
|
|
125
148
|
const messages = surfaceDocToMessages(doc, { surfaceId: 's1', catalogId: 'c1' })
|
|
@@ -136,7 +136,14 @@ function blockToComponent(block: FormBlock): {
|
|
|
136
136
|
case 'text':
|
|
137
137
|
case 'tel':
|
|
138
138
|
case 'url':
|
|
139
|
-
return withData({
|
|
139
|
+
return withData({
|
|
140
|
+
id,
|
|
141
|
+
component: 'TextField',
|
|
142
|
+
label,
|
|
143
|
+
variant: 'shortText',
|
|
144
|
+
...(block.placeholder ? { placeholder: block.placeholder } : {}),
|
|
145
|
+
value: { path },
|
|
146
|
+
})
|
|
140
147
|
case 'email':
|
|
141
148
|
return withData({
|
|
142
149
|
id,
|
|
@@ -144,12 +151,27 @@ function blockToComponent(block: FormBlock): {
|
|
|
144
151
|
label,
|
|
145
152
|
variant: 'shortText',
|
|
146
153
|
validationRegexp: EMAIL_REGEXP,
|
|
154
|
+
...(block.placeholder ? { placeholder: block.placeholder } : {}),
|
|
147
155
|
value: { path },
|
|
148
156
|
})
|
|
149
157
|
case 'textarea':
|
|
150
|
-
return withData({
|
|
158
|
+
return withData({
|
|
159
|
+
id,
|
|
160
|
+
component: 'TextField',
|
|
161
|
+
label,
|
|
162
|
+
variant: 'longText',
|
|
163
|
+
...(block.placeholder ? { placeholder: block.placeholder } : {}),
|
|
164
|
+
value: { path },
|
|
165
|
+
})
|
|
151
166
|
case 'number':
|
|
152
|
-
return withData({
|
|
167
|
+
return withData({
|
|
168
|
+
id,
|
|
169
|
+
component: 'TextField',
|
|
170
|
+
label,
|
|
171
|
+
variant: 'number',
|
|
172
|
+
...(block.placeholder ? { placeholder: block.placeholder } : {}),
|
|
173
|
+
value: { path },
|
|
174
|
+
})
|
|
153
175
|
|
|
154
176
|
case 'select':
|
|
155
177
|
return withData({
|
|
@@ -337,7 +359,10 @@ function componentToBlock(node: A2uiComponentNode): FormBlock | null {
|
|
|
337
359
|
if (variant === 'longText') type = 'textarea'
|
|
338
360
|
else if (variant === 'number') type = 'number'
|
|
339
361
|
else if (asLiteral(node.validationRegexp) === EMAIL_REGEXP) type = 'email'
|
|
340
|
-
|
|
362
|
+
const block: FormBlock = { ...base(type), name }
|
|
363
|
+
const placeholder = asLiteral(node.placeholder)
|
|
364
|
+
if (placeholder) block.placeholder = placeholder
|
|
365
|
+
return block
|
|
341
366
|
}
|
|
342
367
|
case 'CheckBox':
|
|
343
368
|
return { ...base('toggle'), name }
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
2
3
|
import { Catalog, type ComponentContext } from '@a2ui/web_core/v0_9'
|
|
3
4
|
import {
|
|
4
5
|
basicCatalog,
|
|
5
6
|
createBinderlessComponentImplementation,
|
|
6
7
|
type ReactComponentImplementation,
|
|
7
8
|
} from '@a2ui/react/v0_9'
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
Checkbox,
|
|
11
|
+
DatePicker,
|
|
12
|
+
Input,
|
|
13
|
+
InputNumber,
|
|
14
|
+
Radio,
|
|
15
|
+
Select,
|
|
16
|
+
Switch,
|
|
17
|
+
TimePicker,
|
|
18
|
+
Typography,
|
|
19
|
+
Upload,
|
|
20
|
+
} from 'antd'
|
|
9
21
|
import dayjs, { type Dayjs } from 'dayjs'
|
|
10
22
|
import type { FormFilePlaceholderIcon, FormMediaSize } from '../types/index'
|
|
11
23
|
import {
|
|
@@ -37,8 +49,12 @@ const SKOPON_COMPONENT_NAMES = new Set([
|
|
|
37
49
|
'CheckBox',
|
|
38
50
|
'DateTimeInput',
|
|
39
51
|
'FileUpload',
|
|
52
|
+
'TextField',
|
|
53
|
+
'Text',
|
|
40
54
|
])
|
|
41
55
|
|
|
56
|
+
const NON_MARKDOWN_TEXT_VARIANTS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'caption'])
|
|
57
|
+
|
|
42
58
|
function getBasicSchema(name: string): z.ZodTypeAny {
|
|
43
59
|
const impl = [...basicCatalog.components.values()].find((c) => c.name === name)
|
|
44
60
|
if (!impl) throw new Error(`Missing basic catalog component: ${name}`)
|
|
@@ -526,6 +542,114 @@ const FileUploadImpl = createBinderlessComponentImplementation(
|
|
|
526
542
|
FileUploadPreview,
|
|
527
543
|
)
|
|
528
544
|
|
|
545
|
+
function TextFieldPreview({ context }: { context: ComponentContext }) {
|
|
546
|
+
const { interactive } = useA2uiPreviewMode()
|
|
547
|
+
const { value, setValue } = useSkoponBoundField(context)
|
|
548
|
+
const props = context.componentModel.properties as Record<string, unknown>
|
|
549
|
+
const label = readString(props.label)
|
|
550
|
+
const placeholder = readString(props.placeholder)
|
|
551
|
+
const variant = readString(props.variant) || 'shortText'
|
|
552
|
+
const stringValue = asOptionalString(value) ?? ''
|
|
553
|
+
const disabled = !interactive
|
|
554
|
+
|
|
555
|
+
const controlClassName = 'form-block-preview-control'
|
|
556
|
+
|
|
557
|
+
let control: ReactNode
|
|
558
|
+
if (variant === 'longText') {
|
|
559
|
+
control = (
|
|
560
|
+
<Input.TextArea
|
|
561
|
+
disabled={disabled}
|
|
562
|
+
className={controlClassName}
|
|
563
|
+
placeholder={placeholder || '长文本回答...'}
|
|
564
|
+
autoSize={{ minRows: 2, maxRows: 6 }}
|
|
565
|
+
value={stringValue}
|
|
566
|
+
onChange={(e) => setValue(e.target.value)}
|
|
567
|
+
/>
|
|
568
|
+
)
|
|
569
|
+
} else if (variant === 'number') {
|
|
570
|
+
control = (
|
|
571
|
+
<InputNumber
|
|
572
|
+
disabled={disabled}
|
|
573
|
+
className={controlClassName}
|
|
574
|
+
style={{ width: '100%' }}
|
|
575
|
+
placeholder={placeholder || '数字'}
|
|
576
|
+
value={stringValue ? Number(stringValue) : undefined}
|
|
577
|
+
onChange={(next) => setValue(next == null ? '' : String(next))}
|
|
578
|
+
/>
|
|
579
|
+
)
|
|
580
|
+
} else if (variant === 'obscured') {
|
|
581
|
+
control = (
|
|
582
|
+
<Input.Password
|
|
583
|
+
disabled={disabled}
|
|
584
|
+
className={controlClassName}
|
|
585
|
+
placeholder={placeholder}
|
|
586
|
+
value={stringValue}
|
|
587
|
+
onChange={(e) => setValue(e.target.value)}
|
|
588
|
+
/>
|
|
589
|
+
)
|
|
590
|
+
} else {
|
|
591
|
+
control = (
|
|
592
|
+
<Input
|
|
593
|
+
disabled={disabled}
|
|
594
|
+
className={controlClassName}
|
|
595
|
+
placeholder={placeholder}
|
|
596
|
+
value={stringValue}
|
|
597
|
+
onChange={(e) => setValue(e.target.value)}
|
|
598
|
+
/>
|
|
599
|
+
)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return (
|
|
603
|
+
<div className="form-block-preview">
|
|
604
|
+
{label ? <div className="form-block-preview-label">{label}</div> : null}
|
|
605
|
+
{control}
|
|
606
|
+
</div>
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const TextFieldImpl = createBinderlessComponentImplementation(
|
|
611
|
+
{
|
|
612
|
+
name: 'TextField',
|
|
613
|
+
schema: z
|
|
614
|
+
.object({
|
|
615
|
+
label: z.any().optional(),
|
|
616
|
+
variant: z.any().optional(),
|
|
617
|
+
placeholder: z.any().optional(),
|
|
618
|
+
validationRegexp: z.any().optional(),
|
|
619
|
+
value: z.any().optional(),
|
|
620
|
+
})
|
|
621
|
+
.passthrough(),
|
|
622
|
+
},
|
|
623
|
+
TextFieldPreview,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
function TextPreview({ context }: { context: ComponentContext }) {
|
|
627
|
+
const props = context.componentModel.properties as Record<string, unknown>
|
|
628
|
+
const text = readString(props.text)
|
|
629
|
+
const variant = readString(props.variant) || 'body'
|
|
630
|
+
|
|
631
|
+
if (NON_MARKDOWN_TEXT_VARIANTS.has(variant)) {
|
|
632
|
+
if (variant === 'caption') {
|
|
633
|
+
return (
|
|
634
|
+
<Typography.Text type="secondary" className="form-block-preview-paragraph">
|
|
635
|
+
<em>{text}</em>
|
|
636
|
+
</Typography.Text>
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
const Tag = variant as 'h1' | 'h2' | 'h3' | 'h4' | 'h5'
|
|
640
|
+
return <Tag className="form-block-preview-heading">{text}</Tag>
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return (
|
|
644
|
+
<Typography.Paragraph className="form-block-preview-paragraph">{text}</Typography.Paragraph>
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const TextImpl = createBinderlessComponentImplementation(
|
|
649
|
+
{ name: 'Text', schema: getBasicSchema('Text') },
|
|
650
|
+
TextPreview,
|
|
651
|
+
)
|
|
652
|
+
|
|
529
653
|
/** 构建合并后的 skopon catalog(预览每次重建,避免 HMR 缓存旧组件)。 */
|
|
530
654
|
export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
|
|
531
655
|
const baseComponents = [...basicCatalog.components.values()].filter(
|
|
@@ -542,6 +666,8 @@ export function buildSkoponCatalog(): Catalog<ReactComponentImplementation> {
|
|
|
542
666
|
CheckBoxImpl,
|
|
543
667
|
DateTimeInputImpl,
|
|
544
668
|
FileUploadImpl,
|
|
669
|
+
TextFieldImpl,
|
|
670
|
+
TextImpl,
|
|
545
671
|
]
|
|
546
672
|
const functions = [...basicCatalog.functions.values()]
|
|
547
673
|
return new Catalog(SKOPON_CATALOG_ID, components, functions, basicCatalog.themeSchema)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
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, createRef } from 'react'
|
|
15
|
+
import { render, cleanup, fireEvent } from '@testing-library/react'
|
|
16
|
+
import { blocksToA2ui } from '../adapter/a2uiAdapter'
|
|
17
|
+
import SkoponFormRenderer, { type SkoponFormRendererRef } from '../components/SkoponFormRenderer'
|
|
18
|
+
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
class ResizeObserverMock {
|
|
21
|
+
observe() {}
|
|
22
|
+
unobserve() {}
|
|
23
|
+
disconnect() {}
|
|
24
|
+
}
|
|
25
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function renderTextareaSurface() {
|
|
29
|
+
const doc = blocksToA2ui(
|
|
30
|
+
{
|
|
31
|
+
title: '',
|
|
32
|
+
description: '',
|
|
33
|
+
blocks: [
|
|
34
|
+
{
|
|
35
|
+
id: 'b-bio',
|
|
36
|
+
type: 'textarea',
|
|
37
|
+
name: 'bio',
|
|
38
|
+
label: '简介',
|
|
39
|
+
placeholder: '请填写简介',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'b-desc',
|
|
43
|
+
type: 'paragraph',
|
|
44
|
+
label: '这是一段说明文字',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{ includeHeader: false },
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const ref = createRef<SkoponFormRendererRef>()
|
|
52
|
+
const view = render(
|
|
53
|
+
createElement(SkoponFormRenderer, {
|
|
54
|
+
ref,
|
|
55
|
+
doc,
|
|
56
|
+
surfaceId: 'test-form',
|
|
57
|
+
fieldNames: ['bio'],
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
return { ref, ...view }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('SkoponFormRenderer TextField longText', () => {
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
cleanup()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('renders Ant Design TextArea for textarea blocks', () => {
|
|
69
|
+
const { container, getByText } = renderTextareaSurface()
|
|
70
|
+
expect(getByText('简介')).toBeTruthy()
|
|
71
|
+
const textarea = container.querySelector('textarea.ant-input')
|
|
72
|
+
expect(textarea).toBeTruthy()
|
|
73
|
+
expect(textarea?.getAttribute('placeholder')).toBe('请填写简介')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('reads textarea values via getValues()', () => {
|
|
77
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
78
|
+
const { container, ref } = renderTextareaSurface()
|
|
79
|
+
const textarea = container.querySelector('textarea.ant-input')
|
|
80
|
+
expect(textarea).toBeTruthy()
|
|
81
|
+
if (textarea) {
|
|
82
|
+
fireEvent.change(textarea, { target: { value: 'hello world' } })
|
|
83
|
+
}
|
|
84
|
+
expect(ref.current?.getValues(['bio'])).toEqual({ bio: 'hello world' })
|
|
85
|
+
expect(warnSpy.mock.calls.some((call) => String(call[0]).includes('[useMarkdown]'))).toBe(false)
|
|
86
|
+
warnSpy.mockRestore()
|
|
87
|
+
})
|
|
88
|
+
})
|