├── .browserslistrc ├── .eslintignore ├── public ├── favicon.ico └── index.html ├── src ├── demos │ ├── index.ts │ ├── demo.ts │ └── simple.ts ├── main.ts ├── shims-vue.d.ts ├── plugins │ ├── customKeyword.tsx │ └── customFormat.tsx ├── components │ ├── PasswordWiget.tsx │ └── MonacoEditor.tsx └── App.tsx ├── .prettierrc ├── babel.config.js ├── schema-tests ├── test2.js └── test1.js ├── jest.config.js ├── .gitignore ├── lib ├── index.ts ├── fields │ ├── StringField.vue │ ├── NumberField.vue │ ├── NumberField.tsx │ ├── StringField.tsx │ ├── ObjectField.tsx │ └── ArrayField.tsx ├── theme-default │ ├── index.tsx │ ├── NumberWidget.tsx │ ├── TextWidget.tsx │ ├── SelectionWidget.tsx │ └── FormItem.tsx ├── context.ts ├── widgets │ └── Selection.tsx ├── SchemaItem.tsx ├── theme.tsx ├── types.ts ├── validator.ts ├── SchemaForm.tsx └── utils.ts ├── vue.config.js ├── .github └── workflows │ └── test-coverage.yml ├── tsconfig.json ├── .eslintrc.js ├── tests └── unit │ ├── example.spec.ts │ ├── utils │ └── TestComponent.tsx │ ├── ObjectFiled.spec.ts │ └── ArrayField.spec.ts ├── README.md └── package.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | schema-tests 2 | MonacoEditor.tsx 3 | vue.config.js 4 | /tests -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jokcy/vjsf-imooc/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/demos/index.ts: -------------------------------------------------------------------------------- 1 | import simple from './simple' 2 | import demo from './demo' 3 | 4 | export default [demo, simple] 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "always", 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'], 3 | plugins: [['@vue/babel-plugin-jsx', { mergeProps: false }]], 4 | } 5 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp, defineComponent, h, reactive, ref } from 'vue' 2 | // import App from './App.vue' 3 | import App from './App' 4 | 5 | createApp(App).mount('#app') 6 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import { defineComponent } from "vue"; 3 | const component: ReturnType; 4 | export default component; 5 | } 6 | -------------------------------------------------------------------------------- /schema-tests/test2.js: -------------------------------------------------------------------------------- 1 | const Ajv = require('ajv') 2 | 3 | const ajv = new Ajv() 4 | 5 | const validate = ajv.compile({ 6 | type: 'object', 7 | properties: { 8 | select: { 9 | type: 'number', 10 | minimum: 10, 11 | }, 12 | }, 13 | }) 14 | 15 | const r = validate({ 16 | select: 5, 17 | }) 18 | 19 | console.log(r) 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 3 | moduleFileExtensions: [ 4 | 'js', 5 | 'jsx', 6 | 'json', 7 | 'ts', 8 | 'tsx', 9 | // tell Jest to handle *.vue files 10 | 'vue', 11 | ], 12 | transform: { 13 | '^.+\\.vue$': 'vue-jest', 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /coverage 5 | 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/plugins/customKeyword.tsx: -------------------------------------------------------------------------------- 1 | import { CustomKeyword } from '../../lib/types' 2 | 3 | const keyword: CustomKeyword = { 4 | name: 'test', 5 | deinition: { 6 | macro: () => { 7 | return { 8 | minLength: 10, 9 | } 10 | }, 11 | }, 12 | transformSchema(schema) { 13 | return { 14 | ...schema, 15 | minLength: 10, 16 | } 17 | }, 18 | } 19 | 20 | export default keyword 21 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h } from 'vue' 2 | 3 | import SchemaFrom from './SchemaForm' 4 | import NumberFiled from './fields/NumberField' 5 | import StringField from './fields/StringField' 6 | import ArrayField from './fields/ArrayField' 7 | 8 | import SelectionWidget from './widgets/Selection' 9 | 10 | import ThemeProvider from './theme' 11 | 12 | export default SchemaFrom 13 | 14 | export * from './types' 15 | 16 | export { NumberFiled, StringField, ArrayField, SelectionWidget, ThemeProvider } 17 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin') 2 | const CircularDependencyPlugin = require('circular-dependency-plugin') 3 | 4 | const isLib = process.env.TYPE === 'lib' 5 | 6 | module.exports = { 7 | configureWebpack(config) { 8 | // console.log(config.plugins) 9 | }, 10 | chainWebpack(config) { 11 | if (!isLib) { 12 | config.plugin('monaco').use(new MonacoWebpackPlugin()) 13 | } 14 | config.plugin('circular').use(new CircularDependencyPlugin()) 15 | }, 16 | pwa: {}, 17 | } 18 | -------------------------------------------------------------------------------- /lib/fields/StringField.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /lib/theme-default/index.tsx: -------------------------------------------------------------------------------- 1 | import SelectionWidget from './SelectionWidget' 2 | 3 | import { CommonWidgetPropsDefine, CommonWidgetDefine } from '../types' 4 | import { defineComponent } from 'vue' 5 | 6 | import TextWidget from './TextWidget' 7 | import NumberWidget from './NumberWidget' 8 | 9 | const CommonWidget: CommonWidgetDefine = defineComponent({ 10 | props: CommonWidgetPropsDefine, 11 | setup() { 12 | return () => null 13 | }, 14 | }) 15 | 16 | export default { 17 | widgets: { 18 | SelectionWidget, 19 | TextWidget, 20 | NumberWidget, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /lib/context.ts: -------------------------------------------------------------------------------- 1 | import { inject, reactive, Ref } from 'vue' 2 | import { CommonFieldType, CommonWidgetDefine, Theme, Schema } from './types' 3 | 4 | export const SchemaFormContextKey = Symbol() 5 | 6 | export function useVJSFContext() { 7 | const context: 8 | | { 9 | SchemaItem: CommonFieldType 10 | formatMapRef: Ref<{ [key: string]: CommonWidgetDefine }> 11 | transformSchemaRef: Ref<(schema: Schema) => Schema> 12 | } 13 | | undefined = inject(SchemaFormContextKey) 14 | 15 | if (!context) { 16 | throw Error('SchemaForm needed') 17 | } 18 | 19 | return context 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yml: -------------------------------------------------------------------------------- 1 | name: test-coverage 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | node-version: [8.x, 10.x, 12.x] 12 | os: [ubuntu-latest, macos-latest, windows-latest] 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm run test:unit 22 | env: 23 | CI: true 24 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib/fields/NumberField.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 33 | -------------------------------------------------------------------------------- /lib/theme-default/NumberWidget.tsx: -------------------------------------------------------------------------------- 1 | import { CommonWidgetPropsDefine, CommonWidgetDefine } from '../types' 2 | import { defineComponent } from 'vue' 3 | 4 | import { withFormItem } from './FormItem' 5 | 6 | const NumberWidget: CommonWidgetDefine = withFormItem( 7 | defineComponent({ 8 | props: CommonWidgetPropsDefine, 9 | setup(props) { 10 | const handleChange = (e: any) => { 11 | const value = e.target.value 12 | e.target.value = props.value 13 | props.onChange(value) 14 | } 15 | return () => { 16 | return ( 17 | 23 | ) 24 | } 25 | }, 26 | }), 27 | ) 28 | 29 | export default NumberWidget 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env", 16 | "jest" 17 | ], 18 | "paths": { 19 | "@/*": [ 20 | "src/*" 21 | ] 22 | }, 23 | "lib": [ 24 | "esnext", 25 | "dom", 26 | "dom.iterable", 27 | "scripthost" 28 | ] 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx", 33 | "src/**/*.vue", 34 | "tests/**/*.ts", 35 | "tests/**/*.tsx" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /src/components/PasswordWiget.tsx: -------------------------------------------------------------------------------- 1 | import { CommonWidgetPropsDefine, CommonWidgetDefine } from '../../lib/types' 2 | import { defineComponent } from 'vue' 3 | 4 | import { withFormItem } from '../../lib/theme-default/FormItem' 5 | 6 | const PasswordWidget: CommonWidgetDefine = withFormItem( 7 | defineComponent({ 8 | name: 'PasswordWidget', 9 | props: CommonWidgetPropsDefine, 10 | setup(props) { 11 | const handleChange = (e: any) => { 12 | const value = e.target.value 13 | e.target.value = props.value 14 | props.onChange(value) 15 | } 16 | return () => { 17 | return ( 18 | 23 | ) 24 | } 25 | }, 26 | }), 27 | ) 28 | 29 | export default PasswordWidget 30 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/typescript/recommended', 10 | '@vue/prettier', 11 | '@vue/prettier/@typescript-eslint', 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 2020, 15 | }, 16 | rules: { 17 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 18 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 19 | '@typescript-eslint/no-use-before-define': 'off', 20 | 'no-prototype-builtins': 'off', 21 | '@typescript-eslint/no-explicit-any': 'off', 22 | 'vue/no-mutating-props': 'off', 23 | }, 24 | overrides: [ 25 | { 26 | files: [ 27 | '**/__tests__/*.{j,t}s?(x)', 28 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 29 | ], 30 | env: { 31 | jest: true, 32 | }, 33 | }, 34 | ], 35 | } 36 | -------------------------------------------------------------------------------- /tests/unit/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, shallowMount } from '@vue/test-utils' 2 | import { defineComponent, h } from 'vue' 3 | 4 | import JsonSchemaForm, { NumberFiled } from '../../lib' 5 | import TestComponent from './utils/TestComponent' 6 | 7 | describe('JsonSchemaFrom', () => { 8 | it('should render correct number field', async () => { 9 | let value = '' 10 | const wrapper = mount(TestComponent, { 11 | props: { 12 | schema: { 13 | type: 'number', 14 | }, 15 | value: value, 16 | onChange: (v) => { 17 | value = v 18 | }, 19 | }, 20 | }) 21 | 22 | const numberFiled = wrapper.findComponent(NumberFiled) 23 | expect(numberFiled.exists()).toBeTruthy() 24 | // await numberFiled.props('onChange')('123') 25 | const input = numberFiled.find('input') 26 | input.element.value = '123' 27 | input.trigger('input') 28 | expect(value).toBe(123) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /lib/theme-default/TextWidget.tsx: -------------------------------------------------------------------------------- 1 | import { CommonWidgetPropsDefine, CommonWidgetDefine } from '../types' 2 | import { computed, defineComponent } from 'vue' 3 | 4 | import { withFormItem } from './FormItem' 5 | 6 | const TextWidget: CommonWidgetDefine = withFormItem( 7 | defineComponent({ 8 | name: 'TextWidget', 9 | props: CommonWidgetPropsDefine, 10 | setup(props) { 11 | const handleChange = (e: any) => { 12 | const value = e.target.value 13 | e.target.value = props.value 14 | props.onChange(value) 15 | } 16 | 17 | const styleRef = computed(() => { 18 | return { 19 | color: (props.options && props.options.color) || 'black', 20 | } 21 | }) 22 | 23 | return () => { 24 | return ( 25 | 31 | ) 32 | } 33 | }, 34 | }), 35 | ) 36 | 37 | export default TextWidget 38 | -------------------------------------------------------------------------------- /lib/fields/NumberField.tsx: -------------------------------------------------------------------------------- 1 | import { FiledPropsDefine, CommonWidgetNames } from '../types' 2 | import { defineComponent } from 'vue' 3 | 4 | import { getWidget } from '../theme' 5 | 6 | export default defineComponent({ 7 | name: 'NumberFeild', 8 | props: FiledPropsDefine, 9 | setup(props) { 10 | const handleChange = (v: string) => { 11 | // const value = e.target.value 12 | 13 | const num = Number(v) 14 | 15 | if (Number.isNaN(num)) { 16 | props.onChange(undefined) 17 | } else { 18 | props.onChange(num) 19 | } 20 | } 21 | 22 | const NumberWidgetRef = getWidget(CommonWidgetNames.NumberWidget) 23 | 24 | return () => { 25 | const NumberWidget = NumberWidgetRef.value 26 | const { rootSchema, errorSchema, ...rest } = props 27 | // return 28 | return ( 29 | 34 | ) 35 | } 36 | }, 37 | }) 38 | -------------------------------------------------------------------------------- /src/demos/demo.ts: -------------------------------------------------------------------------------- 1 | import PasswordWidget from '../components/PasswordWiget' 2 | 3 | export default { 4 | name: 'Demo', 5 | schema: { 6 | type: 'object', 7 | properties: { 8 | pass1: { 9 | type: 'string', 10 | // minLength: 10, 11 | test: true, 12 | title: 'password', 13 | }, 14 | pass2: { 15 | type: 'string', 16 | minLength: 10, 17 | title: 're try password', 18 | }, 19 | color: { 20 | type: 'string', 21 | format: 'color', 22 | title: 'Input Color', 23 | }, 24 | }, 25 | }, 26 | async customValidate(data: any, errors: any) { 27 | return new Promise((resolve) => { 28 | setTimeout(() => { 29 | if (data.pass1 !== data.pass2) { 30 | errors.pass2.addError('密码必须相同') 31 | } 32 | resolve() 33 | }, 2000) 34 | }) 35 | }, 36 | uiSchema: { 37 | properties: { 38 | pass1: { 39 | widget: PasswordWidget, 40 | }, 41 | pass2: { 42 | color: 'red', 43 | }, 44 | }, 45 | }, 46 | default: 1, 47 | } 48 | -------------------------------------------------------------------------------- /tests/unit/utils/TestComponent.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType } from 'vue' 2 | import JsonSchemaForm, { Schema, ThemeProvider } from '../../../lib' 3 | import defaultTheme from '../../../lib/theme-default' 4 | 5 | // vjsf-theme-default // import {ThemeProvider} from 'vue3-jsonschema-form' 6 | // vue3-jsonschema-form 7 | 8 | export const ThemeDefaultProvider = defineComponent({ 9 | setup(p, { slots }) { 10 | return () => ( 11 | 12 | {slots.default && slots.default()} 13 | 14 | ) 15 | }, 16 | }) 17 | 18 | export default defineComponent({ 19 | name: 'TestComponent', 20 | props: { 21 | schema: { 22 | type: Object as PropType, 23 | required: true, 24 | }, 25 | value: { 26 | required: true, 27 | }, 28 | onChange: { 29 | type: Function as PropType<(v: any) => void>, 30 | required: true, 31 | }, 32 | }, 33 | setup(props) { 34 | return () => ( 35 | 36 | 37 | 38 | ) 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /src/plugins/customFormat.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, computed } from 'vue' 2 | import { CustomFormat, CommonWidgetPropsDefine } from '../../lib/types' 3 | 4 | import { withFormItem } from '../../lib/theme-default/FormItem' 5 | 6 | const format: CustomFormat = { 7 | name: 'color', 8 | definition: { 9 | type: 'string', 10 | validate: /^#[0-9A-Fa-f]{6}$/, 11 | }, 12 | component: withFormItem( 13 | defineComponent({ 14 | name: 'ColorWidget', 15 | props: CommonWidgetPropsDefine, 16 | setup(props) { 17 | const handleChange = (e: any) => { 18 | const value = e.target.value 19 | e.target.value = props.value 20 | props.onChange(value) 21 | } 22 | 23 | const styleRef = computed(() => { 24 | return { 25 | color: (props.options && props.options.color) || 'black', 26 | } 27 | }) 28 | 29 | return () => { 30 | return ( 31 | 37 | ) 38 | } 39 | }, 40 | }), 41 | ), 42 | } 43 | 44 | export default format 45 | -------------------------------------------------------------------------------- /lib/fields/StringField.tsx: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent } from 'vue' 2 | 3 | import { FiledPropsDefine, CommonWidgetNames } from '../types' 4 | import { getWidget } from '../theme' 5 | 6 | export default defineComponent({ 7 | name: 'StringFeild', 8 | props: FiledPropsDefine, 9 | setup(props) { 10 | const handleChange = (v: string) => { 11 | // console.log(e) 12 | props.onChange(v) 13 | } 14 | 15 | const TextWidgetRef = computed(() => { 16 | const widgetRef = getWidget(CommonWidgetNames.TextWidget, props) 17 | return widgetRef.value 18 | }) 19 | 20 | const widgetOptionsRef = computed(() => { 21 | const { widget, properties, items, ...rest } = props.uiSchema 22 | return rest 23 | }) 24 | 25 | return () => { 26 | const { rootSchema, errorSchema, ...rest } = props 27 | 28 | const TextWidget = TextWidgetRef.value 29 | 30 | return ( 31 | 37 | ) 38 | 39 | // return ( 40 | // 41 | // ) 42 | } 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /lib/theme-default/SelectionWidget.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType, ref, watch, watchEffect } from 'vue' 2 | import { SelectionWidgetPropsDefine, SelectionWidgetDefine } from '../types' 3 | 4 | import { withFormItem } from './FormItem' 5 | 6 | const Selection: SelectionWidgetDefine = withFormItem( 7 | defineComponent({ 8 | name: 'SelectionWidget', 9 | props: SelectionWidgetPropsDefine, 10 | setup(props) { 11 | const currentValueRef = ref(props.value) 12 | 13 | watch(currentValueRef, (newv, oldv) => { 14 | if (newv !== props.value) { 15 | props.onChange(newv) 16 | } 17 | }) 18 | 19 | watch( 20 | () => props.value, 21 | (v) => { 22 | if (v !== currentValueRef.value) { 23 | currentValueRef.value = v 24 | } 25 | }, 26 | ) 27 | 28 | watchEffect(() => { 29 | console.log(currentValueRef.value, '------------->') 30 | }) 31 | 32 | return () => { 33 | const { options } = props 34 | return ( 35 | 40 | ) 41 | } 42 | }, 43 | }), 44 | ) 45 | 46 | export default Selection 47 | -------------------------------------------------------------------------------- /lib/widgets/Selection.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType, ref, watch, watchEffect } from 'vue' 2 | 3 | export default defineComponent({ 4 | name: 'SelectionWidget', 5 | props: { 6 | value: {}, 7 | onChange: { 8 | type: Function as PropType<(v: any) => void>, 9 | required: true, 10 | }, 11 | options: { 12 | type: Array as PropType< 13 | { 14 | key: string 15 | value: any 16 | }[] 17 | >, 18 | required: true, 19 | }, 20 | }, 21 | setup(props) { 22 | const currentValueRef = ref(props.value) 23 | 24 | watch(currentValueRef, (newv, oldv) => { 25 | if (newv !== props.value) { 26 | props.onChange(newv) 27 | } 28 | }) 29 | 30 | watch( 31 | () => props.value, 32 | (v) => { 33 | if (v !== currentValueRef.value) { 34 | currentValueRef.value = v 35 | } 36 | }, 37 | ) 38 | 39 | watchEffect(() => { 40 | console.log(currentValueRef.value, '------------->') 41 | }) 42 | 43 | return () => { 44 | const { options } = props 45 | return ( 46 | 51 | ) 52 | } 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # API 设计 2 | 3 | ```jsx 4 | 12 | ``` 13 | 14 | ### schema 15 | 16 | json schema 对象,用来定义数据,同时也是我们定义表单的依据 17 | 18 | ### value 19 | 20 | 表单的数据结果,你可以从外部改变这个 value,在表单被编辑的时候,会通过`onChange`透出 value 21 | 22 | 需要注意的是,因为 vue 使用的是可变数据,如果每次数据变化我们都去改变`value`的对象地址,那么会导致整个表单都需要重新渲染,这会导致性能降低。 23 | 从实践中来看,我们传入的对象,在内部修改其 field 的值基本不会有什么副作用,所以我们会使用这种方式来进行实现。也就是说,如果`value`是一个对象, 24 | 那么从`JsonSchemaForm`内部修改的值,并不会改变`value`对象本身。我们仍然会触发`onChange`,因为可能在表单变化之后,使用者需要进行一些操作。 25 | 26 | ### onChange 27 | 28 | 在表单值有任何变化的时候会触发该回调方法,并把新的值进行返回 29 | 30 | ### locale 31 | 32 | 语言,使用`ajv-i18n`指定错误信息使用的语言 33 | 34 | ### contextRef 35 | 36 | 你需要传入一个 vue3 的`Ref`对象,我们会在这个对象上挂载`doValidate`方法,你可以通过 37 | 38 | ```ts 39 | const yourRef = ref({}) 40 | 41 | onMounted(() => { 42 | yourRef.value.doValidate() 43 | }) 44 | 45 | 46 | ``` 47 | 48 | 这样来主动让表单进行校验。 49 | 50 | ### uiSchema 51 | 52 | 对表单的展现进行一些定制,其类型如下: 53 | 54 | ```ts 55 | export interface VueJsonSchemaConfig { 56 | title?: string 57 | descrription?: string 58 | component?: string 59 | additionProps?: { 60 | [key: string]: any 61 | } 62 | withFormItem?: boolean 63 | widget?: 'checkbox' | 'textarea' | 'select' | 'radio' | 'range' | string 64 | items?: UISchema | UISchema[] 65 | } 66 | export interface UISchema extends VueJsonSchemaConfig { 67 | properties?: { 68 | [property: string]: UISchema 69 | } 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /lib/theme-default/FormItem.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue' 2 | import { CommonWidgetPropsDefine, CommonWidgetDefine } from '../types' 3 | 4 | import { createUseStyles } from 'vue-jss' 5 | 6 | const useStyles = createUseStyles({ 7 | container: {}, 8 | label: { 9 | display: 'block', 10 | color: '#777', 11 | }, 12 | errorText: { 13 | color: 'red', 14 | fontSize: 12, 15 | margin: '5px 0', 16 | padding: 0, 17 | paddingLeft: 20, 18 | }, 19 | }) 20 | 21 | const FormItem = defineComponent({ 22 | name: 'FormItem', 23 | props: CommonWidgetPropsDefine, 24 | setup(props, { slots }) { 25 | const classesRef = useStyles() 26 | return () => { 27 | const { schema, errors } = props 28 | const classes = classesRef.value 29 | return ( 30 |
31 | 32 | {slots.default && slots.default()} 33 |
    34 | {errors?.map((err) => ( 35 |
  • {err}
  • 36 | ))} 37 |
38 |
39 | ) 40 | } 41 | }, 42 | }) 43 | 44 | export default FormItem 45 | 46 | // HOC: Higher Order Component: 高阶组件 47 | export function withFormItem(Widget: any) { 48 | return defineComponent({ 49 | name: `Wrapped${Widget.name}`, 50 | props: CommonWidgetPropsDefine, 51 | setup(props, { attrs, slots }) { 52 | return () => { 53 | return ( 54 | 55 | 56 | 57 | ) 58 | } 59 | }, 60 | }) as any 61 | } 62 | -------------------------------------------------------------------------------- /lib/SchemaItem.tsx: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, PropType } from 'vue' 2 | 3 | import { Schema, SchemaTypes, FiledPropsDefine } from './types' 4 | // import StringField from './fields/StringField' 5 | import StringField from './fields/StringField' 6 | import NumberField from './fields/NumberField' 7 | 8 | import ObjectField from './fields/ObjectField' 9 | import ArrayField from './fields/ArrayField' 10 | 11 | import { retrieveSchema } from './utils' 12 | import { useVJSFContext } from './context' 13 | 14 | export default defineComponent({ 15 | name: 'SchemaItem', 16 | props: FiledPropsDefine, 17 | setup(props) { 18 | const formContext = useVJSFContext() 19 | 20 | const retrievedSchemaRef = computed(() => { 21 | const { schema, rootSchema, value } = props 22 | return formContext.transformSchemaRef.value( 23 | retrieveSchema(schema, rootSchema, value), 24 | ) 25 | }) 26 | 27 | return () => { 28 | const { schema, rootSchema, value } = props 29 | 30 | const retrievedSchema = retrievedSchemaRef.value 31 | 32 | // TODO: 如果type没有指定,我们需要猜测这个type 33 | 34 | const type = schema.type 35 | 36 | let Component: any 37 | 38 | switch (type) { 39 | case SchemaTypes.STRING: { 40 | Component = StringField 41 | break 42 | } 43 | case SchemaTypes.NUMBER: { 44 | Component = NumberField 45 | break 46 | } 47 | case SchemaTypes.OBJECT: { 48 | Component = ObjectField 49 | break 50 | } 51 | case SchemaTypes.ARRAY: { 52 | Component = ArrayField 53 | break 54 | } 55 | default: { 56 | console.warn(`${type} is not supported`) 57 | } 58 | } 59 | 60 | return 61 | } 62 | }, 63 | }) 64 | -------------------------------------------------------------------------------- /lib/fields/ObjectField.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, DefineComponent } from 'vue' 2 | 3 | import { FiledPropsDefine, CommonFieldType } from '../types' 4 | import { isObject } from '../utils' 5 | import { SchemaFormContextKey, useVJSFContext } from '../context' 6 | 7 | // import SchemaItem from '../SchemaItem' 8 | 9 | // console.log(SchemaItem) 10 | 11 | const schema = { 12 | type: 'object', 13 | properties: { 14 | name: { 15 | type: 'string', 16 | }, 17 | age: { 18 | type: 'number', 19 | }, 20 | }, 21 | } 22 | 23 | type A = DefineComponent 24 | 25 | export default defineComponent({ 26 | name: 'ObjectField', 27 | props: FiledPropsDefine, 28 | setup(props) { 29 | const context = useVJSFContext() 30 | 31 | const handleObjectFieldChange = (key: string, v: any) => { 32 | const value: any = isObject(props.value) ? props.value : {} 33 | 34 | if (v === undefined) { 35 | delete value[key] 36 | } else { 37 | value[key] = v 38 | } 39 | 40 | props.onChange(value) 41 | } 42 | 43 | return () => { 44 | const { schema, rootSchema, value, errorSchema, uiSchema } = props 45 | 46 | const { SchemaItem } = context 47 | 48 | const properties = schema.properties || {} 49 | 50 | const currentValue: any = isObject(value) ? value : {} 51 | 52 | return Object.keys(properties).map((k: string, index: number) => ( 53 | handleObjectFieldChange(k, v)} 61 | /> 62 | )) 63 | } 64 | }, 65 | }) 66 | -------------------------------------------------------------------------------- /lib/theme.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | defineComponent, 4 | inject, 5 | PropType, 6 | provide, 7 | ComputedRef, 8 | ref, 9 | ExtractPropTypes, 10 | } from 'vue' 11 | import { 12 | Theme, 13 | SelectionWidgetNames, 14 | CommonWidgetNames, 15 | SelectionWidgetDefine, 16 | UISchema, 17 | CommonWidgetDefine, 18 | FiledPropsDefine, 19 | } from './types' 20 | import { isObject } from './utils' 21 | import { useVJSFContext } from './context' 22 | 23 | const THEME_PROVIDER_KEY = Symbol() 24 | 25 | const ThemeProvider = defineComponent({ 26 | name: 'VJSFThemeProvider', 27 | props: { 28 | theme: { 29 | type: Object as PropType, 30 | required: true, 31 | }, 32 | }, 33 | setup(props, { slots }) { 34 | const context = computed(() => props.theme) 35 | 36 | provide(THEME_PROVIDER_KEY, context) 37 | 38 | return () => slots.default && slots.default() 39 | }, 40 | }) 41 | 42 | export function getWidget( 43 | name: T, 44 | props?: ExtractPropTypes, 45 | ) { 46 | const formContext = useVJSFContext() 47 | 48 | if (props) { 49 | const { uiSchema, schema } = props 50 | if (uiSchema?.widget && isObject(uiSchema.widget)) { 51 | return ref(uiSchema.widget as CommonWidgetDefine) 52 | } 53 | if (schema.format) { 54 | if (formContext.formatMapRef.value[schema.format]) { 55 | return ref(formContext.formatMapRef.value[schema.format]) 56 | } 57 | } 58 | } 59 | 60 | const context: ComputedRef | undefined = inject>( 61 | THEME_PROVIDER_KEY, 62 | ) 63 | if (!context) { 64 | throw new Error('vjsf theme required') 65 | } 66 | 67 | const widgetRef = computed(() => { 68 | return context.value.widgets[name] 69 | }) 70 | 71 | return widgetRef 72 | } 73 | 74 | export default ThemeProvider 75 | -------------------------------------------------------------------------------- /src/demos/simple.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Simple', 3 | schema: { 4 | description: 'A simple form example.', 5 | type: 'object', 6 | required: ['firstName', 'lastName'], 7 | properties: { 8 | firstName: { 9 | title: 'firstName', 10 | type: 'string', 11 | default: 'Chuck', 12 | }, 13 | lastName: { 14 | title: 'lastName', 15 | type: 'string', 16 | }, 17 | telephone: { 18 | title: 'telephone', 19 | type: 'string', 20 | minLength: 10, 21 | }, 22 | staticArray: { 23 | title: 'staticArray', 24 | type: 'array', 25 | items: [ 26 | { 27 | type: 'string', 28 | }, 29 | { 30 | type: 'number', 31 | }, 32 | ], 33 | }, 34 | singleTypeArray: { 35 | title: 'singleTypeArray', 36 | type: 'array', 37 | items: { 38 | type: 'object', 39 | properties: { 40 | name: { 41 | type: 'string', 42 | }, 43 | age: { 44 | type: 'number', 45 | }, 46 | }, 47 | }, 48 | }, 49 | multiSelectArray: { 50 | title: 'multiSelectArray', 51 | type: 'array', 52 | items: { 53 | type: 'string', 54 | enum: ['123', '456', '789'], 55 | }, 56 | }, 57 | }, 58 | }, 59 | uiSchema: { 60 | title: 'A registration form', 61 | properties: { 62 | firstName: { 63 | title: 'First name', 64 | }, 65 | lastName: { 66 | title: 'Last name', 67 | }, 68 | telephone: { 69 | title: 'Telephone', 70 | }, 71 | }, 72 | }, 73 | default: { 74 | firstName: 'Chuck', 75 | lastName: 'Norris', 76 | age: 75, 77 | bio: 'Roundhouse kicking asses since 1940', 78 | password: 'noneed', 79 | singleTypeArray: [{ name: 'jokcy', age: 12 }], 80 | }, 81 | } 82 | -------------------------------------------------------------------------------- /schema-tests/test1.js: -------------------------------------------------------------------------------- 1 | // Node.js require: 2 | const Ajv = require('ajv') 3 | const localize = require('ajv-i18n') 4 | 5 | const schema = { 6 | type: 'object', 7 | properties: { 8 | name: { 9 | type: 'string', 10 | // test: false, 11 | errorMessage: { 12 | type: '必须是字符串', 13 | minLength: '长度不能小于10', 14 | }, 15 | // format: 'test', 16 | minLength: 10, 17 | }, 18 | age: { 19 | type: 'number', 20 | }, 21 | pets: { 22 | type: 'array', 23 | items: [ 24 | { 25 | type: 'string', 26 | maxLength: 2, 27 | }, 28 | { 29 | type: 'number', 30 | }, 31 | ], 32 | }, 33 | isWorker: { 34 | type: 'boolean', 35 | }, 36 | }, 37 | required: ['name', 'age'], 38 | } 39 | 40 | const ajv = new Ajv({ allErrors: true, jsonPointers: true }) // options can be passed, e.g. {allErrors: true} 41 | // ajv.addFormat('test', (data) => { 42 | // console.log(data, '------------') 43 | // return data === 'haha' 44 | // }) 45 | require('ajv-errors')(ajv) 46 | ajv.addKeyword('test', { 47 | macro() { 48 | return { 49 | minLength: 10, 50 | } 51 | }, 52 | // compile(sch, parentSchema) { 53 | // console.log(sch, parentSchema) 54 | // // return true 55 | // return () => true 56 | // }, 57 | // metaSchema: { 58 | // type: 'boolean', 59 | // }, 60 | // validate: function fun(schema, data) { 61 | // // console.log(schema, data) 62 | 63 | // fun.errors = [ 64 | // { 65 | // keyword: 'test', 66 | // dataPath: '.name', 67 | // schemaPath: '#/properties/name/test', 68 | // params: { keyword: 'test' }, 69 | // message: 'hello error message', 70 | // }, 71 | // ] 72 | 73 | // return false 74 | // }, 75 | }) 76 | const validate = ajv.compile(schema) 77 | const valid = validate({ 78 | name: '12', 79 | age: 18, 80 | pets: ['mimi', 12], 81 | isWorker: true, 82 | }) 83 | if (!valid) { 84 | localize.zh(validate.errors) 85 | console.log(validate.errors) 86 | } 87 | -------------------------------------------------------------------------------- /tests/unit/ObjectFiled.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, shallowMount } from '@vue/test-utils' 2 | import { defineComponent, h } from 'vue' 3 | 4 | import JsonSchemaForm, { NumberFiled, StringField } from '../../lib' 5 | 6 | import TestComponent from './utils/TestComponent' 7 | 8 | describe('ObjectFiled', () => { 9 | let schema: any 10 | beforeEach(() => { 11 | schema = { 12 | type: 'object', 13 | properties: { 14 | name: { 15 | type: 'string', 16 | }, 17 | age: { 18 | type: 'number', 19 | }, 20 | }, 21 | } 22 | }) 23 | 24 | it('should render properties to correct fileds', async () => { 25 | const wrapper = mount(TestComponent, { 26 | props: { 27 | schema, 28 | value: {}, 29 | onChange: () => {}, 30 | }, 31 | }) 32 | 33 | const strFiled = wrapper.findComponent(StringField) 34 | const numField = wrapper.findComponent(NumberFiled) 35 | 36 | expect(strFiled.exists()).toBeTruthy() 37 | expect(numField.exists()).toBeTruthy() 38 | }) 39 | 40 | it('should change value when sub fields trigger onChange', async () => { 41 | let value: any = {} 42 | const wrapper = mount(TestComponent, { 43 | props: { 44 | schema, 45 | value: value, 46 | onChange: (v) => { 47 | value = v 48 | }, 49 | }, 50 | }) 51 | 52 | const strFiled = wrapper.findComponent(StringField) 53 | const numField = wrapper.findComponent(NumberFiled) 54 | 55 | await strFiled.props('onChange')('1') 56 | expect(value.name).toEqual('1') 57 | await numField.props('onChange')(1) 58 | expect(value.age).toEqual(1) 59 | // expect(numField.exists()).toBeTruthy() 60 | }) 61 | 62 | it('should render properties to correct fileds', async () => { 63 | let value: any = { 64 | name: '123', 65 | } 66 | const wrapper = mount(TestComponent, { 67 | props: { 68 | schema, 69 | value: value, 70 | onChange: (v) => { 71 | value = v 72 | }, 73 | }, 74 | }) 75 | 76 | const strFiled = wrapper.findComponent(StringField) 77 | // const numField = wrapper.findComponent(NumberFiled) 78 | await strFiled.props('onChange')(undefined) 79 | 80 | expect(value.name).toBeUndefined() 81 | // expect(numField.exists()).toBeTruthy() 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /tests/unit/ArrayField.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount, shallowMount } from '@vue/test-utils' 2 | import { defineComponent, h } from 'vue' 3 | 4 | import { 5 | SelectionWidget, 6 | NumberFiled, 7 | StringField, 8 | ArrayField, 9 | } from '../../lib' 10 | 11 | import TestComponent from './utils/TestComponent' 12 | 13 | describe('ArrayFiled', () => { 14 | it('should render multi type', () => { 15 | const wrapper = mount(TestComponent, { 16 | props: { 17 | schema: { 18 | type: 'array', 19 | items: [ 20 | { 21 | type: 'string', 22 | }, 23 | { 24 | type: 'number', 25 | }, 26 | ], 27 | }, 28 | value: [], 29 | onChange: () => {}, 30 | }, 31 | }) 32 | 33 | const arr = wrapper.findComponent(ArrayField) 34 | const str = arr.findComponent(StringField) 35 | const num = arr.findComponent(NumberFiled) 36 | 37 | expect(str.exists()).toBeTruthy() 38 | expect(num.exists()).toBeTruthy() 39 | }) 40 | 41 | it('should render single type', () => { 42 | const wrapper = mount(TestComponent, { 43 | props: { 44 | schema: { 45 | type: 'array', 46 | items: { 47 | type: 'string', 48 | }, 49 | }, 50 | value: ['1', '2'], 51 | onChange: () => {}, 52 | }, 53 | }) 54 | 55 | const arr = wrapper.findComponent(ArrayField) 56 | const strs = arr.findAllComponents(StringField) 57 | // const num = arr.findComponent(NumberFiled) 58 | 59 | expect(strs.length).toBe(2) 60 | expect(strs[0].props('value')).toBe('1') 61 | // expect(num.exists()).toBeTruthy() 62 | }) 63 | 64 | it('should render single type', () => { 65 | const wrapper = mount(TestComponent, { 66 | props: { 67 | schema: { 68 | type: 'array', 69 | items: { 70 | type: 'string', 71 | enum: ['1', '2', '3'], 72 | }, 73 | }, 74 | value: [], 75 | onChange: () => {}, // elsint-disable-line 76 | }, 77 | }) 78 | 79 | const arr = wrapper.findComponent(ArrayField) 80 | const select = arr.findComponent(SelectionWidget) 81 | // const num = arr.findComponent(NumberFiled) 82 | 83 | expect(select.exists()).toBeTruthy() 84 | // expect(num.exists()).toBeTruthy() 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue3-json-schema-form", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build:core": "TYPE=lib vue-cli-service build --target lib --name index --no-clean lib/index.ts", 8 | "build:theme": "TYPE=lib vue-cli-service build --target lib --name theme-default/index --no-clean lib/theme-default/index.tsx", 9 | "build": "rimraf dist && npm run build:core && npm run build:theme", 10 | "test:unit": "vue-cli-service test:unit", 11 | "test:unit:cov": "vue-cli-service test:unit --coverage", 12 | "lint": "vue-cli-service lint" 13 | }, 14 | "dependencies": { 15 | "ajv": "^6.12.4", 16 | "ajv-errors": "^1.0.1", 17 | "ajv-i18n": "^3.5.0", 18 | "core-js": "^3.6.5", 19 | "json-schema-merge-allof": "^0.7.0", 20 | "jsonpointer": "^4.1.0", 21 | "jss": "^10.4.0", 22 | "jss-preset-default": "^10.4.0", 23 | "lodash.topath": "^4.5.2", 24 | "lodash.union": "^4.6.0", 25 | "vue": "^3.0.0", 26 | "vue-jss": "^0.0.4" 27 | }, 28 | "devDependencies": { 29 | "@types/jest": "^24.0.19", 30 | "@types/json-schema-merge-allof": "^0.6.0", 31 | "@types/lodash.topath": "^4.5.6", 32 | "@types/lodash.union": "^4.6.6", 33 | "@typescript-eslint/eslint-plugin": "^2.33.0", 34 | "@typescript-eslint/parser": "^2.33.0", 35 | "@vue/babel-plugin-jsx": "^1.0.0-rc.3", 36 | "@vue/cli-plugin-babel": "~4.5.6", 37 | "@vue/cli-plugin-eslint": "~4.5.6", 38 | "@vue/cli-plugin-typescript": "~4.5.6", 39 | "@vue/cli-plugin-unit-jest": "~4.5.6", 40 | "@vue/cli-service": "~4.5.6", 41 | "@vue/compiler-sfc": "^3.0.0-0", 42 | "@vue/eslint-config-prettier": "^6.0.0", 43 | "@vue/eslint-config-typescript": "^5.0.2", 44 | "@vue/test-utils": "2.0.0-beta.5", 45 | "circular-dependency-plugin": "^5.2.0", 46 | "eslint": "^6.7.2", 47 | "eslint-plugin-prettier": "^3.1.3", 48 | "eslint-plugin-vue": "^7.0.0-0", 49 | "lint-staged": "^9.5.0", 50 | "monaco-editor": "^0.20.0", 51 | "monaco-editor-webpack-plugin": "^1.9.1", 52 | "prettier": "^1.19.1", 53 | "rimraf": "^3.0.2", 54 | "typescript": "~3.9.3", 55 | "vue-jest": "^5.0.0-alpha.4" 56 | }, 57 | "gitHooks": { 58 | "pre-commit": "lint-staged" 59 | }, 60 | "lint-staged": { 61 | "*.{js,jsx,vue,ts,tsx}": [ 62 | "vue-cli-service lint", 63 | "git add" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/MonacoEditor.tsx: -------------------------------------------------------------------------------- 1 | /* eslint no-use-before-define: 0 */ 2 | 3 | import { defineComponent, ref, onMounted, watch, onBeforeUnmount, shallowReadonly, shallowRef } from 'vue' 4 | 5 | import * as Monaco from 'monaco-editor' 6 | 7 | import type { PropType, Ref } from 'vue' 8 | import { createUseStyles } from 'vue-jss' 9 | 10 | const useStyles = createUseStyles({ 11 | container: { 12 | border: '1px solid #eee', 13 | display: 'flex', 14 | flexDirection: 'column', 15 | borderRadius: 5 16 | }, 17 | title: { 18 | backgroundColor: '#eee', 19 | padding: '10px 0', 20 | paddingLeft: 20, 21 | }, 22 | code: { 23 | flexGrow: 1 24 | } 25 | }) 26 | 27 | export default defineComponent({ 28 | props: { 29 | code: { 30 | type: String as PropType, 31 | required: true 32 | }, 33 | onChange: { 34 | type: Function as PropType<(value: string, event: Monaco.editor.IModelContentChangedEvent) => void>, 35 | required: true 36 | }, 37 | title: { 38 | type: String as PropType, 39 | required: true 40 | } 41 | }, 42 | setup(props) { 43 | // must be shallowRef, if not, editor.getValue() won't work 44 | const editorRef = shallowRef() 45 | 46 | const containerRef = ref() 47 | 48 | let _subscription: Monaco.IDisposable | undefined 49 | let __prevent_trigger_change_event = false 50 | 51 | onMounted(() => { 52 | const editor = editorRef.value = Monaco.editor.create(containerRef.value, { 53 | value: props.code, 54 | language: 'json', 55 | formatOnPaste: true, 56 | tabSize: 2, 57 | minimap: { 58 | enabled: false, 59 | }, 60 | }) 61 | 62 | _subscription = editor.onDidChangeModelContent((event) => { 63 | console.log('--------->', __prevent_trigger_change_event) 64 | if (!__prevent_trigger_change_event) { 65 | props.onChange(editor.getValue(), event); 66 | } 67 | }); 68 | }) 69 | 70 | onBeforeUnmount(() => { 71 | if (_subscription) 72 | _subscription.dispose() 73 | }) 74 | 75 | watch(() => props.code, (v) => { 76 | const editor = editorRef.value 77 | const model = editor.getModel() 78 | if (v !== model.getValue()) { 79 | editor.pushUndoStop(); 80 | __prevent_trigger_change_event = true 81 | // pushEditOperations says it expects a cursorComputer, but doesn't seem to need one. 82 | model.pushEditOperations( 83 | [], 84 | [ 85 | { 86 | range: model.getFullModelRange(), 87 | text: v, 88 | }, 89 | ] 90 | ); 91 | editor.pushUndoStop(); 92 | __prevent_trigger_change_event = false 93 | } 94 | // if (v !== editorRef.value.getValue()) { 95 | // editorRef.value.setValue(v) 96 | // } 97 | }) 98 | 99 | const classesRef = useStyles() 100 | 101 | return () => { 102 | 103 | const classes = classesRef.value 104 | 105 | return ( 106 |
107 |
{props.title}
108 |
109 |
110 | ) 111 | } 112 | } 113 | }) -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { PropType, defineComponent, DefineComponent } from 'vue' 2 | import { FormatDefinition, KeywordDefinition, CompilationContext } from 'ajv' 3 | import { ErrorSchema } from './validator' 4 | 5 | export enum SchemaTypes { 6 | 'NUMBER' = 'number', 7 | 'INTEGER' = 'integer', 8 | 'STRING' = 'string', 9 | 'OBJECT' = 'object', 10 | 'ARRAY' = 'array', 11 | 'BOOLEAN' = 'boolean', 12 | } 13 | 14 | type SchemaRef = { $ref: string } 15 | 16 | // type Schema = any 17 | export interface Schema { 18 | type?: SchemaTypes | string 19 | const?: any 20 | format?: string 21 | 22 | title?: string 23 | default?: any 24 | 25 | properties?: { 26 | [key: string]: Schema 27 | } 28 | items?: Schema | Schema[] | SchemaRef 29 | uniqueItems?: any 30 | dependencies?: { 31 | [key: string]: string[] | Schema | SchemaRef 32 | } 33 | oneOf?: Schema[] 34 | anyOf?: Schema[] 35 | allOf?: Schema[] 36 | // TODO: uiSchema 37 | // vjsf?: VueJsonSchemaConfig 38 | required?: string[] 39 | enum?: any[] 40 | enumNames?: any[] 41 | enumKeyValue?: any[] 42 | additionalProperties?: any 43 | additionalItems?: Schema 44 | 45 | minLength?: number 46 | maxLength?: number 47 | minimun?: number 48 | maximum?: number 49 | multipleOf?: number 50 | exclusiveMaximum?: number 51 | exclusiveMinimum?: number 52 | } 53 | 54 | export const FiledPropsDefine = { 55 | schema: { 56 | type: Object as PropType, 57 | required: true, 58 | }, 59 | uiSchema: { 60 | type: Object as PropType, 61 | required: true, 62 | }, 63 | value: { 64 | required: true, 65 | }, 66 | onChange: { 67 | type: Function as PropType<(v: any) => void>, 68 | required: true, 69 | }, 70 | rootSchema: { 71 | type: Object as PropType, 72 | required: true, 73 | }, 74 | errorSchema: { 75 | type: Object as PropType, 76 | required: true, 77 | }, 78 | } as const 79 | 80 | export const TypeHelperComponent = defineComponent({ 81 | props: FiledPropsDefine, 82 | }) 83 | 84 | export type CommonFieldType = typeof TypeHelperComponent 85 | 86 | export const CommonWidgetPropsDefine = { 87 | value: {}, 88 | onChange: { 89 | type: Function as PropType<(v: any) => void>, 90 | required: true, 91 | }, 92 | errors: { 93 | type: Array as PropType, 94 | }, 95 | schema: { 96 | type: Object as PropType, 97 | required: true, 98 | }, 99 | options: { 100 | type: Object as PropType<{ [keys: string]: any }>, 101 | }, 102 | } as const 103 | 104 | export const SelectionWidgetPropsDefine = { 105 | ...CommonWidgetPropsDefine, 106 | options: { 107 | type: Array as PropType< 108 | { 109 | key: string 110 | value: any 111 | }[] 112 | >, 113 | required: true, 114 | }, 115 | } as const 116 | 117 | export type CommonWidgetDefine = DefineComponent< 118 | typeof CommonWidgetPropsDefine, 119 | {}, 120 | {} 121 | > 122 | 123 | export type SelectionWidgetDefine = DefineComponent< 124 | typeof SelectionWidgetPropsDefine, 125 | {}, 126 | {} 127 | > 128 | 129 | export enum SelectionWidgetNames { 130 | SelectionWidget = 'SelectionWidget', 131 | } 132 | 133 | export enum CommonWidgetNames { 134 | TextWidget = 'TextWidget', 135 | NumberWidget = 'NumberWidget', 136 | } 137 | 138 | export interface Theme { 139 | widgets: { 140 | [SelectionWidgetNames.SelectionWidget]: SelectionWidgetDefine 141 | [CommonWidgetNames.TextWidget]: CommonWidgetDefine 142 | [CommonWidgetNames.NumberWidget]: CommonWidgetDefine 143 | } 144 | } 145 | 146 | export type UISchema = { 147 | widget?: string | CommonWidgetDefine 148 | properties?: { 149 | [key: string]: UISchema 150 | } 151 | items?: UISchema | UISchema[] 152 | } & { 153 | [key: string]: any 154 | } 155 | 156 | export interface CustomFormat { 157 | name: string 158 | definition: FormatDefinition 159 | component: CommonWidgetDefine 160 | } 161 | 162 | interface VjsfKeywordDefinition { 163 | type?: string | Array 164 | async?: boolean 165 | $data?: boolean 166 | errors?: boolean | string 167 | metaSchema?: object 168 | // schema: false makes validate not to expect schema (ValidateFunction) 169 | schema?: boolean 170 | statements?: boolean 171 | dependencies?: Array 172 | modifying?: boolean 173 | valid?: boolean 174 | // one and only one of the following properties should be present 175 | macro: ( 176 | schema: any, 177 | parentSchema: object, 178 | it: CompilationContext, 179 | ) => object | boolean 180 | } 181 | 182 | export interface CustomKeyword { 183 | name: string 184 | deinition: VjsfKeywordDefinition 185 | transformSchema: (originSchema: Schema) => Schema 186 | } 187 | -------------------------------------------------------------------------------- /lib/validator.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import toPath from 'lodash.topath' 3 | const i18n = require('ajv-i18n') // eslint-disable-line 4 | 5 | import { Schema } from './types' 6 | import { isObject } from './utils' 7 | 8 | interface TransformedErrorObject { 9 | name: string 10 | property: string 11 | message: string | undefined 12 | params: Ajv.ErrorParameters 13 | schemaPath: string 14 | } 15 | 16 | interface ErrorSchemaObject { 17 | [level: string]: ErrorSchema 18 | } 19 | 20 | export type ErrorSchema = ErrorSchemaObject & { 21 | __errors?: string[] 22 | } 23 | function toErrorSchema(errors: TransformedErrorObject[]) { 24 | if (errors.length < 1) return {} 25 | 26 | return errors.reduce((errorSchema, error) => { 27 | const { property, message } = error 28 | const path = toPath(property) // .pass1 /obj/a -> [obj, a] 29 | let parent = errorSchema 30 | 31 | // If the property is at the root (.level1) then toPath creates 32 | // an empty array element at the first index. Remove it. 33 | if (path.length > 0 && path[0] === '') { 34 | path.splice(0, 1) 35 | } 36 | 37 | // { 38 | // obj: { 39 | // a: {} 40 | // } 41 | // } // /obj/a 42 | for (const segment of path.slice(0)) { 43 | if (!(segment in parent)) { 44 | ;(parent as any)[segment] = {} 45 | } 46 | parent = parent[segment] 47 | } 48 | 49 | if (Array.isArray(parent.__errors)) { 50 | // We store the list of errors for this node in a property named __errors 51 | // to avoid name collision with a possible sub schema field named 52 | // "errors" (see `validate.createErrorHandler`). 53 | parent.__errors = parent.__errors.concat(message || '') 54 | } else { 55 | if (message) { 56 | parent.__errors = [message] 57 | } 58 | } 59 | return errorSchema 60 | }, {} as ErrorSchema) 61 | } 62 | 63 | function transformErrors( 64 | errors: Ajv.ErrorObject[] | null | undefined, 65 | ): TransformedErrorObject[] { 66 | if (errors === null || errors === undefined) return [] 67 | 68 | return errors.map(({ message, dataPath, keyword, params, schemaPath }) => { 69 | return { 70 | name: keyword, 71 | property: `${dataPath}`, 72 | message, 73 | params, 74 | schemaPath, 75 | } 76 | }) 77 | } 78 | 79 | export async function validateFormData( 80 | validator: Ajv.Ajv, 81 | formData: any, 82 | schema: Schema, 83 | locale = 'zh', 84 | customValidate?: (data: any, errors: any) => void, 85 | ) { 86 | let validationError = null 87 | try { 88 | validator.validate(schema, formData) 89 | } catch (err) { 90 | validationError = err 91 | } 92 | 93 | i18n[locale](validator.errors) 94 | let errors = transformErrors(validator.errors) 95 | 96 | if (validationError) { 97 | errors = [ 98 | ...errors, 99 | { 100 | message: validationError.message, 101 | } as TransformedErrorObject, 102 | ] 103 | } 104 | 105 | const errorSchema = toErrorSchema(errors) 106 | 107 | if (!customValidate) { 108 | return { 109 | errors, 110 | errorSchema, 111 | valid: errors.length === 0, 112 | } 113 | } 114 | 115 | /** 116 | * { 117 | * obj: { 118 | * a: { b: str } 119 | * __errors: [] 120 | * } 121 | * } 122 | * 123 | * raw.obj.a 124 | */ 125 | const proxy = createErrorProxy() 126 | await customValidate(formData, proxy) 127 | const newErrorSchema = mergeObjects(errorSchema, proxy, true) 128 | 129 | return { 130 | errors, 131 | errorSchema: newErrorSchema, 132 | valid: errors.length === 0, 133 | } 134 | } 135 | 136 | function createErrorProxy() { 137 | const raw = {} 138 | return new Proxy(raw, { 139 | get(target, key, reciver) { 140 | if (key === 'addError') { 141 | return (msg: string) => { 142 | const __errors = Reflect.get(target, '__errors', reciver) 143 | if (__errors && Array.isArray(__errors)) { 144 | __errors.push(msg) 145 | } else { 146 | ;(target as any).__errors = [msg] 147 | } 148 | } 149 | } 150 | const res = Reflect.get(target, key, reciver) 151 | if (res === undefined) { 152 | const p: any = createErrorProxy() 153 | ;(target as any)[key] = p 154 | return p 155 | } 156 | 157 | return res 158 | }, 159 | }) 160 | } 161 | 162 | export function mergeObjects(obj1: any, obj2: any, concatArrays = false) { 163 | // Recursively merge deeply nested objects. 164 | const acc = Object.assign({}, obj1) // Prevent mutation of source object. 165 | return Object.keys(obj2).reduce((acc, key) => { 166 | const left = obj1 ? obj1[key] : {}, 167 | right = obj2[key] 168 | if (obj1 && obj1.hasOwnProperty(key) && isObject(right)) { 169 | acc[key] = mergeObjects(left, right, concatArrays) 170 | } else if (concatArrays && Array.isArray(left) && Array.isArray(right)) { 171 | acc[key] = left.concat(right) 172 | } else { 173 | acc[key] = right 174 | } 175 | return acc 176 | }, acc) 177 | } 178 | -------------------------------------------------------------------------------- /lib/SchemaForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | defineComponent, 3 | PropType, 4 | provide, 5 | Ref, 6 | watchEffect, 7 | watch, 8 | shallowRef, 9 | ref, 10 | computed, 11 | } from 'vue' 12 | 13 | import Ajv, { Options } from 'ajv' 14 | 15 | import { 16 | CommonWidgetDefine, 17 | CustomFormat, 18 | CustomKeyword, 19 | Schema, 20 | SchemaTypes, 21 | Theme, 22 | UISchema, 23 | } from './types' 24 | 25 | import SchemaItem from './SchemaItem' 26 | import { SchemaFormContextKey } from './context' 27 | import { validateFormData, ErrorSchema } from './validator' 28 | import keyword from '@/plugins/customKeyword' 29 | 30 | type A = typeof SchemaItem 31 | 32 | interface ContextRef { 33 | doValidate: () => Promise<{ 34 | errors: any[] 35 | valid: boolean 36 | }> 37 | } 38 | 39 | const defaultAjvOptions: Options = { 40 | allErrors: true, 41 | // jsonPointers: true, 42 | } 43 | 44 | export default defineComponent({ 45 | props: { 46 | schema: { 47 | type: Object as PropType, 48 | required: true, 49 | }, 50 | value: { 51 | required: true, 52 | }, 53 | onChange: { 54 | type: Function as PropType<(v: any) => void>, 55 | required: true, 56 | }, 57 | contextRef: { 58 | type: Object as PropType>, 59 | }, 60 | ajvOptions: { 61 | type: Object as PropType, 62 | }, 63 | locale: { 64 | type: String, 65 | default: 'zh', 66 | }, 67 | customValidate: { 68 | type: Function as PropType<(data: any, errors: any) => void>, 69 | }, 70 | customFormats: { 71 | type: [Array, Object] as PropType, 72 | }, 73 | customKeywords: { 74 | type: [Array, Object] as PropType, 75 | }, 76 | uiSchema: { 77 | type: Object as PropType, 78 | }, 79 | }, 80 | name: 'SchemaForm', 81 | setup(props, { slots, emit, attrs }) { 82 | const handleChange = (v: any) => { 83 | props.onChange(v) 84 | } 85 | 86 | const errorSchemaRef: Ref = shallowRef({}) 87 | 88 | const validatorRef: Ref = shallowRef() as any 89 | 90 | watchEffect(() => { 91 | validatorRef.value = new Ajv({ 92 | ...defaultAjvOptions, 93 | ...props.ajvOptions, 94 | }) 95 | 96 | if (props.customFormats) { 97 | const customFormats = Array.isArray(props.customFormats) 98 | ? props.customFormats 99 | : [props.customFormats] 100 | customFormats.forEach((format) => { 101 | validatorRef.value.addFormat(format.name, format.definition) 102 | }) 103 | } 104 | 105 | if (props.customKeywords) { 106 | const customKeywords = Array.isArray(props.customKeywords) 107 | ? props.customKeywords 108 | : [props.customKeywords] 109 | customKeywords.forEach((keyword) => 110 | validatorRef.value.addKeyword(keyword.name, keyword.deinition), 111 | ) 112 | } 113 | }) 114 | 115 | const validateResolveRef = ref() 116 | const validateIndex = ref(0) 117 | 118 | watch( 119 | () => props.value, 120 | () => { 121 | if (validateResolveRef.value) { 122 | doValidate() 123 | } 124 | }, 125 | { deep: true }, 126 | ) 127 | 128 | async function doValidate() { 129 | console.log('start validate -------->') 130 | const index = (validateIndex.value += 1) 131 | const result = await validateFormData( 132 | validatorRef.value, 133 | props.value, 134 | props.schema, 135 | props.locale, 136 | props.customValidate, 137 | ) 138 | 139 | if (index !== validateIndex.value) return 140 | console.log('end validate -------->') 141 | 142 | errorSchemaRef.value = result.errorSchema 143 | 144 | validateResolveRef.value(result) 145 | validateResolveRef.value = undefined 146 | 147 | // return result 148 | } 149 | 150 | watch( 151 | () => props.contextRef, 152 | () => { 153 | if (props.contextRef) { 154 | props.contextRef.value = { 155 | doValidate() { 156 | return new Promise((resolve) => { 157 | validateResolveRef.value = resolve 158 | doValidate() 159 | }) 160 | }, 161 | } 162 | } 163 | }, 164 | { 165 | immediate: true, 166 | }, 167 | ) 168 | 169 | const formatMapRef = computed(() => { 170 | if (props.customFormats) { 171 | const customFormats = Array.isArray(props.customFormats) 172 | ? props.customFormats 173 | : [props.customFormats] 174 | return customFormats.reduce((result, format) => { 175 | // validatorRef.value.addFormat(format.name, format.definition) 176 | result[format.name] = format.component 177 | return result 178 | }, {} as { [key: string]: CommonWidgetDefine }) 179 | } else { 180 | return {} 181 | } 182 | }) 183 | 184 | const transformSchemaRef = computed(() => { 185 | if (props.customKeywords) { 186 | const customKeywords = Array.isArray(props.customKeywords) 187 | ? props.customKeywords 188 | : [props.customKeywords] 189 | 190 | return (schema: Schema) => { 191 | let newSchema = schema 192 | customKeywords.forEach((keyword) => { 193 | if ((newSchema as any)[keyword.name]) { 194 | newSchema = keyword.transformSchema(schema) 195 | } 196 | }) 197 | return newSchema 198 | } 199 | } 200 | return (s: Schema) => s 201 | }) 202 | 203 | const context: any = { 204 | SchemaItem, 205 | formatMapRef, 206 | transformSchemaRef, 207 | // theme: props.theme, 208 | } 209 | 210 | provide(SchemaFormContextKey, context) 211 | 212 | return () => { 213 | const { schema, value, uiSchema } = props 214 | return ( 215 | 223 | ) 224 | } 225 | }, 226 | }) 227 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, ref, Ref, reactive, watchEffect } from 'vue' 2 | import { createUseStyles } from 'vue-jss' 3 | 4 | import MonacoEditor from './components/MonacoEditor' 5 | 6 | import demos from './demos' 7 | 8 | import SchemaForm, { ThemeProvider } from '../lib' 9 | import themeDefault from '../lib/theme-default' 10 | 11 | import customFormat from './plugins/customFormat' 12 | import customKeywords from './plugins/customKeyword' 13 | 14 | // TODO: 在lib中export 15 | type Schema = any 16 | type UISchema = any 17 | 18 | function toJson(data: any) { 19 | return JSON.stringify(data, null, 2) 20 | } 21 | 22 | const useStyles = createUseStyles({ 23 | container: { 24 | display: 'flex', 25 | flexDirection: 'column', 26 | height: '100%', 27 | width: '1200px', 28 | margin: '0 auto', 29 | }, 30 | menu: { 31 | marginBottom: 20, 32 | }, 33 | code: { 34 | width: 700, 35 | flexShrink: 0, 36 | }, 37 | codePanel: { 38 | minHeight: 400, 39 | marginBottom: 20, 40 | }, 41 | uiAndValue: { 42 | display: 'flex', 43 | justifyContent: 'space-between', 44 | '& > *': { 45 | width: '46%', 46 | }, 47 | }, 48 | content: { 49 | display: 'flex', 50 | }, 51 | form: { 52 | padding: '0 20px', 53 | flexGrow: 1, 54 | }, 55 | menuButton: { 56 | appearance: 'none', 57 | borderWidth: 0, 58 | backgroundColor: 'transparent', 59 | cursor: 'pointer', 60 | display: 'inline-block', 61 | padding: 15, 62 | borderRadius: 5, 63 | '&:hover': { 64 | background: '#efefef', 65 | }, 66 | }, 67 | menuSelected: { 68 | background: '#337ab7', 69 | color: '#fff', 70 | '&:hover': { 71 | background: '#337ab7', 72 | }, 73 | }, 74 | }) 75 | 76 | export default defineComponent({ 77 | setup() { 78 | const selectedRef: Ref = ref(0) 79 | 80 | const demo: { 81 | schema: Schema | null 82 | data: any 83 | uiSchema: UISchema | null 84 | schemaCode: string 85 | dataCode: string 86 | uiSchemaCode: string 87 | customValidate: ((d: any, e: any) => void) | undefined 88 | } = reactive({ 89 | schema: null, 90 | data: {}, 91 | uiSchema: {}, 92 | schemaCode: '', 93 | dataCode: '', 94 | uiSchemaCode: '', 95 | customValidate: undefined, 96 | }) 97 | 98 | watchEffect(() => { 99 | const index = selectedRef.value 100 | const d: any = demos[index] 101 | demo.schema = d.schema 102 | demo.data = d.default 103 | demo.uiSchema = d.uiSchema 104 | demo.schemaCode = toJson(d.schema) 105 | demo.dataCode = toJson(d.default) 106 | demo.uiSchemaCode = toJson(d.uiSchema) 107 | demo.customValidate = d.customValidate 108 | }) 109 | 110 | const methodRef: Ref = ref() 111 | 112 | const classesRef = useStyles() 113 | 114 | const handleChange = (v: any) => { 115 | demo.data = v 116 | demo.dataCode = toJson(v) 117 | } 118 | 119 | function handleCodeChange( 120 | filed: 'schema' | 'data' | 'uiSchema', 121 | value: string, 122 | ) { 123 | try { 124 | const json = JSON.parse(value) 125 | demo[filed] = json 126 | ;(demo as any)[`${filed}Code`] = value 127 | } catch (err) { 128 | // some thing 129 | } 130 | } 131 | 132 | const handleSchemaChange = (v: string) => handleCodeChange('schema', v) 133 | const handleDataChange = (v: string) => handleCodeChange('data', v) 134 | const handleUISchemaChange = (v: string) => handleCodeChange('uiSchema', v) 135 | 136 | const contextRef = ref() 137 | const nameRef = ref() 138 | 139 | function validateForm() { 140 | contextRef.value.doValidate().then((result: any) => { 141 | console.log(result, '......') 142 | }) 143 | } 144 | 145 | return () => { 146 | const classes = classesRef.value 147 | const selected = selectedRef.value 148 | 149 | // console.log(methodRef, nameRef) 150 | 151 | return ( 152 | // 153 | // 154 |
155 |
156 |

Vue3 JsonSchema Form

157 |
158 | {demos.map((demo, index) => ( 159 | 168 | ))} 169 |
170 |
171 |
172 |
173 | 179 |
180 | 186 | 192 |
193 |
194 |
195 | 196 | 207 | 208 | {/* */} 215 | 216 |
217 |
218 |
219 | //
220 | //
221 | ) 222 | } 223 | }, 224 | }) 225 | -------------------------------------------------------------------------------- /lib/fields/ArrayField.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType } from 'vue' 2 | import { createUseStyles } from 'vue-jss' 3 | 4 | import { FiledPropsDefine, Schema, SelectionWidgetNames } from '../types' 5 | 6 | import { useVJSFContext } from '../context' 7 | import { getWidget } from '../theme' 8 | import { isObject } from 'lib/utils' 9 | 10 | // import SelectionWidget from '../widgets/Selection' 11 | 12 | const useStyles = createUseStyles({ 13 | container: { 14 | border: '1px solid #eee', 15 | }, 16 | actions: { 17 | background: '#eee', 18 | padding: 10, 19 | textAlign: 'right', 20 | }, 21 | action: { 22 | '& + &': { 23 | marginLeft: 10, 24 | }, 25 | }, 26 | content: { 27 | padding: 10, 28 | }, 29 | }) 30 | 31 | const ArrayItemWrapper = defineComponent({ 32 | name: 'ArrayItemWrapper', 33 | props: { 34 | onAdd: { 35 | type: Function as PropType<(index: number) => void>, 36 | required: true, 37 | }, 38 | onDelete: { 39 | type: Function as PropType<(index: number) => void>, 40 | required: true, 41 | }, 42 | onUp: { 43 | type: Function as PropType<(index: number) => void>, 44 | required: true, 45 | }, 46 | onDown: { 47 | type: Function as PropType<(index: number) => void>, 48 | required: true, 49 | }, 50 | index: { 51 | type: Number, 52 | required: true, 53 | }, 54 | }, 55 | setup(props, { slots }) { 56 | const classesRef = useStyles() 57 | 58 | const context = useVJSFContext() 59 | 60 | const handleAdd = () => props.onAdd(props.index) 61 | const handleDown = () => props.onDown(props.index) 62 | const handleUp = () => props.onUp(props.index) 63 | const handleDelete = () => props.onDelete(props.index) 64 | 65 | return () => { 66 | const classes = classesRef.value 67 | 68 | return ( 69 |
70 |
71 | 74 | 77 | 80 | 83 |
84 |
{slots.default && slots.default()}
85 |
86 | ) 87 | } 88 | }, 89 | }) 90 | 91 | /** 92 | * { 93 | * items: { type: string }, 94 | * } 95 | * 96 | * { 97 | * items: [ 98 | * { type: string }, 99 | * { type: number } 100 | * ] 101 | * } 102 | * 103 | * { 104 | * items: { type: string, enum: ['1', '2'] }, 105 | * } 106 | */ 107 | export default defineComponent({ 108 | name: 'ArrayField', 109 | props: FiledPropsDefine, 110 | setup(props) { 111 | const context = useVJSFContext() 112 | 113 | const handleArrayItemChange = (v: any, index: number) => { 114 | const { value } = props 115 | const arr = Array.isArray(value) ? value : [] 116 | 117 | arr[index] = v 118 | 119 | props.onChange(arr) 120 | } 121 | 122 | const handleAdd = (index: number) => { 123 | const { value } = props 124 | const arr = Array.isArray(value) ? value : [] 125 | 126 | arr.splice(index + 1, 0, undefined) 127 | 128 | props.onChange(arr) 129 | } 130 | 131 | const handleDelete = (index: number) => { 132 | const { value } = props 133 | const arr = Array.isArray(value) ? value : [] 134 | 135 | arr.splice(index, 1) 136 | 137 | props.onChange(arr) 138 | } 139 | 140 | const handleUp = (index: number) => { 141 | if (index === 0) return 142 | const { value } = props 143 | const arr = Array.isArray(value) ? value : [] 144 | 145 | const item = arr.splice(index, 1) 146 | arr.splice(index - 1, 0, item[0]) 147 | 148 | props.onChange(arr) 149 | } 150 | 151 | const handleDown = (index: number) => { 152 | const { value } = props 153 | const arr = Array.isArray(value) ? value : [] 154 | 155 | if (index === arr.length - 1) return 156 | 157 | const item = arr.splice(index, 1) 158 | arr.splice(index + 1, 0, item[0]) 159 | 160 | props.onChange(arr) 161 | } 162 | 163 | const SelectionWidgetRef = getWidget(SelectionWidgetNames.SelectionWidget) 164 | 165 | return () => { 166 | // const SelectionWidget = context.theme.widgets.SelectionWidget 167 | const SelectionWidget = SelectionWidgetRef.value 168 | const { schema, rootSchema, value, errorSchema, uiSchema } = props 169 | 170 | const SchemaItem = context.SchemaItem 171 | 172 | const isMultiType = Array.isArray(schema.items) 173 | const isSelect = schema.items && (schema.items as any).enum 174 | 175 | if (isMultiType) { 176 | const items: Schema[] = schema.items as any 177 | const arr = Array.isArray(value) ? value : [] 178 | return items.map((s: Schema, index: number) => { 179 | const itemsUiSchema = uiSchema.items 180 | const us = Array.isArray(itemsUiSchema) 181 | ? itemsUiSchema[index] || {} 182 | : itemsUiSchema || {} 183 | return ( 184 | handleArrayItemChange(v, index)} 192 | /> 193 | ) 194 | }) 195 | } else if (!isSelect) { 196 | const arr = Array.isArray(value) ? value : [] 197 | 198 | return arr.map((v: any, index: number) => { 199 | return ( 200 | 207 | handleArrayItemChange(v, index)} 215 | /> 216 | 217 | ) 218 | }) 219 | } else { 220 | const enumOptions = (schema as any).items.enum 221 | const options = enumOptions.map((e: any) => ({ 222 | key: e, 223 | value: e, 224 | })) 225 | return ( 226 | 233 | ) 234 | } 235 | } 236 | }, 237 | }) 238 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | 3 | import { Schema } from './types' 4 | 5 | import jsonpointer from 'jsonpointer' 6 | import union from 'lodash.union' 7 | import mergeAllOf from 'json-schema-merge-allof' 8 | 9 | export function isObject(thing: any) { 10 | return typeof thing === 'object' && thing !== null && !Array.isArray(thing) 11 | } 12 | 13 | export function isEmptyObject(thing: any) { 14 | return isObject(thing) && Object.keys(thing).length === 0 15 | } 16 | 17 | export function hasOwnProperty(obj: any, key: string) { 18 | /** 19 | * 直接调用`obj.hasOwnProperty`有可能会因为 20 | * obj 覆盖了 prototype 上的 hasOwnProperty 而产生错误 21 | */ 22 | return Object.prototype.hasOwnProperty.call(obj, key) 23 | } 24 | 25 | // import { isObject, hasOwnProperty, getSchemaType, guessType } from './utils' 26 | // import { validateData } from './validator' 27 | 28 | // TODO: 应该跟SchemaForm的instance保持一致 29 | const defaultInstance = new Ajv() 30 | export function validateData(schema: any, data: any) { 31 | const valid = defaultInstance.validate(schema, data) 32 | return { 33 | valid, 34 | errors: defaultInstance.errors, 35 | } 36 | } 37 | 38 | // function resolveSchema(schema: any, data: any = {}) {} 39 | export function resolveSchema(schema: Schema, rootSchema = {}, formData = {}) { 40 | if (hasOwnProperty(schema, '$ref')) { 41 | return resolveReference(schema, rootSchema, formData) 42 | } else if (hasOwnProperty(schema, 'dependencies')) { 43 | const resolvedSchema = resolveDependencies(schema, rootSchema, formData) 44 | return retrieveSchema(resolvedSchema, rootSchema, formData) 45 | } else if (hasOwnProperty(schema, 'allOf') && Array.isArray(schema.allOf)) { 46 | return { 47 | ...schema, 48 | allOf: schema.allOf.map((allOfSubschema) => 49 | retrieveSchema(allOfSubschema, rootSchema, formData), 50 | ), 51 | } 52 | } else { 53 | // No $ref or dependencies attribute found, returning the original schema. 54 | return schema 55 | } 56 | } 57 | 58 | export function retrieveSchema( 59 | schema: any, 60 | rootSchema = {}, 61 | formData: any = {}, 62 | ): Schema { 63 | if (!isObject(schema)) { 64 | return {} as Schema 65 | } 66 | let resolvedSchema = resolveSchema(schema, rootSchema, formData) 67 | 68 | // TODO: allOf and additionalProperties not implemented 69 | if ('allOf' in schema) { 70 | try { 71 | resolvedSchema = mergeAllOf({ 72 | // TODO: Schema type not suitable 73 | ...resolvedSchema, 74 | allOf: resolvedSchema.allOf, 75 | } as any) as Schema 76 | } catch (e) { 77 | console.warn('could not merge subschemas in allOf:\n' + e) 78 | const { allOf, ...resolvedSchemaWithoutAllOf } = resolvedSchema 79 | return resolvedSchemaWithoutAllOf 80 | } 81 | } 82 | const hasAdditionalProperties = 83 | resolvedSchema.hasOwnProperty('additionalProperties') && 84 | resolvedSchema.additionalProperties !== false 85 | if (hasAdditionalProperties) { 86 | // put formData existing additional properties into schema 87 | return stubExistingAdditionalProperties( 88 | resolvedSchema, 89 | rootSchema, 90 | formData, 91 | ) 92 | } 93 | return resolvedSchema 94 | } 95 | 96 | export const ADDITIONAL_PROPERTY_FLAG = '__additional_property' 97 | // This function will create new "properties" items for each key in our formData 98 | export function stubExistingAdditionalProperties( 99 | schema: Schema, 100 | rootSchema: Schema = {}, 101 | formData: any = {}, 102 | ) { 103 | // Clone the schema so we don't ruin the consumer's original 104 | schema = { 105 | ...schema, 106 | properties: { ...schema.properties }, 107 | } 108 | 109 | Object.keys(formData).forEach((key) => { 110 | if ((schema as any).properties.hasOwnProperty(key)) { 111 | // No need to stub, our schema already has the property 112 | return 113 | } 114 | 115 | let additionalProperties 116 | if (schema.additionalProperties.hasOwnProperty('$ref')) { 117 | additionalProperties = retrieveSchema( 118 | { $ref: schema.additionalProperties['$ref'] }, 119 | rootSchema, 120 | formData, 121 | ) 122 | } else if (schema.additionalProperties.hasOwnProperty('type')) { 123 | additionalProperties = { ...schema.additionalProperties } 124 | } else { 125 | additionalProperties = { type: guessType(formData[key]) } 126 | } 127 | 128 | // The type of our new key should match the additionalProperties value; 129 | ;(schema as any).properties[key] = additionalProperties 130 | // Set our additional property flag so we know it was dynamically added 131 | ;(schema as any).properties[key][ADDITIONAL_PROPERTY_FLAG] = true 132 | }) 133 | 134 | return schema 135 | } 136 | 137 | // export function processSchema( 138 | // schema: any, 139 | // rootSchema: any = {}, 140 | // data: any = {} 141 | // ): Schema { 142 | // if (hasOwnProperty(schema, '$ref')) { 143 | // return resolveReference(schema, rootSchema, data) 144 | // } 145 | // if (hasOwnProperty(schema, 'dependencies')) { 146 | // const resolvedSchema = resolveSchema(schema) 147 | // } 148 | // } 149 | 150 | function resolveReference(schema: any, rootSchema: any, formData: any): Schema { 151 | // Retrieve the referenced schema definition. 152 | const $refSchema = findSchemaDefinition(schema.$ref, rootSchema) 153 | // Drop the $ref property of the source schema. 154 | const { $ref, ...localSchema } = schema 155 | // Update referenced schema definition with local schema properties. 156 | return retrieveSchema({ ...$refSchema, ...localSchema }, rootSchema, formData) 157 | } 158 | 159 | export function findSchemaDefinition($ref: string, rootSchema = {}): Schema { 160 | const origRef = $ref 161 | if ($ref.startsWith('#')) { 162 | // Decode URI fragment representation. 163 | $ref = decodeURIComponent($ref.substring(1)) 164 | } else { 165 | throw new Error(`Could not find a definition for ${origRef}.`) 166 | } 167 | const current = jsonpointer.get(rootSchema, $ref) 168 | if (current === undefined) { 169 | throw new Error(`Could not find a definition for ${origRef}.`) 170 | } 171 | if (hasOwnProperty(current, '$ref')) { 172 | // return { ...current, findSchemaDefinition(current.$ref, rootSchema) } ? 173 | return findSchemaDefinition(current.$ref, rootSchema) 174 | } 175 | return current 176 | } 177 | 178 | function resolveDependencies( 179 | schema: any, 180 | rootSchema: any, 181 | formData: any, 182 | ): Schema { 183 | // Drop the dependencies from the source schema. 184 | let { dependencies = {}, ...resolvedSchema } = schema // eslint-disable-line 185 | if ('oneOf' in resolvedSchema) { 186 | resolvedSchema = 187 | resolvedSchema.oneOf[ 188 | getMatchingOption(formData, resolvedSchema.oneOf, rootSchema) 189 | ] 190 | } else if ('anyOf' in resolvedSchema) { 191 | resolvedSchema = 192 | resolvedSchema.anyOf[ 193 | getMatchingOption(formData, resolvedSchema.anyOf, rootSchema) 194 | ] 195 | } 196 | return processDependencies(dependencies, resolvedSchema, rootSchema, formData) 197 | } 198 | function processDependencies( 199 | dependencies: any, 200 | resolvedSchema: any, 201 | rootSchema: any, 202 | formData: any, 203 | ): Schema { 204 | // Process dependencies updating the local schema properties as appropriate. 205 | for (const dependencyKey in dependencies) { 206 | // Skip this dependency if its trigger property is not present. 207 | if (formData[dependencyKey] === undefined) { 208 | continue 209 | } 210 | // Skip this dependency if it is not included in the schema (such as when dependencyKey is itself a hidden dependency.) 211 | if ( 212 | resolvedSchema.properties && 213 | !(dependencyKey in resolvedSchema.properties) 214 | ) { 215 | continue 216 | } 217 | const { 218 | [dependencyKey]: dependencyValue, 219 | ...remainingDependencies 220 | } = dependencies 221 | if (Array.isArray(dependencyValue)) { 222 | resolvedSchema = withDependentProperties(resolvedSchema, dependencyValue) 223 | } else if (isObject(dependencyValue)) { 224 | resolvedSchema = withDependentSchema( 225 | resolvedSchema, 226 | rootSchema, 227 | formData, 228 | dependencyKey, 229 | dependencyValue, 230 | ) 231 | } 232 | return processDependencies( 233 | remainingDependencies, 234 | resolvedSchema, 235 | rootSchema, 236 | formData, 237 | ) 238 | } 239 | return resolvedSchema 240 | } 241 | 242 | function withDependentProperties(schema: any, additionallyRequired: any) { 243 | if (!additionallyRequired) { 244 | return schema 245 | } 246 | const required = Array.isArray(schema.required) 247 | ? Array.from(new Set([...schema.required, ...additionallyRequired])) 248 | : additionallyRequired 249 | return { ...schema, required: required } 250 | } 251 | 252 | function withDependentSchema( 253 | schema: any, 254 | rootSchema: any, 255 | formData: any, 256 | dependencyKey: any, 257 | dependencyValue: any, 258 | ) { 259 | // retrieveSchema 260 | const { oneOf, ...dependentSchema } = retrieveSchema( 261 | dependencyValue, 262 | rootSchema, 263 | formData, 264 | ) 265 | schema = mergeSchemas(schema, dependentSchema) 266 | // Since it does not contain oneOf, we return the original schema. 267 | if (oneOf === undefined) { 268 | return schema 269 | } else if (!Array.isArray(oneOf)) { 270 | throw new Error(`invalid: it is some ${typeof oneOf} instead of an array`) 271 | } 272 | // Resolve $refs inside oneOf. 273 | const resolvedOneOf = oneOf.map((subschema) => 274 | hasOwnProperty(subschema, '$ref') 275 | ? resolveReference(subschema, rootSchema, formData) 276 | : subschema, 277 | ) 278 | return withExactlyOneSubschema( 279 | schema, 280 | rootSchema, 281 | formData, 282 | dependencyKey, 283 | resolvedOneOf, 284 | ) 285 | } 286 | 287 | function withExactlyOneSubschema( 288 | schema: any, 289 | rootSchema: any, 290 | formData: any, 291 | dependencyKey: any, 292 | oneOf: any, 293 | ) { 294 | const validSubschemas = oneOf.filter((subschema: any) => { 295 | if (!subschema.properties) { 296 | return false 297 | } 298 | const { [dependencyKey]: conditionPropertySchema } = subschema.properties 299 | if (conditionPropertySchema) { 300 | const conditionSchema = { 301 | type: 'object', 302 | properties: { 303 | [dependencyKey]: conditionPropertySchema, 304 | }, 305 | } 306 | // TODO: validate formdata 307 | const { errors } = validateData(conditionSchema, formData) 308 | return !errors || errors.length === 0 309 | } 310 | }) 311 | if (validSubschemas.length !== 1) { 312 | console.warn( 313 | "ignoring oneOf in dependencies because there isn't exactly one subschema that is valid", 314 | ) 315 | return schema 316 | } 317 | // debugger 318 | const subschema = validSubschemas[0] 319 | const { 320 | [dependencyKey]: conditionPropertySchema, 321 | ...dependentSubschema 322 | } = subschema.properties 323 | const dependentSchema = { ...subschema, properties: dependentSubschema } 324 | return mergeSchemas( 325 | schema, 326 | // retrieveSchema 327 | retrieveSchema(dependentSchema, rootSchema, formData), 328 | ) 329 | } 330 | 331 | // Recursively merge deeply nested schemas. 332 | // The difference between mergeSchemas and mergeObjects 333 | // is that mergeSchemas only concats arrays for 334 | // values under the "required" keyword, and when it does, 335 | // it doesn't include duplicate values. 336 | export function mergeSchemas(obj1: any, obj2: any) { 337 | const acc = Object.assign({}, obj1) // Prevent mutation of source object. 338 | return Object.keys(obj2).reduce((acc, key) => { 339 | const left = obj1 ? obj1[key] : {}, 340 | right = obj2[key] 341 | if (obj1 && hasOwnProperty(obj1, key) && isObject(right)) { 342 | acc[key] = mergeSchemas(left, right) 343 | } else if ( 344 | obj1 && 345 | obj2 && 346 | (getSchemaType(obj1) === 'object' || getSchemaType(obj2) === 'object') && 347 | key === 'required' && 348 | Array.isArray(left) && 349 | Array.isArray(right) 350 | ) { 351 | // Don't include duplicate values when merging 352 | // "required" fields. 353 | acc[key] = union(left, right) 354 | } else { 355 | acc[key] = right 356 | } 357 | return acc 358 | }, acc) 359 | } 360 | 361 | // export function getVJSFConfig( 362 | // schema: Schema, 363 | // uiSchema: VueJsonSchemaConfig | undefined, 364 | // ): VueJsonSchemaConfig { 365 | // if (uiSchema) return uiSchema 366 | // return schema.vjsf || {} 367 | // } 368 | 369 | /* Gets the type of a given schema. */ 370 | export function getSchemaType(schema: Schema): string | undefined { 371 | const { type } = schema 372 | 373 | if (!type && schema.const) { 374 | return guessType(schema.const) 375 | } 376 | 377 | if (!type && schema.enum) { 378 | return 'string' 379 | } 380 | 381 | if (!type && (schema.properties || schema.additionalProperties)) { 382 | return 'object' 383 | } 384 | 385 | const t: any = type 386 | if (t instanceof Array && t.length === 2 && t.includes('null')) { 387 | return t.find((type) => type !== 'null') 388 | } 389 | 390 | return type 391 | 392 | // let { type } = schema 393 | 394 | // if (type) return type 395 | 396 | // if (!type && schema.const) { 397 | // return guessType(schema.const) 398 | // } 399 | 400 | // if (!type && schema.enum) { 401 | // // return 'string' 402 | // return guessType(schema.enum[0]) 403 | // } 404 | 405 | // if (!type && (schema.properties || schema.additionalProperties)) { 406 | // return 'object' 407 | // } 408 | 409 | // console.warn('can not guess schema type, just use object', schema) 410 | // return 'object' 411 | 412 | // if (type instanceof Array && type.length === 2 && type.includes('null')) { 413 | // return type.find((type) => type !== 'null') 414 | // } 415 | } 416 | 417 | // In the case where we have to implicitly create a schema, it is useful to know what type to use 418 | // based on the data we are defining 419 | export const guessType = function guessType(value: any) { 420 | if (Array.isArray(value)) { 421 | return 'array' 422 | } else if (typeof value === 'string') { 423 | return 'string' 424 | } else if (value == null) { 425 | return 'null' 426 | } else if (typeof value === 'boolean') { 427 | return 'boolean' 428 | } else if (!isNaN(value)) { 429 | return 'number' 430 | } else if (typeof value === 'object') { 431 | return 'object' 432 | } 433 | // Default to string if we can't figure it out 434 | return 'string' 435 | } 436 | 437 | export function isConstant(schema: Schema) { 438 | return ( 439 | (Array.isArray(schema.enum) && schema.enum.length === 1) || 440 | schema.hasOwnProperty('const') 441 | ) 442 | } 443 | 444 | export function isSelect(_schema: any, rootSchema: Schema = {}) { 445 | const schema = retrieveSchema(_schema, rootSchema) 446 | const altSchemas = schema.oneOf || schema.anyOf 447 | if (Array.isArray(schema.enum)) { 448 | return true 449 | } else if (Array.isArray(altSchemas)) { 450 | return altSchemas.every((altSchemas) => isConstant(altSchemas)) 451 | } 452 | return false 453 | } 454 | 455 | export function isMultiSelect(schema: Schema, rootSchema: Schema = {}) { 456 | if (!schema.uniqueItems || !schema.items) { 457 | return false 458 | } 459 | return isSelect(schema.items, rootSchema) 460 | } 461 | 462 | // TODO: change oneOf selected based on data 463 | export function getMatchingOption( 464 | formData: any, 465 | options: Schema[], 466 | isValid: (schema: Schema, data: any) => boolean, 467 | ) { 468 | for (let i = 0; i < options.length; i++) { 469 | const option = options[i] 470 | 471 | // If the schema describes an object then we need to add slightly more 472 | // strict matching to the schema, because unless the schema uses the 473 | // "requires" keyword, an object will match the schema as long as it 474 | // doesn't have matching keys with a conflicting type. To do this we use an 475 | // "anyOf" with an array of requires. This augmentation expresses that the 476 | // schema should match if any of the keys in the schema are present on the 477 | // object and pass validation. 478 | if (option.properties) { 479 | // Create an "anyOf" schema that requires at least one of the keys in the 480 | // "properties" object 481 | const requiresAnyOf = { 482 | anyOf: Object.keys(option.properties).map((key) => ({ 483 | required: [key], 484 | })), 485 | } 486 | 487 | let augmentedSchema 488 | 489 | // If the "anyOf" keyword already exists, wrap the augmentation in an "allOf" 490 | if (option.anyOf) { 491 | // Create a shallow clone of the option 492 | const { ...shallowClone } = option 493 | 494 | if (!shallowClone.allOf) { 495 | shallowClone.allOf = [] 496 | } else { 497 | // If "allOf" already exists, shallow clone the array 498 | shallowClone.allOf = shallowClone.allOf.slice() 499 | } 500 | 501 | shallowClone.allOf.push(requiresAnyOf) 502 | 503 | augmentedSchema = shallowClone 504 | } else { 505 | augmentedSchema = Object.assign({}, option, requiresAnyOf) 506 | } 507 | 508 | // Remove the "required" field as it's likely that not all fields have 509 | // been filled in yet, which will mean that the schema is not valid 510 | delete augmentedSchema.required 511 | 512 | if (isValid(augmentedSchema, formData)) { 513 | return i 514 | } 515 | } else if (isValid(options[i], formData)) { 516 | return i 517 | } 518 | } 519 | return 0 520 | } 521 | 522 | export function mergeDefaultsWithFormData(defaults: any, formData: any): any { 523 | if (Array.isArray(formData)) { 524 | if (!Array.isArray(defaults)) { 525 | defaults = [] 526 | } 527 | return formData.map((value, idx) => { 528 | if (defaults[idx]) { 529 | return mergeDefaultsWithFormData(defaults[idx], value) 530 | } 531 | return value 532 | }) 533 | } else if (isObject(formData)) { 534 | const acc = Object.assign({}, defaults) // Prevent mutation of source object. 535 | return Object.keys(formData).reduce((acc, key) => { 536 | acc[key] = mergeDefaultsWithFormData( 537 | defaults ? defaults[key] : {}, 538 | formData[key], 539 | ) 540 | return acc 541 | }, acc) 542 | } else { 543 | return formData 544 | } 545 | } 546 | 547 | export function getDefaultFormState( 548 | _schema: Schema, 549 | formData: any, 550 | // rootSchema = {}, 551 | // includeUndefinedValues = false, 552 | ) { 553 | if (!isObject(_schema)) { 554 | throw new Error('Invalid schema: ' + _schema) 555 | } 556 | // const schema = retrieveSchema(_schema, rootSchema, formData) 557 | const defaults = _schema.default 558 | // TODO: I guess we don't need to get default from children schema 559 | // const defaults = computeDefaults( 560 | // schema, 561 | // _schema.default, 562 | // rootSchema, 563 | // formData, 564 | // includeUndefinedValues 565 | // ); 566 | if (typeof formData === 'undefined') { 567 | // No form data? Use schema defaults. 568 | return defaults 569 | } 570 | if (isObject(formData) || Array.isArray(formData)) { 571 | return mergeDefaultsWithFormData(defaults, formData) 572 | } 573 | if (formData === 0 || formData === false || formData === '') { 574 | return formData 575 | } 576 | return formData || defaults 577 | } 578 | --------------------------------------------------------------------------------