@qtoggle/qui 0.0.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.
Files changed (202) hide show
  1. package/.eslintignore +2 -0
  2. package/.eslintrc.json +492 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
  5. package/.github/ISSUE_TEMPLATE/improvement_proposal.md +20 -0
  6. package/.github/workflows/main.yml +74 -0
  7. package/.pre-commit-config.yaml +8 -0
  8. package/LICENSE.txt +177 -0
  9. package/README.md +4 -0
  10. package/font/dejavusans-bold.woff +0 -0
  11. package/font/dejavusans-bolditalic.woff +0 -0
  12. package/font/dejavusans-italic.woff +0 -0
  13. package/font/dejavusans-regular.woff +0 -0
  14. package/img/qui-icons.svg +1937 -0
  15. package/js/base/base.js +47 -0
  16. package/js/base/condition-variable.js +92 -0
  17. package/js/base/errors.js +36 -0
  18. package/js/base/i18n.js +20 -0
  19. package/js/base/mixwith.js +135 -0
  20. package/js/base/require-js-compat.js +78 -0
  21. package/js/base/signal.js +91 -0
  22. package/js/base/singleton.js +66 -0
  23. package/js/base/timer.js +126 -0
  24. package/js/config.js +184 -0
  25. package/js/forms/common-fields/check-field.js +42 -0
  26. package/js/forms/common-fields/choice-buttons-field.js +30 -0
  27. package/js/forms/common-fields/color-combo-field.js +37 -0
  28. package/js/forms/common-fields/combo-field.js +108 -0
  29. package/js/forms/common-fields/common-fields.js +23 -0
  30. package/js/forms/common-fields/composite-field.js +132 -0
  31. package/js/forms/common-fields/custom-html-field.js +51 -0
  32. package/js/forms/common-fields/email-field.js +30 -0
  33. package/js/forms/common-fields/file-picker-field.js +46 -0
  34. package/js/forms/common-fields/jquery-ui-field.js +111 -0
  35. package/js/forms/common-fields/labels-field.js +69 -0
  36. package/js/forms/common-fields/numeric-field.js +39 -0
  37. package/js/forms/common-fields/password-field.js +28 -0
  38. package/js/forms/common-fields/phone-field.js +26 -0
  39. package/js/forms/common-fields/progress-disk-field.js +69 -0
  40. package/js/forms/common-fields/push-button-field.js +138 -0
  41. package/js/forms/common-fields/slider-field.js +51 -0
  42. package/js/forms/common-fields/text-area-field.js +34 -0
  43. package/js/forms/common-fields/text-field.js +89 -0
  44. package/js/forms/common-fields/up-down-field.js +85 -0
  45. package/js/forms/common-forms/common-forms.js +16 -0
  46. package/js/forms/common-forms/options-form.js +77 -0
  47. package/js/forms/common-forms/page-form.js +115 -0
  48. package/js/forms/form-button.js +202 -0
  49. package/js/forms/form-field.js +1183 -0
  50. package/js/forms/form.js +1181 -0
  51. package/js/forms/forms.js +68 -0
  52. package/js/global-glass.js +100 -0
  53. package/js/icons/default-stock.js +173 -0
  54. package/js/icons/icon.js +64 -0
  55. package/js/icons/icons.js +16 -0
  56. package/js/icons/multi-state-sprites-icon.js +362 -0
  57. package/js/icons/stock-icon.js +219 -0
  58. package/js/icons/stock.js +98 -0
  59. package/js/icons/stocks.js +57 -0
  60. package/js/index.js +232 -0
  61. package/js/lib/jquery.longpress.js +79 -0
  62. package/js/lib/jquery.module.js +4 -0
  63. package/js/lib/logger.module.js +4 -0
  64. package/js/lib/pep.module.js +4 -0
  65. package/js/lists/common-items/common-items.js +5 -0
  66. package/js/lists/common-items/icon-label-list-item.js +86 -0
  67. package/js/lists/common-lists/common-lists.js +5 -0
  68. package/js/lists/common-lists/page-list.js +53 -0
  69. package/js/lists/list-item.js +147 -0
  70. package/js/lists/list.js +636 -0
  71. package/js/lists/lists.js +26 -0
  72. package/js/main-ui/main-ui.js +64 -0
  73. package/js/main-ui/menu-bar.js +144 -0
  74. package/js/main-ui/options-bar.js +181 -0
  75. package/js/main-ui/status.js +185 -0
  76. package/js/main-ui/top-bar.js +59 -0
  77. package/js/messages/common-message-forms/common-message-forms.js +7 -0
  78. package/js/messages/common-message-forms/confirm-message-form.js +81 -0
  79. package/js/messages/common-message-forms/simple-message-form.js +67 -0
  80. package/js/messages/common-message-forms/sticky-simple-message-form.js +27 -0
  81. package/js/messages/message-form.js +107 -0
  82. package/js/messages/messages.js +21 -0
  83. package/js/messages/sticky-modal-page.js +98 -0
  84. package/js/messages/sticky-modal-progress-message.js +27 -0
  85. package/js/messages/toast.js +164 -0
  86. package/js/navigation.js +654 -0
  87. package/js/pages/breadcrumbs.js +124 -0
  88. package/js/pages/common-pages/common-pages.js +6 -0
  89. package/js/pages/common-pages/modal-progress-page.js +83 -0
  90. package/js/pages/common-pages/structured-page.js +46 -0
  91. package/js/pages/page.js +1018 -0
  92. package/js/pages/pages-context.js +154 -0
  93. package/js/pages/pages.js +252 -0
  94. package/js/pwa.js +337 -0
  95. package/js/sections/section.js +612 -0
  96. package/js/sections/sections.js +300 -0
  97. package/js/tables/common-cells/common-cells.js +7 -0
  98. package/js/tables/common-cells/icon-label-table-cell.js +68 -0
  99. package/js/tables/common-cells/push-button-table-cell.js +133 -0
  100. package/js/tables/common-cells/simple-table-cell.js +37 -0
  101. package/js/tables/common-tables/common-tables.js +5 -0
  102. package/js/tables/common-tables/page-table.js +55 -0
  103. package/js/tables/table-cell.js +198 -0
  104. package/js/tables/table-row.js +126 -0
  105. package/js/tables/table.js +492 -0
  106. package/js/tables/tables.js +36 -0
  107. package/js/theme.js +304 -0
  108. package/js/utils/ajax.js +126 -0
  109. package/js/utils/array.js +194 -0
  110. package/js/utils/colors.js +445 -0
  111. package/js/utils/cookies.js +65 -0
  112. package/js/utils/crypto.js +439 -0
  113. package/js/utils/css.js +234 -0
  114. package/js/utils/date.js +300 -0
  115. package/js/utils/files.js +27 -0
  116. package/js/utils/gestures.js +165 -0
  117. package/js/utils/html.js +76 -0
  118. package/js/utils/misc.js +81 -0
  119. package/js/utils/object.js +324 -0
  120. package/js/utils/promise.js +49 -0
  121. package/js/utils/string.js +192 -0
  122. package/js/utils/url.js +187 -0
  123. package/js/utils/utils.js +3 -0
  124. package/js/utils/visibility-manager.js +211 -0
  125. package/js/views/common-views/common-views.js +7 -0
  126. package/js/views/common-views/icon-label-view.js +210 -0
  127. package/js/views/common-views/progress-view.js +89 -0
  128. package/js/views/common-views/structured-view.js +368 -0
  129. package/js/views/view.js +467 -0
  130. package/js/views/views.js +3 -0
  131. package/js/widgets/base-widget.js +23 -0
  132. package/js/widgets/common-widgets/check-button.js +109 -0
  133. package/js/widgets/common-widgets/choice-buttons.js +322 -0
  134. package/js/widgets/common-widgets/color-combo.js +104 -0
  135. package/js/widgets/common-widgets/combo.js +645 -0
  136. package/js/widgets/common-widgets/common-widgets.js +17 -0
  137. package/js/widgets/common-widgets/email-input.js +7 -0
  138. package/js/widgets/common-widgets/file-picker.js +133 -0
  139. package/js/widgets/common-widgets/labels.js +132 -0
  140. package/js/widgets/common-widgets/numeric-input.js +49 -0
  141. package/js/widgets/common-widgets/password-input.js +91 -0
  142. package/js/widgets/common-widgets/phone-input.js +7 -0
  143. package/js/widgets/common-widgets/progress-disk.js +174 -0
  144. package/js/widgets/common-widgets/push-button.js +155 -0
  145. package/js/widgets/common-widgets/slider.js +455 -0
  146. package/js/widgets/common-widgets/text-area.js +52 -0
  147. package/js/widgets/common-widgets/text-input.js +174 -0
  148. package/js/widgets/common-widgets/up-down.js +351 -0
  149. package/js/widgets/widgets.js +57 -0
  150. package/js/window.js +557 -0
  151. package/jsdoc.conf.json +20 -0
  152. package/less/base.less +123 -0
  153. package/less/forms/common-fields.less +101 -0
  154. package/less/forms/common-forms.less +5 -0
  155. package/less/forms/form-button.less +21 -0
  156. package/less/forms/form-field.less +266 -0
  157. package/less/forms/form.less +131 -0
  158. package/less/global-glass.less +64 -0
  159. package/less/icon-label-view.less +82 -0
  160. package/less/icons.less +144 -0
  161. package/less/lists.less +105 -0
  162. package/less/main-ui.less +328 -0
  163. package/less/messages.less +189 -0
  164. package/less/no-effects.less +24 -0
  165. package/less/pages/breadcrumbs.less +98 -0
  166. package/less/pages/common-pages.less +36 -0
  167. package/less/pages/page.less +70 -0
  168. package/less/progress-view.less +51 -0
  169. package/less/stock-icons.less +43 -0
  170. package/less/structured-view.less +245 -0
  171. package/less/tables.less +84 -0
  172. package/less/theme-dark.less +133 -0
  173. package/less/theme-light.less +132 -0
  174. package/less/theme.less +419 -0
  175. package/less/visibility-manager.less +11 -0
  176. package/less/widgets/check-button.less +96 -0
  177. package/less/widgets/choice-buttons.less +160 -0
  178. package/less/widgets/color-combo.less +33 -0
  179. package/less/widgets/combo.less +230 -0
  180. package/less/widgets/common-buttons.less +120 -0
  181. package/less/widgets/common.less +24 -0
  182. package/less/widgets/input.less +258 -0
  183. package/less/widgets/labels.less +81 -0
  184. package/less/widgets/progress-disk.less +70 -0
  185. package/less/widgets/slider.less +199 -0
  186. package/less/widgets/updown.less +115 -0
  187. package/less/widgets/various.less +36 -0
  188. package/package.json +47 -0
  189. package/pyproject.toml +45 -0
  190. package/qui/__init__.py +110 -0
  191. package/qui/constants.py +1 -0
  192. package/qui/exceptions.py +2 -0
  193. package/qui/j2template.py +71 -0
  194. package/qui/settings.py +60 -0
  195. package/qui/templates/manifest.json +25 -0
  196. package/qui/templates/qui.html +126 -0
  197. package/qui/templates/service-worker.js +188 -0
  198. package/qui/web/__init__.py +0 -0
  199. package/qui/web/tornado.py +220 -0
  200. package/scripts/postinstall.sh +10 -0
  201. package/webpack/webpack-adjust-css-urls-loader.js +36 -0
  202. package/webpack/webpack-common.js +384 -0
@@ -0,0 +1,1181 @@
1
+
2
+ import $ from '$qui/lib/jquery.module.js'
3
+
4
+ import {AssertionError} from '$qui/base/errors.js'
5
+ import {mix} from '$qui/base/mixwith.js'
6
+ import * as Theme from '$qui/theme.js'
7
+ import {asap} from '$qui/utils/misc.js'
8
+ import * as ObjectUtils from '$qui/utils/object.js'
9
+ import {ProgressViewMixin} from '$qui/views/common-views/common-views.js'
10
+ import {StructuredViewMixin} from '$qui/views/common-views/common-views.js'
11
+ import {STATE_NORMAL} from '$qui/views/view.js'
12
+ import ViewMixin from '$qui/views/view.js'
13
+ import * as Window from '$qui/window.js'
14
+
15
+ import {ErrorMapping} from './forms.js'
16
+ import {ValidationError} from './forms.js'
17
+ import {STATE_APPLIED} from './forms.js'
18
+ import FormField from './form-field.js'
19
+
20
+
21
+ /**
22
+ * A form.
23
+ * @alias qui.forms.Form
24
+ * @mixes qui.views.ViewMixin
25
+ * @mixes qui.views.commonviews.StructuredViewMixin
26
+ * @mixes qui.views.commonviews.ProgressViewMixin
27
+ */
28
+ class Form extends mix().with(ViewMixin, StructuredViewMixin, ProgressViewMixin) {
29
+
30
+ /**
31
+ * @constructs
32
+ * @param {Number|String} [width] a specific form width to be used instead of the default
33
+ * @param {Boolean} [noBackground] indicates that the form should have transparent background (defaults to
34
+ * `false`)
35
+ * @param {Boolean} [compact] indicates that the form should be compact to better fit in smaller containers;
36
+ * compact forms have field labels and values on separate lines, to reduce the overall form width (defaults to
37
+ * `false`)
38
+ * @param {Number} [valuesWidth] sets the width of the values column, as percent, relative to the form body; when
39
+ * set to `0` the width of each field's value will be computed automatically; this attribute is ignored for forms
40
+ * that have `compact` set to `true` (defaults to `60`)
41
+ * @param {Boolean} [continuousValidation] if set to `true`, each field will be validated upon change, instead of
42
+ * when the form data is applied (using {@link qui.forms.Form#applyData}). Defaults to `false`
43
+ * @param {Boolean} [closeOnApply] if set to `false`, the form will not automatically close when data is applied
44
+ * (using {@link qui.forms.Form#applyData}). Defaults to `true`
45
+ * @param {Boolean} [autoDisableDefaultButton] controls if the default button is automatically enabled and disabled
46
+ * based on currently changed fields, their validity and applied state. Defaults to `true`
47
+ * @param {qui.forms.FormField[]} [fields] fields to be added to the form
48
+ * @param {qui.forms.FormButton[]} [buttons] buttons to be added to the form
49
+ * @param {Object} [initialData] a dictionary with initial values for the fields
50
+ * @param {...*} args parent class parameters
51
+ */
52
+ constructor({
53
+ width = null,
54
+ noBackground = false,
55
+ compact = false,
56
+ valuesWidth = 60,
57
+ continuousValidation = false,
58
+ closeOnApply = true,
59
+ autoDisableDefaultButton = true,
60
+ fields = [],
61
+ buttons = [],
62
+ initialData = null,
63
+ ...args
64
+ } = {}) {
65
+ super(args)
66
+
67
+ this._width = width
68
+ this._noBackground = noBackground
69
+ this._compact = compact
70
+ this._valuesWidth = valuesWidth
71
+ this._continuousValidation = continuousValidation
72
+ this._closeOnApply = closeOnApply
73
+ this._autoDisableDefaultButton = autoDisableDefaultButton
74
+ this._fields = fields
75
+ this._buttons = buttons
76
+ this._initialData = initialData
77
+
78
+ /* Last known validity state */
79
+ this._isValid = null
80
+ this._validationCache = {}
81
+ this._updateValidationStateASAPHandle = null
82
+
83
+ this._fieldsByName = {}
84
+
85
+ this._errorDiv = null
86
+ }
87
+
88
+
89
+ /* Various attributes */
90
+
91
+ /**
92
+ * Tell if the form uses the compact layout.
93
+ * @returns {Boolean}
94
+ */
95
+ isCompact() {
96
+ return this._compact
97
+ }
98
+
99
+
100
+ /* HTML */
101
+
102
+ makeHTML() {
103
+ return $('<form></form>', {class: 'qui-form'})
104
+ }
105
+
106
+ initHTML(html) {
107
+ super.initHTML(html)
108
+
109
+ html.addClass('unexpanded')
110
+
111
+ if (this._width) {
112
+ html.css('width', this._width)
113
+ }
114
+
115
+ if (this._noBackground) {
116
+ html.addClass('no-background')
117
+ }
118
+
119
+ if (this._compact) {
120
+ html.addClass('compact')
121
+ }
122
+
123
+ /* React on Escape and Enter keys */
124
+ html.on('keydown', function (e) {
125
+
126
+ if (e.which === 27) { /* Escape */
127
+ asap(function () {
128
+ this.cancelAction()
129
+ }.bind(this))
130
+
131
+ return false
132
+ }
133
+ else if (e.which === 13) { /* Enter */
134
+ asap(function () {
135
+ this.defaultAction()
136
+ }.bind(this))
137
+
138
+ return false
139
+ }
140
+
141
+ }.bind(this))
142
+
143
+ /* Override default form submit */
144
+ html.on('submit', function () {
145
+
146
+ asap(function () {
147
+ this.defaultAction()
148
+ }.bind(this))
149
+
150
+ return false
151
+
152
+ }.bind(this))
153
+
154
+ /* Fields */
155
+ let fields = this._fields
156
+ this._fields = []
157
+
158
+ fields.forEach(f => this.addField(-1, f))
159
+
160
+ /* Add buttons */
161
+ let buttons = this._buttons
162
+ this._buttons = []
163
+
164
+ buttons.forEach(b => this.addButton(-1, b))
165
+
166
+ /* Set initial data */
167
+ if (this._initialData) {
168
+ this.setData(this._initialData)
169
+ }
170
+ else if (this._continuousValidation) {
171
+ this.updateValidationState()
172
+ }
173
+
174
+ this._updateBottom()
175
+ }
176
+
177
+ makeBody() {
178
+ /* Body element */
179
+ let bodyDiv = $('<div></div>', {class: 'qui-form-body'})
180
+
181
+ /* Error */
182
+ this._errorDiv = $('<div></div>', {class: 'qui-form-error'})
183
+ let errorLabel = $('<div></div>', {class: 'qui-form-error-label'})
184
+ let errorIcon = $('<span></span>', {class: 'qui-form-error-icon'})
185
+ let errorText = $('<span></span>', {class: 'qui-form-error-text'})
186
+ errorLabel.append(errorIcon).append(errorText)
187
+ this._errorDiv.append(errorLabel)
188
+
189
+ bodyDiv.append(this._errorDiv)
190
+
191
+ return bodyDiv
192
+ }
193
+
194
+ makeBottom() {
195
+ return $('<div></div>', {class: 'qui-form-bottom'})
196
+ }
197
+
198
+ _updateBottom() {
199
+ /* Add/remove "buttonless" class */
200
+ this.getHTML().toggleClass('buttonless', this._buttons.length === 0)
201
+
202
+ /* Update grid template */
203
+ if (this.isCompact() || Window.isSmallScreen()) {
204
+ this.getBottom().css('grid-template-columns', `repeat(${this._buttons.length}, 1fr)`)
205
+ }
206
+ }
207
+
208
+
209
+ /* Buttons */
210
+
211
+ /**
212
+ * Add a button to the form.
213
+ * @param {Number} index the index where the button should be added; `-1` will add the button at the end
214
+ * @param {qui.forms.FormButton} button the button
215
+ */
216
+ addButton(index, button) {
217
+ button.getHTML().on('click', () => this.onButtonPress(button))
218
+ button.setForm(this)
219
+
220
+ if (index >= 0 && this._bottomDiv.children().length) {
221
+ this._buttons.splice(index, 0, button)
222
+ this._bottomDiv.children(`:eq(${index})`).before(button.getHTML())
223
+ }
224
+ else {
225
+ this._buttons.push(button)
226
+ this._bottomDiv.append(button.getHTML())
227
+ }
228
+
229
+ this._updateBottom()
230
+ this.updateButtonsState()
231
+ }
232
+
233
+ /**
234
+ * Remove a button from the form.
235
+ * @param {String} id the button id
236
+ */
237
+ removeButton(id) {
238
+ if (!this._bottomDiv) { /* Not created yet */
239
+ return
240
+ }
241
+
242
+ let index = this._buttons.findIndex(b => b.getId() === id)
243
+ if (index >= 0) {
244
+ this._buttons[index].getHTML().remove()
245
+ this._buttons.splice(index, 1)
246
+ }
247
+
248
+ this._updateBottom()
249
+ this.updateButtonsState()
250
+ }
251
+
252
+ /**
253
+ * Return the form button with the given id.
254
+ * @param {String} id the button id
255
+ * @returns {?qui.forms.FormButton}
256
+ */
257
+ getButton(id) {
258
+ /* Ensure HTML is created */
259
+ this.getHTML()
260
+
261
+ return this._buttons.find(b => b.getId() === id) || null
262
+ }
263
+
264
+ /**
265
+ * Return all form buttons.
266
+ * @returns {qui.forms.FormButton[]}
267
+ */
268
+ getButtons() {
269
+ return this._buttons.slice()
270
+ }
271
+
272
+ /**
273
+ * Called when one of the form buttons is pressed.
274
+ * @param {qui.forms.FormButton} button
275
+ */
276
+ onButtonPress(button) {
277
+ }
278
+
279
+ /**
280
+ * Update internal form state related to buttons.
281
+ */
282
+ updateButtonsState() {
283
+ if (this._autoDisableDefaultButton) {
284
+ let defaultButton = this._buttons.find(b => b.isDefault())
285
+ if (defaultButton) {
286
+ let enabled
287
+ if (this._continuousValidation) {
288
+ enabled = this._isValid !== false
289
+ }
290
+ else {
291
+ enabled = (this.getChangedFieldNames().length > 0) ||
292
+ (this._fields.length === 0)
293
+ }
294
+
295
+ if (enabled) {
296
+ defaultButton.enable()
297
+ }
298
+ else {
299
+ defaultButton.disable()
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+
306
+ /* Fields */
307
+
308
+ /**
309
+ * Add a field to the form.
310
+ * @param {Number} index the index where the field should be added; `-1` will add the field at the end
311
+ * @param {qui.forms.FormField} field the field
312
+ */
313
+ addField(index, field) {
314
+ /* Ensure HTML is created */
315
+ this.getHTML()
316
+
317
+ let name = field.getName()
318
+ if (name in this._fieldsByName) {
319
+ throw new AssertionError(`Field with name "${name}" already present on form`)
320
+ }
321
+
322
+ field.setForm(this)
323
+
324
+ if (index >= 0 && index < this._fields.length) {
325
+ this._fields.splice(index, 0, field)
326
+ this._bodyDiv.children(`div.qui-form-field:eq(${index})`).before(field.getHTML())
327
+ }
328
+ else {
329
+ this._fields.push(field)
330
+ this._bodyDiv.append(field.getHTML())
331
+ }
332
+ this._fieldsByName[name] = field
333
+
334
+ /* The form itself may no longer be valid */
335
+ this._clearValidationCache('')
336
+
337
+ this.updateButtonsState()
338
+ this.updateFieldsState()
339
+ }
340
+
341
+ /**
342
+ * Remove a field from the form.
343
+ * @param {String|qui.forms.FormField} nameOrField the name of the field or a field object
344
+ */
345
+ removeField(nameOrField) {
346
+ if (!this._bodyDiv) { /* Not created yet */
347
+ return
348
+ }
349
+
350
+ let name
351
+ if (nameOrField instanceof FormField) {
352
+ name = nameOrField.getName()
353
+ }
354
+ else {
355
+ name = nameOrField
356
+ }
357
+
358
+ let index = this._fields.findIndex(function (field) {
359
+ return field.getName() === name
360
+ })
361
+
362
+ if (index >= 0) {
363
+ this._fields.splice(index, 1)
364
+ }
365
+
366
+ this._bodyDiv.children(`div.qui-form-field[data-name=${name}]`).remove()
367
+
368
+ delete this._fieldsByName[name]
369
+
370
+ /* Invalidate form's validation cache */
371
+ this._clearValidationCache(name)
372
+
373
+ this.updateButtonsState()
374
+ this.updateFieldsState()
375
+ }
376
+
377
+ /**
378
+ * Return the field with a given name. If no such field is found, `null` is returned.
379
+ * @param {String} name the name of the field
380
+ * @returns {?qui.forms.FormField}
381
+ */
382
+ getField(name) {
383
+ /* Ensure fields are properly added to form */
384
+ this.getHTML()
385
+
386
+ return this._fieldsByName[name] || null
387
+ }
388
+
389
+ /**
390
+ * Return the index of the given field. If the field does not belong to this form, `-1` is returned.
391
+ * @param {qui.forms.FormField|String} field the field or a field name
392
+ * @returns {Number}
393
+ */
394
+ getFieldIndex(field) {
395
+ /* Ensure fields are properly added to form */
396
+ this.getHTML()
397
+
398
+ if (typeof field === 'string') {
399
+ return this._fields.findIndex(f => f.getName() === field)
400
+ }
401
+ else {
402
+ return this._fields.indexOf(field)
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Return the list of all fields.
408
+ * @returns {qui.forms.FormField[]}
409
+ */
410
+ getFields() {
411
+ return this._fields.slice()
412
+ }
413
+
414
+ /**
415
+ * Update internal form state related to fields.
416
+ */
417
+ updateFieldsState() {
418
+ /* Update {first-last}-visible CSS classes */
419
+ let firstVisible = false
420
+ let lastVisibleIndex = -1
421
+ this._fields.forEach(function (field, i) {
422
+ let html = field.getHTML()
423
+ html.removeClass('first-visible last-visible')
424
+
425
+ if (!field.isHidden()) {
426
+ if (!firstVisible) {
427
+ firstVisible = true
428
+ html.addClass('first-visible')
429
+ }
430
+ lastVisibleIndex = i
431
+ }
432
+ })
433
+
434
+ if (lastVisibleIndex >= 0) {
435
+ this._fields[lastVisibleIndex].getHTML().addClass('last-visible')
436
+ }
437
+ }
438
+
439
+
440
+ /* Data & validation */
441
+
442
+ _validateData(fieldName) {
443
+ /* Ensure HTML is created */
444
+ this.getHTML()
445
+
446
+ let form = this
447
+
448
+ /* Gather raw form data for validation */
449
+ let data = ObjectUtils.mapValue(this._fieldsByName, function (field) {
450
+ return field.getValue()
451
+ })
452
+
453
+ let promisesDict = {}
454
+
455
+ /* Validate fields */
456
+ let fields = this._fields
457
+ if (fieldName) {
458
+ /* A specific field was given, restrict the validation to given field */
459
+ fields = [this._fieldsByName[fieldName]]
460
+ }
461
+
462
+ fields.forEach(function (field) {
463
+ let name = field.getName()
464
+ let value = data[name]
465
+ let fieldPromise
466
+
467
+ /* Use cached validation result, if available */
468
+ let cached = form._validationCache[name]
469
+ if (cached != null && !(cached instanceof Promise)) {
470
+ if (cached === true) { /* Validation passed */
471
+ fieldPromise = Promise.resolve()
472
+ }
473
+ else { /* Assuming (cached instanceof qui.forms.ValidationError) */
474
+ fieldPromise = Promise.reject(cached)
475
+ }
476
+ }
477
+ else {
478
+ /* Field internal validation */
479
+ fieldPromise = field._validate(value, data).then(function () {
480
+
481
+ /* Field validation in form context */
482
+ return form.validateField(name, value, data)
483
+
484
+ })
485
+
486
+ /* Schedule after pending validation */
487
+ if (cached instanceof Promise) {
488
+ fieldPromise = cached.catch(() => {}).then(function () {
489
+ return this
490
+ }.bind(fieldPromise))
491
+ }
492
+
493
+ fieldPromise = fieldPromise.then(function () {
494
+
495
+ form._validationCache[name] = true
496
+ return value
497
+
498
+ }).catch(function (error) {
499
+
500
+ if (field.isHidden()) {
501
+ /* Hidden fields are always considered valid */
502
+ form._validationCache[name] = true
503
+ }
504
+ else {
505
+ form._validationCache[name] = error
506
+ throw error
507
+ }
508
+
509
+ })
510
+
511
+ /* Set pending validation */
512
+ form._validationCache[name] = fieldPromise
513
+ }
514
+
515
+ promisesDict[name] = fieldPromise
516
+ })
517
+
518
+ let errors = {}
519
+ ObjectUtils.forEach(promisesDict, function (fieldName, promise) {
520
+ promise.catch(function (error) {
521
+ errors[fieldName] = error
522
+ })
523
+ })
524
+
525
+ let fieldsPromise = Promise.all(Object.values(promisesDict)).then(function () {
526
+
527
+ if (fieldName) {
528
+ return data[fieldName]
529
+ }
530
+ else {
531
+ return data
532
+ }
533
+
534
+ }).catch(function () {
535
+
536
+ if (fieldName) {
537
+ throw errors[fieldName]
538
+ }
539
+ else {
540
+ throw new ErrorMapping(errors)
541
+ }
542
+
543
+ })
544
+
545
+ if (!fieldName) {
546
+ /* Validate the form itself */
547
+ let formPromise
548
+
549
+ /* Use cached validation result, if available */
550
+ let cached = form._validationCache['']
551
+ if (cached != null && !(cached instanceof Promise)) {
552
+ if (cached === true) { /* Validation passed */
553
+ formPromise = Promise.resolve(data)
554
+ }
555
+ else { /* Assuming (cached instanceof qui.forms.ValidationError) */
556
+ formPromise = Promise.reject(cached)
557
+ }
558
+
559
+ return fieldsPromise.then(() => formPromise)
560
+ }
561
+ else { /* We don't have a cached result available right away */
562
+
563
+ /* Call form validation after the fields have been validated; this ensures that validate() is always
564
+ * called with valid field values */
565
+
566
+ formPromise = fieldsPromise.then(function () {
567
+
568
+ return form._validate(data)
569
+
570
+ }).then(function () {
571
+
572
+ form._validationCache[''] = true
573
+ return data
574
+
575
+ })
576
+
577
+ /* Schedule current validation after cached (pending) validation */
578
+ if (cached instanceof Promise) {
579
+ let p = formPromise
580
+ formPromise = cached.catch(() => {}).then(() => p)
581
+ }
582
+
583
+ /* Set pending validation */
584
+ form._validationCache[''] = formPromise
585
+
586
+ return formPromise
587
+ }
588
+ }
589
+ else {
590
+ return fieldsPromise
591
+ }
592
+ }
593
+
594
+ /**
595
+ * Invalidate the validation cache for a specific field as well as for the form itself. If no field name is given,
596
+ * the entire validation cache is cleared.
597
+ * @private
598
+ * @param {String} [fieldName] the name of the field whose cache should be invalidated
599
+ */
600
+ _clearValidationCache(fieldName) {
601
+ let keys
602
+
603
+ if (fieldName != null) {
604
+ keys = ['', fieldName]
605
+ }
606
+ else {
607
+ keys = Object.keys(this._validationCache)
608
+ }
609
+
610
+ keys.forEach(function (key) {
611
+ if (!(this._validationCache[key] instanceof Promise)) {
612
+ delete this._validationCache[key]
613
+ }
614
+ }, this)
615
+
616
+ this._isValid = null
617
+ }
618
+
619
+ /**
620
+ * Wrap validate() into a try/catch and return a validation promise.
621
+ * @private
622
+ * @param {Object} data
623
+ * @returns {Promise}
624
+ */
625
+ _validate(data) {
626
+ try {
627
+ return this.validate(data) || Promise.resolve()
628
+ }
629
+ catch (e) {
630
+ if (e instanceof ValidationError) {
631
+ return Promise.reject(e)
632
+ }
633
+ else {
634
+ throw e
635
+ }
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Tell if given form data is valid as a whole or not. This method is called only with valid field data. Override
641
+ * this method to implement custom form validation.
642
+ * @param {Object} data the form data to validate
643
+ * @returns {?Promise<*,qui.forms.ValidationError>} `null` or a resolved promise, if the data is valid; a promise
644
+ * rejected with a validation error otherwise;
645
+ * @throws qui.forms.ValidationError the validation error can also be thrown instead of being returned in a rejected
646
+ * promise
647
+ */
648
+ validate(data) {
649
+ return null
650
+ }
651
+
652
+ /**
653
+ * Override this method to implement custom field validation in the context of this form.
654
+ * @param {String} name the field name
655
+ * @param {*} value the field value to validate
656
+ * @param {Object} data the form unvalidated data
657
+ * @returns {?Promise<*,qui.forms.ValidationError>} `null` or a resolved promise, if the value is valid; a promise
658
+ * rejected with a validation error otherwise;
659
+ * @throws qui.forms.ValidationError the validation error can also be thrown instead of being returned in a rejected
660
+ * response
661
+ */
662
+ validateField(name, value, data) {
663
+ return null
664
+ }
665
+
666
+ /**
667
+ * Used for continuous validation. Runs a "background" validation to:
668
+ * * show possible field/form errors
669
+ * * update internal validity state
670
+ * * enable/disable default form button
671
+ * @param {Object<String,Error>} [extraErrors] a dictionary containing any extra field errors that should be
672
+ * considered in addition to those returned by the validation mechanism.
673
+ * @returns {Promise<Object>} a promise that is resolved with form data, if the form is valid (the promise is never
674
+ * rejected)
675
+ */
676
+ updateValidationState(extraErrors = null) {
677
+ let hasExtraErrorsSentinel = new Error()
678
+
679
+ return this._validateData().then(function (data) {
680
+
681
+ this._clearErrors()
682
+
683
+ if (this._isValid === false || this._isValid == null) { /* Form just became valid */
684
+ this._isValid = true
685
+ this.onValid(data)
686
+ this.updateButtonsState()
687
+ this.updateFieldsState()
688
+ }
689
+
690
+ if (extraErrors && Object.keys(extraErrors).length) {
691
+ throw hasExtraErrorsSentinel /* extraErrors will be merged in soon, in catch() */
692
+ }
693
+
694
+ return data
695
+
696
+ }.bind(this)).catch(function (e) {
697
+
698
+ let errors
699
+ if (e === hasExtraErrorsSentinel) {
700
+ errors = extraErrors
701
+ }
702
+ else if (e instanceof ErrorMapping) {
703
+ errors = e.errors
704
+ }
705
+ else { /* If an exception is thrown during validation, consider it a form error */
706
+ errors = {'': e}
707
+ }
708
+
709
+ ObjectUtils.forEach(errors, function (name, error) {
710
+
711
+ /* Do not show error messages for unchanged fields */
712
+ if (name && !this._fieldsByName[name].isChanged()) {
713
+ errors[name] = ''
714
+ }
715
+
716
+ }, this)
717
+
718
+ /* Do not show form error unless at least one field has been changed */
719
+ if (this.getChangedFieldNames().length === 0) {
720
+ delete errors['']
721
+ }
722
+
723
+ this._clearErrors()
724
+ this._setErrors(errors)
725
+
726
+ if (this._isValid === true || this._isValid == null) { /* Form just became invalid */
727
+ this._isValid = false
728
+ this.onInvalid()
729
+ this.updateButtonsState()
730
+ this.updateFieldsState()
731
+ }
732
+
733
+ }.bind(this))
734
+ }
735
+
736
+ /**
737
+ * Calls {@link qui.forms.Form#updateValidationState} asap, preventing multiple unnecessary successive calls.
738
+ */
739
+ updateValidationStateASAP() {
740
+ if (this._updateValidationStateASAPHandle) {
741
+ clearTimeout(this._updateValidationStateASAPHandle)
742
+ }
743
+
744
+ this._updateValidationStateASAPHandle = asap(function () {
745
+
746
+ this._updateValidationStateASAPHandle = null
747
+ this.updateValidationState()
748
+
749
+ }.bind(this))
750
+ }
751
+
752
+ /**
753
+ * Return the current value of a field, in a promise, after making sure the entire form is valid.
754
+ * @returns {Promise<*>} a promise that is resolved with the field value, if the form is valid, and is rejected, if
755
+ * the form is invalid
756
+ */
757
+ getFieldValue(fieldName) {
758
+ return this._validateData(fieldName).catch(function (error) {
759
+ return Promise.reject(error)
760
+ })
761
+ }
762
+
763
+ /**
764
+ * Return the current value of a field without performing any validation.
765
+ * @returns {?*} the current field value or `null` if no such field is found
766
+ */
767
+ getUnvalidatedFieldValue(fieldName) {
768
+ let field = this._fieldsByName[fieldName]
769
+ if (!field) {
770
+ return null
771
+ }
772
+
773
+ return field.getValue()
774
+ }
775
+
776
+ /**
777
+ * Return the current form data in a promise, after making sure it is valid.
778
+ * @returns {Promise} a promise that is resolved with a dictionary of field values, if the form is valid, and is
779
+ * rejected with a dictionary of errors associated to invalid fields, if the form is invalid
780
+ */
781
+ getData() {
782
+ return this._validateData()
783
+ }
784
+
785
+ /**
786
+ * Return the current form data without validating fields or the form.
787
+ * @returns {Object} a dictionary with field values
788
+ */
789
+ getUnvalidatedData() {
790
+ return ObjectUtils.mapValue(this._fieldsByName, function (field) {
791
+ return field.getValue()
792
+ })
793
+ }
794
+
795
+ /**
796
+ * Update the form data.
797
+ * @param {Object} data the new form data
798
+ */
799
+ setData(data) {
800
+ /* Ensure HTML is created */
801
+ this.getHTML()
802
+
803
+ this._clearValidationCache()
804
+
805
+ ObjectUtils.forEach(data, function (name, value) {
806
+
807
+ let field = this._fieldsByName[name]
808
+ if (!field) {
809
+ return
810
+ }
811
+
812
+ field.setValue(value)
813
+
814
+ }.bind(this))
815
+
816
+ if (this._continuousValidation) {
817
+ this.updateValidationState()
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Return the names of the fields whose values have been changed.
823
+ * @returns {String[]}
824
+ */
825
+ getChangedFieldNames() {
826
+ return this._fields.filter(f => f.isChanged()).map(f => f.getName())
827
+ }
828
+
829
+ /**
830
+ * Called whenever a field value changes.
831
+ * @param {Object} data the *unvalidated* form data
832
+ * @param {String} fieldName the name of the field that was changed
833
+ */
834
+ onChange(data, fieldName) {
835
+ }
836
+
837
+ /**
838
+ * Called whenever a field value changes and the form is entirely valid. This method is only called for forms with
839
+ * `continuousValidation` set to `true`.
840
+ * @param {Object} data the validated form data
841
+ * @param {String} fieldName the name of the field that was changed
842
+ */
843
+ onChangeValid(data, fieldName) {
844
+ }
845
+
846
+ /**
847
+ * Called when the form data becomes valid.
848
+ * @param {Object} data the form data
849
+ */
850
+ onValid(data) {
851
+ }
852
+
853
+ /**
854
+ * Called when the form data becomes invalid.
855
+ */
856
+ onInvalid() {
857
+ }
858
+
859
+
860
+ /* Applying */
861
+
862
+ /**
863
+ * A function that applies the form data.
864
+ *
865
+ * If `null` or `undefined` is returned, the form data is considered applied right away. If a promise is returned,
866
+ * data is considered applied when the promise is resolved and {@link qui.forms.Form#setApplied} is called to mark
867
+ * the form as applied.
868
+ *
869
+ * A rejected promise will set a form error using {@link qui.views.ViewMixin#setError}. To set field errors, reject
870
+ * the returned promise with a {@link qui.forms.ErrorMapping}. A null error will simply cancel the application,
871
+ * without setting any error.
872
+ *
873
+ * Form will be put in progress using {@link qui.views.ViewMixin#setProgress} while promise is active.
874
+ *
875
+ * @param {Object} data form data to be applied
876
+ * @returns {?Promise<*,Error>}
877
+ * @throws Error errors while applying the data can also be thrown
878
+ */
879
+ applyData(data) {
880
+ }
881
+
882
+ /**
883
+ * Called when the user closes the form without applying the data.
884
+ */
885
+ onCancel() {
886
+ }
887
+
888
+ /**
889
+ * Implement this method to define how a field value is applied when it changes. This method can be used to
890
+ * implement continuous form saving, eliminating the need of a general form save button.
891
+ *
892
+ * For continuous form saving to work, the form attribute `continuousValidation` must be set to `true`.
893
+ *
894
+ * If a promise is returned, value is considered applied when the promise is resolved and
895
+ * {@link qui.forms.Form#setApplied} is called to mark the form as applied. A rejected promise will set a field
896
+ * error using {@link qui.views.ViewMixin#setError}.
897
+ *
898
+ * The field will be put in progress using {@link qui.views.ViewMixin#setProgress} while promise is active.
899
+ *
900
+ * Returning `null` or `undefined` indicates that values cannot be applied continuously for the given field (this is
901
+ * the default behaviour).
902
+ *
903
+ * @param {*} value the validated field value
904
+ * @param {String} fieldName the name of the field that was changed
905
+ * @returns {?Promise<*,Error>}
906
+ * @throws Error errors in applying the value can also be thrown
907
+ */
908
+ applyField(value, fieldName) {
909
+ return null
910
+ }
911
+
912
+ _handleFieldChange(field) {
913
+ let name = field.getName()
914
+
915
+ /* If continuous validation is disabled, simply call onChange with corresponding arguments and return */
916
+ if (!this._continuousValidation) {
917
+ /* Gather raw (unvalidated) form data */
918
+ let data = ObjectUtils.mapValue(this._fieldsByName, function (field) {
919
+ return field.getValue()
920
+ })
921
+
922
+ this.onChange(data, name)
923
+ this.updateButtonsState()
924
+ this.updateFieldsState()
925
+
926
+ return
927
+ }
928
+
929
+ /* Invalidate validation cache for this field, as well as the form's */
930
+ this.clearApplied()
931
+ this._clearValidationCache(name)
932
+ this._clearErrors(name)
933
+ this._validateData(name).then(function (value) {
934
+
935
+ let whenApplied = this.applyField(value, name)
936
+ if (whenApplied) {
937
+ field.setProgress()
938
+ whenApplied.then(function () {
939
+ field.setApplied()
940
+ })
941
+
942
+ return whenApplied
943
+ }
944
+
945
+ }.bind(this)).catch(function (error) {
946
+
947
+ field.setError(error)
948
+
949
+ return error /* Pass error further as a simple argument */
950
+
951
+ }).then(function (error) {
952
+
953
+ /* Gather raw (unvalidated) form data */
954
+ let data = ObjectUtils.mapValue(this._fieldsByName, function (field) {
955
+ return field.getValue()
956
+ })
957
+
958
+ this.onChange(data, name)
959
+ let extraErrors = error ? {[name]: error} : null
960
+
961
+ this.updateValidationState(extraErrors).then(function (data) {
962
+
963
+ if (this._isValid) {
964
+ this.onChangeValid(data, name)
965
+ }
966
+
967
+ }.bind(this))
968
+
969
+ }.bind(this))
970
+ }
971
+
972
+
973
+ /* Error state */
974
+
975
+ showError(message) {
976
+ if (message) {
977
+ this._errorDiv.find('span.qui-form-error-text').html(message)
978
+ this.getHTML().addClass('has-error')
979
+ }
980
+
981
+ this._errorDiv[0].scrollIntoView({
982
+ behavior: 'smooth',
983
+ block: 'center',
984
+ inline: 'nearest'
985
+ })
986
+ }
987
+
988
+ hideError() {
989
+ this.getHTML().removeClass('has-error')
990
+ Theme.afterTransition(function () {
991
+
992
+ if (this.hasError()) {
993
+ return /* Error has been reshown */
994
+ }
995
+
996
+ this._errorDiv.find('span.qui-form-error-text').html('')
997
+
998
+ }.bind(this), this.getHTML())
999
+ }
1000
+
1001
+
1002
+ /* Applied state */
1003
+
1004
+ /**
1005
+ * Put the form in the applied state. The view state is set to {@link qui.forms.STATE_APPLIED}.
1006
+ */
1007
+ setApplied() {
1008
+ this.setState(STATE_APPLIED)
1009
+ }
1010
+
1011
+ /**
1012
+ * Put the form in the normal state, but only if the current state is {@link qui.forms.STATE_APPLIED}.
1013
+ */
1014
+ clearApplied() {
1015
+ if (this.getState() === STATE_APPLIED) {
1016
+ this.setState(STATE_NORMAL)
1017
+ }
1018
+ }
1019
+
1020
+ /**
1021
+ * Tell if the form data has been applied (and thus there are no pending changes).
1022
+ * @returns {Boolean}
1023
+ */
1024
+ isApplied() {
1025
+ /* A form is applied when either it is itself in applied state, or when all of its fields are applied
1026
+ * themselves */
1027
+
1028
+ if (this.getState() === STATE_APPLIED) {
1029
+ return true
1030
+ }
1031
+
1032
+ return this._fields.every(function (f) {
1033
+ return f.isApplied()
1034
+ })
1035
+ }
1036
+
1037
+
1038
+ /* Errors */
1039
+
1040
+ _clearErrors(fieldName) {
1041
+ if (!fieldName) {
1042
+ /* General form error */
1043
+ this.clearError()
1044
+
1045
+ /* Field errors */
1046
+ this.getFields().forEach(field => field.clearError())
1047
+ }
1048
+ else { /* Specific field error */
1049
+ let field = this.getField(fieldName)
1050
+ if (field) {
1051
+ field.clearError()
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ _setErrors(errors) {
1057
+ ObjectUtils.forEach(errors, function (name, msg) {
1058
+ if (name) {
1059
+ let field = this.getField(name)
1060
+ if (field) {
1061
+ field.setError(msg)
1062
+ }
1063
+ }
1064
+ else {
1065
+ this.setError(msg)
1066
+ }
1067
+ }, this)
1068
+
1069
+ /* Always unminimize form upon errors */
1070
+ if (this.isMinimizable() && this.isMinimized()) {
1071
+ this.unminimize()
1072
+ }
1073
+ }
1074
+
1075
+
1076
+ /* Actions */
1077
+
1078
+ /**
1079
+ * Execute the default form action. By default this is {@link qui.forms.Form#proceed}.
1080
+ */
1081
+ defaultAction() {
1082
+ this.proceed()
1083
+ }
1084
+
1085
+ /**
1086
+ * Execute the cancel form action. By default this is {@link qui.forms.Form#close}.
1087
+ */
1088
+ cancelAction() {
1089
+ this.close(/* force = */ true)
1090
+ }
1091
+
1092
+ /**
1093
+ * Run the form by gathering, validating and applying form data.
1094
+ * @returns {Promise}
1095
+ */
1096
+ proceed() {
1097
+ this.setProgress()
1098
+
1099
+ let dataValidated
1100
+
1101
+ if (!this._continuousValidation) {
1102
+ /* When continuous validation is disabled, we need to manually catch errors when form data is applied, and
1103
+ * display them onto the form */
1104
+
1105
+ this._clearValidationCache()
1106
+ this._clearErrors()
1107
+
1108
+ dataValidated = this._validateData().catch(function (e) {
1109
+
1110
+ let errorMapping = new ErrorMapping(e)
1111
+ this._clearErrors()
1112
+ this._setErrors(errorMapping.errors)
1113
+
1114
+ throw errorMapping
1115
+
1116
+ }.bind(this))
1117
+ }
1118
+ else {
1119
+ dataValidated = this._validateData()
1120
+ }
1121
+
1122
+ return dataValidated.then(function (data) {
1123
+
1124
+ let whenApplied
1125
+ try {
1126
+ whenApplied = this.applyData(data)
1127
+ }
1128
+ catch (e) {
1129
+ whenApplied = Promise.reject(e)
1130
+ }
1131
+
1132
+ if (!whenApplied) {
1133
+ whenApplied = Promise.resolve()
1134
+ }
1135
+
1136
+ whenApplied.then(function () {
1137
+
1138
+ this.setApplied()
1139
+ this._fields.forEach(f => f.clearChanged())
1140
+ this.updateButtonsState()
1141
+ this.updateFieldsState()
1142
+
1143
+ if (this._closeOnApply) {
1144
+ if (!this.isClosed()) {
1145
+ this.close(/* force = */ true)
1146
+ }
1147
+ }
1148
+
1149
+ }.bind(this)).catch(function (e) {
1150
+
1151
+ this.clearProgress()
1152
+
1153
+ /* e may be null/undefined, in which case, the form apply has simply been cancelled */
1154
+ if (e) {
1155
+ let errorMapping = new ErrorMapping(e)
1156
+ this._clearErrors()
1157
+ this._setErrors(errorMapping.errors)
1158
+ }
1159
+
1160
+ }.bind(this))
1161
+
1162
+ }.bind(this)).catch(function () {
1163
+
1164
+ this.clearProgress()
1165
+
1166
+ }.bind(this))
1167
+ }
1168
+
1169
+ /**
1170
+ * Close the form.
1171
+ */
1172
+ close() {
1173
+ if (!this.isApplied()) {
1174
+ this.onCancel()
1175
+ }
1176
+ }
1177
+
1178
+ }
1179
+
1180
+
1181
+ export default Form