@projectcaluma/ember-form 14.8.3 → 14.9.1

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 (54) hide show
  1. package/addon/components/cf-content.hbs +44 -39
  2. package/addon/components/cf-content.js +50 -14
  3. package/addon/components/cf-field/input/checkbox.hbs +3 -1
  4. package/addon/components/cf-field/input/checkbox.js +15 -0
  5. package/addon/components/cf-field/input/date.hbs +38 -31
  6. package/addon/components/cf-field/input/float.hbs +22 -15
  7. package/addon/components/cf-field/input/integer.hbs +22 -15
  8. package/addon/components/cf-field/input/number-separator.hbs +19 -12
  9. package/addon/components/cf-field/input/number-separator.js +10 -2
  10. package/addon/components/cf-field/input/powerselect.hbs +2 -2
  11. package/addon/components/cf-field/input/powerselect.js +12 -0
  12. package/addon/components/cf-field/input/radio.hbs +3 -1
  13. package/addon/components/cf-field/input/radio.js +15 -0
  14. package/addon/components/cf-field/input/table.hbs +31 -1
  15. package/addon/components/cf-field/input/table.js +10 -0
  16. package/addon/components/cf-field/input/text.hbs +21 -14
  17. package/addon/components/cf-field/input/textarea.hbs +21 -14
  18. package/addon/components/cf-field/input-compare/changes-note.hbs +9 -0
  19. package/addon/components/cf-field/input-compare/text-diff.hbs +6 -0
  20. package/addon/components/cf-field/input-compare/textarea-diff.hbs +6 -0
  21. package/addon/components/cf-field/input-compare.hbs +50 -0
  22. package/addon/components/cf-field/input-compare.js +60 -0
  23. package/addon/components/cf-field/input.hbs +1 -0
  24. package/addon/components/cf-field.hbs +17 -8
  25. package/addon/components/cf-field.js +15 -3
  26. package/addon/components/cf-form-wrapper.hbs +1 -0
  27. package/addon/components/cf-form.hbs +1 -0
  28. package/addon/components/cf-navigation-item.hbs +14 -4
  29. package/addon/components/cf-navigation.hbs +1 -0
  30. package/addon/components/timeline-select.hbs +20 -0
  31. package/addon/gql/queries/document-answers-compare.graphql +157 -0
  32. package/addon/helpers/get-widget.js +17 -31
  33. package/addon/instance-initializers/form-widget-overrides.js +6 -0
  34. package/addon/lib/answer.js +38 -3
  35. package/addon/lib/compare.js +161 -0
  36. package/addon/lib/document.js +10 -2
  37. package/addon/lib/field.js +81 -1
  38. package/addon/lib/fieldset.js +11 -0
  39. package/addon/lib/navigation.js +51 -1
  40. package/addon/lib/parsers.js +57 -5
  41. package/addon/services/caluma-store.js +12 -0
  42. package/app/components/cf-field/input-compare/changes-note.js +1 -0
  43. package/app/components/cf-field/input-compare/text-diff.js +1 -0
  44. package/app/components/cf-field/input-compare/textarea-diff.js +1 -0
  45. package/app/components/cf-field/input-compare.js +1 -0
  46. package/app/components/timeline-select.js +1 -0
  47. package/app/styles/@projectcaluma/ember-form.scss +1 -0
  48. package/app/styles/_cf-content-compare.scss +214 -0
  49. package/app/styles/_cf-navigation.scss +1 -0
  50. package/package.json +5 -5
  51. package/translations/de.yaml +9 -0
  52. package/translations/en.yaml +9 -0
  53. package/translations/fr.yaml +9 -0
  54. package/translations/it.yaml +9 -0
@@ -0,0 +1,161 @@
1
+ import { isEmpty } from "@ember/utils";
2
+ import isEqual from "lodash.isequal";
3
+
4
+ import { decodeId } from "@projectcaluma/ember-core/helpers/decode-id";
5
+ import { parseDocument } from "@projectcaluma/ember-form/lib/parsers";
6
+
7
+ /**
8
+ * Serializes a value into a comparable format, ignoring
9
+ * falsey differences.
10
+ *
11
+ * @param {*} v
12
+ * @returns {*}
13
+ */
14
+ export function comparisonValue(v) {
15
+ // ignore falsey differences.
16
+ if (isEmpty(v)) {
17
+ return null;
18
+ }
19
+
20
+ // compare arrays as sorted serialized strings.
21
+ if (Array.isArray(v)) {
22
+ return v.map(comparisonValue).toSorted().toString();
23
+ }
24
+
25
+ return v;
26
+ }
27
+
28
+ /**
29
+ * Filters a table answer to a comparable format, dropping all
30
+ * non-relevant fields for comparison.
31
+ *
32
+ * @param {*} answer
33
+ * @returns {Object|null}
34
+ */
35
+ export function filterTableAnswer(answer) {
36
+ const v = { ...answer.node };
37
+ if (isEmpty(v)) {
38
+ return null;
39
+ }
40
+
41
+ // drop question object, but keep the slug for comparison.
42
+ v.questionSlug = v.question?.slug ?? "";
43
+
44
+ // drop all non-relevant fields for value comparison.
45
+ delete v.__typename;
46
+ delete v.historyDate;
47
+ delete v.historyType;
48
+ delete v.historyUserId;
49
+ delete v.question;
50
+ delete v.id;
51
+ delete v.documentId;
52
+
53
+ // flatten all remaining keys as comparable values.
54
+ for (const key of Object.keys(v)) {
55
+ v[key] = comparisonValue(v[key]);
56
+ }
57
+
58
+ return v;
59
+ }
60
+
61
+ /**
62
+ * Creates an object that is suitable for comparison, filtering out
63
+ * non-relevant fields and sorting answers by question slug.
64
+ *
65
+ * @param {Object} doc
66
+ * @returns {Object}
67
+ */
68
+ export function comparableDocument(doc) {
69
+ return {
70
+ id: doc.id,
71
+ answers: {
72
+ edges: doc.answers.edges
73
+ .map((answer) => ({
74
+ node: filterTableAnswer(answer),
75
+ }))
76
+ .toSorted((a, b) => {
77
+ return a.node.questionSlug.localeCompare(b.node.questionSlug);
78
+ }),
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Compares two table documents for equality.
85
+ *
86
+ * @param {Object} docA
87
+ * @param {Object} docB
88
+ * @returns {Boolean}
89
+ */
90
+ export function compareTableDocument(docA, docB) {
91
+ return isEqual(comparableDocument(docA), comparableDocument(docB));
92
+ }
93
+
94
+ /**
95
+ * Compares current and historical table values, returning
96
+ * a list of documents with correct history types for diffing.
97
+ *
98
+ * @param {*} owner
99
+ * @param {*} field
100
+ * @param {*} value
101
+ * @param {*} historicalValue
102
+ * @returns a set of documents for comparison
103
+ */
104
+ export function historicalTableValue(owner, field, value, historicalValue) {
105
+ const Document = owner.factoryFor("caluma-model:document").class;
106
+
107
+ return (
108
+ value
109
+ .map((document) => {
110
+ let historyType = document.historyType;
111
+
112
+ // find corresponding historical document to compare.
113
+ const historicalDocument = historicalValue?.find(
114
+ (histDoc) => histDoc.id === document.id,
115
+ );
116
+
117
+ // if the document is marked as removed and there is no historical counterpart,
118
+ // we skip it from the diff view. (the original document does not
119
+ // include documents that were removed before the historical snapshot).
120
+ if (historyType === "-" && !historicalDocument) {
121
+ return false;
122
+ }
123
+
124
+ if (historyType === "~") {
125
+ if (!historicalDocument) {
126
+ // If a document is marked as modified, but has no historical counterpart,
127
+ // it means it was added as a new document in the current set.
128
+ // If there is another document in the historical set with identical
129
+ // flat table values, we treat this as an unchanged document.
130
+ // (when re-added the same with a different id)
131
+ historyType = historicalValue?.find(
132
+ (histDoc) =>
133
+ decodeId(histDoc.id) !== decodeId(document.id) &&
134
+ compareTableDocument(histDoc, document),
135
+ )
136
+ ? "="
137
+ : "+";
138
+ } else if (compareTableDocument(document, historicalDocument)) {
139
+ // If the modified document has identical flat table values
140
+ // to the historical document, we treat it as identical.
141
+ historyType = "=";
142
+ }
143
+ }
144
+
145
+ return new Document({
146
+ raw: parseDocument({
147
+ ...document,
148
+ historicalAnswers: historicalDocument?.answers,
149
+ historyType,
150
+ }),
151
+ parentDocument: field.document,
152
+ historicalDocument: historicalDocument
153
+ ? parseDocument(historicalDocument)
154
+ : undefined,
155
+ owner,
156
+ });
157
+ })
158
+ // filter out dropped documents;
159
+ .filter(Boolean)
160
+ );
161
+ }
@@ -27,19 +27,23 @@ export default class Document extends Base {
27
27
  parentDocument,
28
28
  parentField,
29
29
  dataSourceContext,
30
+ historicalDocument,
31
+ compare,
30
32
  ...args
31
33
  }) {
32
34
  assert(
33
35
  "A graphql document `raw` must be passed",
34
- raw?.__typename === "Document",
36
+ raw?.__typename.includes("Document"),
35
37
  );
36
38
 
37
39
  super({ raw, ...args });
38
40
 
41
+ this.historicalDocument = historicalDocument;
39
42
  this.parentDocument = parentDocument;
40
43
  this.parentField = parentField;
41
44
  this.dataSourceContext =
42
45
  dataSourceContext ?? parentDocument?.dataSourceContext;
46
+ this.compare = compare ?? parentDocument?.compare;
43
47
 
44
48
  this.pushIntoStore();
45
49
 
@@ -66,7 +70,11 @@ export default class Document extends Base {
66
70
  this,
67
71
  this.calumaStore.find(`${this.pk}:Form:${form.slug}`) ||
68
72
  new (owner.factoryFor("caluma-model:fieldset").class)({
69
- raw: { form, answers: this.raw.answers },
73
+ raw: {
74
+ form,
75
+ answers: this.raw.answers,
76
+ historicalAnswers: this.historicalDocument?.answers,
77
+ },
70
78
  document: this,
71
79
  owner,
72
80
  }),
@@ -21,6 +21,7 @@ import saveDocumentTableAnswerMutation from "@projectcaluma/ember-form/gql/mutat
21
21
  import getDocumentUsedDynamicOptionsQuery from "@projectcaluma/ember-form/gql/queries/document-used-dynamic-options.graphql";
22
22
  import refreshAnswerQuery from "@projectcaluma/ember-form/gql/queries/refresh-answer.graphql";
23
23
  import Base from "@projectcaluma/ember-form/lib/base";
24
+ import { comparisonValue } from "@projectcaluma/ember-form/lib/compare";
24
25
  import dependencies from "@projectcaluma/ember-form/lib/dependencies";
25
26
 
26
27
  export const TYPE_MAP = {
@@ -114,12 +115,23 @@ export default class Field extends Base {
114
115
  return;
115
116
  }
116
117
 
118
+ // If comparison is enabled and there is no answer, add the required
119
+ // historical raw answer data, and mark answer as not changed.
120
+ const rawHistorical = this.compare
121
+ ? {
122
+ historyType: "=",
123
+ historyDate: this.compare.to,
124
+ }
125
+ : {};
126
+
117
127
  answer = new Answer({
118
128
  raw: {
119
129
  id: null,
120
130
  __typename: answerType,
121
131
  question: { slug: this.raw.question.slug },
122
132
  [camelize(answerType.replace(/Answer$/, "Value"))]: null,
133
+ historical: null,
134
+ ...rawHistorical,
123
135
  },
124
136
  field: this,
125
137
  owner,
@@ -127,7 +139,12 @@ export default class Field extends Base {
127
139
  } else {
128
140
  answer =
129
141
  this.calumaStore.find(`Answer:${decodeId(this.raw.answer.id)}`) ||
130
- new Answer({ raw: this.raw.answer, field: this, owner });
142
+ new Answer({
143
+ raw: this.raw.answer,
144
+ historical: this.raw.historicalAnswer,
145
+ field: this,
146
+ owner,
147
+ });
131
148
  }
132
149
 
133
150
  this.answer = associateDestroyableChild(this, answer);
@@ -166,6 +183,14 @@ export default class Field extends Base {
166
183
  */
167
184
  _components = new Set();
168
185
 
186
+ /**
187
+ * Get the compare context via the fieldset.
188
+ * @property {Object} compare
189
+ */
190
+ get compare() {
191
+ return this.fieldset.compare;
192
+ }
193
+
169
194
  /**
170
195
  * The primary key of the field. Consists of the document and question primary
171
196
  * keys.
@@ -197,6 +222,27 @@ export default class Field extends Base {
197
222
  return `${this.pk}:label`;
198
223
  }
199
224
 
225
+ /**
226
+ * Whether the field has been modified between two form timelines.
227
+ *
228
+ * @property {Boolean} isModified
229
+ */
230
+ get isModified() {
231
+ // table answers are manually compared to check if values actually changed,
232
+ // use the historyType attribute to check if changes were made.
233
+ if (this.question.raw.__typename === "TableQuestion") {
234
+ return (this.answer?.value ?? []).some((a) => {
235
+ return a.raw.historyType !== "=";
236
+ });
237
+ }
238
+
239
+ // other answer types can be compared directly, ignoring falsey differences.
240
+ return (
241
+ comparisonValue(this.answer?.value) !==
242
+ comparisonValue(this.answer?.historicalValue)
243
+ );
244
+ }
245
+
200
246
  /**
201
247
  * Whether the field is valid.
202
248
  *
@@ -293,6 +339,19 @@ export default class Field extends Base {
293
339
  return this.answer?.value;
294
340
  }
295
341
 
342
+ /**
343
+ * The historical value of the field
344
+ *
345
+ * @property {*} historicalValue
346
+ */
347
+ get historicalValue() {
348
+ if (this.question.isCalculated) {
349
+ return this.calculatedValue;
350
+ }
351
+
352
+ return this.answer?.historicalValue;
353
+ }
354
+
296
355
  /**
297
356
  * The computed value of a calculated float question
298
357
  *
@@ -465,6 +524,27 @@ export default class Field extends Base {
465
524
  return this.question.isMultipleChoice ? selected : selected[0];
466
525
  }
467
526
 
527
+ /**
528
+ * The historically selected option. This property is only used for choice
529
+ * questions. It can either return null if no value is selected yet, an
530
+ * object for single choices or an array of objects for multiple choices.
531
+ *
532
+ * @property {null|Object|Object[]} selected
533
+ */
534
+ get historicalSelected() {
535
+ if (!this.question.isChoice && !this.question.isMultipleChoice) {
536
+ return null;
537
+ }
538
+
539
+ const selected = this.options.filter(({ slug }) =>
540
+ this.question.isMultipleChoice
541
+ ? (this.historicalValue || []).includes(slug)
542
+ : this.historicalValue === slug,
543
+ );
544
+
545
+ return this.question.isMultipleChoice ? selected : selected[0];
546
+ }
547
+
468
548
  /**
469
549
  * The field's JEXL context.
470
550
  *
@@ -59,6 +59,9 @@ export default class Fieldset extends Base {
59
59
  answer: this.raw.answers.find(
60
60
  (answer) => answer?.question?.slug === question.slug,
61
61
  ),
62
+ historicalAnswer: this.raw.historicalAnswers?.find(
63
+ (answer) => answer?.question?.slug === question.slug,
64
+ ),
62
65
  },
63
66
  fieldset: this,
64
67
  owner,
@@ -69,6 +72,14 @@ export default class Fieldset extends Base {
69
72
  this.fields = fields;
70
73
  }
71
74
 
75
+ /**
76
+ * Get the compare context via the document.
77
+ * @property {Object} compare
78
+ */
79
+ get compare() {
80
+ return this.document.compare;
81
+ }
82
+
72
83
  /**
73
84
  * The primary key of the fieldset. Consists of the document and form primary
74
85
  * keys.
@@ -5,12 +5,17 @@ import {
5
5
  registerDestructor,
6
6
  } from "@ember/destroyable";
7
7
  import { action } from "@ember/object";
8
- import { next, cancel, once } from "@ember/runloop";
8
+ import { cancel, next, once } from "@ember/runloop";
9
9
  import { inject as service } from "@ember/service";
10
10
  import { cached } from "tracked-toolbox";
11
11
 
12
12
  import Base from "@projectcaluma/ember-form/lib/base";
13
13
 
14
+ const COMPARE_STATES = {
15
+ EQUAL: "equal",
16
+ MODIFIED: "modified",
17
+ };
18
+
14
19
  const STATES = {
15
20
  EMPTY: "empty",
16
21
  IN_PROGRESS: "in-progress",
@@ -177,6 +182,28 @@ export class NavigationItem extends Base {
177
182
  return this.navigable || Boolean(this.visibleChildren.length);
178
183
  }
179
184
 
185
+ /**
186
+ * The current compare state consisting of the items and the childrens fieldset
187
+ * state.
188
+ *
189
+ * This can be one of 2 states:
190
+ * - `equal` if every fieldset is `equal`
191
+ * - `modified` if there are `modified` fieldsets
192
+ *
193
+ * @property {String} state
194
+ */
195
+ @cached
196
+ get compareState() {
197
+ const states = [
198
+ this.compareFieldsetState,
199
+ ...this.visibleChildren.map((child) => child.compareFieldsetState),
200
+ ].filter(Boolean);
201
+
202
+ return states.some((state) => state === COMPARE_STATES.MODIFIED)
203
+ ? COMPARE_STATES.MODIFIED
204
+ : COMPARE_STATES.EQUAL;
205
+ }
206
+
180
207
  /**
181
208
  * The current state consisting of the items and the childrens fieldset
182
209
  * state.
@@ -238,6 +265,29 @@ export class NavigationItem extends Base {
238
265
  );
239
266
  }
240
267
 
268
+ /**
269
+ * The current comparestate of the item's fieldset. This does not consider the state
270
+ * of children items.
271
+ *
272
+ * This can be one of 2 states:
273
+ * - `equal` if every field is equal
274
+ * - `modified` if there are modified fields
275
+ *
276
+ * @property {String} compareFieldsetState
277
+ */
278
+ @cached
279
+ get compareFieldsetState() {
280
+ if (!this.visibleFields.length) {
281
+ return null;
282
+ }
283
+
284
+ if (this.visibleFields.some((f) => f.isModified)) {
285
+ return COMPARE_STATES.MODIFIED;
286
+ }
287
+
288
+ return COMPARE_STATES.EQUAL;
289
+ }
290
+
241
291
  /**
242
292
  * The current state of the item's fieldset. This does not consider the state
243
293
  * of children items.
@@ -1,9 +1,9 @@
1
- import { assert } from "@ember/debug";
1
+ import { assert, warn } from "@ember/debug";
2
2
 
3
3
  export const parseDocument = (response) => {
4
4
  assert(
5
5
  "The passed document must be a GraphQL document",
6
- response.__typename === "Document",
6
+ response.__typename.includes("Document"),
7
7
  );
8
8
  assert("The passed document must include a form", response.form);
9
9
  assert("The passed document must include answers", response.answers);
@@ -11,7 +11,12 @@ export const parseDocument = (response) => {
11
11
  return {
12
12
  ...response,
13
13
  rootForm: parseForm(response.form),
14
- answers: response.answers.edges.map(({ node }) => parseAnswer(node)),
14
+ answers: response.answers.edges.map(({ node }) =>
15
+ parseAnswer(node, response.historyType),
16
+ ),
17
+ historicalAnswers: response.historicalAnswers?.edges.map(({ node }) =>
18
+ parseAnswer(node, response.historyType),
19
+ ),
15
20
  forms: parseFormTree(response.form),
16
21
  };
17
22
  };
@@ -42,15 +47,61 @@ export const parseFormTree = (response) => {
42
47
  ];
43
48
  };
44
49
 
45
- export const parseAnswer = (response) => {
50
+ export const parseAnswer = (response, historyType = null) => {
46
51
  assert(
47
52
  "The passed answer must be a GraphQL answer",
48
53
  /Answer$/.test(response.__typename),
49
54
  );
50
55
 
51
- return { ...response };
56
+ // if a whole document is marked as added or removed, we need to
57
+ // propagate that to all the underlying answers as well.
58
+ return {
59
+ ...response,
60
+ // If the parent document was removed/added, propagate that
61
+ // history type to the answer as well.
62
+ historyType: ["-", "+"].includes(historyType)
63
+ ? historyType
64
+ : response.historyType,
65
+ };
52
66
  };
53
67
 
68
+ /**
69
+ * Parse the widget and detects a widget override.
70
+ *
71
+ * @param {Array} params
72
+ * @param {Object} options
73
+ * @returns {Object} {widget: String, override: Boolean}
74
+ */
75
+ export function parseWidgetType(calumaOptions, params, options = {}) {
76
+ for (const obj of params) {
77
+ let widget = obj?.raw?.meta?.widgetOverride;
78
+ if (obj?.useNumberSeparatorWidget) {
79
+ widget = "cf-field/input/number-separator";
80
+ }
81
+
82
+ if (!widget) {
83
+ continue;
84
+ }
85
+
86
+ if (
87
+ !calumaOptions
88
+ .getComponentOverrides()
89
+ .find(({ component }) => component === widget)
90
+ ) {
91
+ warn(
92
+ `Widget override "${widget}" is not registered. Please register it by calling \`calumaOptions.registerComponentOverride\``,
93
+ widget,
94
+ { id: "ember-caluma.unregistered-override" },
95
+ );
96
+ continue;
97
+ }
98
+
99
+ return { widget, override: true };
100
+ }
101
+
102
+ return { widget: options?.default ?? "cf-field/input", override: false };
103
+ }
104
+
54
105
  export const parseQuestion = (response) => {
55
106
  assert(
56
107
  "The passed question must be a GraphQL question",
@@ -66,4 +117,5 @@ export default {
66
117
  parseFormTree,
67
118
  parseAnswer,
68
119
  parseQuestion,
120
+ parseWidgetType,
69
121
  };
@@ -16,6 +16,18 @@ export default class CalumaStoreService extends Service {
16
16
  obj.storeKey,
17
17
  );
18
18
 
19
+ // Do not use the store for historical objects, because in the diff
20
+ // view we switch between different versions of the same object
21
+ // with the same ID, which should not be retrieved from the store.
22
+ const historyRegex = new RegExp("^Historical");
23
+ if (
24
+ historyRegex.test(obj?.raw?.__typename) ||
25
+ historyRegex.test(obj?.raw?.answer?.__typename) ||
26
+ (obj?.raw?.answers || []).some((a) => historyRegex.test(a?.__typename))
27
+ ) {
28
+ return false;
29
+ }
30
+
19
31
  const existing = this._store.get(obj.storeKey);
20
32
 
21
33
  if (existing) {
@@ -0,0 +1 @@
1
+ export { default } from "@projectcaluma/ember-form/components/cf-field/input-compare/changes-note";
@@ -0,0 +1 @@
1
+ export { default } from "@projectcaluma/ember-form/components/cf-field/input-compare/text-diff";
@@ -0,0 +1 @@
1
+ export { default } from "@projectcaluma/ember-form/components/cf-field/input-compare/textarea-diff";
@@ -0,0 +1 @@
1
+ export { default } from "@projectcaluma/ember-form/components/cf-field/input-compare";
@@ -0,0 +1 @@
1
+ export { default } from "@projectcaluma/ember-form/components/timeline-select";
@@ -1,5 +1,6 @@
1
1
  @import "ember-uikit/variables-theme";
2
2
  @import "../cf-field";
3
+ @import "../cf-content-compare";
3
4
  @import "../cf-navigation";
4
5
  @import "../flatpickr";
5
6
  @import "../uikit-overwrites";