@projectcaluma/ember-form 10.0.1 → 11.0.0-beta.10

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. package/CHANGELOG.md +1181 -0
  2. package/addon/components/cf-content.hbs +36 -41
  3. package/addon/components/cf-content.js +48 -29
  4. package/addon/components/cf-field/info.hbs +2 -2
  5. package/addon/components/cf-field/info.js +0 -15
  6. package/addon/components/cf-field/input/action-button.hbs +18 -19
  7. package/addon/components/cf-field/input/action-button.js +9 -7
  8. package/addon/components/cf-field/input/checkbox.hbs +6 -2
  9. package/addon/components/cf-field/input/checkbox.js +9 -29
  10. package/addon/components/cf-field/input/date.hbs +8 -5
  11. package/addon/components/cf-field/input/date.js +28 -10
  12. package/addon/components/cf-field/input/file.hbs +2 -2
  13. package/addon/components/cf-field/input/file.js +10 -11
  14. package/addon/components/cf-field/input/float.hbs +4 -4
  15. package/addon/components/cf-field/input/integer.hbs +5 -5
  16. package/addon/components/cf-field/input/radio.hbs +4 -1
  17. package/addon/components/cf-field/input/static.hbs +1 -1
  18. package/addon/components/cf-field/input/table.hbs +24 -24
  19. package/addon/components/cf-field/input/table.js +12 -10
  20. package/addon/components/cf-field/input/text.hbs +5 -5
  21. package/addon/components/cf-field/input/textarea.hbs +6 -5
  22. package/addon/components/cf-field/input.hbs +10 -1
  23. package/addon/components/cf-field/input.js +1 -1
  24. package/addon/components/cf-field/label.hbs +1 -1
  25. package/addon/components/cf-field-value.hbs +22 -7
  26. package/addon/components/cf-field-value.js +8 -38
  27. package/addon/components/cf-field.hbs +14 -6
  28. package/addon/components/cf-field.js +22 -8
  29. package/addon/components/cf-navigation-item.hbs +2 -2
  30. package/addon/components/cf-navigation.hbs +4 -1
  31. package/addon/components/document-validity.js +17 -2
  32. package/addon/gql/fragments/field.graphql +45 -0
  33. package/addon/gql/mutations/save-document-table-answer.graphql +1 -1
  34. package/addon/gql/mutations/save-document.graphql +1 -0
  35. package/addon/gql/queries/{get-document-answers.graphql → document-answers.graphql} +2 -1
  36. package/addon/gql/queries/{get-document-forms.graphql → document-forms.graphql} +2 -1
  37. package/addon/gql/queries/{get-document-used-dynamic-options.graphql → document-used-dynamic-options.graphql} +2 -1
  38. package/addon/gql/queries/{get-dynamic-options.graphql → dynamic-options.graphql} +2 -1
  39. package/addon/gql/queries/{get-fileanswer-info.graphql → fileanswer-info.graphql} +2 -1
  40. package/addon/helpers/get-widget.js +50 -0
  41. package/addon/lib/answer.js +108 -72
  42. package/addon/lib/base.js +32 -23
  43. package/addon/lib/dependencies.js +36 -71
  44. package/addon/lib/document.js +92 -96
  45. package/addon/lib/field.js +374 -407
  46. package/addon/lib/fieldset.js +46 -47
  47. package/addon/lib/form.js +27 -15
  48. package/addon/lib/navigation.js +211 -192
  49. package/addon/lib/question.js +103 -94
  50. package/addon/services/caluma-store.js +10 -6
  51. package/app/helpers/get-widget.js +4 -0
  52. package/blueprints/@projectcaluma/ember-form/index.js +1 -0
  53. package/package.json +30 -25
  54. package/addon/components/cf-navigation.js +0 -9
  55. package/addon/instance-initializers/setup-pikaday-i18n.js +0 -35
@@ -1,15 +1,15 @@
1
1
  import { getOwner } from "@ember/application";
2
2
  import { assert } from "@ember/debug";
3
- import { computed, defineProperty } from "@ember/object";
4
- import { equal, not, reads } from "@ember/object/computed";
3
+ import { associateDestroyableChild } from "@ember/destroyable";
5
4
  import { inject as service } from "@ember/service";
6
5
  import { camelize } from "@ember/string";
6
+ import { isEmpty } from "@ember/utils";
7
+ import { tracked } from "@glimmer/tracking";
7
8
  import { queryManager } from "ember-apollo-client";
8
- import { task } from "ember-concurrency";
9
+ import { restartableTask, lastValue, dropTask } from "ember-concurrency";
9
10
  import { validate } from "ember-validators";
10
- import cloneDeep from "lodash.clonedeep";
11
11
  import isEqual from "lodash.isequal";
12
- import { all, resolve } from "rsvp";
12
+ import { cached } from "tracked-toolbox";
13
13
 
14
14
  import { decodeId } from "@projectcaluma/ember-core/helpers/decode-id";
15
15
  import saveDocumentDateAnswerMutation from "@projectcaluma/ember-form/gql/mutations/save-document-date-answer.graphql";
@@ -19,12 +19,9 @@ import saveDocumentIntegerAnswerMutation from "@projectcaluma/ember-form/gql/mut
19
19
  import saveDocumentListAnswerMutation from "@projectcaluma/ember-form/gql/mutations/save-document-list-answer.graphql";
20
20
  import saveDocumentStringAnswerMutation from "@projectcaluma/ember-form/gql/mutations/save-document-string-answer.graphql";
21
21
  import saveDocumentTableAnswerMutation from "@projectcaluma/ember-form/gql/mutations/save-document-table-answer.graphql";
22
- import getDocumentUsedDynamicOptionsQuery from "@projectcaluma/ember-form/gql/queries/get-document-used-dynamic-options.graphql";
22
+ import getDocumentUsedDynamicOptionsQuery from "@projectcaluma/ember-form/gql/queries/document-used-dynamic-options.graphql";
23
23
  import Base from "@projectcaluma/ember-form/lib/base";
24
- import {
25
- nestedDependencyParents,
26
- dependencies,
27
- } from "@projectcaluma/ember-form/lib/dependencies";
24
+ import dependencies from "@projectcaluma/ember-form/lib/dependencies";
28
25
 
29
26
  export const TYPE_MAP = {
30
27
  TextQuestion: "StringAnswer",
@@ -42,10 +39,20 @@ export const TYPE_MAP = {
42
39
  DateQuestion: "DateAnswer",
43
40
  };
44
41
 
45
- const fieldIsHidden = (field) => {
42
+ const MUTATION_MAP = {
43
+ FloatAnswer: saveDocumentFloatAnswerMutation,
44
+ IntegerAnswer: saveDocumentIntegerAnswerMutation,
45
+ StringAnswer: saveDocumentStringAnswerMutation,
46
+ ListAnswer: saveDocumentListAnswerMutation,
47
+ FileAnswer: saveDocumentFileAnswerMutation,
48
+ DateAnswer: saveDocumentDateAnswerMutation,
49
+ TableAnswer: saveDocumentTableAnswerMutation,
50
+ };
51
+
52
+ const fieldIsHiddenOrEmpty = (field) => {
46
53
  return (
47
54
  field.hidden ||
48
- (field.question.__typename !== "TableQuestion" &&
55
+ (!field.question.isTable &&
49
56
  (field.answer.value === null || field.answer.value === undefined))
50
57
  );
51
58
  };
@@ -55,61 +62,44 @@ const fieldIsHidden = (field) => {
55
62
  *
56
63
  * @class Field
57
64
  */
58
- export default Base.extend({
59
- saveDocumentFloatAnswerMutation,
60
- saveDocumentIntegerAnswerMutation,
61
- saveDocumentStringAnswerMutation,
62
- saveDocumentListAnswerMutation,
63
- saveDocumentFileAnswerMutation,
64
- saveDocumentDateAnswerMutation,
65
- saveDocumentTableAnswerMutation,
66
-
67
- intl: service(),
68
- calumaStore: service(),
69
- validator: service(),
70
-
71
- apollo: queryManager(),
72
-
73
- init(...args) {
74
- assert("A document must be passed", this.document);
75
-
76
- defineProperty(this, "pk", {
77
- writable: false,
78
- value: `${this.document.pk}:Question:${this.raw.question.slug}`,
79
- });
65
+ export default class Field extends Base {
66
+ @service intl;
80
67
 
81
- this._super(...args);
68
+ @queryManager apollo;
82
69
 
83
- this._createQuestion();
84
- this._createAnswer();
70
+ constructor({ fieldset, ...args }) {
71
+ assert("`fieldset` must be passed as an argument", fieldset);
85
72
 
86
- this.set("_errors", []);
87
- },
73
+ super({ fieldset, ...args });
88
74
 
89
- willDestroy(...args) {
90
- this._super(...args);
75
+ this.fieldset = fieldset;
91
76
 
92
- if (this.answer) {
93
- this.answer.destroy();
94
- }
95
- },
77
+ this.pushIntoStore();
78
+
79
+ this._createQuestion();
80
+ this._createAnswer();
81
+ }
96
82
 
97
83
  _createQuestion() {
84
+ const owner = getOwner(this);
85
+
98
86
  const question =
99
87
  this.calumaStore.find(`Question:${this.raw.question.slug}`) ||
100
- getOwner(this)
101
- .factoryFor("caluma-model:question")
102
- .create({ raw: this.raw.question });
88
+ new (owner.factoryFor("caluma-model:question").class)({
89
+ raw: this.raw.question,
90
+ owner,
91
+ });
103
92
 
104
93
  if (question.isDynamic) {
105
94
  question.loadDynamicOptions.perform();
106
95
  }
107
96
 
108
- this.set("question", question);
109
- },
97
+ this.question = question;
98
+ }
110
99
 
111
100
  _createAnswer() {
112
- const AnswerFactory = getOwner(this).factoryFor("caluma-model:answer");
101
+ const owner = getOwner(this);
102
+ const Answer = owner.factoryFor("caluma-model:answer").class;
113
103
  let answer;
114
104
 
115
105
  // no answer passed, create an empty one
@@ -121,182 +111,188 @@ export default Base.extend({
121
111
  return;
122
112
  }
123
113
 
124
- answer = AnswerFactory.create({
114
+ answer = new Answer({
125
115
  raw: {
116
+ id: null,
126
117
  __typename: answerType,
127
118
  question: { slug: this.raw.question.slug },
128
119
  [camelize(answerType.replace(/Answer$/, "Value"))]: null,
129
120
  },
130
121
  field: this,
122
+ owner,
131
123
  });
132
124
  } else {
133
125
  answer =
134
126
  this.calumaStore.find(`Answer:${decodeId(this.raw.answer.id)}`) ||
135
- AnswerFactory.create({ raw: this.raw.answer, field: this });
127
+ new Answer({ raw: this.raw.answer, field: this, owner });
136
128
  }
137
129
 
138
- this.set("answer", answer);
139
- },
130
+ this.answer = associateDestroyableChild(this, answer);
131
+ }
140
132
 
141
133
  /**
142
134
  * The question to this field
143
135
  *
144
136
  * @property {Question} question
145
- * @accessor
146
137
  */
147
- question: null,
138
+ question = null;
148
139
 
149
140
  /**
150
141
  * The answer to this field. It is possible for this to be `null` if the
151
142
  * question is of the static question type.
152
143
  *
153
144
  * @property {Answer} answer
154
- * @accessor
155
145
  */
156
- answer: null,
146
+ answer = null;
147
+
148
+ /**
149
+ * The raw error objects which are later translated to readable messages.
150
+ *
151
+ * @property {Object[]} _errors
152
+ * @private
153
+ */
154
+ @tracked _errors = [];
155
+
156
+ /**
157
+ * Currently rendered field components that use this field. This is used in
158
+ * the document validity component to await all current save tasks before
159
+ * validating.
160
+ *
161
+ * @property {Set} _components
162
+ * @private
163
+ */
164
+ _components = new Set();
165
+
166
+ /**
167
+ * The primary key of the field. Consists of the document and question primary
168
+ * keys.
169
+ *
170
+ * @property {String} pk
171
+ */
172
+ @cached
173
+ get pk() {
174
+ return `${this.document.pk}:Question:${this.raw.question.slug}`;
175
+ }
157
176
 
158
177
  /**
159
178
  * Whether the field is valid.
160
179
  *
161
180
  * @property {Boolean} isValid
162
- * @accessor
163
181
  */
164
- isValid: equal("errors.length", 0),
182
+ get isValid() {
183
+ return this.errors.length === 0;
184
+ }
165
185
 
166
186
  /**
167
187
  * Whether the field is invalid.
168
188
  *
169
189
  * @property {Boolean} isInvalid
170
- * @accessor
171
190
  */
172
- isInvalid: not("isValid"),
191
+ get isInvalid() {
192
+ return !this.isValid;
193
+ }
173
194
 
174
195
  /**
175
196
  * Whether the field is new (never saved to the backend service or empty)
176
197
  *
177
198
  * @property {Boolean} isNew
178
- * @accessor
179
199
  */
180
- isNew: computed("answer.isNew", function () {
200
+ get isNew() {
181
201
  return !this.answer || this.answer.isNew;
182
- }),
202
+ }
183
203
 
184
204
  /**
185
205
  * Only table values, this is used for certain computed property keys.
186
206
  *
187
207
  * @property {Document[]} tableValue
188
- * @accessor
189
208
  */
190
- tableValue: computed("value", "question.isTable", function () {
209
+ get tableValue() {
191
210
  return this.question.isTable ? this.value : [];
192
- }),
211
+ }
193
212
 
194
213
  /**
195
214
  * Whether the field has the defined default answer of the question as value.
196
215
  *
197
216
  * @property {Boolean} isDefault
198
- * @accessor
199
217
  */
200
- isDefault: computed(
201
- "value",
202
- "tableValue.@each.flatAnswerMap",
203
- "question.{isTable,defaultValue}",
204
- function () {
205
- if (!this.value || !this.question.defaultValue) return false;
218
+ get isDefault() {
219
+ if (!this.value || !this.question.defaultValue) return false;
206
220
 
207
- const value = this.question.isTable
208
- ? this.tableValue.map((doc) => doc.flatAnswerMap)
209
- : this.value;
221
+ const value = this.question.isTable
222
+ ? this.tableValue.map((doc) => doc.flatAnswerMap)
223
+ : this.value;
210
224
 
211
- return isEqual(value, this.question.defaultValue);
212
- }
213
- ),
225
+ return isEqual(value, this.question.defaultValue);
226
+ }
214
227
 
215
228
  /**
216
229
  * Whether the field is dirty. This will be true, if there is a value on the
217
230
  * answer which differs from the default value of the question.
218
231
  *
219
232
  * @property {Boolean} isDirty
220
- * @accessor
221
- */
222
- isDirty: computed(
223
- "isDefault",
224
- "isNew",
225
- "question.isCalculated",
226
- "validate.lastSuccessful",
227
- function () {
228
- if (this.question.isCalculated || this.isDefault) {
229
- return false;
230
- }
231
-
232
- return Boolean(this.validate.lastSuccessful) || !this.isNew;
233
+ */
234
+ get isDirty() {
235
+ if (this.question.isCalculated || this.isDefault) {
236
+ return false;
233
237
  }
234
- ),
238
+
239
+ return Boolean(this.validate.lastSuccessful) || !this.isNew;
240
+ }
235
241
 
236
242
  /**
237
243
  * The type of the question
238
244
  *
239
245
  * @property {String} questionType
240
- * @accessor
241
246
  */
242
- questionType: reads("question.__typename"),
247
+ get questionType() {
248
+ return this.question.raw.__typename;
249
+ }
243
250
 
244
251
  /**
245
252
  * The document this field belongs to
246
253
  *
247
254
  * @property {Document} document
248
- * @accessor
249
255
  */
250
- document: reads("fieldset.document"),
256
+ get document() {
257
+ return this.fieldset.document;
258
+ }
251
259
 
252
260
  /**
253
261
  * The value of the field
254
262
  *
255
263
  * @property {*} value
256
- * @accessor
257
- */
258
- value: computed(
259
- "question.isCalculated",
260
- "calculatedValue",
261
- "answer.value",
262
- function () {
263
- if (this.question.isCalculated) {
264
- return this.calculatedValue;
265
- }
266
-
267
- return this.answer?.value;
264
+ */
265
+ get value() {
266
+ if (this.question.isCalculated) {
267
+ return this.calculatedValue;
268
268
  }
269
- ),
269
+
270
+ return this.answer?.value;
271
+ }
270
272
 
271
273
  /**
272
- * The computed value of a caluclated question
274
+ * The computed value of a calculated float question
273
275
  *
274
276
  * @property {*} calculatedValue
275
- * @accessor
276
- */
277
- calculatedValue: computed(
278
- "document.jexl",
279
- "calculatedDependencies.@each.{hidden,value}",
280
- "jexlContext",
281
- "question.{isCalculated,calcExpression}",
282
- function () {
283
- if (
284
- !this.question.isCalculated ||
285
- !this.calculatedDependencies.every((field) => !field.hidden)
286
- ) {
287
- return null;
288
- }
277
+ */
278
+ @cached
279
+ get calculatedValue() {
280
+ if (
281
+ !this.question.isCalculated ||
282
+ !this.calculatedDependencies.every((field) => !field.hidden)
283
+ ) {
284
+ return null;
285
+ }
289
286
 
290
- try {
291
- return this.document.jexl.evalSync(
292
- this.question.calcExpression,
293
- this.jexlContext
294
- );
295
- } catch (error) {
296
- return null;
297
- }
287
+ try {
288
+ return this.document.jexl.evalSync(
289
+ this.question.raw.calcExpression,
290
+ this.jexlContext
291
+ );
292
+ } catch (error) {
293
+ return null;
298
294
  }
299
- ),
295
+ }
300
296
 
301
297
  /**
302
298
  * Fetch all formerly used dynamic options for this question. This will be
@@ -306,7 +302,8 @@ export default Base.extend({
306
302
  * @return {Object[]} Formerly used dynamic options
307
303
  * @private
308
304
  */
309
- _fetchUsedDynamicOptions: task(function* () {
305
+ @dropTask
306
+ *_fetchUsedDynamicOptions() {
310
307
  if (!this.question.isDynamic) return null;
311
308
 
312
309
  const edges = yield this.apollo.query(
@@ -325,16 +322,15 @@ export default Base.extend({
325
322
  slug,
326
323
  label,
327
324
  }));
328
- }),
325
+ }
329
326
 
330
327
  /**
331
328
  * The formerly used dynamic options for this question.
332
329
  *
333
330
  * @property {Object[]} usedDynamicOptions
334
- * @accessor
335
- *
336
331
  */
337
- usedDynamicOptions: reads("_fetchUsedDynamicOptions.lastSuccessful.value"),
332
+ @lastValue("_fetchUsedDynamicOptions")
333
+ usedDynamicOptions;
338
334
 
339
335
  /**
340
336
  * The available options for choice questions. This only works for the
@@ -348,79 +344,67 @@ export default Base.extend({
348
344
  * This will also return the disabled state of the option. An option can only
349
345
  * be disabled, if it is an old value used in a dynamic question.
350
346
  *
351
- * @property {Null|Object[]} options
352
- * @accessor
353
- */
354
- options: computed(
355
- "_fetchUsedDynamicOptions.lastSuccessful",
356
- "question.{options.[],isChoice,isDynamic,isMultipleChoice}",
357
- "usedDynamicOptions.[]",
358
- "value",
359
- function () {
360
- if (!this.question.isChoice && !this.question.isMultipleChoice) {
361
- return null;
362
- }
363
-
364
- const selected =
365
- (this.question.isMultipleChoice ? this.value : [this.value]) || [];
347
+ * @property {null|Object[]} options
348
+ */
349
+ @cached
350
+ get options() {
351
+ if (!this.question.isChoice && !this.question.isMultipleChoice) {
352
+ return null;
353
+ }
366
354
 
367
- const options = this.question.options.filter(
368
- (option) => !option.disabled || selected.includes(option.slug)
369
- );
355
+ const selected =
356
+ (this.question.isMultipleChoice ? this.value : [this.value]) || [];
370
357
 
371
- const hasUnknownValue = !selected.every((slug) =>
372
- options.find((option) => option.slug === slug)
373
- );
358
+ const options = this.question.options.filter(
359
+ (option) => !option.disabled || selected.includes(option.slug)
360
+ );
374
361
 
375
- if (this.question.isDynamic && hasUnknownValue) {
376
- if (!this._fetchUsedDynamicOptions.lastSuccessful) {
377
- // Fetch used dynamic options if not done yet
378
- this._fetchUsedDynamicOptions.perform();
379
- }
362
+ const hasUnknownValue = !selected.every((slug) =>
363
+ options.find((option) => option.slug === slug)
364
+ );
380
365
 
381
- return [
382
- ...options,
383
- ...(this.usedDynamicOptions || [])
384
- .filter((used) => {
385
- return (
386
- selected.includes(used.slug) &&
387
- !options.find((option) => option.slug === used.slug)
388
- );
389
- })
390
- .map((used) => ({ ...used, disabled: true })),
391
- ];
366
+ if (this.question.isDynamic && hasUnknownValue) {
367
+ if (!this._fetchUsedDynamicOptions.lastSuccessful) {
368
+ // Fetch used dynamic options if not done yet
369
+ this._fetchUsedDynamicOptions.perform();
392
370
  }
393
371
 
394
- return options;
372
+ return [
373
+ ...options,
374
+ ...(this.usedDynamicOptions || [])
375
+ .filter((used) => {
376
+ return (
377
+ selected.includes(used.slug) &&
378
+ !options.find((option) => option.slug === used.slug)
379
+ );
380
+ })
381
+ .map((used) => ({ ...used, disabled: true })),
382
+ ];
395
383
  }
396
- ),
384
+
385
+ return options;
386
+ }
397
387
 
398
388
  /**
399
389
  * The currently selected option. This property is only used for choice
400
390
  * questions. It can either return null if no value is selected yet, an
401
391
  * object for single choices or an array of objects for multiple choices.
402
392
  *
403
- * @property {Null|Object|Object[]} selected
404
- * @accessor
393
+ * @property {null|Object|Object[]} selected
405
394
  */
406
- selected: computed(
407
- "options.@each.slug",
408
- "question.{isChoice,isMultipleChoice}",
409
- "value",
410
- function () {
411
- if (!this.question.isChoice && !this.question.isMultipleChoice) {
412
- return null;
413
- }
395
+ get selected() {
396
+ if (!this.question.isChoice && !this.question.isMultipleChoice) {
397
+ return null;
398
+ }
414
399
 
415
- const selected = this.options.filter(({ slug }) =>
416
- this.question.isMultipleChoice
417
- ? (this.value || []).includes(slug)
418
- : this.value === slug
419
- );
400
+ const selected = this.options.filter(({ slug }) =>
401
+ this.question.isMultipleChoice
402
+ ? (this.value || []).includes(slug)
403
+ : this.value === slug
404
+ );
420
405
 
421
- return this.question.isMultipleChoice ? selected : selected[0];
422
- }
423
- ),
406
+ return this.question.isMultipleChoice ? selected : selected[0];
407
+ }
424
408
 
425
409
  /**
426
410
  * The field's JEXL context.
@@ -435,33 +419,25 @@ export default Base.extend({
435
419
  * - `info.root.formMeta`: The new property for the root form meta.
436
420
  *
437
421
  * @property {Object} jexlContext
438
- * @accessor
439
- */
440
- jexlContext: computed(
441
- "document.jexlContext",
442
- "fieldset.{form.slug,form.meta,field.fieldset.form.slug,field.fieldset.form.meta}",
443
- function () {
444
- const context = cloneDeep(this.document.jexlContext);
445
-
446
- const form = this.get("fieldset.form");
447
- const parent = this.get("fieldset.field.fieldset.form");
448
-
449
- return {
450
- ...context,
451
- info: {
452
- ...context.info,
453
- form: form.slug,
454
- formMeta: form.meta,
455
- parent: parent
456
- ? {
457
- form: parent.slug,
458
- formMeta: parent.meta,
459
- }
460
- : null,
461
- },
462
- };
463
- }
464
- ),
422
+ */
423
+ get jexlContext() {
424
+ const parent = this.fieldset.field?.fieldset.form;
425
+
426
+ return {
427
+ ...this.document.jexlContext,
428
+ info: {
429
+ ...this.document.jexlContext.info,
430
+ form: this.fieldset.form.slug,
431
+ formMeta: this.fieldset.form.raw.meta,
432
+ parent: parent
433
+ ? {
434
+ form: parent.slug,
435
+ formMeta: parent.raw.meta,
436
+ }
437
+ : null,
438
+ },
439
+ };
440
+ }
465
441
 
466
442
  /**
467
443
  * Fields that are referenced in the `calcExpression` JEXL expression
@@ -470,14 +446,8 @@ export default Base.extend({
470
446
  * expression needs to be re-evaluated.
471
447
  *
472
448
  * @property {Field[]} calculatedDependencies
473
- * @accessor
474
449
  */
475
- _calculatedNestedDependencyParents: nestedDependencyParents(
476
- "question.calcExpression"
477
- ),
478
- calculatedDependencies: dependencies("question.calcExpression", {
479
- nestedParentsPath: "_calculatedNestedDependencyParents",
480
- }),
450
+ @dependencies("question.raw.calcExpression") calculatedDependencies;
481
451
 
482
452
  /**
483
453
  * Fields that are referenced in the `isHidden` JEXL expression
@@ -486,12 +456,8 @@ export default Base.extend({
486
456
  * expression needs to be re-evaluated.
487
457
  *
488
458
  * @property {Field[]} hiddenDependencies
489
- * @accessor
490
459
  */
491
- _hiddenNestedDependencyParents: nestedDependencyParents("question.isHidden"),
492
- hiddenDependencies: dependencies("question.isHidden", {
493
- nestedParentsPath: "_hiddenNestedDependencyParents",
494
- }),
460
+ @dependencies("question.raw.isHidden") hiddenDependencies;
495
461
 
496
462
  /**
497
463
  * Fields that are referenced in the `isRequired` JEXL expression
@@ -500,14 +466,8 @@ export default Base.extend({
500
466
  * expression needs to be re-evaluated.
501
467
  *
502
468
  * @property {Field[]} optionalDependencies
503
- * @accessor
504
469
  */
505
- _optionalNestedDependencyParents: nestedDependencyParents(
506
- "question.isRequired"
507
- ),
508
- optionalDependencies: dependencies("question.isRequired", {
509
- nestedParentsPath: "_optionalNestedDependencyParents",
510
- }),
470
+ @dependencies("question.raw.isRequired") optionalDependencies;
511
471
 
512
472
  /**
513
473
  * The field's hidden state
@@ -515,99 +475,85 @@ export default Base.extend({
515
475
  * A question is hidden if:
516
476
  * - The form question field of the fieldset is hidden
517
477
  * - All depending field (used in the expression) are hidden
518
- * - The evaluated `question.isHidden` expression returns `true`
478
+ * - The evaluated `question.raw.isHidden` expression returns `true`
519
479
  *
520
480
  * @property {Boolean} hidden
521
481
  */
522
- hidden: computed(
523
- "document.jexl",
524
- "fieldset.field.hidden",
525
- "hiddenDependencies.@each.{hidden,value}",
526
- "jexlContext",
527
- "question.isHidden",
528
- "pk",
529
- function () {
530
- if (
531
- this.fieldset.field?.hidden ||
532
- (this.hiddenDependencies.length &&
533
- this.hiddenDependencies.every(fieldIsHidden))
534
- ) {
535
- return true;
536
- }
482
+ @cached
483
+ get hidden() {
484
+ if (
485
+ this.fieldset.field?.hidden ||
486
+ (this.hiddenDependencies.length &&
487
+ this.hiddenDependencies.every(fieldIsHiddenOrEmpty))
488
+ ) {
489
+ return true;
490
+ }
537
491
 
538
- try {
539
- return this.document.jexl.evalSync(
540
- this.question.isHidden,
541
- this.jexlContext
542
- );
543
- } catch (error) {
544
- throw new Error(
545
- `Error while evaluating \`isHidden\` expression on field \`${this.pk}\`: ${error.message}`
546
- );
547
- }
492
+ try {
493
+ return this.document.jexl.evalSync(
494
+ this.question.raw.isHidden,
495
+ this.jexlContext
496
+ );
497
+ } catch (error) {
498
+ throw new Error(
499
+ `Error while evaluating \`isHidden\` expression on field \`${this.pk}\`: ${error.message}`
500
+ );
548
501
  }
549
- ),
502
+ }
550
503
 
551
504
  /**
552
505
  * The field's optional state
553
506
  *
554
507
  * The field is optional if:
508
+ * - The question is of the type form or calculated float
555
509
  * - The form question field of the fieldset is hidden
556
510
  * - All depending fields (used in the expression) are hidden
557
- * - The evaluated `question.isRequired` expression returns `false`
511
+ * - The evaluated `question.raw.isRequired` expression returns `false`
558
512
  * - The question type is FormQuestion or CalculatedFloatQuestion
559
513
  *
560
514
  * @property {Boolean} optional
561
515
  */
562
- optional: computed(
563
- "document.jexl",
564
- "fieldset.field.hidden",
565
- "jexlContext",
566
- "optionalDependencies.@each.{hidden,value}",
567
- "question.{__typename,isRequired}",
568
- "pk",
569
- function () {
570
- if (
571
- this.fieldset.field?.hidden ||
572
- ["FormQuestion", "CalculatedFloatQuestion"].includes(
573
- this.question.__typename
574
- ) ||
575
- (this.optionalDependencies.length &&
576
- this.optionalDependencies.every(fieldIsHidden))
577
- ) {
578
- return true;
579
- }
516
+ @cached
517
+ get optional() {
518
+ if (
519
+ ["FormQuestion", "CalculatedFloatQuestion"].includes(this.questionType) ||
520
+ this.fieldset.field?.hidden ||
521
+ (this.optionalDependencies.length &&
522
+ this.optionalDependencies.every(fieldIsHiddenOrEmpty))
523
+ ) {
524
+ return true;
525
+ }
580
526
 
581
- try {
582
- return !this.document.jexl.evalSync(
583
- this.question.isRequired,
584
- this.jexlContext
585
- );
586
- } catch (error) {
587
- throw new Error(
588
- `Error while evaluating \`isRequired\` expression on field \`${this.pk}\`: ${error.message}`
589
- );
590
- }
527
+ try {
528
+ return !this.document.jexl.evalSync(
529
+ this.question.raw.isRequired,
530
+ this.jexlContext
531
+ );
532
+ } catch (error) {
533
+ throw new Error(
534
+ `Error while evaluating \`isRequired\` expression on field \`${this.pk}\`: ${error.message}`
535
+ );
591
536
  }
592
- ),
537
+ }
593
538
 
594
539
  /**
595
540
  * Task to save a field. This uses a different mutation for every answer
596
541
  * type.
597
542
  *
598
- * @method save.perform
543
+ * @method save
599
544
  * @return {Object} The response from the server
600
545
  */
601
- save: task(function* () {
546
+ @restartableTask
547
+ *save() {
602
548
  if (this.question.isCalculated) {
603
549
  return;
604
550
  }
605
551
 
606
- const type = this.get("answer.__typename");
552
+ const type = this.answer.raw.__typename;
607
553
 
608
554
  const response = yield this.apollo.mutate(
609
555
  {
610
- mutation: this.get(`saveDocument${type}Mutation`),
556
+ mutation: MUTATION_MAP[type],
611
557
  variables: {
612
558
  input: {
613
559
  question: this.question.slug,
@@ -621,49 +567,47 @@ export default Base.extend({
621
567
  `saveDocument${type}.answer`
622
568
  );
623
569
 
624
- if (this.isNew) {
625
- // if the answer was new we need to set a pk an push the answer to the
626
- // store
627
- this.answer.set("pk", `Answer:${decodeId(response.id)}`);
628
-
629
- this.calumaStore.push(this.answer);
630
- }
570
+ const wasNew = this.isNew;
631
571
 
632
- // update the existing answer
633
- this.answer.setProperties(response);
572
+ Object.entries(response).forEach(([key, value]) => {
573
+ this.answer.raw[key] = value;
574
+ });
634
575
 
635
- this.set("raw.answer", response);
636
- this.set("answer.raw", response);
576
+ if (wasNew) {
577
+ this.answer.pushIntoStore();
578
+ }
637
579
 
638
580
  return response;
639
- }).restartable(),
581
+ }
640
582
 
641
583
  /**
642
- * The error messages on this field.
584
+ * The translated error messages
643
585
  *
644
586
  * @property {String[]} errors
645
- * @accessor
646
587
  */
647
- errors: computed("_errors.[]", function () {
588
+ @cached
589
+ get errors() {
648
590
  return this._errors.map(({ type, context, value }) => {
649
591
  return this.intl.t(
650
592
  `caluma.form.validation.${type}`,
651
593
  Object.assign({}, context, { value })
652
594
  );
653
595
  });
654
- }),
596
+ }
655
597
 
656
598
  /**
657
599
  * Validate the field. Every field goes through the required validation and
658
600
  * the validation for the given question type. This mutates the `errors` on
659
601
  * the field.
660
602
  *
661
- * @method validate.perform
603
+ * @method validate
662
604
  */
663
- validate: task(function* () {
664
- const specificValidation = this.get(`_validate${this.question.__typename}`);
605
+ @restartableTask
606
+ *validate() {
607
+ const specificValidation = this[`_validate${this.questionType}`];
608
+
665
609
  assert(
666
- `Missing validation function for ${this.question.__typename}`,
610
+ `Missing validation function for ${this.questionType}`,
667
611
  specificValidation
668
612
  );
669
613
 
@@ -672,7 +616,7 @@ export default Base.extend({
672
616
  specificValidation,
673
617
  ];
674
618
 
675
- const errors = (yield all(
619
+ const errors = (yield Promise.all(
676
620
  validationFns.map(async (fn) => {
677
621
  const res = await fn.call(this);
678
622
 
@@ -682,121 +626,144 @@ export default Base.extend({
682
626
  .reduce((arr, e) => [...arr, ...e], []) // flatten the array
683
627
  .filter((e) => typeof e === "object");
684
628
 
685
- this.set("_errors", errors);
686
- }).restartable(),
629
+ this._errors = errors;
630
+ }
631
+
632
+ /**
633
+ * Validate the value against the regexes of the given format validators.
634
+ *
635
+ * @method _validateFormatValidators
636
+ * @return {Array<Boolean|Object>} An array of error objects or `true`
637
+ * @private
638
+ */
639
+ _validateFormatValidators() {
640
+ const validators =
641
+ this.question.raw.formatValidators?.edges.map((edge) => edge.node) ?? [];
642
+ const value = this.answer.value;
643
+
644
+ if (isEmpty(value)) {
645
+ // empty values should not be validated since they are handled by the
646
+ // requiredness validation
647
+ return validators.map(() => true);
648
+ }
649
+
650
+ return validators.map((validator) => {
651
+ return (
652
+ new RegExp(validator.regex).test(value) || {
653
+ type: "format",
654
+ context: { errorMsg: validator.errorMsg },
655
+ value,
656
+ }
657
+ );
658
+ });
659
+ }
687
660
 
688
661
  /**
689
662
  * Method to validate if a question is required or not.
690
663
  *
691
664
  * @method _validateRequired
692
- * @return {RSVP.Promise} Returns an promise which resolves into an object if invalid or true if valid
693
- * @internal
665
+ * @return {Boolean|Object} Returns an object if invalid or true if valid
666
+ * @private
694
667
  */
695
- async _validateRequired() {
668
+ _validateRequired() {
696
669
  return (
697
670
  this.optional ||
698
- validate("presence", this.get("answer.value"), { presence: true })
671
+ validate("presence", this.answer.value, { presence: true })
699
672
  );
700
- },
673
+ }
701
674
 
702
675
  /**
703
676
  * Method to validate a text question. This checks if the value longer than
704
677
  * predefined by the question.
705
678
  *
706
679
  * @method _validateTextQuestion
707
- * @return {Object|Boolean} Returns an object if invalid or true if valid
708
- * @internal
680
+ * @return {Array<Boolean|Object>} An array of error objects or `true`
681
+ * @private
709
682
  */
710
- async _validateTextQuestion() {
683
+ _validateTextQuestion() {
711
684
  return [
712
- ...(await this.validator.validate(
713
- this.get("answer.value"),
714
- this.get("question.meta.formatValidators") || []
715
- )),
716
- validate("length", this.get("answer.value"), {
717
- min: this.get("question.textMinLength") || 0,
718
- max: this.get("question.textMaxLength") || Number.POSITIVE_INFINITY,
685
+ ...this._validateFormatValidators(),
686
+ validate("length", this.answer.value, {
687
+ min: this.question.raw.textMinLength || 0,
688
+ max: this.question.raw.textMaxLength || Number.POSITIVE_INFINITY,
719
689
  }),
720
690
  ];
721
- },
691
+ }
722
692
 
723
693
  /**
724
694
  * Method to validate a textarea question. This checks if the value longer
725
695
  * than predefined by the question.
726
696
  *
727
697
  * @method _validateTextareaQuestion
728
- * @return {Object|Boolean} Returns an object if invalid or true if valid
729
- * @internal
698
+ * @return {Array<Boolean|Object>} An array of error objects or `true`
699
+ * @private
730
700
  */
731
- async _validateTextareaQuestion() {
701
+ _validateTextareaQuestion() {
732
702
  return [
733
- ...(await this.validator.validate(
734
- this.get("answer.value"),
735
- this.get("question.meta.formatValidators") || []
736
- )),
737
- validate("length", this.get("answer.value"), {
738
- min: this.get("question.textareaMinLength") || 0,
739
- max: this.get("question.textareaMaxLength") || Number.POSITIVE_INFINITY,
703
+ ...this._validateFormatValidators(),
704
+ validate("length", this.answer.value, {
705
+ min: this.question.raw.textareaMinLength || 0,
706
+ max: this.question.raw.textareaMaxLength || Number.POSITIVE_INFINITY,
740
707
  }),
741
708
  ];
742
- },
709
+ }
743
710
 
744
711
  /**
745
712
  * Method to validate an integer question. This checks if the value is bigger
746
713
  * or less than the options provided by the question.
747
714
  *
748
715
  * @method _validateIntegerQuestion
749
- * @return {Object|Boolean} Returns an object if invalid or true if valid
750
- * @internal
716
+ * @return {Boolean|Object} Returns an object if invalid or true if valid
717
+ * @private
751
718
  */
752
719
  _validateIntegerQuestion() {
753
- return validate("number", this.get("answer.value"), {
720
+ return validate("number", this.answer.value, {
754
721
  integer: true,
755
- gte: this.get("question.integerMinValue") || Number.NEGATIVE_INFINITY,
756
- lte: this.get("question.integerMaxValue") || Number.POSITIVE_INFINITY,
722
+ gte: this.question.raw.integerMinValue || Number.NEGATIVE_INFINITY,
723
+ lte: this.question.raw.integerMaxValue || Number.POSITIVE_INFINITY,
757
724
  });
758
- },
725
+ }
759
726
 
760
727
  /**
761
728
  * Method to validate a float question. This checks if the value is bigger or
762
729
  * less than the options provided by the question.
763
730
  *
764
731
  * @method _validateFloatQuestion
765
- * @return {Object|Boolean} Returns an object if invalid or true if valid
766
- * @internal
732
+ * @return {Boolean|Object} Returns an object if invalid or true if valid
733
+ * @private
767
734
  */
768
735
  _validateFloatQuestion() {
769
- return validate("number", this.get("answer.value"), {
770
- gte: this.get("question.floatMinValue") || Number.NEGATIVE_INFINITY,
771
- lte: this.get("question.floatMaxValue") || Number.POSITIVE_INFINITY,
736
+ return validate("number", this.answer.value, {
737
+ gte: this.question.raw.floatMinValue || Number.NEGATIVE_INFINITY,
738
+ lte: this.question.raw.floatMaxValue || Number.POSITIVE_INFINITY,
772
739
  });
773
- },
740
+ }
774
741
 
775
742
  /**
776
743
  * Method to validate a radio question. This checks if the value is included
777
744
  * in the provided options of the question.
778
745
  *
779
746
  * @method _validateChoiceQuestion
780
- * @return {Object|Boolean} Returns an object if invalid or true if valid
781
- * @internal
747
+ * @return {Boolean|Object} Returns an object if invalid or true if valid
748
+ * @private
782
749
  */
783
750
  _validateChoiceQuestion() {
784
- return validate("inclusion", this.get("answer.value"), {
751
+ return validate("inclusion", this.answer.value, {
785
752
  allowBlank: true,
786
753
  in: (this.options || []).map(({ slug }) => slug),
787
754
  });
788
- },
755
+ }
789
756
 
790
757
  /**
791
758
  * Method to validate a checkbox question. This checks if the all of the
792
759
  * values are included in the provided options of the question.
793
760
  *
794
761
  * @method _validateMultipleChoiceQuestion
795
- * @return {Object[]|Boolean[]|Mixed[]} Returns per value an object if invalid or true if valid
796
- * @internal
762
+ * @return {Boolean|Object} Returns an object if invalid or true if valid
763
+ * @private
797
764
  */
798
765
  _validateMultipleChoiceQuestion() {
799
- const value = this.get("answer.value");
766
+ const value = this.answer.value;
800
767
  if (!value) {
801
768
  return true;
802
769
  }
@@ -805,34 +772,34 @@ export default Base.extend({
805
772
  in: (this.options || []).map(({ slug }) => slug),
806
773
  })
807
774
  );
808
- },
775
+ }
809
776
 
810
777
  /**
811
778
  * Method to validate a radio question. This checks if the value is included
812
779
  * in the provided options of the question.
813
780
  *
814
781
  * @method _validateChoiceQuestion
815
- * @return {Object|Boolean} Returns an object if invalid or true if valid
816
- * @internal
782
+ * @return {Promise<Boolean|Object>} A promise which resolves into an object if invalid or true if valid
783
+ * @private
817
784
  */
818
785
  async _validateDynamicChoiceQuestion() {
819
786
  await this.question.loadDynamicOptions.perform();
820
787
 
821
- return validate("inclusion", this.get("answer.value"), {
788
+ return validate("inclusion", this.answer.value, {
822
789
  in: (this.options || []).map(({ slug }) => slug),
823
790
  });
824
- },
791
+ }
825
792
 
826
793
  /**
827
794
  * Method to validate a checkbox question. This checks if the all of the
828
795
  * values are included in the provided options of the question.
829
796
  *
830
797
  * @method _validateMultipleChoiceQuestion
831
- * @return {Object[]|Boolean[]|Mixed[]} Returns per value an object if invalid or true if valid
832
- * @internal
798
+ * @return {Promise<Boolean[]|Object[]|Mixed[]>} A promise which resolves into an array of objects if invalid or true if valid
799
+ * @private
833
800
  */
834
801
  async _validateDynamicMultipleChoiceQuestion() {
835
- const value = this.get("answer.value");
802
+ const value = this.answer.value;
836
803
 
837
804
  if (!value) {
838
805
  return true;
@@ -845,45 +812,45 @@ export default Base.extend({
845
812
  in: (this.options || []).map(({ slug }) => slug),
846
813
  });
847
814
  });
848
- },
815
+ }
849
816
 
850
817
  /**
851
818
  * Dummy method for the validation of file uploads.
852
819
  *
853
820
  * @method _validateFileQuestion
854
- * @return {RSVP.Promise}
821
+ * @return {Boolean} Always returns true
855
822
  * @private
856
823
  */
857
824
  _validateFileQuestion() {
858
- return resolve(true);
859
- },
825
+ return true;
826
+ }
860
827
 
861
828
  /**
862
829
  * Method to validate a date question.
863
830
  *
864
831
  * @method _validateDateQuestion
865
832
  * @return {Object[]|Boolean[]|Mixed[]} Returns per value an object if invalid or true if valid
866
- * @internal
833
+ * @private
867
834
  */
868
835
  _validateDateQuestion() {
869
- return validate("date", this.get("answer.value"), {
836
+ return validate("date", this.answer.value, {
870
837
  allowBlank: true,
871
838
  });
872
- },
839
+ }
873
840
 
874
841
  /**
875
842
  * Dummy method for the validation of table fields
876
843
  *
877
844
  * @method _validateTableQuestion
878
- * @return {RSVP.Promise}
845
+ * @return {Promise<Boolean|Object>} A promise which resolves into an object if invalid or true if valid
879
846
  * @private
880
847
  */
881
848
  async _validateTableQuestion() {
882
849
  if (!this.value) return true;
883
850
 
884
- const rowValidations = await all(
851
+ const rowValidations = await Promise.all(
885
852
  this.value.map(async (row) => {
886
- const validFields = await all(
853
+ const validFields = await Promise.all(
887
854
  row.fields.map(async (field) => {
888
855
  await field.validate.perform();
889
856
 
@@ -902,49 +869,49 @@ export default Base.extend({
902
869
  value: null,
903
870
  }
904
871
  );
905
- },
872
+ }
906
873
 
907
874
  /**
908
875
  * Dummy method for the validation of static fields
909
876
  *
910
877
  * @method _validateStaticQuestion
911
- * @return {RSVP.Promise}
878
+ * @return {Boolean} Always returns true
912
879
  * @private
913
880
  */
914
881
  _validateStaticQuestion() {
915
- return resolve(true);
916
- },
882
+ return true;
883
+ }
917
884
 
918
885
  /**
919
886
  * Dummy method for the validation of form fields
920
887
  *
921
888
  * @method _validateFormQuestion
922
- * @return {RSVP.Promise}
889
+ * @return {Boolean} Always returns true
923
890
  * @private
924
891
  */
925
892
  _validateFormQuestion() {
926
- return resolve(true);
927
- },
893
+ return true;
894
+ }
928
895
 
929
896
  /**
930
897
  * Dummy method for the validation of calculated float fields
931
898
  *
932
899
  * @method _validateCalculatedFloatQuestion
933
- * @return {RSVP.Promise}
900
+ * @return {Boolean} Always returns true
934
901
  * @private
935
902
  */
936
903
  _validateCalculatedFloatQuestion() {
937
- return resolve(true);
938
- },
904
+ return true;
905
+ }
939
906
 
940
907
  /**
941
908
  * Dummy method for the validation of work item button fields
942
909
  *
943
910
  * @method _validateActionButtonQuestion
944
- * @return {RSVP.Promise}
911
+ * @return {Boolean} Always returns true
945
912
  * @private
946
913
  */
947
914
  _validateActionButtonQuestion() {
948
- return resolve(true);
949
- },
950
- });
915
+ return true;
916
+ }
917
+ }