├── .browserslistrc ├── src ├── core │ ├── schme-type-renderers │ │ ├── NullRenderer.vue │ │ ├── ConstantRenderer.vue │ │ ├── BooleanRenderer.vue │ │ ├── CustomRenderer.vue │ │ ├── NumberRenderer.vue │ │ ├── StringRenderer.vue │ │ ├── ObjectRenderer.vue │ │ └── ArrayRenderer.vue │ ├── mixins │ │ ├── index.ts │ │ ├── select-mixin.ts │ │ ├── current-value.js │ │ ├── error-message.ts │ │ └── base.ts │ ├── types.ts │ ├── format-component-map.ts │ ├── deprecaped-SchemaRenderer.vue │ ├── validator │ │ ├── types.ts │ │ ├── index.ts │ │ ├── date-time-range-keyword.ts │ │ └── instance-default-options.ts │ ├── utils │ │ ├── index.ts │ │ ├── common-renderer-props.ts │ │ ├── common.ts │ │ └── schema.ts │ ├── FormItem.vue │ ├── SchemaItem.vue │ ├── index.ts │ └── SchemaForm.vue ├── assets │ └── logo.png ├── shims-vue.d.ts ├── plugins │ ├── index.ts │ ├── image-uploader.ts │ └── ImageUploader.vue ├── demos │ ├── required.js │ ├── boolean.js │ ├── index.js │ ├── custom.js │ ├── number.js │ ├── string.js │ ├── deps.js │ ├── nest-deps.js │ ├── complex.js │ ├── date.js │ └── array.js ├── shims-tsx.d.ts ├── theme-element-ui │ ├── Form.vue │ ├── Alert.vue │ ├── Constant.vue │ ├── ColorPicker.vue │ ├── Switch.vue │ ├── DateTimePicker.vue │ ├── FormItem.vue │ ├── DatePicker.vue │ ├── Selection.vue │ ├── TextInput.vue │ ├── ArrayItemAddAction.vue │ ├── TimePicker.vue │ ├── base │ │ ├── DatePicker.vue │ │ └── ArrayItemActions.vue │ ├── NumberInput.vue │ ├── DateTimeRangePicker.vue │ ├── index.ts │ ├── SingleTypeArrayWrapper.vue │ └── form-component-props │ │ └── index.ts ├── main.ts ├── CustomInput.vue ├── App-ts.vue ├── custom-keyword.ts └── App.vue ├── public ├── favicon.ico └── index.html ├── jest.config.js ├── docs ├── fetures.md ├── implements.md ├── renderer-props.md └── dependencies.md ├── tests └── unit │ ├── utils │ ├── Custom.vue │ ├── index.ts │ └── Helper.vue │ ├── default-value │ ├── boolean.spec.ts │ ├── number.spec.ts │ ├── string.spec.ts │ ├── object.spec.ts │ └── array.spec.ts │ ├── theme-element │ ├── constant.spec.ts │ ├── switch.spec.ts │ ├── numberInput.spec.ts │ ├── textInput.spec.ts │ ├── colorPicker.spec.ts │ ├── datetime.spec.ts │ ├── timePicker.spec.ts │ ├── date.spec.ts │ ├── selection.spec.ts │ └── dateTimeRangePicker.spec.ts │ ├── value-change │ ├── array-value.spec.ts │ └── reset-value.spec.ts │ └── vjsf │ ├── ui-schema.spec.ts │ └── vjsf.spec.ts ├── babel.config.js ├── .prettierrc ├── publish.sh ├── .gitignore ├── .github └── workflows │ ├── build.yml │ └── test-coverage.yml ├── .eslintrc.js ├── tsconfig.json ├── vue.config.js └── package.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /src/core/schme-type-renderers/NullRenderer.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanwu-fe/vue-json-schema-form/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wanwu-fe/vue-json-schema-form/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 3 | } 4 | -------------------------------------------------------------------------------- /docs/fetures.md: -------------------------------------------------------------------------------- 1 | # 关于插件设计 2 | 3 | 插件应该满足以下要求 4 | 5 | - 能够定义`format`,并可以定义`format`需要使用的组件 6 | 7 | # 4-17 工作内容 8 | 9 | - 插件功能 10 | - 错误提醒 11 | - [x] form props 12 | -------------------------------------------------------------------------------- /tests/unit/utils/Custom.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@vue/cli-plugin-babel/preset', 5 | { 6 | useBuiltIns: false, 7 | }, 8 | ], 9 | ], 10 | } 11 | -------------------------------------------------------------------------------- /src/core/mixins/index.ts: -------------------------------------------------------------------------------- 1 | // import currentValue from './current-value' 2 | 3 | import errorMessage from './error-message' 4 | import selectMixin from './select-mixin' 5 | 6 | export * from './base' 7 | 8 | export { errorMessage, selectMixin } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "es5", 5 | "arrowParens": "always", 6 | "endOfLine": "lf", 7 | "tabWidth": 2, 8 | "htmlWhitespaceSensitivity": "css", 9 | "vueIndentScriptAndStyle": true 10 | } 11 | -------------------------------------------------------------------------------- /src/plugins/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import ImageUploader, { plugin } from './image-uploader' 4 | 5 | export default { 6 | install(vue: typeof Vue) { 7 | vue.use(ImageUploader) 8 | }, 9 | } 10 | 11 | export { plugin as imagePlugin } 12 | -------------------------------------------------------------------------------- /src/demos/required.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Required', 3 | schema: { 4 | type: 'object', 5 | properties: { 6 | name: { 7 | type: 'string', 8 | }, 9 | age: { 10 | type: 'number', 11 | }, 12 | }, 13 | required: ['name', 'age'], 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | rm -rf dist 2 | 3 | npm run build:doc 4 | 5 | cd dist 6 | 7 | git init 8 | 9 | git checkout -B gh-pages 10 | 11 | git add . 12 | 13 | git commit -m "publish" 14 | 15 | git remote add origin https://github.com/wanwu-fe/vue-json-schema-form.git 16 | 17 | git push origin gh-pages -f 18 | 19 | cd .. 20 | 21 | rm -rf dist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | coverage 5 | .vscode 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 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /src/demos/boolean.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Boolean', 3 | schema: { 4 | type: 'object', 5 | properties: { 6 | selected: { 7 | type: 'boolean', 8 | title: '是否选中', 9 | }, 10 | defaultTrue: { 11 | type: 'boolean', 12 | default: true, 13 | title: '默认选中', 14 | }, 15 | }, 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/theme-element-ui/Form.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /tests/unit/utils/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import Helper from './Helper.vue' // @ts-ignore 4 | import Custom from './Custom.vue' 5 | 6 | import JsonSchemaForm from '../../../src/core' 7 | import ThemeElement from '../../../src/theme-element-ui' 8 | 9 | export function initialize() { 10 | Vue.use(JsonSchemaForm) 11 | Vue.use(ThemeElement) 12 | // Vue.use(Custom) 13 | Vue.component(Custom.name, Custom) 14 | } 15 | 16 | export { Helper, Custom } 17 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import { AjvFormat, AjvKeyword } from './validator/types' 2 | import { Schema } from './utils/schema' 3 | 4 | interface CustomFormat extends AjvFormat { 5 | component: String // jsf-text-input || your-custom-component 6 | } 7 | 8 | interface CustomKeyword extends AjvKeyword { 9 | transformSchema?: (originSchema: Schema) => Schema 10 | } 11 | 12 | export interface JsonSchemFormPlugin { 13 | customFormats?: CustomFormat[] 14 | customKeywords?: CustomKeyword[] 15 | } 16 | -------------------------------------------------------------------------------- /src/theme-element-ui/Alert.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /src/core/format-component-map.ts: -------------------------------------------------------------------------------- 1 | interface StringMap { 2 | [key: string]: string 3 | } 4 | 5 | export const stringFormatComponentMap: StringMap = { 6 | default: 'jsf-text-input', 7 | color: 'jsf-color-picker', 8 | date: 'jsf-date-picker', 9 | 'date-time': 'jsf-date-time-picker', 10 | time: 'jsf-time-picker', 11 | } 12 | 13 | export const numberFormatComponentMap: StringMap = { 14 | default: 'jsf-number-input', 15 | date: 'jsf-date-picker', 16 | 'date-time': 'jsf-date-time-picker', 17 | time: 'jsf-time-picker', 18 | } 19 | -------------------------------------------------------------------------------- /tests/unit/utils/Helper.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 25 | -------------------------------------------------------------------------------- /src/core/schme-type-renderers/ConstantRenderer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import 'element-ui/lib/theme-chalk/index.css' 4 | 5 | import CustomInput from './CustomInput.vue' 6 | import schemaForm from './core' 7 | import JsonSchemaFormThemeElementUI from './theme-element-ui' 8 | 9 | import App from './App.vue' 10 | 11 | Vue.config.productionTip = false 12 | 13 | Vue.component('custom-input', CustomInput) 14 | Vue.use(schemaForm) 15 | Vue.use(JsonSchemaFormThemeElementUI) 16 | 17 | // Vue.component('jsf-image-uploader', ImageUploader) 18 | 19 | new Vue({ 20 | render: (h) => h(App), 21 | }).$mount('#app') 22 | -------------------------------------------------------------------------------- /src/theme-element-ui/Constant.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 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 build 22 | env: 23 | CI: true 24 | -------------------------------------------------------------------------------- /src/demos/index.js: -------------------------------------------------------------------------------- 1 | import stringDemo from './string' 2 | import numberDemo from './number' 3 | import arrayDemo from './array' 4 | import booleanDemo from './boolean' 5 | import requiredDemo from './required' 6 | import depDemo from './deps' 7 | import dateDemo from './date' 8 | import customDemo from './custom' 9 | import complexDemo from './complex' 10 | import nestDepsDemo from './nest-deps' 11 | 12 | export default [ 13 | stringDemo, 14 | numberDemo, 15 | arrayDemo, 16 | booleanDemo, 17 | requiredDemo, 18 | depDemo, 19 | dateDemo, 20 | customDemo, 21 | complexDemo, 22 | nestDepsDemo, 23 | ] 24 | -------------------------------------------------------------------------------- /src/plugins/image-uploader.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { JsonSchemFormPlugin } from '../core/types' 3 | import ImageUploader from './ImageUploader.vue' 4 | 5 | const URL_REG = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/ // eslint-disable-line 6 | 7 | const plugin: JsonSchemFormPlugin = { 8 | customFormats: [ 9 | { 10 | name: 'image', 11 | definition: URL_REG, 12 | component: 'jsf-image-uploader', 13 | }, 14 | ], 15 | } 16 | 17 | export { plugin } 18 | export default { 19 | install(vue: typeof Vue) { 20 | vue.component('jsf-image-uploader', ImageUploader) 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /src/CustomInput.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 26 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App-ts.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 19 | 20 | 30 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | env: { 5 | node: true, 6 | }, 7 | 8 | parserOptions: { 9 | parser: '@typescript-eslint/parser', 10 | }, 11 | 12 | ignorePatterns: ['__test__'], 13 | 14 | rules: { 15 | 'no-console': 'off', 16 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 17 | 'no-unused-vars': 'off', 18 | }, 19 | 20 | overrides: [ 21 | { 22 | files: [ 23 | '**/__tests__/*.{j,t}s?(x)', 24 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 25 | ], 26 | env: { 27 | jest: true, 28 | }, 29 | }, 30 | ], 31 | 32 | extends: [ 33 | 'plugin:vue/essential', 34 | 'eslint:recommended', 35 | '@vue/prettier', 36 | '@vue/typescript', 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /src/core/deprecaped-SchemaRenderer.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": ["webpack-env", "jest"], 15 | "declaration": true, 16 | "declarationDir": "dist", 17 | "paths": { 18 | "@/*": ["src/*"] 19 | }, 20 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"] 21 | }, 22 | "include": [ 23 | "src/**/*.ts", 24 | "src/**/*.tsx", 25 | "src/**/*.vue", 26 | "tests/**/*.ts", 27 | "tests/**/*.tsx" 28 | ], 29 | "exclude": ["node_modules"] 30 | } 31 | -------------------------------------------------------------------------------- /src/core/validator/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Options, 3 | FormatDefinition, 4 | FormatValidator, 5 | KeywordDefinition, 6 | } from 'ajv' 7 | 8 | export interface AjvFormat { 9 | name: string 10 | definition: FormatValidator | FormatDefinition 11 | } 12 | 13 | export interface AjvKeyword { 14 | name: string 15 | definition: KeywordDefinition 16 | } 17 | 18 | export interface CreateInstanceOptions { 19 | // locale?: string 20 | options?: Options 21 | formats?: AjvFormat | AjvFormat[] 22 | keywords?: AjvKeyword | AjvKeyword[] 23 | } 24 | 25 | export interface ConstantCreateInstanceOptions extends CreateInstanceOptions { 26 | // locale: string 27 | options: Options 28 | formats: AjvFormat[] 29 | keywords: AjvKeyword[] 30 | } 31 | 32 | export interface EnumKeyValueItem { 33 | key: string 34 | value: V 35 | } 36 | -------------------------------------------------------------------------------- /.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:coverage 22 | env: 23 | CI: true 24 | 25 | - name: Upload coverage to Codecov 26 | uses: codecov/codecov-action@v1 27 | with: 28 | flags: unittests 29 | file: ./coverage/clover.xml 30 | fail_ci_if_error: true 31 | -------------------------------------------------------------------------------- /src/theme-element-ui/ColorPicker.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 32 | -------------------------------------------------------------------------------- /src/theme-element-ui/Switch.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 33 | -------------------------------------------------------------------------------- /src/core/schme-type-renderers/BooleanRenderer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 29 | -------------------------------------------------------------------------------- /src/core/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './schema' 2 | export * from './common' 3 | export * from './common-renderer-props' 4 | 5 | var SINGLE_QUOTE = /'|\\/g 6 | function escapeQuotes(str: string) { 7 | return str 8 | .replace(SINGLE_QUOTE, '\\$&') 9 | .replace(/\n/g, '\\n') 10 | .replace(/\r/g, '\\r') 11 | .replace(/\f/g, '\\f') 12 | .replace(/\t/g, '\\t') 13 | } 14 | 15 | function toQuotedString(str: string) { 16 | return "'" + escapeQuotes(str) + "'" 17 | } 18 | 19 | export function escapeJsonPointer(str: string) { 20 | return str.replace(/~/g, '~0').replace(/\//g, '~1') 21 | } 22 | 23 | function joinPaths(a: any, b: any) { 24 | if (a == '""') return b 25 | return (a + ' + ' + b).replace(/' \+ '/g, '') 26 | } 27 | 28 | export function getJsonPointerPath(currentPath: string, prop: string) { 29 | var path = toQuotedString('/' + escapeJsonPointer(prop)) 30 | return joinPaths(currentPath, path) 31 | } 32 | -------------------------------------------------------------------------------- /src/demos/custom.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Custom Component', 3 | schema: { 4 | type: 'object', 5 | properties: { 6 | custom: { 7 | type: 'string', 8 | minLength: 5, 9 | default: '123', 10 | /** 11 | * string: 组件 12 | * object: full config 13 | */ 14 | jsfCustom: 'custom-input', 15 | }, 16 | fullCustom: { 17 | type: 'string', 18 | minLength: 5, 19 | jsfCustom: { 20 | component: 'custom-input', 21 | withFormItem: true, // default true 22 | }, 23 | }, 24 | customVjsf: { 25 | type: 'string', 26 | vjsf: { 27 | component: 'custom-input', 28 | }, 29 | }, 30 | customKeyword: { 31 | type: 'object', 32 | test: true, 33 | vjsf: { 34 | title: '自定义关键字', 35 | }, 36 | }, 37 | }, 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /src/core/FormItem.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 38 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: process.env.TYPE === 'doc' ? '/vue-json-schema-form' : '/', 3 | configureWebpack: (config) => { 4 | if (process.env.NODE_ENV === 'production' && process.env.TYPE !== 'doc') { 5 | config.externals = ['element-ui', 'ajv', 'ajv-i18n', 'vue'] 6 | } 7 | }, 8 | // chainWebpack: (config) => { 9 | // if (process.env.NODE_ENV === 'production' && process.env.TYPE !== 'doc') { 10 | // config.module.rule('ts').uses.delete('cache-loader') 11 | // config.module 12 | // .rule('ts') 13 | // .use('ts-loader') 14 | // .loader('ts-loader') 15 | // .tap((options) => ({ 16 | // ...options, 17 | // transpileOnly: false, 18 | // happyPackMode: false, 19 | // })) 20 | // } 21 | // }, 22 | // parallel: false, 23 | pluginOptions: { 24 | webpackBundleAnalyzer: { 25 | openAnalyzer: false, 26 | }, 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/core/mixins/select-mixin.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Component, Prop } from 'vue-property-decorator' 3 | 4 | @Component 5 | export default class SelectMixin extends Vue { 6 | @Prop() schema: any 7 | 8 | get isSelect() { 9 | return this.schema.enum || this.schema.enumKeyValue 10 | } 11 | 12 | get options() { 13 | if (this.schema.enumKeyValue) return this.schema.enumKeyValue 14 | else if (this.schema.enum) { 15 | const options = this.schema.enum.map((e: string | number) => ({ 16 | key: e, 17 | value: e, 18 | })) 19 | return options 20 | } else return [] 21 | } 22 | 23 | getOptions() { 24 | if (this.schema.enumKeyValue) return this.schema.enumKeyValue 25 | else if (this.schema.enmu) { 26 | const options = this.schema.enum.map((e: string | number) => ({ 27 | key: e, 28 | value: e, 29 | })) 30 | return options 31 | } else return [] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/core/utils/common-renderer-props.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Component, Prop } from 'vue-property-decorator' 3 | 4 | @Component 5 | class CommonBasePorps extends Vue { 6 | @Prop() value: any 7 | @Prop({ type: Function }) onChange: any 8 | @Prop({ type: String }) format: any 9 | @Prop({ type: Object }) schema: any 10 | @Prop() errors: any 11 | @Prop({ type: String }) title: any 12 | @Prop({ type: Boolean }) required: any 13 | @Prop({ type: Boolean }) requiredError: any 14 | @Prop({ type: String }) description: any 15 | @Prop({ type: Object }) vjsf: any 16 | } 17 | 18 | const commonBasePropsMixin = { 19 | props: { 20 | value: {}, 21 | onChange: { 22 | type: Function, 23 | required: true, 24 | }, 25 | format: String, 26 | schema: Object, 27 | errors: {}, 28 | title: String, 29 | required: Boolean, 30 | requiredError: Boolean, 31 | description: String, 32 | vjsf: Object, 33 | }, 34 | } 35 | 36 | export { CommonBasePorps, commonBasePropsMixin } 37 | -------------------------------------------------------------------------------- /src/theme-element-ui/DateTimePicker.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | -------------------------------------------------------------------------------- /src/core/mixins/current-value.js: -------------------------------------------------------------------------------- 1 | // import { getDefaultValueOfSchema } from '../utils' 2 | 3 | export default function(createWithSchema = false, defaultValue) { 4 | const mixin = { 5 | data() { 6 | return { 7 | currentValue: createWithSchema ? null : defaultValue, 8 | } 9 | }, 10 | watch: { 11 | currentValue(newV) { 12 | if (newV !== this.value) this.$emit('input', newV) 13 | }, 14 | }, 15 | } 16 | 17 | // mixin.created = function() { 18 | // // get default value from schema 19 | // this.currentValue = 20 | // this.value || getDefaultValueOfSchema(this.schema) || defaultValue 21 | // } 22 | 23 | // if (createWithSchema) { 24 | // mixin.created = function() { 25 | // // get default value from schema 26 | // this.currentValue = getDefaultValueOfSchema(this.schema) 27 | // } 28 | // } else { 29 | // mixin.created = function() { 30 | // // get default value from schema 31 | // this.currentValue = this.value 32 | // } 33 | // } 34 | return mixin 35 | } 36 | -------------------------------------------------------------------------------- /src/theme-element-ui/FormItem.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 41 | -------------------------------------------------------------------------------- /src/theme-element-ui/DatePicker.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 44 | -------------------------------------------------------------------------------- /docs/implements.md: -------------------------------------------------------------------------------- 1 | # Keywords Implemented 2 | 3 | - [x] type 4 | - [x] maximum / minimum and exclusiveMaximum / exclusiveMinimum (only use in validation) 5 | - [x] maxLength/minLength 6 | - [x] pattern 7 | - [x] format 8 | - [x] formatMaximum / formatMinimum and formatExclusiveMaximum / formatExclusiveMinimum (only use in validation) 9 | - [x] uniqueItems (only use in validation) 10 | - [x] items 11 | - [x] required 12 | - [x] properties 13 | - [x] enum 14 | - [x] dependencies 15 | 16 | # Keywords in progress 17 | 18 | - [ ] multipleOf 19 | - [ ] maxItems/minItems 20 | - [ ] additionalItems (交互很难确定,比如`additionalItems: ture`理论上可以添加任何类型) 21 | - [ ] additionalProperties 22 | - [ ] const(`"const": { "$data": "1/foo" }`) 23 | - [ ] if/then/else (if we find any usefull case) 24 | - [ ] oneOf/anyOf/allOf 25 | 26 | # Keywords just in validation 27 | 28 | - [ ] not 29 | 30 | # Keywords will not be supported 31 | 32 | - [ ] patternProperties (无法想象出应用场景) 33 | - [ ] patternRequired (同上) 34 | - [ ] propertyNames (既然我们不实现 patternProperties,这个自然没什么意义) 35 | 36 | # Keywords need test case 37 | 38 | - [ ] contains 39 | - [ ] maxProperties/minProperties 40 | -------------------------------------------------------------------------------- /src/theme-element-ui/Selection.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 43 | -------------------------------------------------------------------------------- /src/custom-keyword.ts: -------------------------------------------------------------------------------- 1 | import { JsonSchemFormPlugin } from './core/types' 2 | 3 | /** 4 | * Ajv custom keyword doc: https://ajv.js.org/custom.html 5 | */ 6 | 7 | const plugin: JsonSchemFormPlugin = { 8 | customKeywords: [ 9 | { 10 | name: 'test', 11 | definition: { 12 | // validate(schema: any, data: any) { 13 | // return typeof data === 'object' && data.x === 1 14 | // }, 15 | macro(schema: any) { 16 | return { 17 | ...schema, 18 | type: 'object', 19 | properties: { 20 | x: { 21 | type: 'number', 22 | minimum: 5, 23 | }, 24 | }, 25 | } 26 | }, 27 | errors: true, 28 | }, 29 | transformSchema(schema: any) { 30 | return { 31 | ...schema, 32 | type: 'object', 33 | properties: { 34 | x: { 35 | type: 'number', 36 | vjsf: { 37 | title: '测试数字', 38 | }, 39 | }, 40 | }, 41 | } 42 | }, 43 | }, 44 | ], 45 | } 46 | 47 | export default plugin 48 | -------------------------------------------------------------------------------- /src/demos/number.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'Number', 3 | schema: { 4 | type: 'object', 5 | properties: { 6 | const: { 7 | type: 'number', 8 | const: 12, 9 | vjsf: { 10 | title: '固定值', 11 | }, 12 | }, 13 | simple: { 14 | type: 'number', 15 | default: 5, 16 | vjsf: { 17 | placeholder: '普通', 18 | title: '普通数字', 19 | additionProps: { 20 | min: 10, 21 | }, 22 | }, 23 | }, 24 | minMax: { 25 | type: 'number', 26 | title: '最大最小值', 27 | minimum: 10, 28 | maximum: 20, 29 | }, 30 | integer: { 31 | type: 'integer', 32 | title: '整数', 33 | }, 34 | select: { 35 | type: 'number', 36 | title: '选择', 37 | default: 5, 38 | enumKeyValue: [ 39 | { 40 | key: 'option1', 41 | value: 0, 42 | }, 43 | { 44 | key: 'option2', 45 | value: 2, 46 | }, 47 | { 48 | key: 'option3', 49 | value: 3, 50 | }, 51 | ], 52 | }, 53 | }, 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /src/theme-element-ui/TextInput.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 48 | -------------------------------------------------------------------------------- /src/theme-element-ui/ArrayItemAddAction.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 32 | 33 | 47 | -------------------------------------------------------------------------------- /src/core/mixins/error-message.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Component, Prop } from 'vue-property-decorator' 3 | 4 | function isDependenciesError(schemaPath: string) { 5 | return schemaPath.indexOf('/dependencies/') > 0 6 | } 7 | 8 | @Component({ 9 | inject: { 10 | form: 'formContext', 11 | }, 12 | }) 13 | export default class ErrorMessageMixin extends Vue { 14 | form: any 15 | @Prop({ type: Boolean }) isDependenciesKey: any 16 | @Prop() requiredError: any 17 | get errorMessage() { 18 | if (this.requiredError) return '必须填写' 19 | // const errors = this.form.errors.filter( 20 | // (e: any) => e.dataPath === (this as any).path 21 | // ) 22 | // return error ? error.message : '' 23 | // return this.errors[0] ? this.errors[0].message : '' 24 | return this.firstMatchedError ? this.firstMatchedError.message : '' 25 | } 26 | 27 | get firstMatchedError() { 28 | return this.errors.find((e: any) => { 29 | const schemaPath = e.schemaPath 30 | if (this.isDependenciesKey && isDependenciesError(schemaPath)) { 31 | return false 32 | } 33 | return true 34 | }) 35 | } 36 | 37 | get errors() { 38 | const errors = this.form.errors.filter( 39 | (e: any) => e.dataPath === (this as any).path 40 | ) 41 | return errors 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/theme-element-ui/TimePicker.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 47 | -------------------------------------------------------------------------------- /tests/unit/default-value/boolean.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('boolean renderer default values', () => { 8 | it('should use `false` when no default', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'boolean', 13 | }, 14 | value: undefined, 15 | }), 16 | }) 17 | await wrapper.vm.$nextTick() 18 | expect(wrapper.vm.value).toBe(false) 19 | }) 20 | 21 | it('should use `!!default` when default provided', async () => { 22 | const wrapper: any = mount(Wrapper, { 23 | data: () => ({ 24 | schema: { 25 | type: 'boolean', 26 | default: 1, 27 | }, 28 | value: undefined, 29 | }), 30 | }) 31 | await wrapper.vm.$nextTick() 32 | expect(wrapper.vm.value).toBe(true) 33 | }) 34 | 35 | it('should use `value` when value provided', async () => { 36 | const wrapper: any = mount(Wrapper, { 37 | data: () => ({ 38 | schema: { 39 | type: 'boolean', 40 | default: 1, 41 | }, 42 | value: false, 43 | }), 44 | }) 45 | await wrapper.vm.$nextTick() 46 | expect(wrapper.vm.value).toBe(false) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /docs/renderer-props.md: -------------------------------------------------------------------------------- 1 | 对于每个最终的渲染组件,我们提供一套标准的 props,来帮助我们更好的开发渲染主题 2 | 3 | ### FormItem Props 4 | 5 | ```js 6 | { 7 | // 是否是必填 8 | required: boolean, 9 | // schema 对象 10 | schema: schema, 11 | // 第一个匹配的错误,注意这个会跳过`dependencies`相关的错误 12 | firstMatchedError: this.firstMatchedError, 13 | // 是否必须填写错误 14 | requiredError: boolean, 15 | // 所有错误 16 | errors: this.errors, 17 | // label文本 18 | label: vjsf.title || path, 19 | // 描述 20 | description: vjsf.description, 21 | } 22 | ``` 23 | 24 | ### Renderer Props 25 | 26 | ```js 27 | { 28 | // 值 29 | value: value, 30 | // 数据变化回调 31 | onChange: onChange, 32 | // 格式化 33 | format: this.schema.format, 34 | // schema 对象 35 | schema: this.schema, 36 | // 所有错误 37 | errors: this.errors, 38 | // 标题 39 | title: vjsf.title || path, 40 | // 是否必填 41 | required: this.required, 42 | // 是否必填错误 43 | requiredError: this.requiredError, 44 | // 描述 45 | description: vjsf.description, 46 | ...this.vjsf.additionProps, 47 | } 48 | ``` 49 | 50 | ### vjsf 51 | 52 | 在定义 schema 的时候,我们可以增加一个帮助我们更好得渲染表单的配置对象,比如: 53 | 54 | ```js 55 | const schema = { 56 | type: 'string', 57 | vjsf: { 58 | title: '名字', 59 | description: '描述', 60 | component: 'your-input-component', // 通过该字段可以实现自定义组件 61 | additionProps: { ...props }, // 如果你有对于渲染组件自定义的props,可以通过该对象传递 62 | ...others, 63 | }, 64 | } 65 | ``` 66 | -------------------------------------------------------------------------------- /src/demos/string.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'String', 3 | schema: { 4 | type: 'object', 5 | vjsf: { 6 | title: 'string demo', 7 | }, 8 | default: { 9 | simple: '123', 10 | }, 11 | required: ['simple'], 12 | properties: { 13 | const: { 14 | type: 'string', 15 | const: 'jokcy', 16 | vjsf: { 17 | title: '固定值', 18 | description: '123', 19 | }, 20 | }, 21 | simple: { 22 | type: 'string', 23 | minLength: 2, 24 | default: 'haha', 25 | pattern: '/^abc&/', 26 | errorMessage: { 27 | pattern: '请填写正确的内容', 28 | }, 29 | vjsf: { 30 | placeholder: '请输入普通字符串', 31 | title: '普通字符串', 32 | }, 33 | }, 34 | color: { 35 | type: 'string', 36 | format: 'color', 37 | title: '颜色选择', 38 | }, 39 | select: { 40 | type: 'string', 41 | vjsf: { 42 | title: '选择', 43 | placeholder: '选择框啦', 44 | }, 45 | enumKeyValue: [ 46 | { 47 | key: 'option1', 48 | value: '1', 49 | }, 50 | { 51 | key: 'option2', 52 | value: '2', 53 | }, 54 | { 55 | key: 'option3', 56 | value: '3', 57 | }, 58 | ], 59 | }, 60 | }, 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /src/core/schme-type-renderers/CustomRenderer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 55 | -------------------------------------------------------------------------------- /src/core/schme-type-renderers/NumberRenderer.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 49 | -------------------------------------------------------------------------------- /tests/unit/theme-element/constant.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('constant component', () => { 8 | it('format `const` should render constant component', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'string', 13 | const: 'const-value', 14 | }, 15 | }), 16 | }) 17 | const picker = wrapper.find({ name: 'JsfConstant' }) 18 | expect(picker.vm).not.toBeUndefined() 19 | }) 20 | it('when type of value is string, el value should be the same as const', async () => { 21 | const wrapper: any = mount(Wrapper, { 22 | data: () => ({ 23 | schema: { 24 | type: 'string', 25 | const: 'const-value', 26 | }, 27 | }), 28 | }) 29 | const picker = wrapper.find({ name: 'JsfConstant' }) 30 | await wrapper.vm.$nextTick() 31 | expect(picker.vm.value).toBe('const-value') 32 | }) 33 | it('when type of value is number, el value should be the same as const', async () => { 34 | const wrapper: any = mount(Wrapper, { 35 | data: () => ({ 36 | schema: { 37 | type: 'string', 38 | const: 12, 39 | }, 40 | }), 41 | }) 42 | const picker = wrapper.find({ name: 'JsfConstant' }) 43 | await wrapper.vm.$nextTick() 44 | expect(picker.vm.value).toBe(12) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/demos/deps.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Dependencies', 3 | schema: { 4 | type: 'object', 5 | propertiesOrder: ['selected', 'name1', 'name2', 'name3', 'others'], 6 | properties: { 7 | selected: { 8 | type: 'number', 9 | title: '是否选中', 10 | enum: [1, 2, 3], 11 | default: 2, 12 | }, 13 | others: { 14 | type: 'string', 15 | }, 16 | }, 17 | dependencies: { 18 | selected: { 19 | oneOf: [ 20 | { 21 | properties: { 22 | selected: { 23 | // const: 1, 24 | 25 | const: 1, 26 | }, 27 | name1: { 28 | type: 'string', 29 | title: '名字1', 30 | }, 31 | }, 32 | required: ['name1'], 33 | }, 34 | { 35 | properties: { 36 | selected: { 37 | const: 2, 38 | }, 39 | name2: { 40 | type: 'string', 41 | title: '名字2', 42 | }, 43 | }, 44 | required: ['name2'], 45 | }, 46 | { 47 | properties: { 48 | selected: { 49 | const: 3, 50 | }, 51 | name3: { 52 | type: 'string', 53 | title: '名字3', 54 | }, 55 | }, 56 | required: ['name3'], 57 | }, 58 | ], 59 | }, 60 | }, 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /src/demos/nest-deps.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Nested Object', 3 | schema: { 4 | type: 'object', 5 | properties: { 6 | object: { 7 | type: 'object', 8 | properties: { 9 | selected: { 10 | type: 'number', 11 | title: '是否选中', 12 | enum: [1, 2, 3], 13 | }, 14 | }, 15 | dependencies: { 16 | selected: { 17 | oneOf: [ 18 | { 19 | properties: { 20 | selected: { 21 | const: 1, 22 | }, 23 | name1: { 24 | type: 'string', 25 | title: '名字1', 26 | }, 27 | }, 28 | required: ['name1'], 29 | }, 30 | { 31 | properties: { 32 | selected: { 33 | const: 2, 34 | }, 35 | name2: { 36 | type: 'string', 37 | title: '名字2', 38 | }, 39 | }, 40 | required: ['name2'], 41 | }, 42 | { 43 | properties: { 44 | selected: { 45 | const: 3, 46 | }, 47 | name3: { 48 | type: 'string', 49 | title: '名字3', 50 | }, 51 | }, 52 | required: ['name3'], 53 | }, 54 | ], 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /src/theme-element-ui/base/DatePicker.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 61 | -------------------------------------------------------------------------------- /tests/unit/theme-element/switch.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('switch component', () => { 8 | it('format `switch` should render switch component', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'boolean', 13 | }, 14 | }), 15 | }) 16 | const picker = wrapper.find({ name: 'JsfSwitch' }) 17 | expect(picker.vm).not.toBeUndefined() 18 | }) 19 | it('el value should be the same as value', () => { 20 | const wrapper: any = mount(Wrapper, { 21 | data: () => ({ 22 | schema: { 23 | type: 'boolean', 24 | }, 25 | value: true, 26 | }), 27 | }) 28 | const picker = wrapper.find({ name: 'JsfSwitch' }) 29 | expect(picker.vm.value).toBe(true) 30 | }) 31 | it('el value should be the same as default', async () => { 32 | const wrapper: any = mount(Wrapper, { 33 | data: () => ({ 34 | schema: { 35 | type: 'boolean', 36 | default: true, 37 | }, 38 | value: undefined, 39 | }), 40 | }) 41 | const picker = wrapper.find({ name: 'JsfSwitch' }) 42 | await wrapper.vm.$nextTick() 43 | expect(picker.vm.value).toBe(true) 44 | }) 45 | it('value should change when el emit input event', () => { 46 | const wrapper: any = mount(Wrapper, { 47 | data: () => ({ 48 | schema: { 49 | type: 'boolean', 50 | }, 51 | value: undefined, 52 | }), 53 | }) 54 | const elPicker = wrapper.find({ name: 'JsfSwitch' }) 55 | elPicker.vm.$emit('input', true) 56 | expect(wrapper.vm.value).toBe(true) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /tests/unit/theme-element/numberInput.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('numberInput', () => { 8 | it('format `number` should render numberInput component', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'number', 13 | }, 14 | }), 15 | }) 16 | const picker = wrapper.find({ name: 'JsfNumberInput' }) 17 | expect(picker.vm).not.toBeUndefined() 18 | }) 19 | it('el value should be the same as value', () => { 20 | const wrapper: any = mount(Wrapper, { 21 | data: () => ({ 22 | schema: { 23 | type: 'number', 24 | }, 25 | value: 67, 26 | }), 27 | }) 28 | const picker = wrapper.find({ name: 'JsfNumberInput' }) 29 | expect(picker.vm.value).toBe(67) 30 | }) 31 | it('el value should be the same as default', async () => { 32 | const wrapper: any = mount(Wrapper, { 33 | data: () => ({ 34 | schema: { 35 | type: 'number', 36 | default: 83, 37 | }, 38 | value: undefined, 39 | }), 40 | }) 41 | const picker = wrapper.find({ name: 'JsfNumberInput' }) 42 | await wrapper.vm.$nextTick() 43 | expect(picker.vm.value).toBe(83) 44 | }) 45 | it('value should change when el emit input event', () => { 46 | const wrapper: any = mount(Wrapper, { 47 | data: () => ({ 48 | schema: { 49 | type: 'number', 50 | }, 51 | value: undefined, 52 | }), 53 | }) 54 | const elPicker = wrapper.find({ name: 'JsfNumberInput' }) 55 | elPicker.vm.$emit('input', 35) 56 | expect(wrapper.vm.value).toBe(35) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /tests/unit/theme-element/textInput.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('textInput', () => { 8 | it('format `string` should render textInput component', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'string', 13 | }, 14 | }), 15 | }) 16 | const picker = wrapper.find({ name: 'JsfTextInput' }) 17 | expect(picker.vm).not.toBeUndefined() 18 | }) 19 | it('el value should be the same as value', async () => { 20 | const wrapper: any = mount(Wrapper, { 21 | data: () => ({ 22 | schema: { 23 | type: 'string', 24 | }, 25 | value: 'yes', 26 | }), 27 | }) 28 | const picker = wrapper.find({ name: 'JsfTextInput' }) 29 | expect(picker.vm.value).toBe('yes') 30 | }) 31 | it('el value should be the same as default', async () => { 32 | const wrapper: any = mount(Wrapper, { 33 | data: () => ({ 34 | schema: { 35 | type: 'string', 36 | default: 'yes', 37 | }, 38 | value: undefined, 39 | }), 40 | }) 41 | const picker = wrapper.find({ name: 'JsfTextInput' }) 42 | await wrapper.vm.$nextTick() 43 | expect(picker.vm.value).toBe('yes') 44 | }) 45 | it('value should change when el emit input event', async () => { 46 | const wrapper: any = mount(Wrapper, { 47 | data: () => ({ 48 | schema: { 49 | type: 'string', 50 | }, 51 | value: undefined, 52 | }), 53 | }) 54 | const picker = wrapper.find({ name: 'JsfTextInput' }) 55 | picker.vm.$emit('input', 'yes') 56 | expect(wrapper.vm.value).toBe('yes') 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /src/core/validator/index.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { Options } from 'ajv' 2 | import ajvErrors from 'ajv-errors' 3 | 4 | import { CreateInstanceOptions, ConstantCreateInstanceOptions } from './types' 5 | import defaultOptions from './instance-default-options' 6 | 7 | export * from './types' 8 | 9 | function mergeMaybeArray(a1: any, a2: any) { 10 | const f1 = a1 ? (Array.isArray(a1) ? a1 : [a1]) : [] 11 | const f2 = a2 ? (Array.isArray(a2) ? a2 : [a2]) : [] 12 | return [...f1, ...f2] 13 | } 14 | 15 | function mergerOptions( 16 | op1: CreateInstanceOptions, 17 | op2: CreateInstanceOptions 18 | ): ConstantCreateInstanceOptions { 19 | const ajvOption: Options = { 20 | ...op1.options, 21 | ...op2.options, 22 | } 23 | 24 | const formats = mergeMaybeArray(op1.formats, op2.formats) // TODO: 去除 name 相同的?考虑名称相同但是 `number` 和 `string` 不同 25 | const keywords = mergeMaybeArray(op1.keywords, op2.keywords) 26 | 27 | return { 28 | // locale: op2.locale || op1.locale || 'zh', 29 | options: ajvOption, 30 | formats, 31 | keywords, 32 | } 33 | } 34 | 35 | export function createInstance(opts: CreateInstanceOptions = {}) { 36 | const options = mergerOptions(defaultOptions, opts) 37 | 38 | const { options: instanceOptions, formats, keywords } = options 39 | 40 | const ajv = new Ajv(instanceOptions) 41 | ajvErrors(ajv as any) 42 | 43 | formats.forEach(({ name, definition }) => ajv.addFormat(name, definition)) 44 | keywords.forEach(({ name, definition }) => ajv.addKeyword(name, definition)) 45 | 46 | // ajv.validate 47 | 48 | return ajv 49 | } 50 | 51 | const defaultInstance = createInstance({}) 52 | 53 | export function validateData(schema: any, data: any) { 54 | const valid = defaultInstance.validate(schema, data) 55 | return { 56 | valid, 57 | errors: defaultInstance.errors, 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/demos/complex.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'Complex Demo', 3 | schema: { 4 | type: 'object', 5 | propertiesOrder: ['name', 'age', 'fimaly', 'pets', 'petName'], 6 | properties: { 7 | name: { 8 | type: 'string', 9 | minLength: 5, 10 | }, 11 | age: { 12 | type: 'number', 13 | maximum: 10, 14 | minimum: 5, 15 | }, 16 | fimaly: { 17 | type: 'array', 18 | items: { 19 | type: 'object', 20 | properties: { 21 | name: { 22 | type: 'string', 23 | title: '名字', 24 | }, 25 | releation: { 26 | type: 'string', 27 | title: '关系', 28 | }, 29 | }, 30 | required: ['name', 'releation'], 31 | }, 32 | }, 33 | pets: { 34 | type: 'string', 35 | enum: ['NO', 'ONE'], 36 | }, 37 | nest: { 38 | type: 'object', 39 | properties: { 40 | nest1: { 41 | type: 'string', 42 | }, 43 | nest2: { 44 | type: 'string', 45 | }, 46 | }, 47 | required: ['nest1'], 48 | }, 49 | }, 50 | dependencies: { 51 | pets: { 52 | oneOf: [ 53 | { 54 | properties: { 55 | pets: { 56 | const: 'NO', 57 | }, 58 | }, 59 | }, 60 | { 61 | properties: { 62 | pets: { 63 | const: 'ONE', 64 | }, 65 | petName: { 66 | type: 'string', 67 | }, 68 | }, 69 | required: ['petName'], 70 | }, 71 | ], 72 | }, 73 | }, 74 | required: ['name', 'age'], 75 | }, 76 | } 77 | -------------------------------------------------------------------------------- /docs/dependencies.md: -------------------------------------------------------------------------------- 1 | # demo 2 | 3 | ```js 4 | export default { 5 | name: '依赖关系', 6 | schema: { 7 | type: 'object', 8 | properties: { 9 | selected: { 10 | type: 'number', 11 | title: '是否选中', 12 | enum: [1, 2, 3], 13 | }, 14 | }, 15 | dependencies: { 16 | selected: { 17 | oneOf: [ 18 | { 19 | properties: { 20 | selected: { 21 | // const: 1, 22 | 23 | const: 1, 24 | }, 25 | name1: { 26 | type: 'string', 27 | title: '名字1', 28 | }, 29 | }, 30 | required: ['name1'], 31 | }, 32 | { 33 | properties: { 34 | selected: { 35 | const: 2, 36 | }, 37 | name2: { 38 | type: 'string', 39 | title: '名字2', 40 | }, 41 | }, 42 | required: ['name2'], 43 | }, 44 | { 45 | properties: { 46 | selected: { 47 | const: 3, 48 | }, 49 | name3: { 50 | type: 'string', 51 | title: '名字3', 52 | }, 53 | }, 54 | required: ['name3'], 55 | }, 56 | ], 57 | }, 58 | }, 59 | }, 60 | } 61 | ``` 62 | 63 | # 解释 64 | 65 | 通过`dependencies`声明依赖关系,`dependencies`的`key`是依赖选项,比如在这里`selected`是依赖项,在`selected`有值的情况下才会展示和执行他包含的内容。 66 | 67 | `selected`的值是一个`oneOf`则对应我们对于`selected`不同的结果会现实其中某个结果,比如在这里如果: 68 | 69 | - `selected`是`1`,则我们必须填写`name1` 70 | - `selected`是`2`,则我们必须填写`name2` 71 | - `selected`是`3`,则我们必须填写`name3` 72 | 73 | 注意这里我们在每一项中都声明了一个`selected`,他的类型是`const`也就是固定值,以此我们来强制区分不同的结果,符合`selected`为`1`的结果必定不会符合其他的选项,就完全符合`oneOf`的逻辑。 74 | -------------------------------------------------------------------------------- /src/core/utils/common.ts: -------------------------------------------------------------------------------- 1 | export function isObject(thing: any) { 2 | return typeof thing === 'object' && thing !== null && !Array.isArray(thing) 3 | } 4 | 5 | export function isEmptyObject(thing: any) { 6 | return isObject(thing) && Object.keys(thing).length === 0 7 | } 8 | 9 | export function hasOwnProperty(obj: any, key: string) { 10 | /** 11 | * 直接调用`obj.hasOwnProperty`有可能会因为 12 | * obj 覆盖了 prototype 上的 hasOwnProperty 而产生错误 13 | */ 14 | return Object.prototype.hasOwnProperty.call(obj, key) 15 | } 16 | 17 | /* Gets the type of a given schema. */ 18 | export function getSchemaType(schema: any) { 19 | let { type } = schema 20 | 21 | if (type) return type 22 | 23 | if (!type && schema.const) { 24 | return guessType(schema.const) 25 | } 26 | 27 | if (!type && schema.enum) { 28 | return 'string' 29 | } 30 | 31 | if (!type && (schema.properties || schema.additionalProperties)) { 32 | return 'object' 33 | } 34 | 35 | console.warn('can not guess schema type, just use object', schema) 36 | return 'object' 37 | 38 | // if (type instanceof Array && type.length === 2 && type.includes('null')) { 39 | // return type.find((type) => type !== 'null') 40 | // } 41 | } 42 | 43 | // In the case where we have to implicitly create a schema, it is useful to know what type to use 44 | // based on the data we are defining 45 | export const guessType = function guessType(value: any) { 46 | if (Array.isArray(value)) { 47 | return 'array' 48 | } else if (typeof value === 'string') { 49 | return 'string' 50 | } else if (value == null) { 51 | return 'null' 52 | } else if (typeof value === 'boolean') { 53 | return 'boolean' 54 | } else if (!isNaN(value)) { 55 | return 'number' 56 | } else if (typeof value === 'object') { 57 | return 'object' 58 | } 59 | // Default to string if we can't figure it out 60 | return 'string' 61 | } 62 | -------------------------------------------------------------------------------- /src/theme-element-ui/NumberInput.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 61 | 62 | 73 | -------------------------------------------------------------------------------- /tests/unit/theme-element/colorPicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('colorPicker', () => { 8 | it('format `color` should render colorpicker', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'string', 13 | format: 'color', 14 | }, 15 | }), 16 | }) 17 | const picker = wrapper.find({ name: 'JsfColorPicker' }) 18 | expect(picker.vm).not.toBeUndefined() 19 | }) 20 | it('el value should be the same as value', () => { 21 | const wrapper: any = mount(Wrapper, { 22 | data: () => ({ 23 | schema: { 24 | type: 'string', 25 | format: 'color', 26 | }, 27 | value: 'red', 28 | }), 29 | }) 30 | const picker = wrapper.find({ name: 'JsfColorPicker' }) 31 | expect(picker.vm.value).toBe('red') 32 | }) 33 | it('el value should be the same as default', async () => { 34 | const wrapper: any = mount(Wrapper, { 35 | data: () => ({ 36 | schema: { 37 | type: 'string', 38 | format: 'color', 39 | default: 'red', 40 | }, 41 | value: undefined, 42 | }), 43 | }) 44 | const picker = wrapper.find({ name: 'JsfColorPicker' }) 45 | await wrapper.vm.$nextTick() 46 | expect(picker.vm.value).toBe('red') 47 | }) 48 | it('value should change when el emit input event', () => { 49 | const wrapper: any = mount(Wrapper, { 50 | data: () => ({ 51 | schema: { 52 | type: 'string', 53 | format: 'color', 54 | }, 55 | value: undefined, 56 | }), 57 | }) 58 | const elPicker = wrapper.find({ name: 'JsfColorPicker' }) 59 | elPicker.vm.$emit('input', 'red') 60 | expect(wrapper.vm.value).toBe('red') 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/demos/date.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Date Time', 3 | schema: { 4 | type: 'object', 5 | properties: { 6 | simpleDate: { 7 | type: 'string', 8 | format: 'date', 9 | title: '日期', 10 | }, 11 | numberDate: { 12 | type: 'number', 13 | format: 'date', 14 | title: '时间戳', 15 | // vjsf: { 16 | // additionProps: { 17 | // readonly: true, 18 | // }, 19 | // }, 20 | }, 21 | simpleDateTime: { 22 | type: 'string', 23 | format: 'date-time', 24 | title: '日期时间', 25 | }, 26 | simpleTime: { 27 | type: 'string', 28 | format: 'time', 29 | vjsf: { 30 | title: '时间字符串', 31 | placeholder: '选择时间', 32 | }, 33 | }, 34 | simpleTimeStamp: { 35 | type: 'number', 36 | format: 'time', 37 | title: '只有时间时间戳', 38 | }, 39 | numberDateTime: { 40 | type: 'number', 41 | format: 'date-time', 42 | title: '日期时间时间戳', 43 | }, 44 | simpleDateRange: { 45 | type: 'array', 46 | items: { 47 | type: 'string', 48 | format: 'date', 49 | }, 50 | dateRange: true, 51 | title: '日期区间', 52 | }, 53 | simpleDateTimeRange: { 54 | type: 'array', 55 | items: { 56 | type: 'string', 57 | format: 'date-time', 58 | }, 59 | dateRange: true, 60 | title: '日期时间区间', 61 | }, 62 | numberDateTimeRange: { 63 | type: 'array', 64 | items: { 65 | type: 'number', 66 | format: 'date-time', 67 | }, 68 | dateRange: true, 69 | title: '日期时间区间时间戳', 70 | }, 71 | timeRange: { 72 | type: 'array', 73 | items: { 74 | type: 'number', 75 | format: 'time', 76 | }, 77 | dateTimeRange: true, 78 | vjsf: { 79 | title: '时间区间', 80 | }, 81 | }, 82 | }, 83 | }, 84 | } 85 | -------------------------------------------------------------------------------- /src/theme-element-ui/base/ArrayItemActions.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 57 | 58 | 69 | -------------------------------------------------------------------------------- /src/core/validator/date-time-range-keyword.ts: -------------------------------------------------------------------------------- 1 | import { KeywordDefinition } from 'ajv' 2 | import { isObject } from '../utils' 3 | 4 | const DateRangeKeyword: KeywordDefinition = { 5 | type: 'boolean', 6 | compile(schema) { 7 | const items = schema.items 8 | const type = schema.type 9 | 10 | const validFun = () => true 11 | 12 | if (type !== 'array' || !isObject(items)) { 13 | // throw new Error('date time range keyword should use single time array') 14 | return validFun 15 | } 16 | const itemType: string = items.type 17 | const itemFormat: string = items.format 18 | 19 | if ( 20 | (itemType !== 'number' && itemType !== 'string') || 21 | (itemFormat !== 'date' && itemFormat !== 'date-time') 22 | ) { 23 | // throw new Error('date time must be `number` or `string` type') 24 | return validFun 25 | } 26 | 27 | return (data) => { 28 | const [before, after] = data 29 | return Date.parse(before) <= Date.parse(after) 30 | } 31 | }, 32 | macro() { 33 | return { 34 | minItems: 2, 35 | maxItems: 2, 36 | } 37 | }, 38 | } 39 | 40 | const DateTimeRangeKeyword: KeywordDefinition = { 41 | type: 'boolean', 42 | compile(schema) { 43 | const items = schema.items 44 | const type = schema.type 45 | 46 | const validFun = () => true 47 | 48 | if (type !== 'array' || !isObject(items)) { 49 | // throw new Error('date time range keyword should use single time array') 50 | return validFun 51 | } 52 | const itemType: string = items.type 53 | const itemFormat: string = items.format 54 | 55 | if ( 56 | (itemType !== 'number' && itemType !== 'string') || 57 | (itemFormat !== 'date' && 58 | itemFormat !== 'date-time' && 59 | itemFormat !== 'time') 60 | ) { 61 | // throw new Error('date time must be `number` or `string` type') 62 | return validFun 63 | } 64 | 65 | return (data) => { 66 | const [before, after] = data 67 | return Date.parse(before) <= Date.parse(after) 68 | } 69 | }, 70 | macro() { 71 | return { 72 | minItems: 2, 73 | maxItems: 2, 74 | } 75 | }, 76 | } 77 | 78 | export { DateTimeRangeKeyword, DateRangeKeyword } 79 | -------------------------------------------------------------------------------- /src/theme-element-ui/DateTimeRangePicker.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 63 | 64 | 75 | -------------------------------------------------------------------------------- /tests/unit/value-change/array-value.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('array value change', () => { 8 | it('sort down should work', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'array', 13 | items: { 14 | type: 'string', 15 | }, 16 | }, 17 | value: ['a', 'b'], 18 | }), 19 | }) 20 | const aw = wrapper.find({ name: 'JsfSingleTypeArrayWrapper' }) 21 | expect(aw).not.toBeUndefined() 22 | aw.vm.onDown('a', 0) 23 | expect(wrapper.vm.value).toEqual(['b', 'a']) 24 | }) 25 | 26 | it('sort up should work', async () => { 27 | const wrapper: any = mount(Wrapper, { 28 | data: () => ({ 29 | schema: { 30 | type: 'array', 31 | items: { 32 | type: 'string', 33 | }, 34 | }, 35 | value: ['a', 'b'], 36 | }), 37 | }) 38 | const aw = wrapper.find({ name: 'JsfSingleTypeArrayWrapper' }) 39 | expect(aw).not.toBeUndefined() 40 | aw.vm.onUp('b', 1) 41 | expect(wrapper.vm.value).toEqual(['b', 'a']) 42 | }) 43 | 44 | it('delete should work', async () => { 45 | const wrapper: any = mount(Wrapper, { 46 | data: () => ({ 47 | schema: { 48 | type: 'array', 49 | items: { 50 | type: 'string', 51 | }, 52 | }, 53 | value: ['a', 'b'], 54 | }), 55 | }) 56 | const aw = wrapper.find({ name: 'JsfSingleTypeArrayWrapper' }) 57 | expect(aw).not.toBeUndefined() 58 | aw.vm.onDelete('a', 0) 59 | expect(wrapper.vm.value).toEqual(['b']) 60 | }) 61 | 62 | it('add should work', async () => { 63 | const wrapper: any = mount(Wrapper, { 64 | data: () => ({ 65 | schema: { 66 | type: 'array', 67 | items: { 68 | type: 'string', 69 | }, 70 | }, 71 | value: ['a', 'b'], 72 | }), 73 | }) 74 | const aw = wrapper.find({ name: 'JsfSingleTypeArrayWrapper' }) 75 | expect(aw).not.toBeUndefined() 76 | aw.vm.onAdd('a', 0) 77 | expect(wrapper.vm.value).toEqual(['a', undefined, 'b']) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /src/core/schme-type-renderers/StringRenderer.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 80 | -------------------------------------------------------------------------------- /tests/unit/default-value/number.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('number renderer default values', () => { 8 | it('should use `default` as default value when value is undefined', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'number', 13 | default: 10, 14 | }, 15 | value: undefined, 16 | }), 17 | }) 18 | await wrapper.vm.$nextTick() 19 | expect(wrapper.vm.value).toEqual(10) 20 | }) 21 | 22 | it('should use value when value is not undefined', async () => { 23 | const wrapper: any = mount(Wrapper, { 24 | data: () => ({ 25 | schema: { 26 | type: 'number', 27 | default: 10, 28 | }, 29 | value: 0, 30 | }), 31 | }) 32 | await wrapper.vm.$nextTick() 33 | expect(wrapper.vm.value).toEqual(0) 34 | }) 35 | 36 | it('should use `default` as default value when in object', async () => { 37 | const wrapper: any = mount(Wrapper, { 38 | data: () => ({ 39 | schema: { 40 | type: 'object', 41 | properties: { 42 | age: { 43 | type: 'number', 44 | default: 10, 45 | }, 46 | }, 47 | }, 48 | value: {}, 49 | }), 50 | }) 51 | await wrapper.vm.$nextTick() 52 | expect(wrapper.vm.value.age).toEqual(10) 53 | }) 54 | 55 | it('should not create default value when `default` not provided', async () => { 56 | const wrapper: any = mount(Wrapper, { 57 | data: () => ({ 58 | schema: { 59 | type: 'object', 60 | properties: { 61 | age: { 62 | type: 'number', 63 | }, 64 | }, 65 | }, 66 | value: {}, 67 | }), 68 | }) 69 | await wrapper.vm.$nextTick() 70 | expect(wrapper.vm.value.age).toBeUndefined() 71 | }) 72 | 73 | it('should use object default value as default', async () => { 74 | const wrapper: any = mount(Wrapper, { 75 | data: () => ({ 76 | schema: { 77 | type: 'object', 78 | properties: { 79 | age: { 80 | type: 'number', 81 | }, 82 | }, 83 | default: { 84 | age: 20, 85 | }, 86 | }, 87 | // value: {}, // 如果object赋予对象,则不会使用default 88 | value: '123', 89 | }), 90 | }) 91 | await wrapper.vm.$nextTick() 92 | expect(wrapper.vm.value.age).toEqual(20) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /src/demos/array.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'Array', 3 | schema: { 4 | type: 'object', 5 | properties: { 6 | name: { 7 | type: 'string', 8 | }, 9 | fixed: { 10 | type: 'array', 11 | title: '固定数组', 12 | items: [ 13 | { type: 'string', title: '名字' }, 14 | { type: 'number', title: '年龄' }, 15 | { 16 | type: 'object', 17 | title: '一项', 18 | properties: { 19 | 'name/1': { 20 | type: 'string', 21 | title: '名字', 22 | minLength: 5, 23 | }, 24 | }, 25 | }, 26 | ], 27 | // default: ['Jokcy', 12], 28 | }, 29 | singleType: { 30 | type: 'array', 31 | title: '单一数组', 32 | items: { 33 | type: 'string', 34 | }, 35 | default: ['aaa', 'bbb'], 36 | }, 37 | nestSingleType: { 38 | type: 'array', 39 | title: '嵌套单一数组', 40 | items: { 41 | type: 'object', 42 | properties: { 43 | singleType: { 44 | type: 'array', 45 | title: '单一数组', 46 | items: { 47 | type: 'string', 48 | }, 49 | default: ['aaa', 'bbb'], 50 | }, 51 | }, 52 | }, 53 | default: ['aaa', 'bbb'], 54 | }, 55 | multiChoice: { 56 | type: 'array', 57 | title: '多选', 58 | default: null, 59 | items: { 60 | type: 'string', 61 | enumKeyValue: [ 62 | { 63 | key: 'option1', 64 | value: '1', 65 | }, 66 | { 67 | key: 'option2', 68 | value: '2', 69 | }, 70 | { 71 | key: 'option3', 72 | value: '3', 73 | }, 74 | ], 75 | }, 76 | }, 77 | objectArray: { 78 | type: 'array', 79 | title: '对象数组', 80 | default: [ 81 | { 82 | name: 'aaa', 83 | age: 12, 84 | }, 85 | ], 86 | items: { 87 | type: 'object', 88 | // title: '对象', 89 | properties: { 90 | name: { 91 | type: 'string', 92 | title: '名字', 93 | }, 94 | age: { 95 | type: 'number', 96 | title: '年龄', 97 | }, 98 | }, 99 | }, 100 | }, 101 | }, 102 | }, 103 | } 104 | -------------------------------------------------------------------------------- /src/core/SchemaItem.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 79 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@zdwh/vue-json-schema-form", 3 | "version": "1.0.18", 4 | "private": false, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build:core": "vue-cli-service build --target lib --name index src/core/index.ts --no-clean", 8 | "build:theme": "vue-cli-service build --target lib --name theme-element/index src/theme-element-ui/index.ts --no-clean", 9 | "build:doc": "TYPE=doc vue-cli-service build", 10 | "build": "npm run clean && npm run build:core && npm run build:theme", 11 | "clean": "rimraf dist", 12 | "test:unit": "vue-cli-service test:unit", 13 | "test:coverage": "vue-cli-service test:unit --coverage", 14 | "test:unit:watch": "vue-cli-service test:unit --watch", 15 | "lint": "vue-cli-service lint", 16 | "pub": "npm run build && npm version patch && npm publish --access=public" 17 | }, 18 | "main": "dist/index.common.js", 19 | "publishConfig": { 20 | "registry": "https://registry.npmjs.org/" 21 | }, 22 | "license": "MIT", 23 | "files": [ 24 | "dist" 25 | ], 26 | "gitHooks": { 27 | "pre-commit": "npm run lint" 28 | }, 29 | "dependencies": { 30 | "jsonpointer": "^4.0.1", 31 | "lodash.clonedeep": "^4.5.0", 32 | "lodash.union": "^4.6.0", 33 | "vue-class-component": "^7.2.3", 34 | "vue-property-decorator": "^8.4.1" 35 | }, 36 | "devDependencies": { 37 | "@types/ajv-errors": "^1.0.2", 38 | "@types/jest": "^24.0.19", 39 | "@types/jsonpointer": "^4.0.0", 40 | "@types/lodash": "^4.14.152", 41 | "@types/lodash.clonedeep": "^4.5.6", 42 | "@types/lodash.union": "^4.6.6", 43 | "@types/node": "^13.13.0", 44 | "@typescript-eslint/eslint-plugin": "^2.18.0", 45 | "@typescript-eslint/parser": "^2.18.0", 46 | "@vue/cli-plugin-babel": "~4.2.0", 47 | "@vue/cli-plugin-eslint": "~4.2.0", 48 | "@vue/cli-plugin-typescript": "~4.3.0", 49 | "@vue/cli-plugin-unit-jest": "~4.3.0", 50 | "@vue/cli-service": "~4.2.0", 51 | "@vue/eslint-config-prettier": "^6.0.0", 52 | "@vue/eslint-config-typescript": "^5.0.1", 53 | "@vue/test-utils": "1.0.0-beta.31", 54 | "ajv": "^6.12.0", 55 | "ajv-errors": "^1.0.1", 56 | "ajv-i18n": "^3.5.0", 57 | "babel-eslint": "^10.0.3", 58 | "core-js": "^3.6.4", 59 | "element-ui": "^2.13.0", 60 | "eslint": "^6.7.2", 61 | "eslint-plugin-prettier": "^3.1.1", 62 | "eslint-plugin-vue": "^6.1.2", 63 | "prettier": "^1.19.1", 64 | "rimraf": "^3.0.2", 65 | "stylus": "^0.54.7", 66 | "stylus-loader": "^3.0.2", 67 | "typescript": "~3.8.3", 68 | "vue": "^2.6.11", 69 | "vue-cli-plugin-webpack-bundle-analyzer": "~2.0.0", 70 | "vue-json-pretty": "^1.6.3", 71 | "vue-template-compiler": "^2.6.11" 72 | }, 73 | "peerDependencies": { 74 | "ajv": "^6.12.0", 75 | "ajv-i18n": "^3.5.0", 76 | "element-ui": "^2.13.0", 77 | "jsonpointer": "^4.0.1", 78 | "lodash.union": "^4.6.0", 79 | "vue": "^2.6.11" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/unit/default-value/string.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | // import Wrapper from './Helper.vue' 8 | // import VueSchemaForm from '../..' 9 | 10 | // Vue.config.productionTip = false 11 | // Vue.use(VueSchemaForm) 12 | 13 | describe('string renderer default values', () => { 14 | it('should use `default` as default value when value is undefined', async () => { 15 | const wrapper: any = mount(Wrapper, { 16 | data: () => ({ 17 | schema: { 18 | type: 'string', 19 | default: 'text', 20 | }, 21 | value: undefined, 22 | }), 23 | }) 24 | await wrapper.vm.$nextTick() 25 | expect(wrapper.vm.value).toMatch('text') 26 | }) 27 | 28 | it('should use `default` as default value when value is empty string', async () => { 29 | const wrapper: any = mount(Wrapper, { 30 | data: () => ({ 31 | schema: { 32 | type: 'string', 33 | default: 'text', 34 | }, 35 | value: '', 36 | }), 37 | }) 38 | await wrapper.vm.$nextTick() 39 | 40 | // empty string is string, so we treat it as value 41 | expect(wrapper.vm.value).toMatch('') 42 | }) 43 | 44 | it('should use `default` as default value when in object', async () => { 45 | const wrapper: any = mount(Wrapper, { 46 | data: () => ({ 47 | schema: { 48 | type: 'object', 49 | properties: { 50 | name: { 51 | type: 'string', 52 | default: 'text', 53 | }, 54 | }, 55 | }, 56 | value: {}, 57 | }), 58 | }) 59 | await wrapper.vm.$nextTick() 60 | expect(wrapper.vm.value.name).toMatch('text') 61 | }) 62 | 63 | it('should not create default value when `default` not provided', async () => { 64 | const wrapper: any = mount(Wrapper, { 65 | data: () => ({ 66 | schema: { 67 | type: 'object', 68 | properties: { 69 | name: { 70 | type: 'string', 71 | }, 72 | }, 73 | }, 74 | value: {}, 75 | }), 76 | }) 77 | await wrapper.vm.$nextTick() 78 | expect(wrapper.vm.value.name).toBeUndefined() 79 | }) 80 | 81 | it('should use object default value as default', async () => { 82 | const wrapper: any = mount(Wrapper, { 83 | data: () => ({ 84 | schema: { 85 | type: 'object', 86 | properties: { 87 | name: { 88 | type: 'string', 89 | }, 90 | }, 91 | default: { 92 | name: 'text', 93 | }, 94 | }, 95 | value: null, 96 | }), 97 | }) 98 | await wrapper.vm.$nextTick() 99 | expect(wrapper.vm.value.name).toMatch('text') 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /tests/unit/vjsf/ui-schema.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, mount } from '@vue/test-utils' 2 | 3 | import SchemaItem from '../../../src/core/SchemaItem.vue' 4 | 5 | import { initialize, Helper as Wrapper, Custom } from '../utils' 6 | 7 | initialize() 8 | 9 | describe('ui schema', () => { 10 | it('should render as expect when use ui-schema', async () => { 11 | const wrapper: any = mount(Wrapper, { 12 | data: () => ({ 13 | schema: { 14 | type: 'string', 15 | default: 1, 16 | }, 17 | uiSchema: { 18 | component: 'my-custom', 19 | additionProps: { 20 | a: 'b', 21 | }, 22 | title: 'test title', 23 | }, 24 | value: undefined, 25 | }), 26 | }) 27 | await wrapper.vm.$nextTick() 28 | const test = wrapper.find({ name: 'MyCustom' }) 29 | const attrs = test.attributes() 30 | expect(attrs.a).toBe('b') 31 | expect(attrs.title).toBe('test title') 32 | }) 33 | 34 | it('should pass down ui-schema correctly', async () => { 35 | const wrapper: any = mount(Wrapper, { 36 | data: () => ({ 37 | schema: { 38 | type: 'object', 39 | properties: { 40 | name: { 41 | type: 'string', 42 | }, 43 | age: { 44 | type: 'number', 45 | }, 46 | pets: { 47 | type: 'array', 48 | items: { 49 | type: 'string', 50 | }, 51 | default: ['123'], 52 | }, 53 | }, 54 | }, 55 | uiSchema: { 56 | title: 'object', 57 | properties: { 58 | name: { 59 | additionProps: { 60 | a: 'b', 61 | }, 62 | title: 'name', 63 | }, 64 | age: { 65 | title: 'age', 66 | }, 67 | pets: { 68 | title: 'pets', 69 | items: { 70 | title: 'ptname', 71 | }, 72 | }, 73 | }, 74 | }, 75 | value: undefined, 76 | }), 77 | }) 78 | await wrapper.vm.$nextTick() 79 | const obj = wrapper.find({ name: 'JsfObjectRenderer' }) 80 | const str = wrapper.find({ name: 'JsfStringRenderer' }) 81 | const num = wrapper.find({ name: 'JsfNumberRenderer' }) 82 | const arr = wrapper.find({ name: 'JsfArrayRenderer' }) 83 | // const attrs = test.attributes() 84 | expect(obj.props().uiSchema.title).toBe('object') 85 | // expect(obj.vm.vjsf.title).toBe('object') 86 | expect(str.props().uiSchema.title).toBe('name') 87 | expect(str.vm.vjsf.title).toBe('name') 88 | expect(num.props().uiSchema.title).toBe('age') 89 | expect(num.vm.vjsf.title).toBe('age') 90 | expect(arr.props().uiSchema.title).toBe('pets') 91 | expect(arr.vm.vjsf.title).toBe('pets') 92 | 93 | const arrSubStr = arr.find({ name: 'JsfStringRenderer' }) 94 | expect(arrSubStr.props().uiSchema.title).toBe('ptname') 95 | // expect(attrs.title).toBe('test title') 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /tests/unit/vjsf/vjsf.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, mount } from '@vue/test-utils' 2 | 3 | import SchemaItem from '../../../src/core/SchemaItem.vue' 4 | 5 | import { initialize, Helper as Wrapper, Custom } from '../utils' 6 | 7 | initialize() 8 | 9 | describe('custom component', () => { 10 | it('should render expect props to form components', async () => { 11 | const wrapper: any = mount(Wrapper, { 12 | data: () => ({ 13 | schema: { 14 | type: 'string', 15 | default: 1, 16 | vjsf: { 17 | additionProps: { 18 | a: 'b', 19 | }, 20 | }, 21 | }, 22 | value: undefined, 23 | }), 24 | }) 25 | await wrapper.vm.$nextTick() 26 | const test = wrapper.find({ name: 'JsfTextInput' }) 27 | const props = test.props() 28 | // expect(props.value).toBe(1) 29 | expect(props.value).toBeUndefined() 30 | expect(typeof props.onChange).toBe('function') 31 | expect(props.schema.default).toBe(1) 32 | expect(props.format).toBe('text') 33 | expect(props.title).toBe('') 34 | }) 35 | 36 | it('schema item `.isCustomComponent` should be true when custom component provided', async () => { 37 | const wrapper: any = shallowMount(SchemaItem, { 38 | propsData: { 39 | schema: { 40 | type: 'string', 41 | vjsf: { 42 | component: 'input', 43 | }, 44 | }, 45 | path: '', 46 | rootSchema: {}, 47 | onChange: () => {}, 48 | value: undefined, 49 | }, 50 | provide: { 51 | formContext: { 52 | errors: [], 53 | }, 54 | transformSchema: (s: any) => s, 55 | }, 56 | }) 57 | await wrapper.vm.$nextTick() 58 | expect(wrapper.vm.isCustomComponent).toBe(true) 59 | }) 60 | 61 | it('should pass additionProps to custom component', async () => { 62 | const wrapper: any = mount(Wrapper, { 63 | data: () => ({ 64 | schema: { 65 | type: 'string', 66 | default: 1, 67 | vjsf: { 68 | component: 'my-custom', 69 | additionProps: { 70 | a: 'b', 71 | }, 72 | }, 73 | }, 74 | value: undefined, 75 | }), 76 | }) 77 | await wrapper.vm.$nextTick() 78 | const test = wrapper.find({ name: 'MyCustom' }) 79 | const attrs = test.attributes() 80 | expect(attrs.a).toBe('b') 81 | }) 82 | 83 | it('should pass additionProps to normal component', async () => { 84 | const wrapper: any = mount(Wrapper, { 85 | data: () => ({ 86 | schema: { 87 | type: 'string', 88 | default: 1, 89 | vjsf: { 90 | additionProps: { 91 | a: 'b', 92 | }, 93 | }, 94 | }, 95 | value: undefined, 96 | }), 97 | }) 98 | await wrapper.vm.$nextTick() 99 | const test = wrapper.find({ name: 'JsfTextInput' }) 100 | const attrs = test.attributes() 101 | expect(attrs.a).toBe('b') 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /src/core/validator/instance-default-options.ts: -------------------------------------------------------------------------------- 1 | import { CreateInstanceOptions, EnumKeyValueItem } from './types' 2 | import { 3 | DateRangeKeyword, 4 | DateTimeRangeKeyword, 5 | } from './date-time-range-keyword' 6 | 7 | const COLOR_REG = /^(rgb\s*?\(\s*?(000|0?\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\s*?,\s*?(000|0?\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\s*?,\s*?(000|0?\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\s*?\))$|^(rgba\s*?\(\s*?(000|0?\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\s*?,\s*?(000|0?\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\s*?,\s*?(000|0?\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\s*?,\s*?(0|0\.\d*|1|1.0*)\s*?\))$|^(transparent)$|^(#([a-fA-F0-9]){3})$|^(#([a-fA-F0-9]){6}$)|(^hsl\s*?\(\s*?(000|0?\d{1,2}|[1-2]\d\d|3[0-5]\d|360)\s*?,\s*?(000|100|0?\d{2}|0?0?\d)%\s*?,\s*?(000|100|0?\d{2}|0?0?\d)%\s*?\)$)|(^hsla\s*?\(\s*?(000|0?\d{1,2}|[1-2]\d\d|3[0-5]\d|360)\s*?,\s*?(000|100|0?\d{2}|0?0?\d)%\s*?,\s*?(000|100|0?\d{2}|0?0?\d)%\s*?,\s*?(0|0\.\d*|1|1.0*)\s*?\)$)$/ 8 | 9 | // const URL_REG = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/ // eslint-disable-line 10 | 11 | const defaultOptions: CreateInstanceOptions = { 12 | options: { 13 | allErrors: true, 14 | jsonPointers: true, 15 | }, 16 | formats: [ 17 | { 18 | name: 'color', 19 | definition: (v) => v === '' || COLOR_REG.test(v), 20 | }, 21 | // { 22 | // name: 'image', 23 | // definition: (v) => v === '' || URL_REG.test(v), 24 | // }, 25 | ], 26 | keywords: [ 27 | { 28 | name: 'dateRange', 29 | definition: DateRangeKeyword, 30 | }, 31 | { 32 | name: 'dateTimeRange', 33 | definition: DateTimeRangeKeyword, 34 | }, 35 | { 36 | name: 'enumKeyValue', 37 | definition: { 38 | type: ['number', 'string'], 39 | errors: 'full', 40 | compile: (sch) => { 41 | const values = sch.map( 42 | (s: EnumKeyValueItem) => s.value 43 | ) 44 | // const multi = parentSchema.multiple 45 | 46 | return function doValidate(data) { 47 | const flag = values.indexOf(data) > -1 48 | 49 | if (!flag) { 50 | const fun: any = doValidate 51 | const errors = fun.errors || [] 52 | errors.push({ 53 | keyword: 'enumKeyValue', 54 | message: '请选择一个正确的选项', 55 | params: { 56 | keyword: 'enumKeyValue', 57 | }, 58 | }) 59 | fun.errors = errors 60 | } 61 | 62 | return flag 63 | } 64 | }, 65 | metaSchema: { 66 | type: 'array', 67 | items: { 68 | oneOf: [ 69 | { 70 | type: 'object', 71 | properties: { 72 | key: { type: 'string' }, 73 | value: { type: 'string' }, 74 | }, 75 | }, 76 | { 77 | type: 'object', 78 | properties: { 79 | key: { type: 'string' }, 80 | value: { type: 'number' }, 81 | }, 82 | }, 83 | ], 84 | }, 85 | }, 86 | }, 87 | }, 88 | ], 89 | } 90 | 91 | export default defaultOptions 92 | -------------------------------------------------------------------------------- /src/theme-element-ui/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import { Button } from 'element-ui' 4 | 5 | import Form from './Form.vue' 6 | import FormItem from './FormItem.vue' 7 | import Selection from './Selection.vue' 8 | import TextInput from './TextInput.vue' 9 | import ColorPicker from './ColorPicker.vue' 10 | import NumberInput from './NumberInput.vue' 11 | import DatePicker from './DatePicker.vue' 12 | import TimePicker from './TimePicker.vue' 13 | import DateTimePicker from './DateTimePicker.vue' 14 | import DateTimeRangePicker from './DateTimeRangePicker.vue' 15 | import Switch from './Switch.vue' 16 | import Alert from './Alert.vue' 17 | // import ArrayItemActions from './ArrayItemActions.vue' 18 | // import ArrayItemAddAction from './ArrayItemAddAction.vue' 19 | import SingleTypeArrayWrapper from './SingleTypeArrayWrapper.vue' 20 | import Constant from './Constant.vue' 21 | 22 | import { CommonBase } from './form-component-props' 23 | 24 | function getComponentName(component: any) { 25 | const options = component.options 26 | const name = options.name || component.name 27 | if (!options.name) { 28 | throw new Error(`${name} has no name in option`) 29 | } 30 | return name 31 | } 32 | 33 | export default { 34 | install(vue: typeof Vue) { 35 | const gn = getComponentName 36 | vue.component(gn(Form), Form) 37 | // vue.component(gn(FormItem), FormItem) 38 | vue.component(gn(Selection), Selection) 39 | vue.component(gn(TextInput), TextInput) 40 | vue.component(gn(ColorPicker), ColorPicker) 41 | vue.component(gn(NumberInput), NumberInput) 42 | vue.component(gn(Alert), Alert) 43 | vue.component(gn(DatePicker), DatePicker) 44 | vue.component(gn(TimePicker), TimePicker) 45 | vue.component(gn(DateTimePicker), DateTimePicker) 46 | vue.component(gn(DateTimeRangePicker), DateTimeRangePicker) 47 | vue.component(gn(Switch), Switch) 48 | // vue.component(gn(ArrayItemActions), ArrayItemActions) 49 | // vue.component(gn(ArrayItemAddAction), ArrayItemAddAction) 50 | vue.component(gn(SingleTypeArrayWrapper), SingleTypeArrayWrapper) 51 | vue.component(gn(Constant), Constant) 52 | vue.component('JsfButton', Button) 53 | }, 54 | } 55 | 56 | export const components = { 57 | Form, 58 | Selection, 59 | TextInput, 60 | ColorPicker, 61 | NumberInput, 62 | Alert, 63 | DatePicker, 64 | TimePicker, 65 | DateTimePicker, 66 | Switch, 67 | SingleTypeArrayWrapper, 68 | Constant, 69 | FormItem, 70 | } 71 | 72 | export { 73 | Form, 74 | Selection, 75 | TextInput, 76 | ColorPicker, 77 | NumberInput, 78 | Alert, 79 | DatePicker, 80 | TimePicker, 81 | DateTimePicker, 82 | Switch, 83 | SingleTypeArrayWrapper, 84 | Constant, 85 | FormItem, 86 | CommonBase, 87 | } 88 | 89 | export const FormItemPropsMixin = { 90 | computed: { 91 | formItemProps(): any { 92 | return { 93 | required: (this as any).required, 94 | schema: (this as any).schema, 95 | firstMatchedError: (this as any).firstMatchedError, 96 | requiredError: (this as any).requiredError, 97 | errors: (this as any).errors, 98 | label: (this as any).title, 99 | description: (this as any).description, 100 | } 101 | }, 102 | }, 103 | } 104 | -------------------------------------------------------------------------------- /tests/unit/theme-element/datetime.spec.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount, mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper, Custom } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('datetime', () => { 8 | it('format `datetime` should render datetimepicker', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'string', 13 | format: 'date-time', 14 | }, 15 | }), 16 | }) 17 | const picker = wrapper.find({ name: 'JsfDateTimePicker' }) 18 | await wrapper.vm.$nextTick() 19 | expect(picker.vm).not.toBeUndefined() 20 | }) 21 | 22 | it('format of date-time is string, el value should be the same as default', async () => { 23 | const datetime = '2020-07-17 10:20:30' 24 | const wrapper: any = mount(Wrapper, { 25 | data: () => ({ 26 | schema: { 27 | type: 'string', 28 | format: 'date-time', 29 | default: datetime, 30 | }, 31 | value: undefined, 32 | }), 33 | }) 34 | const elPicker = wrapper.find({ name: 'ElDatePicker' }) 35 | await wrapper.vm.$nextTick() 36 | expect(elPicker.vm.value).toBe(datetime) 37 | }) 38 | 39 | it('format of date-time is string, el value should be the same as value', async () => { 40 | const datetime = '2020-07-17 10:20:30' 41 | const wrapper: any = mount(Wrapper, { 42 | data: () => ({ 43 | schema: { 44 | type: 'string', 45 | format: 'date-time', 46 | }, 47 | value: datetime, 48 | }), 49 | }) 50 | const elPicker = wrapper.find({ name: 'ElDatePicker' }) 51 | expect(elPicker.vm.value).toBe(datetime) 52 | }) 53 | 54 | it('format of date-time is string, value should update when el-date-picker emit input event', async () => { 55 | const datetime = '2020-07-17 10:20:30' 56 | const wrapper: any = mount(Wrapper, { 57 | data: () => ({ 58 | schema: { 59 | type: 'string', 60 | format: 'date-time', 61 | }, 62 | value: undefined, 63 | }), 64 | }) 65 | const elPicker = wrapper.find({ name: 'ElDatePicker' }) 66 | elPicker.vm.$emit('input', datetime) 67 | expect(wrapper.vm.value).toBe(new Date(datetime).toJSON()) // github服务器的识趣跟我们不一样 68 | }) 69 | 70 | it('format of date-time is number, el value should be the same as value', async () => { 71 | const time = new Date('2020-07-15 10:10:10').getTime() 72 | const wrapper: any = mount(Wrapper, { 73 | data: () => ({ 74 | schema: { 75 | type: 'number', 76 | format: 'date-time', 77 | }, 78 | value: time, 79 | }), 80 | }) 81 | const picker = wrapper.find({ name: 'ElDatePicker' }) 82 | expect(picker.vm.value).toBe(time) 83 | }) 84 | it('format of date-time is number, value should update when el emit input event', async () => { 85 | const time = new Date('2020-07-15 10:20:30').getTime() 86 | const wrapper: any = mount(Wrapper, { 87 | data: () => ({ 88 | schema: { 89 | type: 'number', 90 | format: 'date-time', 91 | }, 92 | value: undefined, 93 | }), 94 | }) 95 | const elPicker = wrapper.find({ name: 'ElDatePicker' }) 96 | elPicker.vm.$emit('input', time) 97 | expect(wrapper.vm.value).toBe(time) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /tests/unit/default-value/object.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('object renderer default values', () => { 8 | it('should update value to `{}` if other type provided', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'object', 13 | properties: { 14 | aaa: { 15 | type: 'string', 16 | default: 'text', 17 | }, 18 | }, 19 | }, 20 | value: '', 21 | }), 22 | }) 23 | await wrapper.vm.$nextTick() 24 | expect(wrapper.vm.value.aaa).toMatch('text') 25 | }) 26 | 27 | it('should update value to `{}` if other type provided with value given', async () => { 28 | const wrapper: any = mount(Wrapper, { 29 | data: () => ({ 30 | schema: { 31 | type: 'object', 32 | properties: { 33 | name: { 34 | type: 'string', 35 | default: 'text', 36 | }, 37 | }, 38 | }, 39 | value: 'text', 40 | }), 41 | }) 42 | await wrapper.vm.$nextTick() 43 | expect(wrapper.vm.value.name).toMatch('text') 44 | }) 45 | 46 | it('should use default as default value if `default` provided', async () => { 47 | const wrapper: any = mount(Wrapper, { 48 | data: () => ({ 49 | schema: { 50 | type: 'object', 51 | properties: { 52 | name: { 53 | type: 'string', 54 | default: 'text', 55 | }, 56 | }, 57 | }, 58 | value: 'text', 59 | }), 60 | }) 61 | await wrapper.vm.$nextTick() 62 | expect(wrapper.vm.value.name).toMatch('text') 63 | }) 64 | 65 | it('should use value when create with value provided', async () => { 66 | const wrapper: any = mount(Wrapper, { 67 | data: () => ({ 68 | schema: { 69 | type: 'object', 70 | properties: { 71 | name: { 72 | type: 'string', 73 | default: 'text', 74 | }, 75 | }, 76 | }, 77 | value: { 78 | name: 'jokcy', 79 | }, 80 | }), 81 | }) 82 | await wrapper.vm.$nextTick() 83 | expect(wrapper.vm.value.name).toMatch('jokcy') 84 | }) 85 | 86 | it('should match expected result', async () => { 87 | const wrapper: any = mount(Wrapper, { 88 | data: () => ({ 89 | schema: { 90 | type: 'object', 91 | properties: { 92 | name: { 93 | type: 'string', 94 | default: 'text', 95 | }, 96 | age: { 97 | type: 'number', 98 | default: 10, 99 | }, 100 | pets: { 101 | type: 'array', 102 | items: { 103 | type: 'string', 104 | }, 105 | }, 106 | }, 107 | default: { 108 | pets: ['kiki'], 109 | }, 110 | }, 111 | value: '', 112 | }), 113 | }) 114 | await wrapper.vm.$nextTick() 115 | expect(wrapper.vm.value.name).toMatch('text') 116 | expect(wrapper.vm.value.age).toEqual(10) 117 | expect(wrapper.vm.value.pets).toEqual(['kiki']) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Prop, Component } from 'vue-property-decorator' 3 | 4 | import * as utils from './utils' 5 | 6 | import SchemaForm from './SchemaForm.vue' 7 | import SchemaItem from './SchemaItem.vue' 8 | // import SchemaRenderer from './SchemaRenderer.vue' 9 | // import createValidator from './validator' 10 | 11 | export default { 12 | install(vue: typeof Vue) { 13 | vue.component('JsonSchemaForm', SchemaForm) 14 | vue.component('JsfItem', SchemaItem) 15 | // vue.component(SchemaRenderer.name, SchemaRenderer) 16 | 17 | Promise.resolve().then(() => { 18 | if (!vue.component('JsfForm')) { 19 | throw new Error('要使用JsonSchemaForm请至少使用一个主题') 20 | } 21 | }) 22 | }, 23 | } 24 | 25 | @Component 26 | class ThemeBaseClass extends Vue { 27 | @Prop() value: any 28 | @Prop({ type: Function }) onChange: any 29 | @Prop({ type: String }) format: any 30 | @Prop({ type: Object }) schema: any 31 | @Prop() errors: any 32 | @Prop({ type: String }) title: any 33 | @Prop({ type: Boolean }) required: any 34 | @Prop({ type: Boolean }) requiredError: any 35 | @Prop({ type: String }) description: any 36 | @Prop({ type: Object }) vjsf: any 37 | @Prop({ type: Boolean }) isDependenciesKey: any 38 | 39 | defaultPlaceholder: string = '请输入' 40 | 41 | get placeholder() { 42 | return this.vjsf.placeholder || this.defaultPlaceholder 43 | } 44 | 45 | get additionProps() { 46 | return this.vjsf.additionProps || {} 47 | } 48 | 49 | get firstMatchedError() { 50 | return this.errors.find((e: any) => { 51 | const schemaPath = e.schemaPath 52 | if (this.isDependenciesKey && isDependenciesError(schemaPath)) { 53 | return false 54 | } 55 | return true 56 | }) 57 | } 58 | get formItemProps() { 59 | return { 60 | required: this.required, 61 | schema: this.schema, 62 | firstMatchedError: (this as any).firstMatchedError, 63 | requiredError: this.requiredError, 64 | errors: this.errors, 65 | label: this.title, 66 | description: this.description, 67 | } 68 | } 69 | } 70 | 71 | function isDependenciesError(schemaPath: string) { 72 | return schemaPath.indexOf('/dependencies/') > 0 73 | } 74 | 75 | const ThemeBaseMixin = Vue.extend({ 76 | props: { 77 | value: {}, 78 | onChange: Function, 79 | format: String, 80 | schema: Object, 81 | errors: Array, 82 | title: String, 83 | required: Boolean, 84 | requiredError: Boolean, 85 | description: String, 86 | vjsf: Object, 87 | isDependenciesKey: Boolean, 88 | }, 89 | 90 | data() { 91 | return { 92 | defaultPlaceholder: '请输入', 93 | } 94 | }, 95 | 96 | computed: { 97 | placeholder() { 98 | return (this.vjsf as any).placeholder || (this as any).defaultPlaceholder 99 | }, 100 | additionProps() { 101 | return (this.vjsf as any).additionProps || {} 102 | }, 103 | firstMatchedError() { 104 | return this.errors.find((e: any) => { 105 | const schemaPath = e.schemaPath 106 | if (this.isDependenciesKey && isDependenciesError(schemaPath)) { 107 | return false 108 | } 109 | return true 110 | }) 111 | }, 112 | formItemProps() { 113 | return { 114 | required: this.required, 115 | schema: this.schema, 116 | firstMatchedError: (this as any).firstMatchedError, 117 | requiredError: this.requiredError, 118 | errors: this.errors, 119 | label: this.title, 120 | description: this.description, 121 | } 122 | }, 123 | }, 124 | }) 125 | 126 | export { utils, SchemaForm as JsonSchemaForm, ThemeBaseClass, ThemeBaseMixin } 127 | -------------------------------------------------------------------------------- /tests/unit/theme-element/timePicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('time component', () => { 8 | it('format `time` should render time component', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'number', 13 | format: 'time', 14 | }, 15 | }), 16 | }) 17 | const picker = wrapper.find({ name: 'JsfTimePicker' }) 18 | expect(picker.vm).not.toBeUndefined() 19 | }) 20 | 21 | it('format of time is number, el value should be the same as default', async () => { 22 | const time = new Date('2020-07-29 15:23:07').getTime() 23 | const wrapper: any = mount(Wrapper, { 24 | data: () => ({ 25 | schema: { 26 | type: 'number', 27 | format: 'time', 28 | default: time, 29 | }, 30 | value: undefined, 31 | }), 32 | }) 33 | const elPicker = wrapper.find({ name: 'JsfTimePicker' }) 34 | await wrapper.vm.$nextTick() 35 | expect(elPicker.vm.value).toBe(time) 36 | }) 37 | it('format of time is number, el value should be the same as value', async () => { 38 | const time = new Date('2020-07-29 15:23:07').getTime() 39 | const wrapper: any = mount(Wrapper, { 40 | data: () => ({ 41 | schema: { 42 | type: 'number', 43 | format: 'time', 44 | }, 45 | value: time, 46 | }), 47 | }) 48 | const elPicker = wrapper.find({ name: 'JsfTimePicker' }) 49 | expect(elPicker.vm.value).toBe(time) 50 | }) 51 | it('format of time is number, value should update when el-date-picker emit input event', async () => { 52 | const time = new Date('2020-07-29 15:23:07').getTime() 53 | const wrapper: any = mount(Wrapper, { 54 | data: () => ({ 55 | schema: { 56 | type: 'number', 57 | format: 'time', 58 | }, 59 | value: undefined, 60 | }), 61 | }) 62 | const elPicker = wrapper.find({ name: 'JsfTimePicker' }) 63 | elPicker.vm.$emit('input', time) 64 | expect(wrapper.vm.value).toBe(time) 65 | }) 66 | 67 | it('format of time is string, el value should be the same as default', async () => { 68 | const time = '15:23:07' 69 | const wrapper: any = mount(Wrapper, { 70 | data: () => ({ 71 | schema: { 72 | type: 'string', 73 | format: 'time', 74 | default: time, 75 | }, 76 | value: undefined, 77 | }), 78 | }) 79 | const elPicker = wrapper.find({ name: 'JsfTimePicker' }) 80 | await wrapper.vm.$nextTick() 81 | expect(elPicker.vm.value).toBe(time) 82 | }) 83 | it('format of time is string, el value should be the same as value', async () => { 84 | const time = '15:23:07' 85 | const wrapper: any = mount(Wrapper, { 86 | data: () => ({ 87 | schema: { 88 | type: 'string', 89 | format: 'time', 90 | }, 91 | value: time, 92 | }), 93 | }) 94 | const elPicker = wrapper.find({ name: 'JsfTimePicker' }) 95 | expect(elPicker.vm.value).toBe(time) 96 | }) 97 | it('format of time is string, value should update when el-date-picker emit input event', async () => { 98 | const time = '15:23:07' 99 | const wrapper: any = mount(Wrapper, { 100 | data: () => ({ 101 | schema: { 102 | type: 'string', 103 | format: 'time', 104 | }, 105 | value: undefined, 106 | }), 107 | }) 108 | const elPicker = wrapper.find({ name: 'JsfTimePicker' }) 109 | elPicker.vm.$emit('input', time) 110 | expect(wrapper.vm.value).toBe(time) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /src/theme-element-ui/SingleTypeArrayWrapper.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 64 | 65 | 138 | -------------------------------------------------------------------------------- /src/plugins/ImageUploader.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 127 | 128 | 133 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 113 | 114 | 126 | 127 | 170 | -------------------------------------------------------------------------------- /src/core/mixins/base.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Component, Inject, Prop } from 'vue-property-decorator' 3 | import { getVJSFConfig, escapeJsonPointer } from '../utils' 4 | 5 | // @Component() 6 | // const CommonBase = Vue.extend({ 7 | // props: { 8 | // schema: { 9 | // type: Object, 10 | // required: true, 11 | // }, 12 | // value: { 13 | // required: false, 14 | // }, 15 | // rootSchema: { 16 | // type: Object, 17 | // required: true, 18 | // }, 19 | // path: { 20 | // type: String, 21 | // required: true, 22 | // }, 23 | // onChange: { 24 | // type: Function, 25 | // required: true, 26 | // }, 27 | // required: Boolean, 28 | // requiredError: Boolean, 29 | // }, 30 | 31 | // inject: { 32 | // form: 'formContext', 33 | // }, 34 | // }) 35 | 36 | // export default CommonBase 37 | 38 | @Component 39 | class CommonBaseClass extends Vue { 40 | @Prop({ type: Object, required: true }) schema: any 41 | @Prop({ required: false }) value: any 42 | @Prop({ type: Object, required: true }) rootSchema: any 43 | @Prop({ type: String, required: true }) path: any 44 | @Prop({ type: Function, required: true }) onChange: any 45 | @Prop({ type: Boolean }) required: any 46 | @Prop({ type: Boolean }) requiredError: any 47 | @Prop({ type: Boolean }) isDependenciesKey: any 48 | @Prop({ type: Object }) uiSchema: any 49 | 50 | @Inject('formContext') form: any 51 | 52 | handleChange(v: any) {} 53 | 54 | getPath(name: string) { 55 | return this.path + '/' + escapeJsonPointer(name) 56 | } 57 | } 58 | 59 | function isDependenciesError(schemaPath: string) { 60 | return schemaPath.indexOf('/dependencies/') > 0 61 | } 62 | 63 | @Component 64 | class RendererBaseClass extends CommonBaseClass { 65 | get format() { 66 | return this.schema.format || 'text' 67 | } 68 | 69 | // get errorMessage() { 70 | // if (this.requiredError) return '必须填写' 71 | // // const errors = this.form.errors.filter( 72 | // // (e: any) => e.dataPath === (this as any).path 73 | // // ) 74 | // // return error ? error.message : '' 75 | // // return this.errors[0] ? this.errors[0].message : '' 76 | // return this.firstMatchedError ? this.firstMatchedError.message : '' 77 | // } 78 | 79 | // get firstMatchedError() { 80 | // return this.errors.find((e: any) => { 81 | // const schemaPath = e.schemaPath 82 | // if (this.isDependenciesKey && isDependenciesError(schemaPath)) { 83 | // return false 84 | // } 85 | // return true 86 | // }) 87 | // } 88 | 89 | get errors() { 90 | const errors = this.form.errors.filter( 91 | (e: any) => e.dataPath === (this as any).path 92 | ) 93 | return errors 94 | } 95 | 96 | get rendererProps() { 97 | return { 98 | value: this.value, 99 | onChange: this.handleChange, 100 | format: this.format, 101 | schema: this.schema, 102 | errors: this.errors, 103 | title: this.title, 104 | required: this.required, 105 | requiredError: this.requiredError, 106 | description: this.description, 107 | disabled: !!this.vjsf.disabled, 108 | vjsf: this.vjsf, 109 | path: this.path, 110 | isDependenciesKey: this.isDependenciesKey, 111 | ...this.vjsf.additionProps, 112 | } 113 | } 114 | 115 | // get formItemProps() { 116 | // return { 117 | // required: this.required, 118 | // schema: this.schema, 119 | // firstMatchedError: this.firstMatchedError, 120 | // requiredError: this.requiredError, 121 | // errors: this.errors, 122 | // label: this.title, 123 | // description: this.description, 124 | // } 125 | // } 126 | 127 | get vjsf() { 128 | return getVJSFConfig(this.schema, this.uiSchema) 129 | } 130 | 131 | get title() { 132 | return this.schemaTitle || this.path 133 | } 134 | 135 | get schemaTitle() { 136 | if (this.schema.title) { 137 | console.warn( 138 | 'the usage of `title` in schema will be deprecated soon use `vjsf.title` instead' 139 | ) 140 | return this.schema.title 141 | } 142 | return this.vjsf.title 143 | } 144 | 145 | get description() { 146 | return this.vjsf.description 147 | } 148 | } 149 | 150 | export { CommonBaseClass, RendererBaseClass } 151 | -------------------------------------------------------------------------------- /tests/unit/theme-element/date.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('date', () => { 8 | // string 9 | it('format `date` should render datepicker', async () => { 10 | const wrapper: any = mount(Wrapper, { 11 | data: () => ({ 12 | schema: { 13 | type: 'string', 14 | format: 'date', 15 | }, 16 | }), 17 | }) 18 | const picker = wrapper.find({ name: 'JsfDatePicker' }) 19 | expect(picker.vm).not.toBeUndefined() 20 | }) 21 | 22 | it('value should update when el-date-picker emit input event', async () => { 23 | const wrapper: any = mount(Wrapper, { 24 | data: () => ({ 25 | schema: { 26 | type: 'string', 27 | format: 'date', 28 | }, 29 | value: undefined, 30 | }), 31 | }) 32 | const elPicker = wrapper.find({ name: 'ElDatePicker' }) 33 | elPicker.vm.$emit('input', '123') 34 | expect(wrapper.vm.value).toBe('123') 35 | }) 36 | //TODO: at packages/date-picker/src/picker.vue 37 | // TypeError: dateStr.match is not a function 38 | // 值类型传参有误 39 | // it('get number when type is string', async () => { 40 | // const wrapper: any = mount(Wrapper, { 41 | // data: () => ({ 42 | // schema: { 43 | // type: 'string', 44 | // format: 'date', 45 | // }, 46 | // value: new Date('2020-07-15 10:10:10').getTime(), 47 | // }), 48 | // }) 49 | // const picker = wrapper.find({ name: 'JsfDatePicker' }) 50 | // expect(picker.vm.value).toBeUndefined() 51 | // }) 52 | it('value should be the same as value', async () => { 53 | const date = '2020-07-15' 54 | const wrapper: any = mount(Wrapper, { 55 | data: () => ({ 56 | schema: { 57 | type: 'string', 58 | format: 'date', 59 | }, 60 | value: date, 61 | }), 62 | }) 63 | const picker = wrapper.find({ name: 'JsfDatePicker' }) 64 | expect(picker.vm.value).toBe(date) 65 | }) 66 | it('value should be the same as default', async () => { 67 | const date = '2020-07-15' 68 | const wrapper: any = mount(Wrapper, { 69 | data: () => ({ 70 | schema: { 71 | type: 'string', 72 | format: 'date', 73 | default: date, 74 | }, 75 | value: undefined, 76 | }), 77 | }) 78 | const picker = wrapper.find({ name: 'JsfDatePicker' }) 79 | await wrapper.vm.$nextTick() 80 | expect(picker.vm.value).toBe(date) 81 | }) 82 | // TODO:type传参有误 83 | // it('set datetime when type is date', async () => { 84 | // const wrapper: any = mount(Wrapper, { 85 | // data: () => ({ 86 | // schema: { 87 | // type: 'string', 88 | // format: 'date', 89 | // }, 90 | // value: '2020-07-15 10:20:30', 91 | // }), 92 | // }) 93 | // await wrapper.vm.$nextTick() 94 | // expect(wrapper.vm.value).toBe('2020-07-15') 95 | // }) 96 | 97 | // number 98 | it('format of date is number, value should update when el-date-picker emit input event', async () => { 99 | const time = new Date('2020-07-15 10:10:10').getTime() 100 | const wrapper: any = mount(Wrapper, { 101 | data: () => ({ 102 | schema: { 103 | type: 'number', 104 | format: 'date', 105 | }, 106 | value: undefined, 107 | }), 108 | }) 109 | const elPicker = wrapper.find({ name: 'ElDatePicker' }) 110 | elPicker.vm.$emit('input', time) 111 | expect(wrapper.vm.value).toBe(time) 112 | }) 113 | it('format of date is number, value should be the same as value', async () => { 114 | const time = new Date('2020-07-15 10:10:10').getTime() 115 | const wrapper: any = mount(Wrapper, { 116 | data: () => ({ 117 | schema: { 118 | type: 'number', 119 | format: 'date', 120 | }, 121 | value: time, 122 | }), 123 | }) 124 | const picker = wrapper.find({ name: 'ElDatePicker' }) 125 | expect(picker.vm.value).toBe(time) 126 | }) 127 | // TODO:值类型传参有误 128 | // it('get string when type is number', async () => { 129 | // const wrapper: any = mount(Wrapper, { 130 | // data: () => ({ 131 | // schema: { 132 | // type: 'number', 133 | // format: 'date', 134 | // }, 135 | // value: '2020-07-15', 136 | // }), 137 | // }) 138 | // await wrapper.vm.$nextTick() 139 | // expect(wrapper.vm.value).toBe(new Date('2020-07-15').getTime()) 140 | // }) 141 | }) 142 | -------------------------------------------------------------------------------- /tests/unit/value-change/reset-value.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('when reset value to {}', () => { 8 | it('reset to expect value when value reset', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'object', 13 | properties: { 14 | n: { 15 | type: 'number', 16 | default: 10, 17 | }, 18 | s: { 19 | type: 'string', 20 | default: '123', 21 | }, 22 | b: { 23 | type: 'boolean', 24 | default: true, 25 | }, 26 | a: { 27 | type: 'array', 28 | items: { 29 | type: 'string', 30 | }, 31 | default: ['123'], 32 | }, 33 | }, 34 | }, 35 | value: undefined, 36 | }), 37 | }) 38 | await wrapper.vm.$nextTick() 39 | expect(wrapper.vm.value.n).toEqual(10) 40 | expect(wrapper.vm.value.s).toBe('123') 41 | expect(wrapper.vm.value.b).toBe(true) 42 | expect(wrapper.vm.value.a).toEqual(['123']) 43 | wrapper.vm.value = {} 44 | await wrapper.vm.$nextTick() 45 | expect(wrapper.vm.value.n).toBeUndefined() 46 | expect(wrapper.vm.value.s).toBeUndefined() 47 | expect(wrapper.vm.value.b).toBe(false) 48 | expect(wrapper.vm.value.a).toBeUndefined() 49 | }) 50 | 51 | it('number should become undefined when value reset', async () => { 52 | const wrapper: any = mount(Wrapper, { 53 | data: () => ({ 54 | schema: { 55 | type: 'object', 56 | properties: { 57 | n: { 58 | type: 'number', 59 | default: 10, 60 | }, 61 | }, 62 | }, 63 | value: undefined, 64 | }), 65 | }) 66 | await wrapper.vm.$nextTick() 67 | expect(wrapper.vm.value.n).toEqual(10) 68 | wrapper.vm.value = {} 69 | await wrapper.vm.$nextTick() 70 | expect(wrapper.vm.value.n).toBeUndefined() 71 | }) 72 | 73 | it('string should become undefined when value reset', async () => { 74 | const wrapper: any = mount(Wrapper, { 75 | data: () => ({ 76 | schema: { 77 | type: 'object', 78 | properties: { 79 | n: { 80 | type: 'string', 81 | default: 'text', 82 | }, 83 | }, 84 | }, 85 | value: undefined, 86 | }), 87 | }) 88 | await wrapper.vm.$nextTick() 89 | expect(wrapper.vm.value.n).toMatch('text') 90 | wrapper.vm.value = {} 91 | await wrapper.vm.$nextTick() 92 | expect(wrapper.vm.value.n).toBeUndefined() 93 | }) 94 | 95 | it('reset nest object should work fine', async () => { 96 | const wrapper: any = mount(Wrapper, { 97 | data: () => ({ 98 | schema: { 99 | type: 'object', 100 | properties: { 101 | obj: { 102 | type: 'object', 103 | default: { 104 | a: 'text', 105 | }, 106 | properties: { 107 | a: { 108 | type: 'string', 109 | }, 110 | }, 111 | }, 112 | }, 113 | }, 114 | value: undefined, 115 | }), 116 | }) 117 | await wrapper.vm.$nextTick() 118 | expect(wrapper.vm.value.obj.a).toMatch('text') 119 | wrapper.vm.value = {} 120 | await wrapper.vm.$nextTick() 121 | const text = wrapper.find({ name: 'JsfTextInput' }) 122 | expect(wrapper.vm.value.obj).toBeUndefined() 123 | expect(text.props().value).toBeUndefined() 124 | text.vm.onChange('111') 125 | await wrapper.vm.$nextTick() 126 | expect(text.props().value).toBe('111') 127 | expect(wrapper.vm.value.obj.a).toBe('111') 128 | }) 129 | 130 | it('should not change object default value', async () => { 131 | const schema = { 132 | type: 'object', 133 | default: { 134 | text: '1', 135 | }, 136 | properties: { 137 | text: { 138 | type: 'string', 139 | }, 140 | }, 141 | } 142 | const wrapper: any = mount(Wrapper, { 143 | data: () => ({ 144 | schema, 145 | value: undefined, 146 | }), 147 | }) 148 | await wrapper.vm.$nextTick() 149 | expect(wrapper.vm.value.text).toMatch('1') 150 | const text = wrapper.find({ name: 'JsfTextInput' }) 151 | text.vm.onChange('2') 152 | await wrapper.vm.$nextTick() 153 | expect(wrapper.vm.value.text).toMatch('2') 154 | expect(schema.default.text).toBe('1') 155 | }) 156 | }) 157 | -------------------------------------------------------------------------------- /src/theme-element-ui/form-component-props/index.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { Component, Prop } from 'vue-property-decorator' 3 | 4 | function isDependenciesError(schemaPath: string) { 5 | return schemaPath.indexOf('/dependencies/') > 0 6 | } 7 | 8 | @Component 9 | export class CommonBase extends Vue { 10 | @Prop() value: any 11 | @Prop({ type: Function }) onChange: any 12 | @Prop({ type: String }) format: any 13 | @Prop({ type: Object }) schema: any 14 | @Prop() errors: any 15 | @Prop({ type: String }) title: any 16 | @Prop({ type: Boolean }) required: any 17 | @Prop({ type: Boolean }) requiredError: any 18 | @Prop({ type: String }) description: any 19 | @Prop({ type: Object }) vjsf: any 20 | @Prop({ type: Boolean }) isDependenciesKey: any 21 | @Prop({ type: String }) path: any 22 | 23 | defaultPlaceholder: string = '请输入' 24 | 25 | get placeholder() { 26 | return this.vjsf.placeholder || this.defaultPlaceholder 27 | } 28 | 29 | get additionProps() { 30 | return this.vjsf.additionProps || {} 31 | } 32 | 33 | get firstMatchedError() { 34 | return this.errors.find((e: any) => { 35 | // TODO: dataPath是否所有的key都是正确的 36 | // TODO: 增加所有错误信息种类的test case 37 | const dataPath = e.dataPath 38 | if (this.isDependenciesKey && isDependenciesError(dataPath)) { 39 | return false 40 | } 41 | return dataPath === this.path 42 | }) 43 | } 44 | 45 | get formItemProps() { 46 | return { 47 | required: this.required, 48 | schema: this.schema, 49 | firstMatchedError: this.firstMatchedError, 50 | requiredError: this.requiredError, 51 | errors: this.errors, 52 | label: this.title, 53 | description: this.description, 54 | } 55 | } 56 | } 57 | 58 | @Component 59 | export class FormItemBase extends Vue { 60 | @Prop({ type: String }) format: any 61 | @Prop({ type: Object }) schema: any 62 | @Prop() firstMatchedError: any 63 | @Prop() errors: any 64 | @Prop({ type: String }) label: any 65 | @Prop({ type: Boolean }) required: any 66 | @Prop({ type: Boolean }) requiredError: any 67 | @Prop({ type: String }) description: any 68 | } 69 | 70 | @Component 71 | export class SelectionBase extends CommonBase { 72 | defaultPlaceholder = '请选择' 73 | 74 | @Prop({ type: Number }) multipleLimit: any 75 | @Prop({ type: Boolean }) multiple: any 76 | @Prop({ type: Array }) options: any 77 | } 78 | export const TextInputBase = CommonBase 79 | export const ColorPickBase = CommonBase 80 | export class SwitchBase extends CommonBase { 81 | @Prop({ type: String, default: '#13ce66' }) activeColor: any 82 | @Prop({ type: String, default: '#ff4949' }) inactiveColor: any 83 | } 84 | export const NumberInputBase = CommonBase 85 | 86 | @Component 87 | export class DatePickerBase extends CommonBase { 88 | @Prop({ type: String }) type: any 89 | } 90 | export const DateTimePickerBase = DatePickerBase 91 | export const TimePickerBase = CommonBase 92 | // export const NumberInputBase = CommonBase 93 | 94 | // export const SelectionBase = Vue.extend({ 95 | // props: { 96 | // options: Array, 97 | // value: [String, Number, Array], 98 | // placeholder: { 99 | // type: String, 100 | // default: '请选择', 101 | // }, 102 | // multipleLimit: { 103 | // type: Number, 104 | // default: 0, 105 | // }, 106 | // multiple: { 107 | // type: Boolean, 108 | // default: false, 109 | // }, 110 | // }, 111 | // }) 112 | 113 | // export const TextInputBase = Vue.extend({ 114 | // props: { 115 | // value: { 116 | // required: false, 117 | // }, 118 | // format: String, 119 | // placeholder: String, 120 | // }, 121 | // }) 122 | 123 | // export const ColorPickBase = Vue.extend({ 124 | // props: { 125 | // value: { 126 | // required: false, 127 | // }, 128 | // }, 129 | // }) 130 | 131 | // export const SwitchBase = Vue.extend({ 132 | // props: { 133 | // value: { 134 | // type: Boolean, 135 | // }, 136 | // activeColor: { 137 | // type: String, 138 | // default: '#13ce66', 139 | // }, 140 | // inactiveColor: { 141 | // type: String, 142 | // default: '#ff4949', 143 | // }, 144 | // }, 145 | // }) 146 | 147 | // export const NumberInputBase = Vue.extend({ 148 | // props: { 149 | // value: { 150 | // required: false, 151 | // }, 152 | // placeholder: String, 153 | // schema: Object, 154 | // // min: { 155 | // // type: Number, 156 | // // default: -Infinity, 157 | // // }, 158 | // // max: { 159 | // // type: Number, 160 | // // default: Infinity, 161 | // // }, 162 | // // precision: Number, 163 | // }, 164 | // }) 165 | 166 | // export const DatePickerBase = Vue.extend({ 167 | // props: { 168 | // value: [String, Number, Array], 169 | // schema: { 170 | // type: Object, 171 | // required: true, 172 | // }, 173 | // type: { 174 | // type: String, 175 | // default: '', 176 | // }, 177 | // }, 178 | // }) 179 | 180 | // export const DateTimePickerBase = Vue.extend({ 181 | // props: { 182 | // value: [String, Number], 183 | // schema: { 184 | // type: Object, 185 | // required: true, 186 | // }, 187 | // }, 188 | // }) 189 | -------------------------------------------------------------------------------- /tests/unit/default-value/array.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('array renderer default values', () => { 8 | it('should update value to `[]` if other type provided', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'array', 13 | items: { 14 | type: 'string', 15 | }, 16 | }, 17 | value: '', 18 | }), 19 | }) 20 | await wrapper.vm.$nextTick() 21 | expect(wrapper.vm.value).toBeUndefined() 22 | }) 23 | 24 | it('should update value to `[]` if other type provided with value given', async () => { 25 | const wrapper: any = mount(Wrapper, { 26 | data: () => ({ 27 | schema: { 28 | type: 'array', 29 | items: { 30 | type: 'string', 31 | }, 32 | }, 33 | value: 'text', 34 | }), 35 | }) 36 | await wrapper.vm.$nextTick() 37 | expect(wrapper.vm.value).toBeUndefined() 38 | }) 39 | 40 | it('should use default as default value if `default` provided', async () => { 41 | const wrapper: any = mount(Wrapper, { 42 | data: () => ({ 43 | schema: { 44 | type: 'array', 45 | items: { 46 | type: 'string', 47 | }, 48 | }, 49 | value: ['text'], 50 | }), 51 | }) 52 | await wrapper.vm.$nextTick() 53 | expect(wrapper.vm.value.length).toBe(1) 54 | expect(wrapper.vm.value[0]).toBe('text') 55 | }) 56 | 57 | it('in object should use default as default value if `default` provided', async () => { 58 | const wrapper: any = mount(Wrapper, { 59 | data: () => ({ 60 | schema: { 61 | type: 'object', 62 | properties: { 63 | array: { 64 | type: 'array', 65 | items: { 66 | type: 'string', 67 | }, 68 | default: ['text'], 69 | }, 70 | }, 71 | }, 72 | value: '', 73 | }), 74 | }) 75 | await wrapper.vm.$nextTick() 76 | expect(wrapper.vm.value.array.length).toBe(1) 77 | expect(wrapper.vm.value.array[0]).toBe('text') 78 | }) 79 | 80 | it('in object should use default as default value if object has default', async () => { 81 | const wrapper: any = mount(Wrapper, { 82 | data: () => ({ 83 | schema: { 84 | type: 'object', 85 | properties: { 86 | array: { 87 | type: 'array', 88 | items: { 89 | type: 'string', 90 | }, 91 | }, 92 | }, 93 | default: { 94 | array: ['text'], 95 | }, 96 | }, 97 | value: '', 98 | }), 99 | }) 100 | await wrapper.vm.$nextTick() 101 | expect(wrapper.vm.value.array.length).toBe(1) 102 | expect(wrapper.vm.value.array[0]).toBe('text') 103 | }) 104 | 105 | /****************** fixed array *********************/ 106 | it('should use default as default value if has default', async () => { 107 | const wrapper: any = mount(Wrapper, { 108 | data: () => ({ 109 | schema: { 110 | type: 'array', 111 | items: [ 112 | { 113 | type: 'string', 114 | }, 115 | { 116 | type: 'number', 117 | }, 118 | ], 119 | default: ['text'], 120 | }, 121 | value: '', 122 | }), 123 | }) 124 | await wrapper.vm.$nextTick() 125 | expect(wrapper.vm.value.length).toBe(1) 126 | expect(wrapper.vm.value[0]).toBe('text') 127 | expect(wrapper.vm.value[1]).toBeUndefined() 128 | }) 129 | 130 | it('with fixed array should use `[]` as default value if no default', async () => { 131 | const wrapper: any = mount(Wrapper, { 132 | data: () => ({ 133 | schema: { 134 | type: 'array', 135 | items: [ 136 | { 137 | type: 'string', 138 | }, 139 | { 140 | type: 'number', 141 | }, 142 | ], 143 | }, 144 | value: '', 145 | }), 146 | }) 147 | await wrapper.vm.$nextTick() 148 | expect(wrapper.vm.value).toEqual([]) 149 | // expect(wrapper.vm.value[0]).toBeUndefined() 150 | // expect(wrapper.vm.value[1]).toBeUndefined() 151 | }) 152 | 153 | it('should use value as `currentValue` when created and follow value change', async () => { 154 | const wrapper: any = mount(Wrapper, { 155 | data: () => ({ 156 | schema: { 157 | type: 'array', 158 | items: { 159 | type: 'object', 160 | properties: { 161 | name: { 162 | type: 'string', 163 | }, 164 | }, 165 | }, 166 | }, 167 | value: [ 168 | { 169 | name: '123', 170 | }, 171 | ], 172 | }), 173 | }) 174 | await wrapper.vm.$nextTick() 175 | const ar = wrapper.find({ name: 'JsfArrayRenderer' }) 176 | expect(ar.vm.currentValue).toBe(ar.vm.value) 177 | expect(ar.vm.currentValue).toEqual([{ name: '123' }]) 178 | wrapper.vm.value = [{ name: '456' }] 179 | await wrapper.vm.$nextTick() 180 | expect(ar.vm.currentValue).toBe(ar.vm.value) 181 | expect(ar.vm.currentValue).toEqual([{ name: '456' }]) 182 | // expect(wrapper.vm.value[0]).toBeUndefined() 183 | // expect(wrapper.vm.value[1]).toBeUndefined() 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /tests/unit/theme-element/selection.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('selection', () => { 8 | it('format `number` with `enumKeyValue` should render selection component', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'number', 13 | enumKeyValue: [ 14 | { 15 | key: 'option1', 16 | value: 1, 17 | }, 18 | { 19 | key: 'option2', 20 | value: 2, 21 | }, 22 | { 23 | key: 'option3', 24 | value: 3, 25 | }, 26 | ], 27 | }, 28 | }), 29 | }) 30 | const picker = wrapper.find({ name: 'JsfSelection' }) 31 | expect(picker.vm).not.toBeUndefined() 32 | }) 33 | it('el value should be the same as value', () => { 34 | const wrapper: any = mount(Wrapper, { 35 | data: () => ({ 36 | schema: { 37 | type: 'number', 38 | enumKeyValue: [ 39 | { 40 | key: 'option1', 41 | value: 1, 42 | }, 43 | { 44 | key: 'option2', 45 | value: 2, 46 | }, 47 | { 48 | key: 'option3', 49 | value: 3, 50 | }, 51 | ], 52 | }, 53 | value: 1, 54 | }), 55 | }) 56 | const picker = wrapper.find({ name: 'JsfSelection' }) 57 | expect(picker.vm.value).toBe(1) 58 | }) 59 | it('el value should be the same as default', async () => { 60 | const wrapper: any = mount(Wrapper, { 61 | data: () => ({ 62 | schema: { 63 | type: 'number', 64 | enumKeyValue: [ 65 | { 66 | key: 'option1', 67 | value: 1, 68 | }, 69 | { 70 | key: 'option2', 71 | value: 2, 72 | }, 73 | { 74 | key: 'option3', 75 | value: 3, 76 | }, 77 | ], 78 | default: 1, 79 | }, 80 | value: undefined, 81 | }), 82 | }) 83 | const picker = wrapper.find({ name: 'JsfSelection' }) 84 | await wrapper.vm.$nextTick() 85 | expect(picker.vm.value).toBe(1) 86 | }) 87 | it('value should change when el emit input event', () => { 88 | const wrapper: any = mount(Wrapper, { 89 | data: () => ({ 90 | schema: { 91 | type: 'number', 92 | enumKeyValue: [ 93 | { 94 | key: 'option1', 95 | value: 1, 96 | }, 97 | { 98 | key: 'option2', 99 | value: 2, 100 | }, 101 | { 102 | key: 'option3', 103 | value: 3, 104 | }, 105 | ], 106 | }, 107 | value: undefined, 108 | }), 109 | }) 110 | const picker = wrapper.find({ name: 'JsfSelection' }) 111 | picker.vm.$emit('input', 1) 112 | expect(wrapper.vm.value).toBe(1) 113 | }) 114 | it('when type of selection is multiple, el value should be the same as value', () => { 115 | const wrapper: any = mount(Wrapper, { 116 | data: () => ({ 117 | schema: { 118 | type: 'array', 119 | default: null, 120 | items: { 121 | type: 'string', 122 | enumKeyValue: [ 123 | { 124 | key: 'option1', 125 | value: '1', 126 | }, 127 | { 128 | key: 'option2', 129 | value: '2', 130 | }, 131 | { 132 | key: 'option3', 133 | value: '3', 134 | }, 135 | ], 136 | }, 137 | }, 138 | value: ['1', '2'], 139 | }), 140 | }) 141 | const picker = wrapper.find({ name: 'JsfSelection' }) 142 | expect(picker.vm.value).toStrictEqual(['1', '2']) 143 | }) 144 | it('when type of selection is multiple, el value should be the same as default', async () => { 145 | const wrapper: any = mount(Wrapper, { 146 | data: () => ({ 147 | schema: { 148 | type: 'array', 149 | default: ['1', '2'], 150 | items: { 151 | type: 'string', 152 | enumKeyValue: [ 153 | { 154 | key: 'option1', 155 | value: '1', 156 | }, 157 | { 158 | key: 'option2', 159 | value: '2', 160 | }, 161 | { 162 | key: 'option3', 163 | value: '3', 164 | }, 165 | ], 166 | }, 167 | }, 168 | value: undefined, 169 | }), 170 | }) 171 | const picker = wrapper.find({ name: 'JsfSelection' }) 172 | await wrapper.vm.$nextTick() 173 | expect(picker.vm.value).toStrictEqual(['1', '2']) 174 | }) 175 | it('when type of selection is multiple, value should change when el emit input event', async () => { 176 | const wrapper: any = mount(Wrapper, { 177 | data: () => ({ 178 | schema: { 179 | type: 'array', 180 | default: null, 181 | items: { 182 | type: 'string', 183 | enumKeyValue: [ 184 | { 185 | key: 'option1', 186 | value: '1', 187 | }, 188 | { 189 | key: 'option2', 190 | value: '2', 191 | }, 192 | { 193 | key: 'option3', 194 | value: '3', 195 | }, 196 | ], 197 | }, 198 | }, 199 | value: undefined, 200 | }), 201 | }) 202 | const elPicker = wrapper.find({ name: 'JsfSelection' }) 203 | elPicker.vm.$emit('input', ['1', '2']) 204 | expect(wrapper.vm.value).toStrictEqual(['1', '2']) 205 | }) 206 | }) 207 | -------------------------------------------------------------------------------- /src/core/schme-type-renderers/ObjectRenderer.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 221 | -------------------------------------------------------------------------------- /src/core/SchemaForm.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 237 | -------------------------------------------------------------------------------- /src/core/schme-type-renderers/ArrayRenderer.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 250 | -------------------------------------------------------------------------------- /tests/unit/theme-element/dateTimeRangePicker.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | 3 | import { initialize, Helper as Wrapper } from '../utils' 4 | 5 | initialize() 6 | 7 | describe('dateTimeRange', () => { 8 | it('format `date-time-range` should render datetimepicker', async () => { 9 | const wrapper: any = mount(Wrapper, { 10 | data: () => ({ 11 | schema: { 12 | type: 'array', 13 | items: { 14 | type: 'string', 15 | format: 'date-time', 16 | }, 17 | dateTimeRange: true, 18 | }, 19 | }), 20 | }) 21 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 22 | await wrapper.vm.$nextTick() 23 | expect(picker.vm).not.toBeUndefined() 24 | }) 25 | 26 | it('items format `date` is string, el value should be the same as value', () => { 27 | const date = ['2020-07-17', '2020-07-18'] 28 | const wrapper: any = mount(Wrapper, { 29 | data: () => ({ 30 | schema: { 31 | type: 'array', 32 | items: { 33 | type: 'string', 34 | format: 'date', 35 | }, 36 | dateTimeRange: true, 37 | }, 38 | value: date, 39 | }), 40 | }) 41 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 42 | expect(picker.vm.value).toBe(date) 43 | }) 44 | it('items format `date` is string, el value should be the same as default', async () => { 45 | const date = ['2020-07-17', '2020-07-18'] 46 | const wrapper: any = mount(Wrapper, { 47 | data: () => ({ 48 | schema: { 49 | type: 'array', 50 | items: { 51 | type: 'string', 52 | format: 'date', 53 | }, 54 | dateTimeRange: true, 55 | default: date, 56 | }, 57 | value: undefined, 58 | }), 59 | }) 60 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 61 | await wrapper.vm.$nextTick() 62 | expect(picker.vm.value).toEqual(date) 63 | }) 64 | it('items format `date` is string, value should update when el emit input event', () => { 65 | const date = ['2020-07-17', '2020-07-18'] 66 | const wrapper: any = mount(Wrapper, { 67 | data: () => ({ 68 | schema: { 69 | type: 'array', 70 | items: { 71 | type: 'string', 72 | format: 'date', 73 | }, 74 | dateTimeRange: true, 75 | }, 76 | value: undefined, 77 | }), 78 | }) 79 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 80 | picker.vm.$emit('input', date) 81 | expect(wrapper.vm.value).toBe(date) 82 | }) 83 | 84 | it('items format `date` is number, el value should be the same as value', () => { 85 | const date = [ 86 | new Date('2020-07-17').getTime(), 87 | new Date('2020-07-18').getTime(), 88 | ] 89 | const wrapper: any = mount(Wrapper, { 90 | data: () => ({ 91 | schema: { 92 | type: 'array', 93 | items: { 94 | type: 'number', 95 | format: 'date', 96 | }, 97 | dateTimeRange: true, 98 | }, 99 | value: date, 100 | }), 101 | }) 102 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 103 | expect(picker.vm.value).toBe(date) 104 | }) 105 | it('items format `date` is number, el value should be the same as default', async () => { 106 | const date = [ 107 | new Date('2020-07-17').getTime(), 108 | new Date('2020-07-18').getTime(), 109 | ] 110 | const wrapper: any = mount(Wrapper, { 111 | data: () => ({ 112 | schema: { 113 | type: 'array', 114 | items: { 115 | type: 'number', 116 | format: 'date', 117 | }, 118 | dateTimeRange: true, 119 | default: date, 120 | }, 121 | value: undefined, 122 | }), 123 | }) 124 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 125 | await wrapper.vm.$nextTick() 126 | expect(picker.vm.value).toEqual(date) 127 | }) 128 | it('items format `date` is number, value should update when el emit input event', () => { 129 | const date = [ 130 | new Date('2020-07-17').getTime(), 131 | new Date('2020-07-18').getTime(), 132 | ] 133 | const wrapper: any = mount(Wrapper, { 134 | data: () => ({ 135 | schema: { 136 | type: 'array', 137 | items: { 138 | type: 'number', 139 | format: 'date', 140 | }, 141 | dateTimeRange: true, 142 | }, 143 | value: undefined, 144 | }), 145 | }) 146 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 147 | picker.vm.$emit('input', date) 148 | expect(wrapper.vm.value).toBe(date) 149 | }) 150 | 151 | it('items format `date-time` is string, el value should be the same as value', () => { 152 | const date = ['2020-07-17 10:20:30', '2020-07-18 10:20:30'] 153 | const wrapper: any = mount(Wrapper, { 154 | data: () => ({ 155 | schema: { 156 | type: 'array', 157 | items: { 158 | type: 'string', 159 | format: 'date-time', 160 | }, 161 | dateTimeRange: true, 162 | }, 163 | value: date, 164 | }), 165 | }) 166 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 167 | expect(picker.vm.value).toBe(date) 168 | }) 169 | it('items format `date-time` is string, el value should be the same as default', async () => { 170 | const date = ['2020-07-17 10:20:30', '2020-07-18 10:20:30'] 171 | const wrapper: any = mount(Wrapper, { 172 | data: () => ({ 173 | schema: { 174 | type: 'array', 175 | items: { 176 | type: 'string', 177 | format: 'date-time', 178 | }, 179 | dateTimeRange: true, 180 | default: date, 181 | }, 182 | value: undefined, 183 | }), 184 | }) 185 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 186 | await wrapper.vm.$nextTick() 187 | expect(picker.vm.value).toEqual(date) 188 | }) 189 | it('items format `date-time` is string, value should update when el emit input event', () => { 190 | const date = ['2020-07-17 10:20:30', '2020-07-18 10:20:30'] 191 | const wrapper: any = mount(Wrapper, { 192 | data: () => ({ 193 | schema: { 194 | type: 'array', 195 | items: { 196 | type: 'string', 197 | format: 'date-time', 198 | }, 199 | dateTimeRange: true, 200 | }, 201 | value: undefined, 202 | }), 203 | }) 204 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 205 | picker.vm.$emit('input', date) 206 | expect(wrapper.vm.value).toBe(date) 207 | }) 208 | 209 | it('items format `date-time` is number, el value should be the same as value', () => { 210 | const date = [ 211 | new Date('2020-07-17 10:20:30').getTime(), 212 | new Date('2020-07-18 10:20:30').getTime(), 213 | ] 214 | const wrapper: any = mount(Wrapper, { 215 | data: () => ({ 216 | schema: { 217 | type: 'array', 218 | items: { 219 | type: 'number', 220 | format: 'date-time', 221 | }, 222 | dateTimeRange: true, 223 | }, 224 | value: date, 225 | }), 226 | }) 227 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 228 | expect(picker.vm.value).toBe(date) 229 | }) 230 | it('items format `date-time` is number, el value should be the same as default', async () => { 231 | const date = [ 232 | new Date('2020-07-17 10:20:30').getTime(), 233 | new Date('2020-07-18 10:20:30').getTime(), 234 | ] 235 | const wrapper: any = mount(Wrapper, { 236 | data: () => ({ 237 | schema: { 238 | type: 'array', 239 | items: { 240 | type: 'number', 241 | format: 'date-time', 242 | }, 243 | dateTimeRange: true, 244 | default: date, 245 | }, 246 | value: undefined, 247 | }), 248 | }) 249 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 250 | await wrapper.vm.$nextTick() 251 | expect(picker.vm.value).toEqual(date) 252 | }) 253 | it('items format `date-time` is number, value should update when el emit input event', () => { 254 | const date = [ 255 | new Date('2020-07-17 10:20:30').getTime(), 256 | new Date('2020-07-18 10:20:30').getTime(), 257 | ] 258 | const wrapper: any = mount(Wrapper, { 259 | data: () => ({ 260 | schema: { 261 | type: 'array', 262 | items: { 263 | type: 'number', 264 | format: 'date-time', 265 | }, 266 | dateTimeRange: true, 267 | }, 268 | value: undefined, 269 | }), 270 | }) 271 | const picker = wrapper.find({ name: 'JsfDateTimeRangePicker' }) 272 | picker.vm.$emit('input', date) 273 | expect(wrapper.vm.value).toBe(date) 274 | }) 275 | }) 276 | -------------------------------------------------------------------------------- /src/core/utils/schema.ts: -------------------------------------------------------------------------------- 1 | import jsonpointer from 'jsonpointer' 2 | import union from 'lodash.union' 3 | 4 | import { isObject, hasOwnProperty, getSchemaType } from './common' 5 | import { validateData } from '../validator' 6 | 7 | enum SchemaTypes { 8 | 'NUMBER' = 'number', 9 | 'INTEGER' = 'integer', 10 | 'STRING' = 'string', 11 | 'OBJECT' = 'object', 12 | 'ARRAY' = 'array', 13 | 'BOOLEAN' = 'boolean', 14 | } 15 | 16 | export interface VueJsonSchemaConfig { 17 | title?: string 18 | description?: string 19 | component?: string 20 | additionProps?: { 21 | [key: string]: any 22 | } 23 | withFormItem?: boolean 24 | disabled?: boolean 25 | } 26 | 27 | // type Schema = any 28 | export interface Schema { 29 | type: SchemaTypes 30 | const: any 31 | format?: string 32 | properties?: { 33 | [key: string]: Schema 34 | } 35 | items?: Schema | Schema[] 36 | dependencies?: { 37 | [key: string]: string[] | Schema 38 | } 39 | oneOf?: Schema[] 40 | vjsf?: VueJsonSchemaConfig 41 | required?: string[] 42 | enum: any[] 43 | enumKeyValue: any[] 44 | additionalProperties: any 45 | } 46 | 47 | // function resolveSchema(schema: any, data: any = {}) {} 48 | 49 | export function resolveSchema(schema: any, rootSchema = {}, formData = {}) { 50 | if (hasOwnProperty(schema, '$ref')) { 51 | return resolveReference(schema, rootSchema, formData) 52 | } else if (hasOwnProperty(schema, 'dependencies')) { 53 | const resolvedSchema = resolveDependencies(schema, rootSchema, formData) 54 | return retrieveSchema(resolvedSchema, rootSchema, formData) 55 | } 56 | // else if (hasOwnProperty(schema, "allOf")) { 57 | // return { 58 | // ...schema, 59 | // allOf: schema.allOf.map((allOfSubschema: any) => 60 | // retrieveSchema(allOfSubschema, rootSchema, formData) 61 | // ), 62 | // }; 63 | // } 64 | else { 65 | // No $ref or dependencies attribute found, returning the original schema. 66 | return schema 67 | } 68 | } 69 | 70 | export function retrieveSchema( 71 | schema: any, 72 | rootSchema = {}, 73 | formData = {} 74 | ): Schema { 75 | if (!isObject(schema)) { 76 | return {} as Schema 77 | } 78 | let resolvedSchema = resolveSchema(schema, rootSchema, formData) 79 | 80 | // TODO: allOf and additionalProperties not implemented 81 | // if ("allOf" in schema) { 82 | // try { 83 | // resolvedSchema = mergeAllOf({ 84 | // ...resolvedSchema, 85 | // allOf: resolvedSchema.allOf, 86 | // }); 87 | // } catch (e) { 88 | // console.warn("could not merge subschemas in allOf:\n" + e); 89 | // const { allOf, ...resolvedSchemaWithoutAllOf } = resolvedSchema; 90 | // return resolvedSchemaWithoutAllOf; 91 | // } 92 | // } 93 | // const hasAdditionalProperties = 94 | // resolvedSchema.hasOwnProperty("additionalProperties") && 95 | // resolvedSchema.additionalProperties !== false; 96 | // if (hasAdditionalProperties) { 97 | // return stubExistingAdditionalProperties( 98 | // resolvedSchema, 99 | // rootSchema, 100 | // formData 101 | // ); 102 | // } 103 | return resolvedSchema 104 | } 105 | 106 | // export function processSchema( 107 | // schema: any, 108 | // rootSchema: any = {}, 109 | // data: any = {} 110 | // ): Schema { 111 | // if (hasOwnProperty(schema, '$ref')) { 112 | // return resolveReference(schema, rootSchema, data) 113 | // } 114 | // if (hasOwnProperty(schema, 'dependencies')) { 115 | // const resolvedSchema = resolveSchema(schema) 116 | // } 117 | // } 118 | 119 | function resolveReference(schema: any, rootSchema: any, formData: any): Schema { 120 | // Retrieve the referenced schema definition. 121 | const $refSchema = findSchemaDefinition(schema.$ref, rootSchema) 122 | // Drop the $ref property of the source schema. 123 | const { $ref, ...localSchema } = schema 124 | // Update referenced schema definition with local schema properties. 125 | return retrieveSchema({ ...$refSchema, ...localSchema }, rootSchema, formData) 126 | } 127 | 128 | export function findSchemaDefinition($ref: string, rootSchema = {}): Schema { 129 | const origRef = $ref 130 | if ($ref.startsWith('#')) { 131 | // Decode URI fragment representation. 132 | $ref = decodeURIComponent($ref.substring(1)) 133 | } else { 134 | throw new Error(`Could not find a definition for ${origRef}.`) 135 | } 136 | const current = jsonpointer.get(rootSchema, $ref) 137 | if (current === undefined) { 138 | throw new Error(`Could not find a definition for ${origRef}.`) 139 | } 140 | if (hasOwnProperty(current, '$ref')) { 141 | // return { ...current, findSchemaDefinition(current.$ref, rootSchema) } ? 142 | return findSchemaDefinition(current.$ref, rootSchema) 143 | } 144 | return current 145 | } 146 | 147 | function resolveDependencies( 148 | schema: any, 149 | rootSchema: any, 150 | formData: any 151 | ): Schema { 152 | // Drop the dependencies from the source schema. 153 | let { dependencies = {}, ...resolvedSchema } = schema 154 | // if ("oneOf" in resolvedSchema) { 155 | // resolvedSchema = 156 | // resolvedSchema.oneOf[ 157 | // getMatchingOption(formData, resolvedSchema.oneOf, rootSchema) 158 | // ]; 159 | // } else if ("anyOf" in resolvedSchema) { 160 | // resolvedSchema = 161 | // resolvedSchema.anyOf[ 162 | // getMatchingOption(formData, resolvedSchema.anyOf, rootSchema) 163 | // ]; 164 | // } 165 | return processDependencies(dependencies, resolvedSchema, rootSchema, formData) 166 | } 167 | function processDependencies( 168 | dependencies: any, 169 | resolvedSchema: any, 170 | rootSchema: any, 171 | formData: any 172 | ): Schema { 173 | // Process dependencies updating the local schema properties as appropriate. 174 | for (const dependencyKey in dependencies) { 175 | // Skip this dependency if its trigger property is not present. 176 | if (formData[dependencyKey] === undefined) { 177 | continue 178 | } 179 | // Skip this dependency if it is not included in the schema (such as when dependencyKey is itself a hidden dependency.) 180 | if ( 181 | resolvedSchema.properties && 182 | !(dependencyKey in resolvedSchema.properties) 183 | ) { 184 | continue 185 | } 186 | const { 187 | [dependencyKey]: dependencyValue, 188 | ...remainingDependencies 189 | } = dependencies 190 | if (Array.isArray(dependencyValue)) { 191 | resolvedSchema = withDependentProperties(resolvedSchema, dependencyValue) 192 | } else if (isObject(dependencyValue)) { 193 | resolvedSchema = withDependentSchema( 194 | resolvedSchema, 195 | rootSchema, 196 | formData, 197 | dependencyKey, 198 | dependencyValue 199 | ) 200 | } 201 | return processDependencies( 202 | remainingDependencies, 203 | resolvedSchema, 204 | rootSchema, 205 | formData 206 | ) 207 | } 208 | return resolvedSchema 209 | } 210 | 211 | function withDependentProperties(schema: any, additionallyRequired: any) { 212 | if (!additionallyRequired) { 213 | return schema 214 | } 215 | const required = Array.isArray(schema.required) 216 | ? Array.from(new Set([...schema.required, ...additionallyRequired])) 217 | : additionallyRequired 218 | return { ...schema, required: required } 219 | } 220 | 221 | function withDependentSchema( 222 | schema: any, 223 | rootSchema: any, 224 | formData: any, 225 | dependencyKey: any, 226 | dependencyValue: any 227 | ) { 228 | // retrieveSchema 229 | let { oneOf, ...dependentSchema } = retrieveSchema( 230 | dependencyValue, 231 | rootSchema, 232 | formData 233 | ) 234 | schema = mergeSchemas(schema, dependentSchema) 235 | // Since it does not contain oneOf, we return the original schema. 236 | if (oneOf === undefined) { 237 | return schema 238 | } else if (!Array.isArray(oneOf)) { 239 | throw new Error(`invalid: it is some ${typeof oneOf} instead of an array`) 240 | } 241 | // Resolve $refs inside oneOf. 242 | const resolvedOneOf = oneOf.map((subschema) => 243 | hasOwnProperty(subschema, '$ref') 244 | ? resolveReference(subschema, rootSchema, formData) 245 | : subschema 246 | ) 247 | return withExactlyOneSubschema( 248 | schema, 249 | rootSchema, 250 | formData, 251 | dependencyKey, 252 | resolvedOneOf 253 | ) 254 | } 255 | 256 | function withExactlyOneSubschema( 257 | schema: any, 258 | rootSchema: any, 259 | formData: any, 260 | dependencyKey: any, 261 | oneOf: any 262 | ) { 263 | const validSubschemas = oneOf.filter((subschema: any) => { 264 | if (!subschema.properties) { 265 | return false 266 | } 267 | const { [dependencyKey]: conditionPropertySchema } = subschema.properties 268 | if (conditionPropertySchema) { 269 | const conditionSchema = { 270 | type: 'object', 271 | properties: { 272 | [dependencyKey]: conditionPropertySchema, 273 | }, 274 | } 275 | // TODO: validate formdata 276 | const { errors } = validateData(conditionSchema, formData) 277 | return !errors || errors.length === 0 278 | } 279 | }) 280 | if (validSubschemas.length !== 1) { 281 | console.warn( 282 | "ignoring oneOf in dependencies because there isn't exactly one subschema that is valid" 283 | ) 284 | return schema 285 | } 286 | // debugger 287 | const subschema = validSubschemas[0] 288 | const { 289 | [dependencyKey]: conditionPropertySchema, 290 | ...dependentSubschema 291 | } = subschema.properties 292 | const dependentSchema = { ...subschema, properties: dependentSubschema } 293 | return mergeSchemas( 294 | schema, 295 | // retrieveSchema 296 | retrieveSchema(dependentSchema, rootSchema, formData) 297 | ) 298 | } 299 | 300 | // Recursively merge deeply nested schemas. 301 | // The difference between mergeSchemas and mergeObjects 302 | // is that mergeSchemas only concats arrays for 303 | // values under the "required" keyword, and when it does, 304 | // it doesn't include duplicate values. 305 | export function mergeSchemas(obj1: any, obj2: any) { 306 | var acc = Object.assign({}, obj1) // Prevent mutation of source object. 307 | return Object.keys(obj2).reduce((acc, key) => { 308 | const left = obj1 ? obj1[key] : {}, 309 | right = obj2[key] 310 | if (obj1 && hasOwnProperty(obj1, key) && isObject(right)) { 311 | acc[key] = mergeSchemas(left, right) 312 | } else if ( 313 | obj1 && 314 | obj2 && 315 | (getSchemaType(obj1) === 'object' || getSchemaType(obj2) === 'object') && 316 | key === 'required' && 317 | Array.isArray(left) && 318 | Array.isArray(right) 319 | ) { 320 | // Don't include duplicate values when merging 321 | // "required" fields. 322 | acc[key] = union(left, right) 323 | } else { 324 | acc[key] = right 325 | } 326 | return acc 327 | }, acc) 328 | } 329 | 330 | export function getVJSFConfig( 331 | schema: Schema, 332 | uiSchema: VueJsonSchemaConfig | undefined 333 | ): VueJsonSchemaConfig { 334 | if (uiSchema) return uiSchema 335 | return schema.vjsf || {} 336 | } 337 | --------------------------------------------------------------------------------