@koumoul/vjsf 2.3.1 → 2.5.3

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,8 +16,9 @@ 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
- const debugFlag = navigator.cookieEnabled ? global.localStorage && global.localStorage.debug : null
21
+ const debugFlag = global.navigator && global.navigator.cookieEnabled && global.localStorage && global.localStorage.debug
21
22
 
22
23
  const mountingIncs = {}
23
24
 
@@ -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 = []
@@ -329,28 +369,32 @@ export default {
329
369
  })
330
370
  return slots
331
371
  },
332
- change() {
372
+ change(fastForward = true) {
333
373
  if (!this.changed) return
334
374
  // let input events be interpreted before sending this.value in change event
335
375
  this.$nextTick(() => {
336
376
  this.updateSelectItems()
377
+ if (fastForward) this.fastForwardEvent('change-child', { fullKey: this.fullKey, value: this.value })
337
378
  this.$emit('change', this.value)
338
379
  this.changed = false
339
380
  })
340
381
  },
341
- input(value, initial = false) {
382
+ input(value, initial = false, fastForward = true) {
342
383
  // this.debug('input', JSON.stringify([this.value, value]))
343
384
  if (value === null || value === undefined || value === '') {
344
385
  if (this.fullSchema.nullable) {
345
386
  if (this.value !== null) {
346
387
  this.changed = true
388
+ if (fastForward) this.fastForwardEvent('input-child', { fullKey: this.fullKey, value: null, oldValue: this.value })
347
389
  this.$emit('input', null)
348
390
  } else if (initial) {
391
+ if (fastForward) this.fastForwardEvent('input-child', { fullKey: this.fullKey, value: null, oldValue: this.value })
349
392
  this.$emit('input', null)
350
393
  }
351
394
  } else {
352
395
  if (this.value !== undefined) {
353
396
  this.changed = true
397
+ if (fastForward) this.fastForwardEvent('input-child', { fullKey: this.fullKey, value: undefined, oldValue: this.value })
354
398
  this.$emit('input', undefined)
355
399
  }
356
400
  }
@@ -358,6 +402,7 @@ export default {
358
402
  if (!deepEqual(value, this.value)) {
359
403
  this.changed = true
360
404
  // console.log(this.fullKey, isCyclic(value), value)
405
+ if (fastForward) this.fastForwardEvent('input-child', { fullKey: this.fullKey, value, oldValue: this.value })
361
406
  this.$emit('input', value)
362
407
  }
363
408
  }
@@ -436,15 +481,6 @@ export default {
436
481
  value = this.value.filter(item => ![undefined, null].includes(item))
437
482
  }
438
483
  return this.input(this.fixProperties(value), true)
439
- },
440
- watchKey(key) {
441
- if (key.startsWith('context.')) return 'options.' + key
442
- if (key.startsWith('root.')) return key.replace('root.', 'modelRoot.')
443
- if (key.startsWith('modelRoot.')) return key
444
-
445
- // no specific prefix found, we use modelRoot for retro-compatibility
446
- if (this.modelRoot) return 'modelRoot.' + key
447
- return 'value.' + key
448
484
  }
449
485
  }
450
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
  },
@@ -56,7 +56,7 @@ export default {
56
56
  this.$nextTick(() => {
57
57
  this.showCurrentOneOf = true
58
58
  if (!this.currentOneOf) this.$set(this.subModels, 'currentOneOf', {})
59
- else this.input(this.fixProperties(this.value))
59
+ else this.input(this.fixProperties(this.value), false, false)
60
60
  if (this.triggerChangeCurrentOneOf) {
61
61
  this.$nextTick(() => {
62
62
  this.triggerChangeCurrentOneOf = false
@@ -68,7 +68,7 @@ export default {
68
68
  subModels: {
69
69
  handler() {
70
70
  this.debug('watched subModels')
71
- this.input(this.fixProperties(this.value))
71
+ this.input(this.fixProperties(this.value), false, false)
72
72
  },
73
73
  deep: true
74
74
  }
@@ -185,7 +185,7 @@ export default {
185
185
  if (schema.default !== undefined) value = copy(schema.default)
186
186
  if (value !== undefined && value !== null) {
187
187
  this.$set(wrapper, modelKey, value)
188
- if (!subModelKey) this.input(wrapper)
188
+ if (!subModelKey) this.input(wrapper, false, false)
189
189
  }
190
190
  }
191
191
  return h('v-jsf', {
@@ -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,
@@ -216,9 +217,9 @@ export default {
216
217
  } else {
217
218
  this.$set(wrapper, modelKey, v)
218
219
  }
219
- if (!subModelKey) this.input(wrapper)
220
+ if (!subModelKey) this.input(wrapper, false, false)
220
221
  },
221
- change: v => this.change()
222
+ change: v => this.change(false)
222
223
  }
223
224
  }, this.childSlots(h, schema.key))
224
225
  },
@@ -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 })
@@ -83,7 +83,7 @@ export default {
83
83
  props.appendIcon = ''
84
84
  props.type = 'string'
85
85
  props.validateOnBlur = true
86
- const itemRules = getRules(schemaUtils.prepareFullSchema(this.fullSchema.items, null, this.fullOptions), this.fullOptions)
86
+ const itemRules = getRules(this.fullSchema.items, schemaUtils.prepareFullSchema(this.fullSchema.items, null, this.fullOptions), this.fullOptions)
87
87
  props.rules = props.rules.concat([(values) => {
88
88
  const valuesMessages = values.map(value => {
89
89
  const brokenRule = itemRules.find(rule => {
@@ -8,7 +8,8 @@ export default {
8
8
  return {
9
9
  form: {
10
10
  register: this.register,
11
- unregister: this.unregister
11
+ unregister: this.unregister,
12
+ fastForwardEvent: this.fastForwardEvent
12
13
  }
13
14
  }
14
15
  },
@@ -33,15 +34,23 @@ export default {
33
34
  },
34
35
  hasError() {
35
36
  this.debug('compute hasError')
36
- return !!this.inputs.find(input => input.hasError) || !!this.containerError
37
+ return !!this.inputs.find(input => input.hasError) || !!this.localRuleError
37
38
  },
38
- hasValidatedChildError() {
39
- this.debug('compute hasValidatedChildError')
40
- 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))
41
43
  },
42
44
  childrenWithValidatedErrors() {
43
45
  this.debug('compute childrenWithValidatedErrors')
44
- 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)
45
54
  }
46
55
  },
47
56
  watch: {
@@ -97,6 +106,11 @@ export default {
97
106
  if (initialValidation === 'defined' && this.initiallyDefined && !this.isObjectContainer) {
98
107
  this.validate(true)
99
108
  }
109
+ },
110
+ fastForwardEvent(eventName, data) {
111
+ // emit an event to the top of the vjsf instances tree exactly as it is
112
+ if (this.fullKey === 'root') this.$emit(eventName, data)
113
+ else this.form.fastForwardEvent(eventName, data)
100
114
  }
101
115
  }
102
116
  }
@@ -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')
@@ -44,6 +51,12 @@ schemaUtils.prepareFullSchema = (schema, value, options) => {
44
51
  })
45
52
  }
46
53
 
54
+ // enum with a single item can be used as another way to express const
55
+ if (fullSchema.enum && fullSchema.enum.length === 1) {
56
+ fullSchema.const = fullSchema.enum[0]
57
+ delete fullSchema.enum
58
+ }
59
+
47
60
  if (fullSchema.type !== 'object') return fullSchema
48
61
 
49
62
  // Properties as array for easier loops
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koumoul/vjsf",
3
- "version": "2.3.1",
3
+ "version": "2.5.3",
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": {