@projectcaluma/ember-form-builder 11.1.4 → 11.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * This module holds the application instance which is needed in the validations
3
+ * to allow context of the ember container in order to allow injections of
4
+ * services. The `instance` property will be set in an instance initializer.
5
+ */
6
+ export default { instance: null };
@@ -16,40 +16,17 @@
16
16
  @on-update={{this.updateName}}
17
17
  />
18
18
 
19
- {{#if (or @slug (not this.prefix))}}
20
- <f.input
21
- @type="text"
22
- @name="slug"
23
- @label={{t "caluma.form-builder.form.slug"}}
24
- @required={{true}}
25
- @disabled={{@slug}}
26
- @on-update={{this.updateSlug}}
27
- />
28
- {{else}}
29
- <f.input
30
- @name="slug"
31
- @required={{true}}
32
- @disabled={{@slug}}
33
- @label={{t "caluma.form-builder.question.slug"}}
34
- @on-update={{this.updateSlug value="target.value"}}
35
- as |fi|
36
- >
37
- <div class="cfb-prefixed">
38
- <span class="cfb-prefixed-slug">{{this.prefix}}</span>
39
- <f.input
40
- @type="text"
41
- @model={{fi.model}}
42
- @name={{fi.name}}
43
- @value={{fi.value}}
44
- @update={{fi.update}}
45
- @setDirty={{fi.setDirty}}
46
- @inputId={{fi.inputId}}
47
- @isValid={{fi.isValid}}
48
- @isInvalid={{fi.isInvalid}}
49
- />
50
- </div>
51
- </f.input>
52
- {{/if}}
19
+ <f.input
20
+ @type="text"
21
+ @name="slug"
22
+ @label={{t "caluma.form-builder.form.slug"}}
23
+ @required={{true}}
24
+ @disabled={{not (is-empty @slug)}}
25
+ @renderComponent={{component
26
+ "cfb-slug-input"
27
+ onUnlink=(fn (mut this.slugUnlinked) true)
28
+ }}
29
+ />
53
30
 
54
31
  <f.input
55
32
  @name="description"
@@ -74,7 +51,7 @@
74
51
 
75
52
  <div class="uk-text-right">
76
53
  <f.submit
77
- @disabled={{or f.loading f.model.isInvalid}}
54
+ @disabled={{f.loading}}
78
55
  @label={{t "caluma.form-builder.global.save"}}
79
56
  />
80
57
  </div>
@@ -1,16 +1,14 @@
1
1
  import { action } from "@ember/object";
2
2
  import { inject as service } from "@ember/service";
3
- import { macroCondition, isTesting } from "@embroider/macros";
4
3
  import Component from "@glimmer/component";
5
4
  import { queryManager } from "ember-apollo-client";
6
- import { timeout, restartableTask, dropTask } from "ember-concurrency";
5
+ import { restartableTask, dropTask } from "ember-concurrency";
7
6
  import { trackedTask } from "ember-resources/util/ember-concurrency";
8
7
 
9
8
  import FormValidations from "../../validations/form";
10
9
 
11
10
  import slugify from "@projectcaluma/ember-core/utils/slugify";
12
11
  import saveFormMutation from "@projectcaluma/ember-form-builder/gql/mutations/save-form.graphql";
13
- import checkFormSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-form-slug.graphql";
14
12
  import formEditorGeneralQuery from "@projectcaluma/ember-form-builder/gql/queries/form-editor-general.graphql";
15
13
 
16
14
  export default class CfbFormEditorGeneral extends Component {
@@ -61,16 +59,13 @@ export default class CfbFormEditorGeneral extends Component {
61
59
  @dropTask
62
60
  *submit(changeset) {
63
61
  try {
64
- const slug =
65
- ((!this.args.slug && this.prefix) || "") + changeset.get("slug");
66
-
67
62
  const form = yield this.apollo.mutate(
68
63
  {
69
64
  mutation: saveFormMutation,
70
65
  variables: {
71
66
  input: {
72
67
  name: changeset.get("name"),
73
- slug,
68
+ slug: changeset.get("slug"),
74
69
  description: changeset.get("description"),
75
70
  isArchived: changeset.get("isArchived"),
76
71
  isPublished: changeset.get("isPublished"),
@@ -100,47 +95,15 @@ export default class CfbFormEditorGeneral extends Component {
100
95
  }
101
96
  }
102
97
 
103
- @restartableTask
104
- *validateSlug(slug, changeset) {
105
- /* istanbul ignore next */
106
- if (macroCondition(isTesting())) {
107
- // no timeout
108
- } else {
109
- yield timeout(500);
110
- }
111
-
112
- const res = yield this.apollo.query(
113
- {
114
- query: checkFormSlugQuery,
115
- variables: { slug },
116
- },
117
- "allForms.edges"
118
- );
119
-
120
- if (res && res.length) {
121
- changeset.pushErrors(
122
- "slug",
123
- this.intl.t("caluma.form-builder.validations.form.slug")
124
- );
125
- }
126
- }
127
-
128
98
  @action
129
99
  updateName(value, changeset) {
130
100
  changeset.set("name", value);
131
101
 
132
- if (!this.args.slug) {
133
- const slug = slugify(value, { locale: this.intl.primaryLocale });
134
- changeset.set("slug", slug);
102
+ if (!this.args.slug && !this.slugUnlinked) {
103
+ const slugifiedName = slugify(value, { locale: this.intl.primaryLocale });
104
+ const slug = slugifiedName ? this.prefix + slugifiedName : "";
135
105
 
136
- this.validateSlug.perform(this.prefix + slug, changeset);
106
+ changeset.set("slug", slug);
137
107
  }
138
108
  }
139
-
140
- @action
141
- updateSlug(value, changeset) {
142
- changeset.set("slug", value);
143
-
144
- this.validateSlug.perform(this.prefix + value, changeset);
145
- }
146
109
  }
@@ -5,26 +5,26 @@
5
5
  @handle=".uk-sortable-handle"
6
6
  @onMoved={{this._handleMoved}}
7
7
  @tagName="ul"
8
- class="uk-list uk-list-divider uk-margin-remove-bottom uk-margin-small-top"
8
+ class="uk-list uk-list-divider uk-form-controls uk-margin-small-top"
9
9
  >
10
- {{#each this.optionRows as |row i|}}
11
- <li data-test-row={{concat "option-" (add i 1)}}>
10
+ {{#each @value as |row i|}}
11
+ <li class="cfb-option-row" data-test-row={{concat "option-" (add i 1)}}>
12
12
  <ValidatedForm @model={{row}} as |f|>
13
13
  <div
14
14
  uk-grid
15
- class="uk-grid-small uk-flex uk-flex-middle"
15
+ class="uk-grid-small uk-flex uk-flex-top"
16
16
  id={{row.slug}}
17
17
  >
18
- {{#if (not (and row.isNew (gt this.optionRows.length 1)))}}
19
- <span
20
- data-test-sort-handle
21
- uk-icon="menu"
22
- class="uk-sortable-handle"
23
- role="button"
24
- ></span>
25
- {{/if}}
26
- <div class="uk-width-auto">
27
- {{#if (and row.isNew (gt this.optionRows.length 1))}}
18
+ <div class="uk-width-auto uk-flex uk-flex-middle">
19
+ {{#if this.canReorder}}
20
+ <span
21
+ data-test-sort-handle
22
+ uk-icon="menu"
23
+ class="uk-sortable-handle uk-margin-small-right"
24
+ role="button"
25
+ ></span>
26
+ {{/if}}
27
+ {{#if (is-empty row.id)}}
28
28
  <button
29
29
  data-test-delete-row
30
30
  type="button"
@@ -34,21 +34,25 @@
34
34
  {{on "click" (fn this.deleteRow row)}}
35
35
  >
36
36
  </button>
37
+ {{else}}
38
+ <button
39
+ data-test-archive-row
40
+ type="button"
41
+ class="uk-icon-button"
42
+ uk-icon={{if row.isArchived "refresh" "close"}}
43
+ title={{t
44
+ (concat
45
+ "caluma.form-builder.options."
46
+ (if row.isArchived "restore" "archive")
47
+ )
48
+ }}
49
+ {{on
50
+ "click"
51
+ (fn (changeset-set row "isArchived") (not row.isArchived))
52
+ }}
53
+ >
54
+ </button>
37
55
  {{/if}}
38
- <button
39
- data-test-archive-row
40
- type="button"
41
- class="uk-icon-button"
42
- uk-icon={{if row.isArchived "plus" "close"}}
43
- title={{t
44
- (concat
45
- "caluma.form-builder.options."
46
- (if row.isArchived "restore" "archive")
47
- )
48
- }}
49
- {{on "click" (fn this.toggleRowArchived row)}}
50
- >
51
- </button>
52
56
  </div>
53
57
  <div class="uk-width-expand">
54
58
  <f.input
@@ -56,6 +60,7 @@
56
60
  @inputName={{concat "option-" (add i 1) "-label"}}
57
61
  @required={{true}}
58
62
  @disabled={{row.isArchived}}
63
+ @submitted={{@submitted}}
59
64
  @on-update={{this.updateLabel}}
60
65
  />
61
66
  </div>
@@ -64,8 +69,14 @@
64
69
  @name="slug"
65
70
  @inputName={{concat "option-" (add i 1) "-slug"}}
66
71
  @required={{true}}
67
- @disabled={{not row.isNew}}
68
- @on-update={{this.updateSlug}}
72
+ @disabled={{not (is-empty row.id)}}
73
+ @submitted={{@submitted}}
74
+ @renderComponent={{component
75
+ "cfb-slug-input"
76
+ hidePrefix=true
77
+ prefix=@model.slug
78
+ onUnlink=(fn (mut row.slugUnlinked) true)
79
+ }}
69
80
  />
70
81
  </div>
71
82
  </div>
@@ -1,11 +1,10 @@
1
1
  import { action } from "@ember/object";
2
2
  import { inject as service } from "@ember/service";
3
3
  import Component from "@glimmer/component";
4
- import { tracked } from "@glimmer/tracking";
5
4
  import { queryManager } from "ember-apollo-client";
6
5
  import { Changeset } from "ember-changeset";
7
6
  import lookupValidator from "ember-changeset-validations";
8
- import { task } from "ember-concurrency";
7
+ import { dropTask } from "ember-concurrency";
9
8
 
10
9
  import slugify from "@projectcaluma/ember-core/utils/slugify";
11
10
  import saveChoiceQuestionMutation from "@projectcaluma/ember-form-builder/gql/mutations/save-choice-question.graphql";
@@ -17,90 +16,24 @@ const TYPES = {
17
16
  ChoiceQuestion: saveChoiceQuestionMutation,
18
17
  };
19
18
 
20
- const removeQuestionPrefix = (slug, questionSlug) => {
21
- return slug.replace(new RegExp(`^${questionSlug}-`), "");
22
- };
23
-
24
- const addQuestionPrefix = (slug, questionSlug) => {
25
- return `${questionSlug}-${slug}`;
26
- };
27
-
28
19
  export default class CfbFormEditorQuestionOptions extends Component {
29
- @tracked _optionRows;
30
-
31
20
  @service intl;
32
21
  @service notification;
33
- @queryManager apollo;
34
-
35
- constructor(...args) {
36
- super(...args);
37
-
38
- this._optionRows = this.args.value?.edges?.length
39
- ? this.args.value.edges.map(
40
- (edge) =>
41
- new Changeset(
42
- {
43
- slug: removeQuestionPrefix(edge.node.slug, this.questionSlug),
44
- label: edge.node.label,
45
- isArchived: edge.node.isArchived,
46
- isNew: false,
47
- },
48
- lookupValidator(OptionValidations),
49
- OptionValidations
50
- )
51
- )
52
- : [
53
- new Changeset(
54
- { slug: "", label: "", isNew: true, linkSlug: true },
55
- lookupValidator(OptionValidations),
56
- OptionValidations
57
- ),
58
- ];
59
- }
60
-
61
- get questionSlug() {
62
- return this.args.model.slug;
63
- }
64
-
65
- get optionRows() {
66
- return this._optionRows;
67
- }
68
22
 
69
- _update() {
70
- this.args.update({
71
- edges: this.optionRows
72
- .filter((row) => !row.isNew || row.isDirty)
73
- .map((row) => {
74
- const { label, slug, isArchived } = Object.assign(
75
- {},
76
- row.get("data"),
77
- row.get("change")
78
- );
79
-
80
- return {
81
- node: {
82
- label,
83
- slug: addQuestionPrefix(
84
- removeQuestionPrefix(slug, this.questionSlug),
85
- this.questionSlug
86
- ),
87
- isArchived: Boolean(isArchived),
88
- },
89
- };
90
- }),
91
- });
23
+ @queryManager apollo;
92
24
 
93
- this.args.setDirty();
25
+ get canReorder() {
26
+ return this.args.value.every((row) => row.get("id") !== undefined);
94
27
  }
95
28
 
96
- @task
29
+ @dropTask
97
30
  *reorderOptions(slugs) {
98
31
  try {
99
32
  yield this.apollo.mutate({
100
33
  mutation: TYPES[this.args.model.__typename],
101
34
  variables: {
102
35
  input: {
103
- slug: this.questionSlug,
36
+ slug: this.args.model.slug,
104
37
  label: this.args.model.label,
105
38
  options: slugs,
106
39
  },
@@ -123,55 +56,45 @@ export default class CfbFormEditorQuestionOptions extends Component {
123
56
 
124
57
  @action
125
58
  addRow() {
126
- this._optionRows = [
127
- ...this.optionRows,
59
+ this.args.update([
60
+ ...this.args.value,
128
61
  new Changeset(
129
- { slug: "", label: "", isNew: true, linkSlug: true },
62
+ {
63
+ id: undefined,
64
+ slug: "",
65
+ label: "",
66
+ isArchived: false,
67
+ slugUnlinked: false,
68
+ question: this.args.model.slug,
69
+ },
130
70
  lookupValidator(OptionValidations),
131
71
  OptionValidations
132
72
  ),
133
- ];
73
+ ]);
134
74
 
135
- this._update();
75
+ this.args.setDirty();
136
76
  }
137
77
 
138
78
  @action
139
79
  deleteRow(row) {
140
- this._optionRows = this.optionRows.filter((r) => r !== row);
141
-
142
- this._update();
143
- }
144
-
145
- @action
146
- toggleRowArchived(row) {
147
- row.set("isArchived", !row.get("isArchived"));
148
-
149
- this._update();
80
+ this.args.update(this.args.value.filter((r) => r !== row));
81
+ this.args.setDirty();
150
82
  }
151
83
 
152
84
  @action
153
85
  updateLabel(value, changeset) {
154
86
  changeset.set("label", value);
155
87
 
156
- if (changeset.get("isNew") && changeset.get("linkSlug")) {
157
- changeset.set(
158
- "slug",
159
- slugify(value, { locale: this.intl.primaryLocale })
160
- );
161
- }
162
- this._update();
163
- }
164
-
165
- @action
166
- updateSlug(value, changeset) {
167
- changeset.set("slug", value);
168
- changeset.set("linkSlug", false);
169
- this._update();
170
- }
88
+ if (!changeset.get("id") && !changeset.get("slugUnlinked")) {
89
+ const slugifiedLabel = slugify(value, {
90
+ locale: this.intl.primaryLocale,
91
+ });
92
+ const slug = slugifiedLabel
93
+ ? `${this.args.model.slug}-${slugifiedLabel}`
94
+ : "";
171
95
 
172
- @action
173
- update() {
174
- this._update();
96
+ changeset.set("slug", slug);
97
+ }
175
98
  }
176
99
 
177
100
  @action
@@ -180,12 +103,7 @@ export default class CfbFormEditorQuestionOptions extends Component {
180
103
  const options = [...sortable.$el.children].slice(0, -1);
181
104
 
182
105
  this.reorderOptions.perform(
183
- options.map((option) =>
184
- addQuestionPrefix(
185
- option.firstElementChild.firstElementChild.id,
186
- this.questionSlug
187
- )
188
- )
106
+ options.map((option) => option.firstElementChild.firstElementChild.id)
189
107
  );
190
108
  }
191
109
  }
@@ -34,7 +34,7 @@
34
34
  @hint={{t "caluma.form-builder.question.type-disabled"}}
35
35
  @name="__typename"
36
36
  @required={{true}}
37
- @disabled={{@slug}}
37
+ @disabled={{not (is-empty @slug)}}
38
38
  @on-update={{changeset-set f.model "__typename"}}
39
39
  />
40
40
 
@@ -47,39 +47,16 @@
47
47
 
48
48
  <div uk-grid class="uk-grid-small uk-margin">
49
49
  <div class="uk-width-expand">
50
- {{#if (or @slug (not this.prefix))}}
51
- <f.input
52
- @name="slug"
53
- @label={{t "caluma.form-builder.question.slug"}}
54
- @required={{true}}
55
- @disabled={{@slug}}
56
- @on-update={{this.updateSlug}}
57
- />
58
- {{else}}
59
- <f.input
60
- @name="slug"
61
- @label={{t "caluma.form-builder.question.slug"}}
62
- @required={{true}}
63
- @disabled={{@slug}}
64
- @on-update={{this.updateSlug value="target.value"}}
65
- as |fi|
66
- >
67
- <div class="cfb-prefixed">
68
- <span class="cfb-prefixed-slug">{{this.prefix}}</span>
69
-
70
- <f.input
71
- @model={{fi.model}}
72
- @name={{fi.name}}
73
- @value={{fi.value}}
74
- @update={{fi.update}}
75
- @setDirty={{fi.setDirty}}
76
- @inputId={{fi.inputId}}
77
- @isValid={{fi.isValid}}
78
- @isInvalid={{fi.isInvalid}}
79
- />
80
- </div>
81
- </f.input>
82
- {{/if}}
50
+ <f.input
51
+ @name="slug"
52
+ @label={{t "caluma.form-builder.question.slug"}}
53
+ @required={{true}}
54
+ @disabled={{not (is-empty @slug)}}
55
+ @renderComponent={{component
56
+ "cfb-slug-input"
57
+ onUnlink=(fn (mut this.slugUnlinked) true)
58
+ }}
59
+ />
83
60
  </div>
84
61
 
85
62
  {{#if
@@ -459,7 +436,7 @@
459
436
 
460
437
  <div class="uk-text-right">
461
438
  <f.submit
462
- @disabled={{or f.loading f.model.isInvalid}}
439
+ @disabled={{f.loading}}
463
440
  @label={{t "caluma.form-builder.global.save"}}
464
441
  />
465
442
  </div>
@@ -2,13 +2,12 @@ import { A } from "@ember/array";
2
2
  import { action } from "@ember/object";
3
3
  import { inject as service } from "@ember/service";
4
4
  import { camelize } from "@ember/string";
5
- import { macroCondition, isTesting } from "@embroider/macros";
6
5
  import Component from "@glimmer/component";
7
6
  import { tracked } from "@glimmer/tracking";
8
7
  import { queryManager } from "ember-apollo-client";
9
8
  import Changeset from "ember-changeset";
10
9
  import lookupValidator from "ember-changeset-validations";
11
- import { dropTask, restartableTask, task, timeout } from "ember-concurrency";
10
+ import { dropTask, restartableTask, task } from "ember-concurrency";
12
11
 
13
12
  import { hasQuestionType } from "@projectcaluma/ember-core/helpers/has-question-type";
14
13
  import slugify from "@projectcaluma/ember-core/utils/slugify";
@@ -37,9 +36,9 @@ import saveTableQuestionMutation from "@projectcaluma/ember-form-builder/gql/mut
37
36
  import saveTextQuestionMutation from "@projectcaluma/ember-form-builder/gql/mutations/save-text-question.graphql";
38
37
  import saveTextareaQuestionMutation from "@projectcaluma/ember-form-builder/gql/mutations/save-textarea-question.graphql";
39
38
  import allDataSourcesQuery from "@projectcaluma/ember-form-builder/gql/queries/all-data-sources.graphql";
40
- import checkQuestionSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-question-slug.graphql";
41
39
  import formEditorQuestionQuery from "@projectcaluma/ember-form-builder/gql/queries/form-editor-question.graphql";
42
40
  import formListQuery from "@projectcaluma/ember-form-builder/gql/queries/form-list.graphql";
41
+ import optionValidations from "@projectcaluma/ember-form-builder/validations/option";
43
42
  import validations from "@projectcaluma/ember-form-builder/validations/question";
44
43
 
45
44
  export const TYPES = {
@@ -77,9 +76,9 @@ export default class CfbFormEditorQuestion extends Component {
77
76
  @service notification;
78
77
  @service intl;
79
78
  @service calumaOptions;
79
+
80
80
  @queryManager apollo;
81
81
 
82
- @tracked linkSlug = true;
83
82
  @tracked changeset;
84
83
 
85
84
  @restartableTask
@@ -209,13 +208,10 @@ export default class CfbFormEditorQuestion extends Component {
209
208
  }
210
209
 
211
210
  getInput(changeset) {
212
- const slug =
213
- ((!this.args.slug && this.prefix) || "") + changeset.get("slug");
214
-
215
211
  const rawMeta = changeset.get("meta");
216
212
 
217
213
  const input = {
218
- slug,
214
+ slug: changeset.get("slug"),
219
215
  label: changeset.get("label"),
220
216
  isHidden: changeset.get("isHidden"),
221
217
  infoText: changeset.get("infoText"),
@@ -289,14 +285,14 @@ export default class CfbFormEditorQuestion extends Component {
289
285
 
290
286
  _getMultipleChoiceQuestionInput(changeset) {
291
287
  return {
292
- options: changeset.get("options.edges").map(({ node: { slug } }) => slug),
288
+ options: changeset.get("options").map(({ slug }) => slug),
293
289
  hintText: changeset.get("hintText"),
294
290
  };
295
291
  }
296
292
 
297
293
  _getChoiceQuestionInput(changeset) {
298
294
  return {
299
- options: changeset.get("options.edges").map(({ node: { slug } }) => slug),
295
+ options: changeset.get("options").map(({ slug }) => slug),
300
296
  hintText: changeset.get("hintText"),
301
297
  };
302
298
  }
@@ -358,14 +354,16 @@ export default class CfbFormEditorQuestion extends Component {
358
354
  @task
359
355
  *saveOptions(changeset) {
360
356
  yield Promise.all(
361
- (changeset.get("options.edges") || []).map(async ({ node: option }) => {
362
- const { label, slug, isArchived } = option;
363
-
364
- await this.apollo.mutate({
365
- mutation: saveOptionMutation,
366
- variables: { input: { label, slug, isArchived } },
367
- });
368
- })
357
+ (changeset.get("options") || [])
358
+ .filter((option) => option.get("isDirty"))
359
+ .map(async (option) => {
360
+ const { label, slug, isArchived } = option;
361
+
362
+ await this.apollo.mutate({
363
+ mutation: saveOptionMutation,
364
+ variables: { input: { label, slug, isArchived } },
365
+ });
366
+ })
369
367
  );
370
368
  }
371
369
 
@@ -447,39 +445,36 @@ export default class CfbFormEditorQuestion extends Component {
447
445
  }
448
446
  }
449
447
 
450
- @restartableTask
451
- *validateSlug(slug, changeset) {
452
- /* istanbul ignore next */
453
- if (macroCondition(isTesting())) {
454
- // no timeout
455
- } else {
456
- yield timeout(500);
457
- }
458
-
459
- const res = yield this.apollo.query(
460
- {
461
- query: checkQuestionSlugQuery,
462
- variables: { slug },
463
- },
464
- "allQuestions.edges"
465
- );
466
-
467
- if (res && res.length) {
468
- changeset.pushErrors(
469
- "slug",
470
- this.intl.t("caluma.form-builder.validations.question.slug")
471
- );
472
- }
473
- }
474
-
475
448
  @action
476
449
  async fetchData() {
477
450
  await this.data.perform();
478
451
  await this.availableForms.perform();
479
452
  await this.availableDataSources.perform();
480
453
  if (this.model) {
454
+ const options = this.model.options?.edges?.map(
455
+ (edge) =>
456
+ new Changeset(
457
+ { ...edge.node, slugUnlinked: false, question: this.model.slug },
458
+ lookupValidator(optionValidations),
459
+ optionValidations
460
+ )
461
+ ) ?? [
462
+ new Changeset(
463
+ {
464
+ id: undefined,
465
+ label: "",
466
+ slug: "",
467
+ isArchived: false,
468
+ slugUnlinked: false,
469
+ question: this.model.slug,
470
+ },
471
+ lookupValidator(optionValidations),
472
+ optionValidations
473
+ ),
474
+ ];
475
+
481
476
  this.changeset = new Changeset(
482
- this.model,
477
+ { ...this.model, options },
483
478
  lookupValidator(validations),
484
479
  validations
485
480
  );
@@ -490,23 +485,16 @@ export default class CfbFormEditorQuestion extends Component {
490
485
  updateLabel(value, changeset) {
491
486
  changeset.set("label", value);
492
487
 
493
- if (!this.args.slug && this.linkSlug) {
494
- const slug = slugify(value, { locale: this.intl.primaryLocale });
488
+ if (!this.args.slug && !this.slugUnlinked) {
489
+ const slugifiedLabel = slugify(value, {
490
+ locale: this.intl.primaryLocale,
491
+ });
492
+ const slug = slugifiedLabel ? this.prefix + slugifiedLabel : "";
495
493
 
496
494
  changeset.set("slug", slug);
497
-
498
- this.validateSlug.perform(this.prefix + slug, changeset);
499
495
  }
500
496
  }
501
497
 
502
- @action
503
- updateSlug(value, changeset) {
504
- changeset.set("slug", value);
505
- this.linkSlug = false;
506
-
507
- this.validateSlug.perform(this.prefix + value, changeset);
508
- }
509
-
510
498
  @action
511
499
  updateSubForm(value, changeset) {
512
500
  changeset.set("subForm.slug", value.slug);
@@ -0,0 +1,33 @@
1
+ <div class="uk-margin">
2
+ <@labelComponent />
3
+
4
+ <div class="uk-form-controls">
5
+ <div class="cfb-slug-input">
6
+ {{#if (and (not (or @disabled @hidePrefix)) this.prefix)}}
7
+ <span
8
+ class="cfb-slug-input__prefix
9
+ {{if @isValid 'uk-text-success'}}
10
+ {{if @isInvalid 'uk-text-danger'}}"
11
+ {{did-insert this.calculatePadding}}
12
+ >
13
+ {{this.prefix}}
14
+ </span>
15
+ {{/if}}
16
+ <input
17
+ id={{@inputId}}
18
+ name={{or @inputName @name}}
19
+ type="text"
20
+ class="uk-input cfb-slug-input__input
21
+ {{if @isValid 'uk-form-success'}}
22
+ {{if @isInvalid 'uk-form-danger'}}"
23
+ value={{this.displayValue}}
24
+ disabled={{@disabled}}
25
+ style={{this.inputStyle}}
26
+ {{on "input" this.update}}
27
+ />
28
+ </div>
29
+ </div>
30
+
31
+ <@hintComponent />
32
+ <@errorComponent />
33
+ </div>
@@ -0,0 +1,45 @@
1
+ import { action } from "@ember/object";
2
+ import { inject as service } from "@ember/service";
3
+ import { htmlSafe } from "@ember/template";
4
+ import Component from "@glimmer/component";
5
+ import { tracked } from "@glimmer/tracking";
6
+
7
+ export default class CfbSlugInputComponent extends Component {
8
+ @service calumaOptions;
9
+ @service intl;
10
+
11
+ @tracked padding = null;
12
+
13
+ get prefix() {
14
+ const prefix = this.args.prefix ?? this.calumaOptions.namespace ?? null;
15
+
16
+ return prefix ? `${prefix}-` : "";
17
+ }
18
+
19
+ get inputStyle() {
20
+ return this.padding ? htmlSafe(`padding-left: ${this.padding};`) : "";
21
+ }
22
+
23
+ get displayValue() {
24
+ if (this.args.disabled && !this.args.hidePrefix) {
25
+ return this.args.value;
26
+ }
27
+
28
+ return this.args.value?.replace(new RegExp(`^${this.prefix}`), "") ?? "";
29
+ }
30
+
31
+ @action
32
+ calculatePadding(element) {
33
+ const prefixWidth = element.clientWidth;
34
+ const prefixMargin = window.getComputedStyle(element).marginLeft;
35
+
36
+ this.padding = `calc(${prefixWidth}px + ${prefixMargin})`;
37
+ }
38
+
39
+ @action
40
+ update({ target: { value } }) {
41
+ this.args.update(value ? this.prefix + value : "");
42
+ this.args.setDirty();
43
+ this.args.onUnlink?.();
44
+ }
45
+ }
@@ -1,10 +1,5 @@
1
1
  query CheckFormSlug($slug: String!) {
2
2
  allForms(filter: [{ slugs: [$slug] }]) {
3
- edges {
4
- node {
5
- id
6
- slug
7
- }
8
- }
3
+ totalCount
9
4
  }
10
5
  }
@@ -0,0 +1,19 @@
1
+ query CheckOptionSlug($slug: String!, $question: String!) {
2
+ allQuestions(filter: [{ slugs: [$question] }]) {
3
+ edges {
4
+ node {
5
+ id
6
+ ... on ChoiceQuestion {
7
+ options(filter: [{ slug: $slug }]) {
8
+ totalCount
9
+ }
10
+ }
11
+ ... on MultipleChoiceQuestion {
12
+ options(filter: [{ slug: $slug }]) {
13
+ totalCount
14
+ }
15
+ }
16
+ }
17
+ }
18
+ }
19
+ }
@@ -1,10 +1,5 @@
1
1
  query CheckQuestionSlug($slug: String!) {
2
2
  allQuestions(filter: [{ slugs: [$slug] }]) {
3
- edges {
4
- node {
5
- id
6
- slug
7
- }
8
- }
3
+ totalCount
9
4
  }
10
5
  }
@@ -0,0 +1,9 @@
1
+ import application from "@projectcaluma/ember-form-builder/-private/application";
2
+
3
+ export function initialize(appInstance) {
4
+ application.instance = appInstance;
5
+ }
6
+
7
+ export default {
8
+ initialize,
9
+ };
@@ -1,14 +1,11 @@
1
1
  import {
2
2
  validatePresence,
3
3
  validateLength,
4
- validateFormat,
5
4
  } from "ember-changeset-validations/validators";
6
5
 
6
+ import slugValidation from "@projectcaluma/ember-form-builder/validators/slug";
7
+
7
8
  export default {
8
9
  name: [validatePresence(true), validateLength({ max: 255 })],
9
- slug: [
10
- validatePresence(true),
11
- validateLength({ max: 50 }),
12
- validateFormat({ regex: /^[a-z0-9-]+$/ }),
13
- ],
10
+ slug: slugValidation({ type: "form", maxLength: 50 }),
14
11
  };
@@ -3,10 +3,9 @@ import {
3
3
  validateLength,
4
4
  } from "ember-changeset-validations/validators";
5
5
 
6
- import and from "@projectcaluma/ember-form-builder/utils/and";
7
- import validateSlug from "@projectcaluma/ember-form-builder/validators/slug";
6
+ import slugValidation from "@projectcaluma/ember-form-builder/validators/slug";
8
7
 
9
8
  export default {
10
- label: and(validatePresence(true), validateLength({ max: 1024 })),
11
- slug: validateSlug(),
9
+ label: [validatePresence(true), validateLength({ max: 1024 })],
10
+ slug: slugValidation({ type: "option" }),
12
11
  };
@@ -11,12 +11,12 @@ import and from "@projectcaluma/ember-form-builder/utils/and";
11
11
  import or from "@projectcaluma/ember-form-builder/utils/or";
12
12
  import validateJexl from "@projectcaluma/ember-form-builder/validators/jexl";
13
13
  import validateMeta from "@projectcaluma/ember-form-builder/validators/meta";
14
- import validateSlug from "@projectcaluma/ember-form-builder/validators/slug";
14
+ import slugValidation from "@projectcaluma/ember-form-builder/validators/slug";
15
15
  import validateType from "@projectcaluma/ember-form-builder/validators/type";
16
16
 
17
17
  export default {
18
18
  label: and(validatePresence(true), validateLength({ max: 1024 })),
19
- slug: validateSlug(),
19
+ slug: slugValidation({ type: "question" }),
20
20
 
21
21
  hintText: or(
22
22
  validateType("FormQuestion", true),
@@ -1,25 +1,7 @@
1
- import Changeset from "ember-changeset";
2
- import lookupValidator from "ember-changeset-validations";
3
- import { Promise, all } from "rsvp";
4
-
5
- import OptionValidations from "../validations/option";
6
-
7
1
  export default function validateOptions() {
8
- return (_, value) => {
9
- return new Promise((resolve) => {
10
- all(
11
- value.edges.map(async ({ node: option }) => {
12
- const cs = new Changeset(
13
- option,
14
- lookupValidator(OptionValidations),
15
- OptionValidations
16
- );
17
-
18
- await cs.validate();
19
-
20
- return cs.get("isValid");
21
- })
22
- ).then((res) => resolve(res.every(Boolean) || "Invalid options"));
23
- });
2
+ return (_, newValue) => {
3
+ return (
4
+ newValue.every((option) => option.get("isValid")) || "Invalid options"
5
+ );
24
6
  };
25
7
  }
@@ -1,16 +1,118 @@
1
+ import { setOwner } from "@ember/application";
2
+ import { inject as service } from "@ember/service";
3
+ import { macroCondition, isTesting, importSync } from "@embroider/macros";
4
+ import { queryManager } from "ember-apollo-client";
1
5
  import {
2
6
  validatePresence,
3
7
  validateLength,
4
8
  validateFormat,
5
9
  } from "ember-changeset-validations/validators";
10
+ import { timeout, restartableTask, didCancel } from "ember-concurrency";
6
11
 
7
- import and from "@projectcaluma/ember-form-builder/utils/and";
12
+ import checkFormSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-form-slug.graphql";
13
+ import checkOptionSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-option-slug.graphql";
14
+ import checkQuestionSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-question-slug.graphql";
8
15
 
9
- const validateSlug = () =>
10
- and(
11
- validatePresence(true),
12
- validateLength({ max: 127 }),
13
- validateFormat({ regex: /^[a-z0-9-]+$/ })
14
- );
16
+ export class SlugUniquenessValidator {
17
+ @service intl;
18
+
19
+ @queryManager apollo;
20
+
21
+ queries = {
22
+ form: checkFormSlugQuery,
23
+ question: checkQuestionSlugQuery,
24
+ option: checkOptionSlugQuery,
25
+ };
26
+
27
+ cache = {
28
+ form: {},
29
+ question: {},
30
+ option: {},
31
+ };
32
+
33
+ constructor(type) {
34
+ this.type = type;
35
+ }
36
+
37
+ async validate(key, newValue, oldValue, changes, context) {
38
+ const application = importSync(
39
+ "@projectcaluma/ember-form-builder/-private/application"
40
+ ).default;
41
+
42
+ setOwner(this, application.instance);
43
+
44
+ // If the object already exists or the slug is empty we don't need to check
45
+ // for uniqueness
46
+ if (context.id || !newValue) {
47
+ return true;
48
+ }
49
+
50
+ let isUnique = this.cache[this.type][newValue];
51
+
52
+ if (isUnique === undefined) {
53
+ try {
54
+ // Uniqueness of the slug was not cached, we need to check with the API
55
+ isUnique = await this._validate.perform(newValue, context);
56
+ } catch (error) {
57
+ // Validation task was canceled because we debounce it so we don't cause
58
+ // too many requests on input. This cancelation means that there is
59
+ // another validation ongoing which will then return the needed value.
60
+ // For now, we can just mark it as valid.
61
+ isUnique = didCancel(error);
62
+ }
63
+ }
15
64
 
16
- export default validateSlug;
65
+ return (
66
+ isUnique ||
67
+ this.intl.t(`caluma.form-builder.validations.${this.type}.slug`)
68
+ );
69
+ }
70
+
71
+ @restartableTask
72
+ *_validate(slug, context) {
73
+ /* istanbul ignore next */
74
+ if (macroCondition(isTesting())) {
75
+ // no timeout
76
+ } else {
77
+ yield timeout(500);
78
+ }
79
+
80
+ let count = Infinity;
81
+
82
+ try {
83
+ const response = yield this.apollo.query({
84
+ query: this.queries[this.type],
85
+ variables:
86
+ this.type === "option"
87
+ ? { slug, question: context.question }
88
+ : { slug },
89
+ });
90
+
91
+ if (this.type === "form") {
92
+ count = response.allForms.totalCount;
93
+ } else if (this.type === "question") {
94
+ count = response.allQuestions.totalCount;
95
+ } else if (this.type === "option") {
96
+ count = response.allQuestions.edges[0].node.options.totalCount;
97
+ }
98
+ } catch (error) {
99
+ // do nothing, which will result in count being Infinity which will return
100
+ // a validation error
101
+ }
102
+
103
+ const isUnique = count === 0;
104
+
105
+ this.cache[this.type][slug] = isUnique;
106
+
107
+ return isUnique;
108
+ }
109
+ }
110
+
111
+ export default function slugValidation({ type, maxLength = 127 }) {
112
+ return [
113
+ validatePresence(true),
114
+ validateLength({ max: maxLength }),
115
+ validateFormat({ regex: /^[a-z0-9-]+$/ }),
116
+ new SlugUniquenessValidator(type),
117
+ ];
118
+ }
@@ -0,0 +1 @@
1
+ export { default } from "@projectcaluma/ember-form-builder/components/cfb-slug-input";
@@ -0,0 +1,4 @@
1
+ export {
2
+ default,
3
+ initialize,
4
+ } from "@projectcaluma/ember-form-builder/instance-initializers/application";
@@ -3,22 +3,13 @@
3
3
  @import "../cfb-form-editor/question-list/item";
4
4
  @import "../cfb-form-editor/question";
5
5
  @import "../cfb-navigation";
6
+ @import "../cfb-slug-input";
6
7
  @import "../cfb-uikit-powerselect";
7
8
 
8
9
  .cfb-pointer {
9
10
  cursor: pointer;
10
11
  }
11
12
 
12
- .cfb-prefixed {
13
- display: flex;
14
-
15
- &-slug {
16
- line-height: 40px;
17
- padding-right: 10px;
18
- white-space: nowrap;
19
- }
20
- }
21
-
22
13
  .cfb-code-editor {
23
14
  font-family: $base-code-font-family;
24
15
  letter-spacing: normal;
@@ -0,0 +1,12 @@
1
+ .cfb-slug-input {
2
+ position: relative;
3
+
4
+ &__prefix {
5
+ position: absolute;
6
+ margin-left: $form-padding-horizontal;
7
+ left: 0;
8
+ top: 50%;
9
+ transform: translateY(-50%);
10
+ color: $global-muted-color;
11
+ }
12
+ }
@@ -4,14 +4,24 @@ module.exports = {
4
4
  normalizeEntityName() {},
5
5
 
6
6
  afterInstall() {
7
+ /**
8
+ * Automatically install all ember addons that expose helpers / components
9
+ * used in templates of the addon itself. Other dependencies that are only
10
+ * used in JS code don't need to be installed in the host app and therefore
11
+ * don't have to be included here.
12
+ */
7
13
  return this.addAddonsToProject({
8
14
  packages: [
15
+ { name: "@ember/legacy-built-in-components" },
9
16
  { name: "@projectcaluma/ember-core" },
10
17
  { name: "@projectcaluma/ember-form" },
18
+ { name: "ember-changeset" },
19
+ { name: "ember-composable-helpers" },
11
20
  { name: "ember-engines" },
12
- { name: "ember-math-helpers" },
13
21
  { name: "ember-flatpickr" },
22
+ { name: "ember-math-helpers" },
14
23
  { name: "ember-power-select" },
24
+ { name: "ember-validated-form" },
15
25
  ],
16
26
  });
17
27
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projectcaluma/ember-form-builder",
3
- "version": "11.1.4",
3
+ "version": "11.2.0",
4
4
  "description": "Ember engine for building Caluma forms.",
5
5
  "keywords": [
6
6
  "ember-addon",
@@ -13,10 +13,6 @@
13
13
  "test:ember": "ember test",
14
14
  "test:ember-compatibility": "ember try:each"
15
15
  },
16
- "peerDependencies": {
17
- "ember-engines": ">= 0.8",
18
- "ember-source": "^3.28.0 || ^4.0.0"
19
- },
20
16
  "dependencies": {
21
17
  "@ember/legacy-built-in-components": "^0.4.2",
22
18
  "@ember/render-modifiers": "^2.0.5",
@@ -24,11 +20,11 @@
24
20
  "@embroider/macros": "^1.10.0",
25
21
  "@glimmer/component": "^1.1.2",
26
22
  "@glimmer/tracking": "^1.1.2",
27
- "@projectcaluma/ember-core": "^11.1.3",
28
- "@projectcaluma/ember-form": "^11.1.3",
23
+ "@projectcaluma/ember-core": "^11.2.0",
24
+ "@projectcaluma/ember-form": "^11.2.0",
29
25
  "codejar": "^3.7.0",
30
26
  "ember-apollo-client": "~4.0.2",
31
- "ember-auto-import": "^2.6.1",
27
+ "ember-auto-import": "^2.6.3",
32
28
  "ember-changeset": "^4.1.2",
33
29
  "ember-changeset-validations": "^4.1.1",
34
30
  "ember-cli-babel": "^7.26.11",
@@ -43,14 +39,14 @@
43
39
  "ember-power-select": "^7.0.0",
44
40
  "ember-resources": "^5.6.4",
45
41
  "ember-test-selectors": "^6.0.0",
46
- "ember-uikit": "^7.0.1",
42
+ "ember-uikit": "^7.0.3",
47
43
  "ember-validated-form": "^6.2.0",
48
44
  "graphql": "^15.8.0",
49
45
  "graphql-tag": "^2.12.6",
50
46
  "highlight.js": "^11.7.0",
51
47
  "highlightjs-jexl": "^0.0.5",
52
48
  "jexl": "^2.3.0",
53
- "uikit": "^3.16.13"
49
+ "uikit": "^3.16.15"
54
50
  },
55
51
  "//": [
56
52
  "TODO: remove obsolete dependency to `ember-data` which is only necessary",
@@ -63,9 +59,9 @@
63
59
  "@ember/test-helpers": "2.9.3",
64
60
  "@embroider/test-setup": "2.1.1",
65
61
  "@faker-js/faker": "7.6.0",
66
- "@projectcaluma/ember-testing": "11.1.4",
62
+ "@projectcaluma/ember-testing": "11.2.0",
67
63
  "broccoli-asset-rev": "3.0.0",
68
- "ember-autoresize-modifier": "^0.7.0",
64
+ "ember-autoresize-modifier": "0.7.0",
69
65
  "ember-cli": "4.11.0",
70
66
  "ember-cli-code-coverage": "2.0.0",
71
67
  "ember-cli-dependency-checker": "3.3.1",
@@ -73,8 +69,8 @@
73
69
  "ember-cli-mirage": "3.0.0-alpha.3",
74
70
  "ember-cli-sri": "2.1.1",
75
71
  "ember-cli-terser": "4.0.2",
76
- "ember-data": "4.7.3",
77
- "ember-engines": "0.8.23",
72
+ "ember-data": "4.12.0",
73
+ "ember-engines": "0.9.0",
78
74
  "ember-load-initializers": "2.1.2",
79
75
  "ember-qunit": "6.2.0",
80
76
  "ember-resolver": "10.0.0",
@@ -85,7 +81,11 @@
85
81
  "miragejs": "0.1.47",
86
82
  "qunit": "2.19.4",
87
83
  "qunit-dom": "2.0.0",
88
- "webpack": "5.77.0"
84
+ "webpack": "5.80.0"
85
+ },
86
+ "peerDependencies": {
87
+ "ember-engines": "^0.9.0",
88
+ "ember-source": "^3.28.0 || ^4.0.0"
89
89
  },
90
90
  "engines": {
91
91
  "node": "14.* || 16.* || >= 18"
@@ -175,3 +175,6 @@ caluma:
175
175
 
176
176
  question:
177
177
  slug: "Eine Frage mit diesem Slug existiert bereits"
178
+
179
+ option:
180
+ slug: "Eine Option mit diesem Slug existiert bereits"
@@ -175,3 +175,6 @@ caluma:
175
175
 
176
176
  question:
177
177
  slug: "A question with this slug already exists"
178
+
179
+ option:
180
+ slug: "An option with this slug already exists"
@@ -51,6 +51,7 @@ caluma:
51
51
  isRequired: "Champ obligatoire"
52
52
  staticContent: "Contenu statique"
53
53
  infoText: "Texte d'information"
54
+ hintText: "Texte indicatif"
54
55
  confirmationText: "Texte de confirmation"
55
56
  placeholder: "Caractère générique"
56
57
  isHidden: "Caché (JEXL)"
@@ -174,3 +175,6 @@ caluma:
174
175
 
175
176
  question:
176
177
  slug: "Une question avec ce slug existe déjà"
178
+
179
+ option:
180
+ slug: "Une option avec ce slug existe déjà"