@koumoul/vjsf 2.11.1 → 2.12.0-beta.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.
@@ -0,0 +1,8 @@
1
+ /** @license URI.js v4.4.1 (c) 2011 Gary Court. License: http://github.com/garycourt/uri-js */
2
+
3
+ /**!
4
+ * Sortable 1.10.2
5
+ * @author RubaXa <trash@rubaxa.org>
6
+ * @author owenm <owen23355@gmail.com>
7
+ * @license MIT
8
+ */
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
+ import exprEvalParser from './utils/expr-eval-parser'
19
20
  const expr = require('property-expr')
20
21
 
21
22
  const debugExpr = require('debug')('vjsf:expr')
@@ -260,10 +261,7 @@ export default {
260
261
  this.$watch(watcher, () => {
261
262
  const fullSchema = schemaUtils.prepareFullSchema(this.resolvedSchema, this.value, this.fullOptions)
262
263
  // kinda hackish but prevents triggering large rendering chains when nothing meaningful changes
263
- if (deepEqual(fullSchema, this.fullSchema)) {
264
- } else {
265
- this.fullSchema = fullSchema
266
- }
264
+ if (!deepEqual(fullSchema, this.fullSchema)) this.fullSchema = fullSchema
267
265
  }, {
268
266
  immediate: true,
269
267
  deep: true
@@ -280,7 +278,6 @@ export default {
280
278
  if (!this.fullSchema || this.fullSchema.const !== undefined || this.display === 'hidden' || (this.fullSchema.readOnly && this.fullOptions.hideReadOnly)) {
281
279
  return
282
280
  }
283
-
284
281
  if (this.fullSchema['x-if'] && !this.getFromExpr(this.fullSchema['x-if'])) {
285
282
  return
286
283
  }
@@ -343,6 +340,7 @@ export default {
343
340
 
344
341
  this._vjsf_getters = this._vjsf_getters || {}
345
342
 
343
+ // newFunction can only be defined on main options (not x-options to prevent injection)
346
344
  if (this.initialOptions.evalMethod === 'newFunction') {
347
345
  debugExpr(`evaluate expression "${exp}" with newFunction method`, expData)
348
346
  // use a powerful meta-programming approach with "new Function", not safe if the schema is user-submitted
@@ -351,6 +349,12 @@ export default {
351
349
  const result = this._vjsf_getters[exp](...Object.values(expData))
352
350
  debugExpr(`result`, result)
353
351
  return result
352
+ } else if (this.fullOptions.evalMethod === 'evalExpr') {
353
+ debugExpr(`evaluate expression "${exp}" with exprEval method`, expData)
354
+ // TODO: conserve compiled expression for reuse ?
355
+ const result = exprEvalParser.evaluate(exp, expData)
356
+ debugExpr(result)
357
+ return result
354
358
  } else {
355
359
  exp = this.prefixExpr(exp)
356
360
  debugExpr(`evaluate expression "${exp}" with propertyExpr method`, expData)
@@ -475,9 +479,15 @@ export default {
475
479
  return this.input(undefined)
476
480
  }
477
481
  let value = this.value
482
+
483
+ // create empty objects
478
484
  if (this.fullSchema.type === 'object' && [undefined, null].includes(value) && !this.isSelectProp) {
479
485
  value = {}
480
486
  }
487
+ // in the special case of objects based on select remove empty objects
488
+ if (this.fullSchema.type === 'object' && this.isSelectProp && value && Object.keys(value).length === 0) {
489
+ value = undefined
490
+ }
481
491
 
482
492
  // const always wins
483
493
  if (this.fullSchema.const !== undefined) value = this.fullSchema.const
@@ -5,7 +5,7 @@ import Vue from 'vue'
5
5
  import Draggable from 'vuedraggable'
6
6
  const _global = (typeof window !== 'undefined' && window) || (typeof global !== 'undefined' && global) || {}
7
7
  _global.markdownit = require('markdown-it')
8
- Vue.component('draggable', Draggable)
8
+ Vue.component('Draggable', Draggable)
9
9
  _global.Ajv = require('ajv')
10
10
  _global.ajvLocalize = require('ajv-i18n')
11
11
  _global.ajvAddFormats = require('ajv-formats')
@@ -1,4 +1,5 @@
1
1
  import copy from 'fast-copy'
2
+ import selectUtils from '../utils/select'
2
3
 
3
4
  export default {
4
5
  data() {
@@ -255,7 +256,7 @@ export default {
255
256
  const titleClass = 'py-2 pr-2 ' + this.fullOptions.arrayItemsTitlesClasses[this.sectionDepth] || this.fullOptions.arrayItemsTitlesClasses[this.fullOptions.arrayItemsTitlesClasses.length - 1]
256
257
 
257
258
  let cardChildren = [
258
- h('v-card-title', { props: { primaryTitle: true }, class: titleClass }, [this.itemTitle && item[this.itemTitle], h('v-spacer'), actions]),
259
+ h('v-card-title', { props: { primaryTitle: true }, class: titleClass }, [selectUtils.getObjectTitle(item, this.itemTitle, this.fullSchema), h('v-spacer'), actions]),
259
260
  h('v-card-text', [itemChild])
260
261
  ]
261
262
 
@@ -148,7 +148,7 @@ export default {
148
148
  if (childProp.componentInstance.validate(true)) {
149
149
  this.currentStep += 1
150
150
  }
151
- } } }, 'continue')])
151
+ } } }, this.fullOptions.messages.stepperContinue)])
152
152
  ])]
153
153
  )
154
154
  ]
@@ -312,7 +312,7 @@ export default {
312
312
  this.triggerChangeCurrentOneOf = true
313
313
  }
314
314
  }
315
- flatChildren.push(h('v-select', { props, on }, [this.renderTooltip(h, 'append-outer')]))
315
+ flatChildren.push(h('v-select', { props, on, style: (this.subSchemasConstProp && this.subSchemasConstProp['x-style']) || '' }, [this.renderTooltip(h, 'append-outer')]))
316
316
  if (this.currentOneOf && this.showCurrentOneOf) {
317
317
  flatChildren.push(this.renderChildProp(h, { ...this.currentOneOf, type: 'object', title: null }, 'currentOneOf', this.sectionDepth + 1))
318
318
  }
@@ -20,16 +20,14 @@ export default {
20
20
  if (this.fullSchema.type === 'array' && this.fullSchema.items && this.fullSchema.items.enum) return true
21
21
  if (this.oneOfSelect) return true
22
22
  if (this.examplesSelect) return true
23
- if (this.fromUrl) return true
23
+ // WARNING: it is important not to use this.fromUrl here
24
+ // because it is empty at first when fromUrlParams are not ready yet and it creates initialization problems
25
+ if (this.fullSchema['x-fromUrl']) return true
24
26
  if (this.fromData) return true
25
27
  return false
26
28
  },
27
29
  oneOfSelect() {
28
- if (!this.fullSchema) return
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.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
32
- return false
30
+ return selectUtils.isOneOfSelect(this.fullSchema)
33
31
  },
34
32
  examplesSelect() {
35
33
  if (!this.fullSchema) return
@@ -185,13 +183,17 @@ export default {
185
183
  },
186
184
  renderSelectIcon(h, item) {
187
185
  if (!this.itemIcon) return
188
- const itemIcon = item[this.itemIcon]
186
+ let itemIcon = item[this.itemIcon]
189
187
  if (!itemIcon) return
190
- let iconChild = h('v-icon', null, itemIcon)
188
+ let iconChild
191
189
  if (itemIcon.startsWith('http://') || itemIcon.startsWith('https://')) {
192
190
  iconChild = h('img', { domProps: { src: itemIcon }, style: 'height:100%;width:100%;' })
193
191
  } else if (itemIcon.startsWith('<?xml') || itemIcon.startsWith('<svg')) {
194
192
  iconChild = h('div', { domProps: { innerHTML: itemIcon } })
193
+ } else {
194
+ const prefix = this.fullOptions.iconfont + '-'
195
+ if (this.fullOptions.iconfont && !itemIcon.startsWith(prefix)) itemIcon = prefix + itemIcon
196
+ iconChild = h('v-icon', null, itemIcon)
195
197
  }
196
198
  return h('v-avatar', { props: { tile: true, size: 20 }, class: 'mr-2' }, [iconChild])
197
199
  },
@@ -0,0 +1,21 @@
1
+ import { Parser } from 'expr-eval'
2
+
3
+ export default new Parser({
4
+ // Useless in our use case
5
+ // mathematical
6
+ add: false,
7
+ concatenate: false,
8
+ divide: false,
9
+ factorial: false,
10
+ multiply: false,
11
+ power: false,
12
+ remainer: false,
13
+ substract: false,
14
+ // programatic
15
+ assignement: false,
16
+
17
+ // logical
18
+ logical: true,
19
+ comparison: true,
20
+ in: true
21
+ })
@@ -90,7 +90,8 @@ export const localizedMessages = {
90
90
  mdeGuide: 'Documentation de la syntaxe',
91
91
  undo: 'Undo',
92
92
  redo: 'Redo',
93
- selectAll: 'Select all'
93
+ selectAll: 'Select all',
94
+ stepperContinue: 'continue'
94
95
  },
95
96
  fr: {
96
97
  required: 'Cette information est obligatoire',
@@ -124,7 +125,8 @@ export const localizedMessages = {
124
125
  mdeGuide: 'Syntax documentation',
125
126
  undo: 'Défaire',
126
127
  redo: 'Refaire',
127
- selectAll: 'Tout sélectionner'
128
+ selectAll: 'Tout sélectionner',
129
+ stepperContinue: 'continuer'
128
130
  },
129
131
  es: {
130
132
  required: 'Esta información es requerida',
@@ -139,7 +141,8 @@ export const localizedMessages = {
139
141
  minItems: 'Al menos {minItems} articulos',
140
142
  maxItems: 'Hasta {maxItems} articulos',
141
143
  pattern: 'El formato esperado no se respeta',
142
- selectAll: 'Seleccionar todo'
144
+ selectAll: 'Seleccionar todo',
145
+ stepperContinue: 'continuar'
143
146
  },
144
147
  de: {
145
148
  required: 'Diese Informationen sind erforderlich',
@@ -154,7 +157,8 @@ export const localizedMessages = {
154
157
  minItems: 'Mindestens {minItems} Elemente',
155
158
  maxItems: 'Bis zu {maxItems} Artikel',
156
159
  pattern: 'Das erwartete Format wird nicht eingehalten',
157
- selectAll: 'Wählen Sie Alle'
160
+ selectAll: 'Wählen Sie Alle',
161
+ stepperContinue: 'weiter'
158
162
  },
159
163
  ar: {
160
164
  required: 'هذه المعلومة مطلوبة',
@@ -169,7 +173,8 @@ export const localizedMessages = {
169
173
  minItems: 'قطع {minItems} لا يمكن اختيار أقل من ',
170
174
  maxItems: 'قطع {maxItems} لا يمكن اختيار أكثر من ',
171
175
  pattern: 'لا يوجد تشابه مع النموذج المطلوب',
172
- selectAll: 'اختر الكل'
176
+ selectAll: 'اختر الكل',
177
+ stepperContinue: 'استمر'
173
178
  },
174
179
  tr: {
175
180
  required: 'Zorunlu alan',
@@ -184,7 +189,8 @@ export const localizedMessages = {
184
189
  minItems: 'En az seçenek sayısı {minItems}',
185
190
  maxItems: 'En çok seçenek sayısı {maxItems}',
186
191
  pattern: 'İstenilen paten tutmuyor',
187
- selectAll: 'Hepsini seç'
192
+ selectAll: 'Hepsini seç',
193
+ stepperContinue: 'devam et'
188
194
  },
189
195
  nl: {
190
196
  required: 'Deze informatie is vereist',
@@ -199,7 +205,8 @@ export const localizedMessages = {
199
205
  minItems: 'Minimaal {minItems} antwoorden',
200
206
  maxItems: 'Maximaal {maxItems} antwoorden',
201
207
  pattern: 'Invoer voldoet niet aan verwachte patroon',
202
- selectAll: 'Selecteer alles'
208
+ selectAll: 'Selecteer alles',
209
+ stepperContinue: 'doorgaan'
203
210
  }
204
211
  }
205
212
 
@@ -106,3 +106,34 @@ selectUtils.fillList = (fullSchema, value, selectItems, itemKey) => {
106
106
  })
107
107
  return value.filter(item => !!item)
108
108
  }
109
+
110
+ selectUtils.isOneOfSelect = (fullSchema) => {
111
+ if (!fullSchema) return
112
+ if (fullSchema.type === 'array' && fullSchema.items && ['string', 'integer', 'number'].includes(fullSchema.items.type) && (fullSchema.items.oneOf || fullSchema.items.anyOf)) return true
113
+ if (['string', 'integer', 'number'].includes(fullSchema.type) && fullSchema.oneOf && fullSchema.oneOf[0] && fullSchema.oneOf[0].const !== undefined) return true
114
+ if (['string', 'integer', 'number'].includes(fullSchema.type) && fullSchema.anyOf && fullSchema.anyOf[0] && fullSchema.anyOf[0].const !== undefined) return true
115
+ return false
116
+ }
117
+
118
+ // get an object title if x-itemTitles is defined, handle direct values or values matched to a label through a oneOf/anyof
119
+ selectUtils.getObjectTitle = (item, itemTitle, fullSchema) => {
120
+ if (!itemTitle) return null
121
+ const titlePropertySchema = fullSchema.items && fullSchema.items.properties && fullSchema.items.properties[itemTitle]
122
+ if (titlePropertySchema) {
123
+ const oneOfSelect = selectUtils.isOneOfSelect(titlePropertySchema)
124
+ if (oneOfSelect) {
125
+ const of = titlePropertySchema.anyOf || titlePropertySchema.oneOf
126
+ const ofItem = of.find(ofItem => ofItem.const === item[itemTitle] || (ofItem.enum && ofItem.enum[0] === item[itemTitle]))
127
+ if (ofItem) return ofItem.title
128
+ }
129
+ }
130
+
131
+ if (fullSchema.items && (fullSchema.items.oneOf || fullSchema.items.anyOf)) {
132
+ const of = fullSchema.items.oneOf || fullSchema.items.anyOf
133
+ const props = of[0].properties
134
+ const key = Object.keys(props).find(p => !!props[p].const)
135
+ const ofItem = of.find(ofItem => ofItem.properties[key].const === item[itemTitle])
136
+ if (ofItem) return ofItem.title
137
+ }
138
+ return item[itemTitle]
139
+ }
package/package.json CHANGED
@@ -1,21 +1,20 @@
1
1
  {
2
2
  "name": "@koumoul/vjsf",
3
- "version": "2.11.1",
3
+ "version": "2.12.0-beta.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": {
7
- "lint": "eslint --ext js,vue .",
8
- "lint-fix": "eslint --fix --ext js,vue .",
7
+ "lint": "eslint --ext js .",
8
+ "lint-fix": "eslint --fix --ext js .",
9
9
  "build": "rm -rf dist && webpack && cp lib/VJsf.css dist/main.css",
10
10
  "prepare": "npm run lint && npm test && npm run build && npm run doc-build",
11
11
  "postpublish": "gh-pages-multi deploy -v -s doc/dist -t latest",
12
12
  "test": "jest",
13
+ "test-watch": "jest --watch",
13
14
  "test-update": "jest --updateSnapshot",
14
- "dev": "nuxt doc --port 3133",
15
- "doc-dev": "nuxt doc --port 3133",
16
- "doc-start": "nuxt build doc && nuxt start doc",
17
- "doc-build": "BASE=/vuetify-jsonschema-form/latest/ nuxt generate doc",
18
- "doc-master": "BASE=/vuetify-jsonschema-form/master/ nuxt generate doc && gh-pages-multi deploy -v -s doc/dist -t master"
15
+ "doc-build": "(cd doc && BASE=/vuetify-jsonschema-form/latest/ nuxt generate)",
16
+ "doc-master": "(cd doc && BASE=/vuetify-jsonschema-form/master/ nuxt generate) && gh-pages-multi deploy -v -s doc/dist -t maste",
17
+ "analyze": "webpack --profile --json > dist/stats.json && webpack-bundle-analyzer dist/stats.json"
19
18
  },
20
19
  "jest": {
21
20
  "moduleFileExtensions": [
@@ -33,7 +32,8 @@
33
32
  ],
34
33
  "snapshotSerializers": [
35
34
  "jest-serializer-vue"
36
- ]
35
+ ],
36
+ "testEnvironment": "jsdom"
37
37
  },
38
38
  "repository": {
39
39
  "type": "git",
@@ -46,36 +46,36 @@
46
46
  },
47
47
  "homepage": "https://github.com/koumoul-dev/vuetify-jsonschema-form#readme",
48
48
  "dependencies": {
49
- "@mdi/js": "^6.5.95",
50
- "ajv": "^8.9.0",
51
- "ajv-formats": "^2.1.1",
52
- "ajv-i18n": "^4.2.0",
53
49
  "debounce": "^1.2.1",
54
50
  "debug": "^4.3.3",
55
51
  "fast-copy": "^2.1.1",
56
52
  "fast-equals": "^2.0.4",
53
+ "match-all": "^1.2.6"
54
+ },
55
+ "optionalDependencies": {
56
+ "@mdi/font": "^6.5.95",
57
+ "@mdi/js": "^6.5.95",
58
+ "ajv": "^8.11.0",
59
+ "ajv-formats": "^2.1.1",
60
+ "ajv-i18n": "^4.2.0",
61
+ "expr-eval": "^2.0.2",
57
62
  "markdown-it": "^12.3.2",
58
- "match-all": "^1.2.6",
59
- "object-hash": "^2.2.0",
60
63
  "property-expr": "^2.0.5",
61
64
  "vuedraggable": "^2.24.3"
62
65
  },
66
+ "peerDependencies": {
67
+ "vuetify": "^2.0.0"
68
+ },
63
69
  "devDependencies": {
64
70
  "@babel/core": "^7.16.12",
71
+ "@babel/plugin-transform-runtime": "^7.17.0",
65
72
  "@babel/preset-env": "^7.16.11",
66
73
  "@koumoul/data-fair-search-widget": "^0.3.0",
67
74
  "@koumoul/gh-pages-multi": "^0.6.0",
68
- "@mdi/font": "^6.5.95",
69
- "@nuxtjs/axios": "^5.13.6",
70
- "@nuxtjs/vuetify": "^1.12.3",
71
- "@toast-ui/vue-editor": "^2.5.1",
72
75
  "@vue/test-utils": "^1.3.0",
73
- "axios": "^0.25.0",
74
- "babel-core": "^7.0.0-bridge.0",
75
76
  "babel-eslint": "^10.1.0",
76
- "babel-loader": "^8.2.3",
77
- "brace": "^0.11.1",
78
- "easymde": "^2.16.1",
77
+ "babel-jest": "^27.5.1",
78
+ "babel-loader": "^8.2.4",
79
79
  "eslint": "^6.1.0",
80
80
  "eslint-config-standard": "^13.0.1",
81
81
  "eslint-plugin-import": "^2.18.2",
@@ -84,22 +84,14 @@
84
84
  "eslint-plugin-promise": "^4.2.1",
85
85
  "eslint-plugin-standard": "^4.0.0",
86
86
  "eslint-plugin-vue": "^7.20.0",
87
- "highlight.js": "^11.4.0",
88
- "jest": "^26.1.0",
87
+ "jest": "^27.5.1",
89
88
  "jest-serializer-vue": "^2.0.2",
90
- "mini-css-extract-plugin": "^1.6.2",
91
- "nuxt": "^2.15.8",
92
- "random-words": "^1.1.0",
93
- "sanitize-html": "^2.6.1",
94
- "stringify-object": "^3.3.0",
95
- "tiptap-vuetify": "^2.24.0",
96
89
  "v-mask": "^2.3.0",
97
90
  "vue-axios": "^2.1.5",
98
- "vue-cropperjs": "^4.2.0",
99
91
  "vue-jest": "^3.0.7",
100
- "vue-template-compiler": "^2.6.14",
101
- "vuetify": "^2.6.2",
102
- "webpack-cli": "^4.9.1",
103
- "yaml": "^1.10.2"
92
+ "vue-loader": "^15.9.8",
93
+ "webpack": "^5.72.0",
94
+ "webpack-bundle-analyzer": "^4.5.0",
95
+ "webpack-cli": "^4.9.2"
104
96
  }
105
97
  }
@@ -0,0 +1,10 @@
1
+ # VJSF - test apps - compiled
2
+
3
+ This page tests loading vjsf and all dependencies from compiled sources.
4
+
5
+ ```
6
+ npm install
7
+ (cd ../.. && npm run build)
8
+ ln -s ../../dist dist
9
+ npm start
10
+ ```
@@ -0,0 +1,85 @@
1
+ <html>
2
+ <head>
3
+ <title>VJSF - test apps - compiled</title>
4
+
5
+ <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
6
+ <link href="https://cdn.jsdelivr.net/npm/@mdi/font@6.x/css/materialdesignicons.min.css" rel="stylesheet">
7
+ <link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet">
8
+ <link href="/dist/main.css" rel="stylesheet">
9
+ </head>
10
+ <body>
11
+ <div id="app">
12
+ <v-app>
13
+ <v-main>
14
+ <v-container>
15
+ <h1>VJSF - Test app - Compiled</h1>
16
+
17
+ <h2>Very basic form</h2>
18
+ <v-form v-model="form1.valid">
19
+ <v-jsf :schema="form1.schema" :options="form1.options" v-model="form1.model"></v-jsf>
20
+ </v-form>
21
+ valid={{form1.valid}}, model={{form1.model}}
22
+
23
+ <h2>Form with draggable array elements</h2>
24
+ <v-form v-model="form2.valid">
25
+ <v-jsf :schema="form2.schema" :options="form2.options" v-model="form2.model"></v-jsf>
26
+ </v-form>
27
+ valid={{form2.valid}}, model={{form2.model}}
28
+ </v-container>
29
+
30
+ </v-main>
31
+ </v-app>
32
+ </div>
33
+
34
+ <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
35
+ <script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
36
+ <script src="/dist/main.js"></script>
37
+ <script src="/dist/third-party.js"></script>
38
+ <script>
39
+ Vue.component('VJsf', VJsf.default)
40
+ new Vue({
41
+ el: '#app',
42
+ vuetify: new Vuetify(),
43
+ data() {
44
+ return {
45
+ form1: {
46
+ valid: null,
47
+ schema: {
48
+ type: 'object',
49
+ properties: {
50
+ stringProp: { type: 'string', title: `I'm a string`, description: 'This description is used as a help message.' }
51
+ }
52
+ },
53
+ options: {},
54
+ model: {}
55
+ },
56
+ form2: {
57
+ valid: null,
58
+ schema: {
59
+ type: 'object',
60
+ properties: {
61
+ arrayProp: {
62
+ type: 'array',
63
+ items: {
64
+ type: 'object',
65
+ properties: {
66
+ stringProp: { type: 'string', title: `I'm a string` }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ },
72
+ options: {},
73
+ model: {
74
+ arrayProp: [
75
+ {stringProp: 'value 1'},
76
+ {stringProp: 'value 2'}
77
+ ]
78
+ }
79
+ }
80
+ }
81
+ }
82
+ })
83
+ </script>
84
+ </body>
85
+ </html>