@koumoul/vjsf 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/VJsfNoDeps.js CHANGED
@@ -16,6 +16,7 @@ import MarkdownEditor from './mixins/MarkdownEditor'
16
16
  import Tooltip from './mixins/Tooltip'
17
17
  import Validatable from './mixins/Validatable'
18
18
  import Dependent from './mixins/Dependent'
19
+ const expr = require('property-expr')
19
20
 
20
21
  const debugFlag = global.navigator && global.navigator.cookieEnabled && global.localStorage && global.localStorage.debug
21
22
 
@@ -42,6 +43,7 @@ export default {
42
43
  schema: { type: Object, required: true },
43
44
  value: { required: true },
44
45
  options: { type: Object },
46
+ optionsRoot: { type: Object },
45
47
  modelRoot: { type: [Object, Array, String, Number, Boolean] },
46
48
  modelKey: { type: [String, Number], default: 'root' },
47
49
  parentKey: { type: String, default: '' },
@@ -55,6 +57,9 @@ export default {
55
57
  }
56
58
  },
57
59
  computed: {
60
+ initialOptions() {
61
+ return this.fullKey === 'root' ? (this.options || {}) : this.optionsRoot
62
+ },
58
63
  fullOptions() {
59
64
  this.debug('compute fullOptions')
60
65
  const _global = (typeof window !== 'undefined' && window) || (typeof global !== 'undefined' && global) || {}
@@ -72,17 +77,32 @@ export default {
72
77
 
73
78
  fullOptions.httpLib = fullOptions.httpLib || this.axios || this.$http || this.$axios || _global.axios
74
79
 
80
+ // validator function generator is either given or prepared using ajv if present in the context
75
81
  if (!fullOptions.validator) {
82
+ const ajvLocalize = fullOptions.ajvLocalize || _global.ajvLocalize
83
+ const ajvAddFormats = fullOptions.ajvAddFormats || _global.ajvAddFormats
84
+ const localizeAjv = !!ajvLocalize && fullOptions.locale && ajvLocalize[fullOptions.locale]
76
85
  let ajv = fullOptions.ajv
77
86
  if (!ajv) {
78
- const Ajv = _global.Ajv || (_global.ajv7 && _global.ajv7.default) || (_global.ajv2019 && _global.ajv2019.default)
79
- if (Ajv) ajv = new Ajv()
87
+ const Ajv = fullOptions.Ajv || _global.Ajv || (_global.ajv7 && _global.ajv7.default) || (_global.ajv2019 && _global.ajv2019.default)
88
+ // TODO: use strict mode but remove our x-* annotations before
89
+ if (Ajv) {
90
+ ajv = new Ajv(localizeAjv ? { allErrors: true, messages: false, strict: false } : { strict: false })
91
+ if (ajvAddFormats) ajvAddFormats(ajv)
92
+ ajv.addFormat('hexcolor', /^#[0-9A-Fa-f]{6,8}$/)
93
+ }
80
94
  }
81
95
  if (ajv) {
82
96
  fullOptions.validator = (schema) => {
83
97
  const validate = ajv.compile(schema)
84
98
  return (model) => {
85
- if (!validate(model)) return validate.errors
99
+ const valid = validate(model)
100
+ if (!valid) {
101
+ if (localizeAjv) {
102
+ ajvLocalize[fullOptions.locale](validate.errors)
103
+ }
104
+ return ajv.errorsText(validate.errors, { dataVar: '' })
105
+ }
86
106
  }
87
107
  }
88
108
  }
@@ -133,7 +153,7 @@ export default {
133
153
  rules() {
134
154
  this.debug('compute rules')
135
155
  if (!this.fullSchema) return
136
- return getRules(this.fullSchema, this.fullOptions, this.required, this.isOneOfSelect)
156
+ return getRules(this.schema, this.fullSchema, this.fullOptions, this.required, this.isOneOfSelect)
137
157
  },
138
158
  disabled() {
139
159
  if (!this.fullSchema) return
@@ -254,7 +274,7 @@ export default {
254
274
  return
255
275
  }
256
276
 
257
- if (this.fullSchema['x-if'] && !this.parsePath(this.watchKey(this.fullSchema['x-if']))) {
277
+ if (this.fullSchema['x-if'] && !this.getFromExpr(this.fullSchema['x-if'])) {
258
278
  return
259
279
  }
260
280
 
@@ -309,15 +329,35 @@ export default {
309
329
  }
310
330
  return this._vjsf_cache[key].value
311
331
  },
312
- // inspired by https://github.com/vuejs/vue/blob/dev/src/core/util/lang.js#L29
313
- parsePath(path) {
314
- const segments = path.split('.')
315
- let obj = this
316
- for (let i = 0; i < segments.length; i++) {
317
- if (!obj) return
318
- obj = obj[segments[i]]
332
+ // used by all functionalities that require looking into the data or the context (x-if, fromData, etc)
333
+ getFromExpr(exp) {
334
+ const expData = {
335
+ modelRoot: this.modelRoot,
336
+ root: this.modelRoot,
337
+ model: this.value,
338
+ value: this.value,
339
+ context: this.options.context
340
+ }
341
+ this._vjsf_getters = this._vjsf_getters || {}
342
+
343
+ if (this.initialOptions.evalMethod === 'newFunction') {
344
+ // use a powerful meta-programming approach with "new Function", not safe if the schema is user-submitted
345
+ // eslint-disable-next-line no-new-func
346
+ this._vjsf_getters[exp] = this._vjsf_getters[exp] || new Function(...Object.keys(expData), `return ${exp}`)
347
+ return this._vjsf_getters[exp](...Object.values(expData))
348
+ } else {
349
+ exp = this.prefixExpr(exp)
350
+ // otherwise a safer but not as powerful deep getter method
351
+ this._vjsf_getters[exp] = this._vjsf_getters[exp] || expr.getter(exp, true)
352
+ return this._vjsf_getters[exp](expData)
319
353
  }
320
- return obj
354
+ },
355
+ // used by getFromExpr to support simpler expressions that look into the root model by default
356
+ prefixExpr(key) {
357
+ if (key.startsWith('context.') || key.startsWith('model.') || key.startsWith('value.') || key.startsWith('modelRoot.') || key.startsWith('root.')) return key
358
+ // no specific prefix found, we use modelRoot for retro-compatibility
359
+ if (this.modelRoot) return 'root.' + key
360
+ return 'model.' + key
321
361
  },
322
362
  renderPropSlots(h) {
323
363
  const slots = []
@@ -441,15 +481,6 @@ export default {
441
481
  value = this.value.filter(item => ![undefined, null].includes(item))
442
482
  }
443
483
  return this.input(this.fixProperties(value), true)
444
- },
445
- watchKey(key) {
446
- if (key.startsWith('context.')) return 'options.' + key
447
- if (key.startsWith('root.')) return key.replace('root.', 'modelRoot.')
448
- if (key.startsWith('modelRoot.')) return key
449
-
450
- // no specific prefix found, we use modelRoot for retro-compatibility
451
- if (this.modelRoot) return 'modelRoot.' + key
452
- return 'value.' + key
453
484
  }
454
485
  }
455
486
  }
@@ -7,3 +7,5 @@ const _global = (typeof window !== 'undefined' && window) || (typeof global !==
7
7
  _global.markdownit = require('markdown-it')
8
8
  Vue.component('draggable', Draggable)
9
9
  _global.Ajv = require('ajv')
10
+ _global.ajvLocalize = require('ajv-i18n')
11
+ _global.ajvAddFormats = require('ajv-formats')
@@ -104,6 +104,7 @@ export default {
104
104
  modelKey,
105
105
  parentKey: `${this.fullKey}.`,
106
106
  options: { ...this.fullOptions, hideReadOnly: false },
107
+ optionsRoot: this.initialOptions,
107
108
  sectionDepth: this.sectionDepth + 1,
108
109
  separateValidation: false
109
110
  },
@@ -171,6 +172,7 @@ export default {
171
172
  modelKey: `item-${i}`,
172
173
  parentKey: `${this.fullKey}.`,
173
174
  options,
175
+ optionsRoot: this.initialOptions,
174
176
  sectionDepth: this.sectionDepth + 1,
175
177
  separateValidation: this.fullOptions.editMode !== 'inline'
176
178
  },
@@ -197,6 +197,7 @@ export default {
197
197
  parentKey: `${this.fullKey}.`,
198
198
  required: forceRequired || !!(this.fullSchema.required && this.fullSchema.required.includes(schema.key)),
199
199
  options: { ...this.fullOptions, autofocus: this.fullOptions.autofocus && this.objectContainerChildrenCount === 1 },
200
+ optionsRoot: this.initialOptions,
200
201
  sectionDepth
201
202
  },
202
203
  class: this.fullOptions.childrenClass,
@@ -303,7 +304,7 @@ export default {
303
304
  value: this.currentOneOf,
304
305
  label: (this.subSchemasConstProp && this.subSchemasConstProp.title) || this.fullSchema.title,
305
306
  items: this.subSchemas
306
- .filter(item => !item['x-if'] || !!this.parsePath(this.watchKey(item['x-if'])))
307
+ .filter(item => !item['x-if'] || !!this.getFromExpr(item['x-if']))
307
308
  .filter(item => item.properties && item.properties[this.subSchemasConstProp.key]),
308
309
  required: this.subSchemasRequired,
309
310
  clearable: !this.subSchemasRequired,
@@ -324,6 +325,9 @@ export default {
324
325
  }
325
326
  }
326
327
  return [h('v-row', { class: `ma-0 ${this.fullOptions.objectContainerClass}` }, [
328
+ // display a local error only we don't already have an error displayed in the children
329
+ (this.localRuleError && !this.dedupChildrenWithValidatedErrors.length) && h('v-col', { props: { cols: 12 }, class: { 'px-0': true, 'error--text': true } }, this.localRuleError),
330
+ // display the description as block of text on top of section
327
331
  this.fullSchema.description && !this.subSchemasConstProp && h('v-col', { props: { cols: 12 }, class: { 'pa-0': true }, domProps: { innerHTML: this.htmlDescription } })]
328
332
  .concat(flatChildren).concat(sectionsChildren))
329
333
  ]
@@ -27,7 +27,8 @@ export default {
27
27
  oneOfSelect() {
28
28
  if (!this.fullSchema) return
29
29
  if (this.fullSchema.type === 'array' && this.fullSchema.items && ['string', 'integer', 'number'].includes(this.fullSchema.items.type) && (this.fullSchema.items.oneOf || this.fullSchema.items.anyOf)) return true
30
- if (['string', 'integer', 'number'].includes(this.fullSchema.type) && (this.fullSchema.oneOf || this.fullSchema.anyOf)) return true
30
+ if (['string', 'integer', 'number'].includes(this.fullSchema.type) && this.fullSchema.oneOf && this.fullSchema.oneOf[0] && this.fullSchema.oneOf[0].const !== undefined) return true
31
+ if (['string', 'integer', 'number'].includes(this.fullSchema.type) && this.fullSchema.anyOf && this.fullSchema.anyOf[0] && this.fullSchema.anyOf[0].const !== undefined) return true
31
32
  return false
32
33
  },
33
34
  examplesSelect() {
@@ -104,7 +105,7 @@ export default {
104
105
  const of = schema.anyOf || schema.oneOf
105
106
  this.openEndedSelect = schema.anyOf && !!schema.anyOf.find(item => !item.const && !item.enum)
106
107
  this.rawSelectItems = of
107
- .filter(item => !item['x-if'] || !!this.parsePath(this.watchKey(item['x-if'])))
108
+ .filter(item => !item['x-if'] || !!this.getFromExpr(item['x-if']))
108
109
  .filter(item => !!item.const || !!item.enum)
109
110
  .map(item => ({ ...item, [this.itemKey]: item.const || (item.enum && item.enum[0]), [this.itemTitle]: item.title }))
110
111
  }
@@ -119,14 +120,14 @@ export default {
119
120
  // Case of a select based on an array somewhere in the data
120
121
  if (this.fullSchema['x-fromData']) {
121
122
  this.openEndedSelect = this.customTag === 'v-combobox' || this.fullSchema['x-display'] === 'combobox'
122
- this.$watch(this.watchKey(this.fullSchema['x-fromData']), (val) => {
123
+ this.$watch(() => this.getFromExpr(this.fullSchema['x-fromData']), (val) => {
123
124
  this.rawSelectItems = val
124
125
  }, { immediate: true })
125
126
  }
126
127
  // Watch the dynamic parts of the URL used to fill the select field
127
128
  if (this.fromUrlKeys) {
128
129
  this.fromUrlKeys.forEach(key => {
129
- this.$watch(this.watchKey(key), (val) => {
130
+ this.$watch(() => this.getFromExpr(key), (val) => {
130
131
  this.fromUrlParams[key] = val
131
132
  this.fetchSelectItems()
132
133
  }, { immediate: true })
@@ -31,7 +31,10 @@ export default {
31
31
  const scopedSlots = {}
32
32
  let tooltipSlot = 'append-outer'
33
33
 
34
- if (this.fullSchema.type === 'string') {
34
+ const sep = this.fullSchema.separator || this.fullSchema['x-separator']
35
+
36
+ // simple string
37
+ if (this.fullSchema.type === 'string' && !sep) {
35
38
  if (this.display === 'textarea' || (this.fullSchema.maxLength && this.fullSchema.maxLength > 1000 && this.display !== 'single-line')) {
36
39
  tag = 'v-textarea'
37
40
  Object.assign(props, this.fullOptions.textareaProps)
@@ -43,6 +46,34 @@ export default {
43
46
  }
44
47
  }
45
48
 
49
+ // multivalued string with separator
50
+ if (this.fullSchema.type === 'string' && sep) {
51
+ tag = 'v-combobox'
52
+ Object.assign(props, this.fullOptions.comboboxProps)
53
+ props.chips = true
54
+ props.multiple = true
55
+ props.appendIcon = ''
56
+ props.type = 'string'
57
+ props.validateOnBlur = true
58
+ props.value = this.value ? this.value.split(sep) : []
59
+
60
+ on.input = value => this.input(value.join(sep))
61
+
62
+ scopedSlots.selection = slotProps => {
63
+ const onClose = () => {
64
+ const value = this.value ? this.value.split(sep) : []
65
+ value.splice(slotProps.index, 1)
66
+ this.input(value.join(sep))
67
+ this.change()
68
+ }
69
+ return h('v-chip', {
70
+ props: { close: true },
71
+ on: { 'click:close': onClose }
72
+ }, slotProps.item)
73
+ }
74
+ }
75
+
76
+ // simple boolean
46
77
  if (['number', 'integer'].includes(this.fullSchema.type)) {
47
78
  if (this.display === 'slider') {
48
79
  tag = 'v-slider'
@@ -83,7 +114,7 @@ export default {
83
114
  props.appendIcon = ''
84
115
  props.type = 'string'
85
116
  props.validateOnBlur = true
86
- const itemRules = getRules(schemaUtils.prepareFullSchema(this.fullSchema.items, null, this.fullOptions), this.fullOptions)
117
+ const itemRules = getRules(this.fullSchema.items, schemaUtils.prepareFullSchema(this.fullSchema.items, null, this.fullOptions), this.fullOptions)
87
118
  props.rules = props.rules.concat([(values) => {
88
119
  const valuesMessages = values.map(value => {
89
120
  const brokenRule = itemRules.find(rule => {
@@ -34,15 +34,23 @@ export default {
34
34
  },
35
35
  hasError() {
36
36
  this.debug('compute hasError')
37
- return !!this.inputs.find(input => input.hasError) || !!this.containerError
37
+ return !!this.inputs.find(input => input.hasError) || !!this.localRuleError
38
38
  },
39
- hasValidatedChildError() {
40
- this.debug('compute hasValidatedChildError')
41
- return !!this.inputs.find(input => input.hasValidatedChildError || (input.hasError && (input.validated || input.shouldValidate)))
39
+ hasValidatedError() {
40
+ this.debug('compute hasValidatedError')
41
+ return !!this.inputs.find(input => input.hasValidatedError || (input.hasError && (input.validated || input.shouldValidate))) ||
42
+ (this.localRuleError && (this.validated || this.shouldValidate))
42
43
  },
43
44
  childrenWithValidatedErrors() {
44
45
  this.debug('compute childrenWithValidatedErrors')
45
- return Object.keys(this.childrenInputs).filter(key => !!this.childrenInputs[key].hasValidatedChildError)
46
+ return Object.keys(this.childrenInputs).filter(key => !!this.childrenInputs[key].hasValidatedError)
47
+ },
48
+ localRuleError() {
49
+ if (!this.validated || !this.rules || !this.rules.length) return false
50
+ const brokenRule = this.rules.find(rule => {
51
+ return typeof rule(this.value) === 'string'
52
+ })
53
+ return brokenRule && brokenRule(this.value)
46
54
  }
47
55
  },
48
56
  watch: {
@@ -51,7 +51,9 @@ export const defaultOptions = {
51
51
  autofocus: false,
52
52
  httpOptions: {},
53
53
  selectAll: false,
54
- autoFixArrayItems: true
54
+ autoFixArrayItems: true,
55
+ useValidator: false,
56
+ evalMethod: 'propertyExpr'
55
57
  }
56
58
 
57
59
  export const localizedMessages = {
@@ -1,6 +1,6 @@
1
1
  import { deepEqual } from 'fast-equals'
2
2
 
3
- export const getRules = (fullSchema, options, required, isOneOfSelect) => {
3
+ export const getRules = (schema, fullSchema, options, required, isOneOfSelect) => {
4
4
  const rules = []
5
5
  if (required) {
6
6
  rules.push((val) => (val !== undefined && val !== null && val !== '') || options.messages.required)
@@ -54,6 +54,11 @@ export const getRules = (fullSchema, options, required, isOneOfSelect) => {
54
54
  rules.push((val) => (val === undefined || val === null || !val.find(valItem => !fullSchema.items.oneOf.find(item => deepEqual(item.const, valItem)))) || '')
55
55
  }
56
56
 
57
+ // ajv validation
58
+ if (options.validator && options.useValidator) {
59
+ rules.push(getAjvRule(schema, options.validator))
60
+ }
61
+
57
62
  const customRules = (fullSchema['x-rules'] || []).map(rule => {
58
63
  if (typeof rule === 'string') {
59
64
  const ruleFunction = options.rules && options.rules[rule]
@@ -65,3 +70,12 @@ export const getRules = (fullSchema, options, required, isOneOfSelect) => {
65
70
  }).filter(rule => !!rule)
66
71
  return rules.concat(customRules)
67
72
  }
73
+
74
+ const getAjvRule = (schema, validator, locale) => {
75
+ const validate = validator(schema)
76
+ return (val) => {
77
+ if (val === null || val === undefined) return true
78
+ const error = validate(val)
79
+ return !error || error
80
+ }
81
+ }
@@ -30,6 +30,13 @@ schemaUtils.prepareFullSchema = (schema, value, options) => {
30
30
 
31
31
  if (!fullSchema.type && fullSchema.properties) fullSchema.type = 'object'
32
32
 
33
+ // detect type from combination info
34
+ if (!fullSchema.type) {
35
+ const combination = fullSchema.anyOf || fullSchema.oneOf || fullSchema.allOf
36
+ const typedCombinationItem = combination && combination.find(c => !!c.type)
37
+ if (typedCombinationItem) fullSchema.type = typedCombinationItem.type
38
+ }
39
+
33
40
  // manage null type in an array, for example ['string', 'null']
34
41
  if (Array.isArray(fullSchema.type)) {
35
42
  fullSchema.nullable = fullSchema.type.includes('null')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koumoul/vjsf",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Generate forms for the vuetify UI library (vuejs) based on annotated JSON schemas.",
5
5
  "main": "dist/main.js",
6
6
  "scripts": {
@@ -47,14 +47,16 @@
47
47
  "homepage": "https://github.com/koumoul-dev/vuetify-jsonschema-form#readme",
48
48
  "dependencies": {
49
49
  "@mdi/js": "^5.5.55",
50
- "ajv": "^6.12.0",
50
+ "ajv": "^8.6.2",
51
+ "ajv-formats": "^2.1.1",
52
+ "ajv-i18n": "^4.1.0",
51
53
  "debounce": "^1.2.0",
52
54
  "fast-copy": "^2.1.1",
53
55
  "fast-equals": "^2.0.0",
54
56
  "markdown-it": "^8.4.2",
55
57
  "match-all": "^1.2.5",
56
58
  "object-hash": "^2.1.1",
57
- "property-expr": "^1.5.1",
59
+ "property-expr": "^2.0.4",
58
60
  "vuedraggable": "^2.24.3"
59
61
  },
60
62
  "devDependencies": {