@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/package.json CHANGED
@@ -3,9 +3,13 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.0",
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({ id, component: 'TextField', label, variant: 'shortText', value: { path } })
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({ id, component: 'TextField', label, variant: 'longText', value: { path } })
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({ id, component: 'TextField', label, variant: 'number', value: { path } })
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
- return { ...base(type), name }
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 { Checkbox, DatePicker, Radio, Select, Switch, TimePicker, Typography, Upload } from 'antd'
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
+ })