- {{#if (or @slug (not this.prefix))}}
-
- {{else}}
-
-
- {{this.prefix}}
-
-
-
-
- {{/if}}
+
{{#if
@@ -459,7 +436,7 @@
diff --git a/packages/form-builder/addon/components/cfb-form-editor/question.js b/packages/form-builder/addon/components/cfb-form-editor/question.js
index 78c941c89..c3e1d3f4b 100644
--- a/packages/form-builder/addon/components/cfb-form-editor/question.js
+++ b/packages/form-builder/addon/components/cfb-form-editor/question.js
@@ -2,13 +2,12 @@ import { A } from "@ember/array";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { camelize } from "@ember/string";
-import { macroCondition, isTesting } from "@embroider/macros";
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { queryManager } from "ember-apollo-client";
import Changeset from "ember-changeset";
import lookupValidator from "ember-changeset-validations";
-import { dropTask, restartableTask, task, timeout } from "ember-concurrency";
+import { dropTask, restartableTask, task } from "ember-concurrency";
import { hasQuestionType } from "@projectcaluma/ember-core/helpers/has-question-type";
import slugify from "@projectcaluma/ember-core/utils/slugify";
@@ -37,9 +36,9 @@ import saveTableQuestionMutation from "@projectcaluma/ember-form-builder/gql/mut
import saveTextQuestionMutation from "@projectcaluma/ember-form-builder/gql/mutations/save-text-question.graphql";
import saveTextareaQuestionMutation from "@projectcaluma/ember-form-builder/gql/mutations/save-textarea-question.graphql";
import allDataSourcesQuery from "@projectcaluma/ember-form-builder/gql/queries/all-data-sources.graphql";
-import checkQuestionSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-question-slug.graphql";
import formEditorQuestionQuery from "@projectcaluma/ember-form-builder/gql/queries/form-editor-question.graphql";
import formListQuery from "@projectcaluma/ember-form-builder/gql/queries/form-list.graphql";
+import optionValidations from "@projectcaluma/ember-form-builder/validations/option";
import validations from "@projectcaluma/ember-form-builder/validations/question";
export const TYPES = {
@@ -77,9 +76,9 @@ export default class CfbFormEditorQuestion extends Component {
@service notification;
@service intl;
@service calumaOptions;
+
@queryManager apollo;
- @tracked linkSlug = true;
@tracked changeset;
@restartableTask
@@ -209,13 +208,10 @@ export default class CfbFormEditorQuestion extends Component {
}
getInput(changeset) {
- const slug =
- ((!this.args.slug && this.prefix) || "") + changeset.get("slug");
-
const rawMeta = changeset.get("meta");
const input = {
- slug,
+ slug: changeset.get("slug"),
label: changeset.get("label"),
isHidden: changeset.get("isHidden"),
infoText: changeset.get("infoText"),
@@ -289,14 +285,14 @@ export default class CfbFormEditorQuestion extends Component {
_getMultipleChoiceQuestionInput(changeset) {
return {
- options: changeset.get("options.edges").map(({ node: { slug } }) => slug),
+ options: changeset.get("options").map(({ slug }) => slug),
hintText: changeset.get("hintText"),
};
}
_getChoiceQuestionInput(changeset) {
return {
- options: changeset.get("options.edges").map(({ node: { slug } }) => slug),
+ options: changeset.get("options").map(({ slug }) => slug),
hintText: changeset.get("hintText"),
};
}
@@ -358,14 +354,16 @@ export default class CfbFormEditorQuestion extends Component {
@task
*saveOptions(changeset) {
yield Promise.all(
- (changeset.get("options.edges") || []).map(async ({ node: option }) => {
- const { label, slug, isArchived } = option;
-
- await this.apollo.mutate({
- mutation: saveOptionMutation,
- variables: { input: { label, slug, isArchived } },
- });
- })
+ (changeset.get("options") || [])
+ .filter((option) => option.get("isDirty"))
+ .map(async (option) => {
+ const { label, slug, isArchived } = option;
+
+ await this.apollo.mutate({
+ mutation: saveOptionMutation,
+ variables: { input: { label, slug, isArchived } },
+ });
+ })
);
}
@@ -447,39 +445,36 @@ export default class CfbFormEditorQuestion extends Component {
}
}
- @restartableTask
- *validateSlug(slug, changeset) {
- /* istanbul ignore next */
- if (macroCondition(isTesting())) {
- // no timeout
- } else {
- yield timeout(500);
- }
-
- const res = yield this.apollo.query(
- {
- query: checkQuestionSlugQuery,
- variables: { slug },
- },
- "allQuestions.edges"
- );
-
- if (res && res.length) {
- changeset.pushErrors(
- "slug",
- this.intl.t("caluma.form-builder.validations.question.slug")
- );
- }
- }
-
@action
async fetchData() {
await this.data.perform();
await this.availableForms.perform();
await this.availableDataSources.perform();
if (this.model) {
+ const options = this.model.options?.edges?.map(
+ (edge) =>
+ new Changeset(
+ { ...edge.node, slugUnlinked: false, question: this.model.slug },
+ lookupValidator(optionValidations),
+ optionValidations
+ )
+ ) ?? [
+ new Changeset(
+ {
+ id: undefined,
+ label: "",
+ slug: "",
+ isArchived: false,
+ slugUnlinked: false,
+ question: this.model.slug,
+ },
+ lookupValidator(optionValidations),
+ optionValidations
+ ),
+ ];
+
this.changeset = new Changeset(
- this.model,
+ { ...this.model, options },
lookupValidator(validations),
validations
);
@@ -490,23 +485,16 @@ export default class CfbFormEditorQuestion extends Component {
updateLabel(value, changeset) {
changeset.set("label", value);
- if (!this.args.slug && this.linkSlug) {
- const slug = slugify(value, { locale: this.intl.primaryLocale });
+ if (!this.args.slug && !this.slugUnlinked) {
+ const slugifiedLabel = slugify(value, {
+ locale: this.intl.primaryLocale,
+ });
+ const slug = slugifiedLabel ? this.prefix + slugifiedLabel : "";
changeset.set("slug", slug);
-
- this.validateSlug.perform(this.prefix + slug, changeset);
}
}
- @action
- updateSlug(value, changeset) {
- changeset.set("slug", value);
- this.linkSlug = false;
-
- this.validateSlug.perform(this.prefix + value, changeset);
- }
-
@action
updateSubForm(value, changeset) {
changeset.set("subForm.slug", value.slug);
diff --git a/packages/form-builder/addon/components/cfb-form-editor/question/options.hbs b/packages/form-builder/addon/components/cfb-form-editor/question/options.hbs
index f785e6467..4b540ab6b 100644
--- a/packages/form-builder/addon/components/cfb-form-editor/question/options.hbs
+++ b/packages/form-builder/addon/components/cfb-form-editor/question/options.hbs
@@ -5,26 +5,26 @@
@handle=".uk-sortable-handle"
@onMoved={{this._handleMoved}}
@tagName="ul"
- class="uk-list uk-list-divider uk-margin-remove-bottom uk-margin-small-top"
+ class="uk-list uk-list-divider uk-form-controls uk-margin-small-top"
>
- {{#each this.optionRows as |row i|}}
-
+ {{#each @value as |row i|}}
+
- {{#if (not (and row.isNew (gt this.optionRows.length 1)))}}
-
- {{/if}}
-
- {{#if (and row.isNew (gt this.optionRows.length 1))}}
+
+ {{#if this.canReorder}}
+
+ {{/if}}
+ {{#if (is-empty row.id)}}
+ {{else}}
+
{{/if}}
-
@@ -64,8 +69,14 @@
@name="slug"
@inputName={{concat "option-" (add i 1) "-slug"}}
@required={{true}}
- @disabled={{not row.isNew}}
- @on-update={{this.updateSlug}}
+ @disabled={{not (is-empty row.id)}}
+ @submitted={{@submitted}}
+ @renderComponent={{component
+ "cfb-slug-input"
+ hidePrefix=true
+ prefix=@model.slug
+ onUnlink=(fn (mut row.slugUnlinked) true)
+ }}
/>
diff --git a/packages/form-builder/addon/components/cfb-form-editor/question/options.js b/packages/form-builder/addon/components/cfb-form-editor/question/options.js
index 3d7b13c86..dbb28f83f 100644
--- a/packages/form-builder/addon/components/cfb-form-editor/question/options.js
+++ b/packages/form-builder/addon/components/cfb-form-editor/question/options.js
@@ -1,11 +1,10 @@
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import Component from "@glimmer/component";
-import { tracked } from "@glimmer/tracking";
import { queryManager } from "ember-apollo-client";
import { Changeset } from "ember-changeset";
import lookupValidator from "ember-changeset-validations";
-import { task } from "ember-concurrency";
+import { dropTask } from "ember-concurrency";
import slugify from "@projectcaluma/ember-core/utils/slugify";
import saveChoiceQuestionMutation from "@projectcaluma/ember-form-builder/gql/mutations/save-choice-question.graphql";
@@ -17,90 +16,24 @@ const TYPES = {
ChoiceQuestion: saveChoiceQuestionMutation,
};
-const removeQuestionPrefix = (slug, questionSlug) => {
- return slug.replace(new RegExp(`^${questionSlug}-`), "");
-};
-
-const addQuestionPrefix = (slug, questionSlug) => {
- return `${questionSlug}-${slug}`;
-};
-
export default class CfbFormEditorQuestionOptions extends Component {
- @tracked _optionRows;
-
@service intl;
@service notification;
- @queryManager apollo;
-
- constructor(...args) {
- super(...args);
-
- this._optionRows = this.args.value?.edges?.length
- ? this.args.value.edges.map(
- (edge) =>
- new Changeset(
- {
- slug: removeQuestionPrefix(edge.node.slug, this.questionSlug),
- label: edge.node.label,
- isArchived: edge.node.isArchived,
- isNew: false,
- },
- lookupValidator(OptionValidations),
- OptionValidations
- )
- )
- : [
- new Changeset(
- { slug: "", label: "", isNew: true, linkSlug: true },
- lookupValidator(OptionValidations),
- OptionValidations
- ),
- ];
- }
-
- get questionSlug() {
- return this.args.model.slug;
- }
-
- get optionRows() {
- return this._optionRows;
- }
- _update() {
- this.args.update({
- edges: this.optionRows
- .filter((row) => !row.isNew || row.isDirty)
- .map((row) => {
- const { label, slug, isArchived } = Object.assign(
- {},
- row.get("data"),
- row.get("change")
- );
-
- return {
- node: {
- label,
- slug: addQuestionPrefix(
- removeQuestionPrefix(slug, this.questionSlug),
- this.questionSlug
- ),
- isArchived: Boolean(isArchived),
- },
- };
- }),
- });
+ @queryManager apollo;
- this.args.setDirty();
+ get canReorder() {
+ return this.args.value.every((row) => row.get("id") !== undefined);
}
- @task
+ @dropTask
*reorderOptions(slugs) {
try {
yield this.apollo.mutate({
mutation: TYPES[this.args.model.__typename],
variables: {
input: {
- slug: this.questionSlug,
+ slug: this.args.model.slug,
label: this.args.model.label,
options: slugs,
},
@@ -123,55 +56,45 @@ export default class CfbFormEditorQuestionOptions extends Component {
@action
addRow() {
- this._optionRows = [
- ...this.optionRows,
+ this.args.update([
+ ...this.args.value,
new Changeset(
- { slug: "", label: "", isNew: true, linkSlug: true },
+ {
+ id: undefined,
+ slug: "",
+ label: "",
+ isArchived: false,
+ slugUnlinked: false,
+ question: this.args.model.slug,
+ },
lookupValidator(OptionValidations),
OptionValidations
),
- ];
+ ]);
- this._update();
+ this.args.setDirty();
}
@action
deleteRow(row) {
- this._optionRows = this.optionRows.filter((r) => r !== row);
-
- this._update();
- }
-
- @action
- toggleRowArchived(row) {
- row.set("isArchived", !row.get("isArchived"));
-
- this._update();
+ this.args.update(this.args.value.filter((r) => r !== row));
+ this.args.setDirty();
}
@action
updateLabel(value, changeset) {
changeset.set("label", value);
- if (changeset.get("isNew") && changeset.get("linkSlug")) {
- changeset.set(
- "slug",
- slugify(value, { locale: this.intl.primaryLocale })
- );
- }
- this._update();
- }
-
- @action
- updateSlug(value, changeset) {
- changeset.set("slug", value);
- changeset.set("linkSlug", false);
- this._update();
- }
+ if (!changeset.get("id") && !changeset.get("slugUnlinked")) {
+ const slugifiedLabel = slugify(value, {
+ locale: this.intl.primaryLocale,
+ });
+ const slug = slugifiedLabel
+ ? `${this.args.model.slug}-${slugifiedLabel}`
+ : "";
- @action
- update() {
- this._update();
+ changeset.set("slug", slug);
+ }
}
@action
@@ -180,12 +103,7 @@ export default class CfbFormEditorQuestionOptions extends Component {
const options = [...sortable.$el.children].slice(0, -1);
this.reorderOptions.perform(
- options.map((option) =>
- addQuestionPrefix(
- option.firstElementChild.firstElementChild.id,
- this.questionSlug
- )
- )
+ options.map((option) => option.firstElementChild.firstElementChild.id)
);
}
}
diff --git a/packages/form-builder/addon/components/cfb-slug-input.hbs b/packages/form-builder/addon/components/cfb-slug-input.hbs
new file mode 100644
index 000000000..f65fccd8a
--- /dev/null
+++ b/packages/form-builder/addon/components/cfb-slug-input.hbs
@@ -0,0 +1,33 @@
+
+ <@labelComponent />
+
+
+
+ <@hintComponent />
+ <@errorComponent />
+
\ No newline at end of file
diff --git a/packages/form-builder/addon/components/cfb-slug-input.js b/packages/form-builder/addon/components/cfb-slug-input.js
new file mode 100644
index 000000000..4d5b971a0
--- /dev/null
+++ b/packages/form-builder/addon/components/cfb-slug-input.js
@@ -0,0 +1,45 @@
+import { action } from "@ember/object";
+import { inject as service } from "@ember/service";
+import { htmlSafe } from "@ember/template";
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+
+export default class CfbSlugInputComponent extends Component {
+ @service calumaOptions;
+ @service intl;
+
+ @tracked padding = null;
+
+ get prefix() {
+ const prefix = this.args.prefix ?? this.calumaOptions.namespace ?? null;
+
+ return prefix ? `${prefix}-` : "";
+ }
+
+ get inputStyle() {
+ return this.padding ? htmlSafe(`padding-left: ${this.padding};`) : "";
+ }
+
+ get displayValue() {
+ if (this.args.disabled && !this.args.hidePrefix) {
+ return this.args.value;
+ }
+
+ return this.args.value?.replace(new RegExp(`^${this.prefix}`), "") ?? "";
+ }
+
+ @action
+ calculatePadding(element) {
+ const prefixWidth = element.clientWidth;
+ const prefixMargin = window.getComputedStyle(element).marginLeft;
+
+ this.padding = `calc(${prefixWidth}px + ${prefixMargin})`;
+ }
+
+ @action
+ update({ target: { value } }) {
+ this.args.update(value ? this.prefix + value : "");
+ this.args.setDirty();
+ this.args.onUnlink?.();
+ }
+}
diff --git a/packages/form-builder/addon/gql/queries/check-form-slug.graphql b/packages/form-builder/addon/gql/queries/check-form-slug.graphql
index bf48db483..998652e57 100644
--- a/packages/form-builder/addon/gql/queries/check-form-slug.graphql
+++ b/packages/form-builder/addon/gql/queries/check-form-slug.graphql
@@ -1,10 +1,5 @@
query CheckFormSlug($slug: String!) {
allForms(filter: [{ slugs: [$slug] }]) {
- edges {
- node {
- id
- slug
- }
- }
+ totalCount
}
}
diff --git a/packages/form-builder/addon/gql/queries/check-option-slug.graphql b/packages/form-builder/addon/gql/queries/check-option-slug.graphql
new file mode 100644
index 000000000..f78324817
--- /dev/null
+++ b/packages/form-builder/addon/gql/queries/check-option-slug.graphql
@@ -0,0 +1,19 @@
+query CheckOptionSlug($slug: String!, $question: String!) {
+ allQuestions(filter: [{ slugs: [$question] }]) {
+ edges {
+ node {
+ id
+ ... on ChoiceQuestion {
+ options(filter: [{ slug: $slug }]) {
+ totalCount
+ }
+ }
+ ... on MultipleChoiceQuestion {
+ options(filter: [{ slug: $slug }]) {
+ totalCount
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/form-builder/addon/gql/queries/check-question-slug.graphql b/packages/form-builder/addon/gql/queries/check-question-slug.graphql
index 50d4f9854..1ee5fa85c 100644
--- a/packages/form-builder/addon/gql/queries/check-question-slug.graphql
+++ b/packages/form-builder/addon/gql/queries/check-question-slug.graphql
@@ -1,10 +1,5 @@
query CheckQuestionSlug($slug: String!) {
allQuestions(filter: [{ slugs: [$slug] }]) {
- edges {
- node {
- id
- slug
- }
- }
+ totalCount
}
}
diff --git a/packages/form-builder/addon/instance-initializers/application.js b/packages/form-builder/addon/instance-initializers/application.js
new file mode 100644
index 000000000..2a210537c
--- /dev/null
+++ b/packages/form-builder/addon/instance-initializers/application.js
@@ -0,0 +1,9 @@
+import application from "@projectcaluma/ember-form-builder/-private/application";
+
+export function initialize(appInstance) {
+ application.instance = appInstance;
+}
+
+export default {
+ initialize,
+};
diff --git a/packages/form-builder/addon/validations/form.js b/packages/form-builder/addon/validations/form.js
index 318953c50..c9c2b3568 100644
--- a/packages/form-builder/addon/validations/form.js
+++ b/packages/form-builder/addon/validations/form.js
@@ -1,14 +1,11 @@
import {
validatePresence,
validateLength,
- validateFormat,
} from "ember-changeset-validations/validators";
+import slugValidation from "@projectcaluma/ember-form-builder/validators/slug";
+
export default {
name: [validatePresence(true), validateLength({ max: 255 })],
- slug: [
- validatePresence(true),
- validateLength({ max: 50 }),
- validateFormat({ regex: /^[a-z0-9-]+$/ }),
- ],
+ slug: slugValidation({ type: "form", maxLength: 50 }),
};
diff --git a/packages/form-builder/addon/validations/option.js b/packages/form-builder/addon/validations/option.js
index 16cf053f8..43e4539af 100644
--- a/packages/form-builder/addon/validations/option.js
+++ b/packages/form-builder/addon/validations/option.js
@@ -3,10 +3,9 @@ import {
validateLength,
} from "ember-changeset-validations/validators";
-import and from "@projectcaluma/ember-form-builder/utils/and";
-import validateSlug from "@projectcaluma/ember-form-builder/validators/slug";
+import slugValidation from "@projectcaluma/ember-form-builder/validators/slug";
export default {
- label: and(validatePresence(true), validateLength({ max: 1024 })),
- slug: validateSlug(),
+ label: [validatePresence(true), validateLength({ max: 1024 })],
+ slug: slugValidation({ type: "option" }),
};
diff --git a/packages/form-builder/addon/validations/question.js b/packages/form-builder/addon/validations/question.js
index 9723203aa..9dc255855 100644
--- a/packages/form-builder/addon/validations/question.js
+++ b/packages/form-builder/addon/validations/question.js
@@ -11,12 +11,12 @@ import and from "@projectcaluma/ember-form-builder/utils/and";
import or from "@projectcaluma/ember-form-builder/utils/or";
import validateJexl from "@projectcaluma/ember-form-builder/validators/jexl";
import validateMeta from "@projectcaluma/ember-form-builder/validators/meta";
-import validateSlug from "@projectcaluma/ember-form-builder/validators/slug";
+import slugValidation from "@projectcaluma/ember-form-builder/validators/slug";
import validateType from "@projectcaluma/ember-form-builder/validators/type";
export default {
label: and(validatePresence(true), validateLength({ max: 1024 })),
- slug: validateSlug(),
+ slug: slugValidation({ type: "question" }),
hintText: or(
validateType("FormQuestion", true),
diff --git a/packages/form-builder/addon/validators/options.js b/packages/form-builder/addon/validators/options.js
index f1786c792..62285e7ca 100644
--- a/packages/form-builder/addon/validators/options.js
+++ b/packages/form-builder/addon/validators/options.js
@@ -1,25 +1,7 @@
-import Changeset from "ember-changeset";
-import lookupValidator from "ember-changeset-validations";
-import { Promise, all } from "rsvp";
-
-import OptionValidations from "../validations/option";
-
export default function validateOptions() {
- return (_, value) => {
- return new Promise((resolve) => {
- all(
- value.edges.map(async ({ node: option }) => {
- const cs = new Changeset(
- option,
- lookupValidator(OptionValidations),
- OptionValidations
- );
-
- await cs.validate();
-
- return cs.get("isValid");
- })
- ).then((res) => resolve(res.every(Boolean) || "Invalid options"));
- });
+ return (_, newValue) => {
+ return (
+ newValue.every((option) => option.get("isValid")) || "Invalid options"
+ );
};
}
diff --git a/packages/form-builder/addon/validators/slug.js b/packages/form-builder/addon/validators/slug.js
index 4d8ccc8f6..90af21b7d 100644
--- a/packages/form-builder/addon/validators/slug.js
+++ b/packages/form-builder/addon/validators/slug.js
@@ -1,16 +1,118 @@
+import { setOwner } from "@ember/application";
+import { inject as service } from "@ember/service";
+import { macroCondition, isTesting, importSync } from "@embroider/macros";
+import { queryManager } from "ember-apollo-client";
import {
validatePresence,
validateLength,
validateFormat,
} from "ember-changeset-validations/validators";
+import { timeout, restartableTask, didCancel } from "ember-concurrency";
-import and from "@projectcaluma/ember-form-builder/utils/and";
+import checkFormSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-form-slug.graphql";
+import checkOptionSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-option-slug.graphql";
+import checkQuestionSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-question-slug.graphql";
-const validateSlug = () =>
- and(
- validatePresence(true),
- validateLength({ max: 127 }),
- validateFormat({ regex: /^[a-z0-9-]+$/ })
- );
+export class SlugUniquenessValidator {
+ @service intl;
+
+ @queryManager apollo;
+
+ queries = {
+ form: checkFormSlugQuery,
+ question: checkQuestionSlugQuery,
+ option: checkOptionSlugQuery,
+ };
+
+ cache = {
+ form: {},
+ question: {},
+ option: {},
+ };
+
+ constructor(type) {
+ this.type = type;
+ }
+
+ async validate(key, newValue, oldValue, changes, context) {
+ const application = importSync(
+ "@projectcaluma/ember-form-builder/-private/application"
+ ).default;
+
+ setOwner(this, application.instance);
+
+ // If the object already exists or the slug is empty we don't need to check
+ // for uniqueness
+ if (context.id || !newValue) {
+ return true;
+ }
+
+ let isUnique = this.cache[this.type][newValue];
+
+ if (isUnique === undefined) {
+ try {
+ // Uniqueness of the slug was not cached, we need to check with the API
+ isUnique = await this._validate.perform(newValue, context);
+ } catch (error) {
+ // Validation task was canceled because we debounce it so we don't cause
+ // too many requests on input. This cancelation means that there is
+ // another validation ongoing which will then return the needed value.
+ // For now, we can just mark it as valid.
+ isUnique = didCancel(error);
+ }
+ }
-export default validateSlug;
+ return (
+ isUnique ||
+ this.intl.t(`caluma.form-builder.validations.${this.type}.slug`)
+ );
+ }
+
+ @restartableTask
+ *_validate(slug, context) {
+ /* istanbul ignore next */
+ if (macroCondition(isTesting())) {
+ // no timeout
+ } else {
+ yield timeout(500);
+ }
+
+ let count = Infinity;
+
+ try {
+ const response = yield this.apollo.query({
+ query: this.queries[this.type],
+ variables:
+ this.type === "option"
+ ? { slug, question: context.question }
+ : { slug },
+ });
+
+ if (this.type === "form") {
+ count = response.allForms.totalCount;
+ } else if (this.type === "question") {
+ count = response.allQuestions.totalCount;
+ } else if (this.type === "option") {
+ count = response.allQuestions.edges[0].node.options.totalCount;
+ }
+ } catch (error) {
+ // do nothing, which will result in count being Infinity which will return
+ // a validation error
+ }
+
+ const isUnique = count === 0;
+
+ this.cache[this.type][slug] = isUnique;
+
+ return isUnique;
+ }
+}
+
+export default function slugValidation({ type, maxLength = 127 }) {
+ return [
+ validatePresence(true),
+ validateLength({ max: maxLength }),
+ validateFormat({ regex: /^[a-z0-9-]+$/ }),
+ new SlugUniquenessValidator(type),
+ ];
+}
diff --git a/packages/form-builder/app/components/cfb-slug-input.js b/packages/form-builder/app/components/cfb-slug-input.js
new file mode 100644
index 000000000..5c8861362
--- /dev/null
+++ b/packages/form-builder/app/components/cfb-slug-input.js
@@ -0,0 +1 @@
+export { default } from "@projectcaluma/ember-form-builder/components/cfb-slug-input";
diff --git a/packages/form-builder/app/instance-initializers/application.js b/packages/form-builder/app/instance-initializers/application.js
new file mode 100644
index 000000000..cab32d786
--- /dev/null
+++ b/packages/form-builder/app/instance-initializers/application.js
@@ -0,0 +1,4 @@
+export {
+ default,
+ initialize,
+} from "@projectcaluma/ember-form-builder/instance-initializers/application";
diff --git a/packages/form-builder/app/styles/@projectcaluma/ember-form-builder.scss b/packages/form-builder/app/styles/@projectcaluma/ember-form-builder.scss
index 07df5f801..a2e60841f 100644
--- a/packages/form-builder/app/styles/@projectcaluma/ember-form-builder.scss
+++ b/packages/form-builder/app/styles/@projectcaluma/ember-form-builder.scss
@@ -3,22 +3,13 @@
@import "../cfb-form-editor/question-list/item";
@import "../cfb-form-editor/question";
@import "../cfb-navigation";
+@import "../cfb-slug-input";
@import "../cfb-uikit-powerselect";
.cfb-pointer {
cursor: pointer;
}
-.cfb-prefixed {
- display: flex;
-
- &-slug {
- line-height: 40px;
- padding-right: 10px;
- white-space: nowrap;
- }
-}
-
.cfb-code-editor {
font-family: $base-code-font-family;
letter-spacing: normal;
diff --git a/packages/form-builder/app/styles/cfb-slug-input.scss b/packages/form-builder/app/styles/cfb-slug-input.scss
new file mode 100644
index 000000000..47ddc9504
--- /dev/null
+++ b/packages/form-builder/app/styles/cfb-slug-input.scss
@@ -0,0 +1,12 @@
+.cfb-slug-input {
+ position: relative;
+
+ &__prefix {
+ position: absolute;
+ margin-left: $form-padding-horizontal;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ color: $global-muted-color;
+ }
+}
diff --git a/packages/form-builder/tests/helpers/index.js b/packages/form-builder/tests/helpers/index.js
index 3d0a13d2a..e9bb68ee6 100644
--- a/packages/form-builder/tests/helpers/index.js
+++ b/packages/form-builder/tests/helpers/index.js
@@ -1,9 +1,18 @@
+import { importSync } from "@embroider/macros";
import {
setupApplicationTest as upstreamSetupApplicationTest,
setupRenderingTest as upstreamSetupRenderingTest,
setupTest as upstreamSetupTest,
} from "ember-qunit";
+function setApplicationInstance() {
+ const application = importSync(
+ "@projectcaluma/ember-form-builder/-private/application"
+ ).default;
+
+ application.instance = this.owner;
+}
+
// This file exists to provide wrappers around ember-qunit's / ember-mocha's
// test setup functions. This way, you can easily extend the setup that is
// needed per test type.
@@ -11,6 +20,8 @@ import {
function setupApplicationTest(hooks, options) {
upstreamSetupApplicationTest(hooks, options);
+ hooks.beforeEach(setApplicationInstance);
+
// Additional setup for application tests can be done here.
//
// For example, if you need an authenticated session for each
@@ -30,13 +41,13 @@ function setupApplicationTest(hooks, options) {
function setupRenderingTest(hooks, options) {
upstreamSetupRenderingTest(hooks, options);
- // Additional setup for rendering tests can be done here.
+ hooks.beforeEach(setApplicationInstance);
}
function setupTest(hooks, options) {
upstreamSetupTest(hooks, options);
- // Additional setup for unit tests can be done here.
+ hooks.beforeEach(setApplicationInstance);
}
export { setupApplicationTest, setupRenderingTest, setupTest };
diff --git a/packages/form-builder/tests/integration/components/cfb-form-editor/general-test.js b/packages/form-builder/tests/integration/components/cfb-form-editor/general-test.js
index 6dd121fd4..40813ffa3 100644
--- a/packages/form-builder/tests/integration/components/cfb-form-editor/general-test.js
+++ b/packages/form-builder/tests/integration/components/cfb-form-editor/general-test.js
@@ -30,7 +30,7 @@ module("Integration | Component | cfb-form-editor/general", function (hooks) {
});
test("it validates", async function (assert) {
- assert.expect(2);
+ assert.expect(1);
this.server.create("form", {
name: "Test Name",
@@ -43,7 +43,6 @@ module("Integration | Component | cfb-form-editor/general", function (hooks) {
await fillIn("input[name=name]", "");
await blur("input[name=name]");
- assert.dom("form button[type=submit]").isDisabled();
assert.dom("small.uk-text-danger").hasText("Name can't be blank");
});
@@ -68,9 +67,11 @@ module("Integration | Component | cfb-form-editor/general", function (hooks) {
);
await fillIn("input[name=name]", "Form 1");
- assert.dom("button[type=submit]").isDisabled();
- // TODO: WHY IS THAT SHIT NOT FOUND BY THE TESTSUITE????
+ await click("button[type=submit]");
+
+ assert.dom("input[name=slug]").hasClass("uk-form-danger");
+
await fillIn("input[name=slug]", "form-2");
await click("button[type=submit]");
@@ -199,13 +200,10 @@ module("Integration | Component | cfb-form-editor/general", function (hooks) {
.hasText("t:caluma.form-builder.validations.form.slug:()");
await fillIn("input[name=slug]", "valid-slug");
- await blur("input[name=slug]");
assert.dom("small.uk-text-danger").doesNotExist();
- await fillIn("input[name=name]", "Other Test Slug");
- await blur("input[name=name]");
- await blur("input[name=slug]");
+ await fillIn("input[name=slug]", "other-test-slug");
assert
.dom("small.uk-text-danger")
diff --git a/packages/form-builder/tests/integration/components/cfb-form-editor/question/options-test.js b/packages/form-builder/tests/integration/components/cfb-form-editor/question/options-test.js
index 4c719c160..b5e0d46e4 100644
--- a/packages/form-builder/tests/integration/components/cfb-form-editor/question/options-test.js
+++ b/packages/form-builder/tests/integration/components/cfb-form-editor/question/options-test.js
@@ -1,26 +1,49 @@
-import { render, click, fillIn } from "@ember/test-helpers";
+import { render, click } from "@ember/test-helpers";
+import Changeset from "ember-changeset";
+import lookupValidator from "ember-changeset-validations";
import { hbs } from "ember-cli-htmlbars";
import { setupIntl } from "ember-intl/test-support";
import { module, test } from "qunit";
+import optionValidations from "@projectcaluma/ember-form-builder/validations/option";
import { setupRenderingTest } from "dummy/tests/helpers";
+function optionChangeset({ slug, label, isArchived } = {}) {
+ return new Changeset(
+ {
+ id: slug ?? undefined,
+ label: label ?? "",
+ slug: slug ?? "",
+ isArchived: isArchived ?? false,
+ question: "prefix",
+ slugUnlinked: false,
+ },
+ lookupValidator(optionValidations),
+ optionValidations
+ );
+}
+
module(
"Integration | Component | cfb-form-editor/question/options",
function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks);
+ hooks.beforeEach(function () {
+ this.model = { slug: "prefix" };
+ this.noop = () => {};
+ this.update = async (value) => {
+ this.set("value", value);
+ };
+ });
+
test("it renders", async function (assert) {
assert.expect(3);
- this.set("model", { slug: "prefix" });
- this.set("value", {
- edges: [
- { node: { slug: "prefix-option-1", label: "Option 1" } },
- { node: { slug: "prefix-option-2", label: "Option 2" } },
- ],
- });
+ this.value = [
+ optionChangeset({ label: "Option 1", slug: "prefix-option-1" }),
+ optionChangeset({ label: "Option 2", slug: "prefix-option-2" }),
+ ];
await render(
hbs``
@@ -32,41 +55,21 @@ module(
assert.dom("input[name=option-1-slug]").hasValue("option-1"); // This must trim the prefix!
});
- test("it renders an empty row per default", async function (assert) {
- assert.expect(4);
-
- this.set("model", { slug: "prefix" });
- this.set("value", {
- edges: [],
- });
-
- await render(
- hbs``
- );
-
- assert.dom("li").exists({ count: 2 });
- assert.dom("input[name=option-1-label]").hasValue("");
- assert.dom("input[name=option-1-slug]").hasValue("");
- assert.dom("[data-test-delete-row]").doesNotExist();
- });
-
test("it can add row", async function (assert) {
- assert.expect(1);
+ assert.expect(2);
- this.set("model", { slug: "prefix" });
- this.set("value", {
- edges: [],
- });
- this.set("noop", () => {});
+ this.value = [
+ optionChangeset({ label: "Option 1", slug: "prefix-option-1" }),
+ ];
- await render(
- hbs``
- );
+/>`);
+
+ assert.dom("li").exists({ count: 2 });
await click("[data-test-add-row]");
@@ -76,23 +79,17 @@ module(
test("it can delete unsaved row", async function (assert) {
assert.expect(3);
- this.set("model", { slug: "prefix" });
- this.set("value", {
- edges: [
- { node: { slug: "prefix-option-1", label: "Option 1" } },
- { node: { slug: "prefix-option-2", label: "Option 2" } },
- ],
- });
- this.set("noop", () => {});
+ this.value = [
+ optionChangeset({ label: "Option 1", slug: "prefix-option-1" }),
+ optionChangeset({ label: "Option 2", slug: "prefix-option-2" }),
+ ];
- await render(
- hbs``
- );
+/>`);
assert.dom("li").exists({ count: 3 });
@@ -103,81 +100,19 @@ module(
assert.dom("[data-test-row=option-3]").doesNotExist();
});
- test("it can update", async function (assert) {
- assert.expect(3);
-
- this.set("model", { slug: "prefix" });
- this.set("value", {
- edges: [{ node: { slug: "prefix-option-1", label: "Option 1" } }],
- });
-
- this.set("update", () => {});
- this.set("setDirty", () => {});
-
- await render(
- hbs``
- );
-
- // add some new rows (only one will be filled)
- await click("[data-test-add-row]");
- await click("[data-test-add-row]");
- await click("[data-test-add-row]");
-
- assert.dom("li").exists({ count: 5 });
-
- await fillIn("input[name=option-1-label]", "Option #1");
- await fillIn("input[name=option-2-label]", "Option 2");
-
- // assert.dom("input[name=option-2-slug]").hasValue("option-2");
-
- this.set("update", (value) => {
- // empty rows will be omitted
- assert.deepEqual(value, {
- edges: [
- {
- node: {
- label: "Option #1",
- slug: "prefix-option-1",
- isArchived: false,
- },
- },
- {
- node: {
- label: "Option 2",
- slug: "prefix-x-option-2",
- isArchived: false,
- },
- },
- ],
- });
- });
- this.set("setDirty", () => assert.ok(true));
-
- await fillIn("input[name=option-2-slug]", "x-option-2");
- });
-
test("it can archive/restore options", async function (assert) {
assert.expect(3);
- this.set("model", { slug: "prefix" });
- this.set("value", {
- edges: [
- { node: { slug: "prefix-option-1", label: "Option 1" } },
- { node: { slug: "prefix-option-2", label: "Option 2" } },
- ],
- });
- this.set("noop", () => {});
+ this.value = [
+ optionChangeset({ label: "Option 1", slug: "prefix-option-1" }),
+ optionChangeset({ label: "Option 2", slug: "prefix-option-2" }),
+ ];
await render(
hbs``
);
diff --git a/packages/form-builder/tests/integration/components/cfb-slug-input-test.js b/packages/form-builder/tests/integration/components/cfb-slug-input-test.js
new file mode 100644
index 000000000..ebc1d1b9f
--- /dev/null
+++ b/packages/form-builder/tests/integration/components/cfb-slug-input-test.js
@@ -0,0 +1,61 @@
+import { render } from "@ember/test-helpers";
+import { hbs } from "ember-cli-htmlbars";
+import { module, test } from "qunit";
+
+import { setupRenderingTest } from "dummy/tests/helpers";
+
+module("Integration | Component | cfb-slug-input", function (hooks) {
+ setupRenderingTest(hooks);
+
+ test("it renders without a prefix", async function (assert) {
+ await render(hbs``);
+
+ assert.dom("input").hasValue("my-slug");
+ assert.dom("span.cfb-slug-input__prefix").doesNotExist();
+ });
+
+ test("it renders with a prefix if namespace is given", async function (assert) {
+ this.owner.lookup("service:caluma-options").namespace = "test";
+
+ await render(hbs``);
+
+ assert.dom("input").hasValue("my-slug");
+ assert.dom("span.cfb-slug-input__prefix").hasText("test-");
+ });
+
+ test("it renders with a prefix if passed", async function (assert) {
+ await render(
+ hbs``
+ );
+
+ assert.dom("input").hasValue("my-slug");
+ assert.dom("span.cfb-slug-input__prefix").hasText("question-1-");
+ });
+
+ test("it hides the prefix if disabled or hidePrefix passed", async function (assert) {
+ this.disabled = true;
+ this.hidePrefix = false;
+
+ await render(hbs``);
+
+ assert.dom("input").isDisabled();
+ assert.dom("input").hasValue("question-1-my-slug");
+ assert.dom("span.cfb-slug-input__prefix").doesNotExist();
+
+ this.set("hidePrefix", true);
+
+ assert.dom("input").hasValue("my-slug");
+ assert.dom("span.cfb-slug-input__prefix").doesNotExist();
+
+ this.set("disabled", false);
+
+ assert.dom("input").isEnabled();
+ assert.dom("input").hasValue("my-slug");
+ assert.dom("span.cfb-slug-input__prefix").doesNotExist();
+ });
+});
diff --git a/packages/form-builder/tests/unit/instance-initializers/application-test.js b/packages/form-builder/tests/unit/instance-initializers/application-test.js
new file mode 100644
index 000000000..274aa3a55
--- /dev/null
+++ b/packages/form-builder/tests/unit/instance-initializers/application-test.js
@@ -0,0 +1,34 @@
+import Application from "@ember/application";
+import { run } from "@ember/runloop";
+import Resolver from "ember-resolver";
+import { module, test } from "qunit";
+
+import config from "dummy/config/environment";
+import { initialize } from "dummy/instance-initializers/application";
+
+module("Unit | Instance Initializer | application", function (hooks) {
+ hooks.beforeEach(function () {
+ this.TestApplication = class TestApplication extends Application {
+ modulePrefix = config.modulePrefix;
+ podModulePrefix = config.podModulePrefix;
+ Resolver = Resolver;
+ };
+ this.TestApplication.instanceInitializer({
+ name: "initializer under test",
+ initialize,
+ });
+ this.application = this.TestApplication.create({ autoboot: false });
+ this.instance = this.application.buildInstance();
+ });
+ hooks.afterEach(function () {
+ run(this.instance, "destroy");
+ run(this.application, "destroy");
+ });
+
+ // TODO: Replace this with your real tests.
+ test("it works", async function (assert) {
+ await this.instance.boot();
+
+ assert.ok(true);
+ });
+});
diff --git a/packages/form-builder/tests/unit/validators/options-test.js b/packages/form-builder/tests/unit/validators/options-test.js
index 489ac3e0f..5e3e3a253 100644
--- a/packages/form-builder/tests/unit/validators/options-test.js
+++ b/packages/form-builder/tests/unit/validators/options-test.js
@@ -1,27 +1,20 @@
+import Changeset from "ember-changeset";
import { module, test } from "qunit";
import validateOptions from "@projectcaluma/ember-form-builder/validators/options";
module("Unit | Validator | options", function () {
test("it validates correctly", async function (assert) {
- assert.expect(4);
+ assert.expect(2);
+
+ const option = new Changeset({});
+
+ assert.true(await validateOptions()(null, [option]));
+
+ option.pushErrors("Test");
- assert.true(await validateOptions()(null, { edges: [] }));
- assert.true(
- await validateOptions()(null, {
- edges: [{ node: { slug: "test", label: "test" } }],
- })
- );
- assert.strictEqual(
- await validateOptions()(null, {
- edges: [{ node: { slug: "test", label: "" } }],
- }),
- "Invalid options"
- );
assert.strictEqual(
- await validateOptions()(null, {
- edges: [{ node: { slug: "", label: "test" } }],
- }),
+ await validateOptions()(null, [option]),
"Invalid options"
);
});
diff --git a/packages/form-builder/tests/unit/validators/slug-test.js b/packages/form-builder/tests/unit/validators/slug-test.js
index 5429878db..8ba156c19 100644
--- a/packages/form-builder/tests/unit/validators/slug-test.js
+++ b/packages/form-builder/tests/unit/validators/slug-test.js
@@ -1,34 +1,125 @@
+import { setupMirage } from "ember-cli-mirage/test-support";
+import { setupIntl } from "ember-intl/test-support";
import { module, test } from "qunit";
-import validateSlug from "@projectcaluma/ember-form-builder/validators/slug";
+import { SlugUniquenessValidator } from "@projectcaluma/ember-form-builder/validators/slug";
+import { setupTest } from "dummy/tests/helpers";
-module("Unit | Validator | slug", function () {
- test("it validates", function (assert) {
- assert.expect(1);
+module("Unit | Validator | slug", function (hooks) {
+ setupTest(hooks);
+ setupMirage(hooks);
+ setupIntl(hooks);
- assert.true(validateSlug()("test", "test-slug-valid-123"));
- });
+ test("it validates uniqueness of form slugs", async function (assert) {
+ const validator = new SlugUniquenessValidator("form");
+
+ this.server.post("/graphql", { data: { allForms: { totalCount: 1 } } });
+
+ // count is 1 -> invalid
+ assert.strictEqual(
+ await validator.validate(null, "slug", null, null, { id: undefined }),
+ "t:caluma.form-builder.validations.form.slug:()"
+ );
- test("it validates presence", function (assert) {
- assert.expect(2);
+ // id is given -> valid
+ assert.true(
+ await validator.validate(null, "slug", null, null, { id: "x" })
+ );
- assert.strictEqual(validateSlug()("test", ""), "Test can't be blank");
- assert.strictEqual(validateSlug()("test", null), "Test can't be blank");
+ this.server.post("/graphql", { data: { allForms: { totalCount: 0 } } });
+
+ // count is 0 -> valid
+ assert.true(
+ await validator.validate(null, "slug-new", null, null, {
+ id: undefined,
+ })
+ );
});
- test("it validates length", function (assert) {
- assert.expect(1);
+ test("it validates uniqueness of question slugs", async function (assert) {
+ const validator = new SlugUniquenessValidator("question");
+
+ this.server.post("/graphql", { data: { allQuestions: { totalCount: 1 } } });
+ // count is 1 -> invalid
assert.strictEqual(
- validateSlug()("test", "x".repeat(128)),
- "Test is too long (maximum is 127 characters)"
+ await validator.validate(null, "slug", null, null, { id: undefined }),
+ "t:caluma.form-builder.validations.question.slug:()"
+ );
+
+ // id is given -> valid
+ assert.true(
+ await validator.validate(null, "slug", null, null, { id: "x" })
+ );
+
+ this.server.post("/graphql", { data: { allQuestions: { totalCount: 0 } } });
+
+ // count is 0 -> valid
+ assert.true(
+ await validator.validate(null, "slug-new", null, null, {
+ id: undefined,
+ })
);
});
- test("it validates format", function (assert) {
- assert.expect(2);
+ test("it validates uniqueness of option slugs", async function (assert) {
+ const validator = new SlugUniquenessValidator("option");
- assert.strictEqual(validateSlug()("test", "AA"), "Test is invalid");
- assert.strictEqual(validateSlug()("test", "#"), "Test is invalid");
+ this.server.post("/graphql", {
+ data: {
+ allQuestions: {
+ edges: [
+ {
+ node: {
+ id: btoa("Question:d663ed84-e8f4-4b58-ae77-775db34e39b8"),
+ options: { totalCount: 1 },
+ __typename: "ChoiceQuestion",
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ // count is 1 -> invalid
+ assert.strictEqual(
+ await validator.validate(null, "slug", null, null, {
+ id: undefined,
+ question: "question-slug",
+ }),
+ "t:caluma.form-builder.validations.option.slug:()"
+ );
+
+ // id is given -> valid
+ assert.true(
+ await validator.validate(null, "slug", null, null, {
+ id: "x",
+ question: "question-slug",
+ })
+ );
+
+ this.server.post("/graphql", {
+ data: {
+ allQuestions: {
+ edges: [
+ {
+ node: {
+ id: btoa("Question:d663ed84-e8f4-4b58-ae77-775db34e39b8"),
+ options: { totalCount: 0 },
+ __typename: "ChoiceQuestion",
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ // count is 0 -> valid
+ assert.true(
+ await validator.validate(null, "slug-new", null, null, {
+ id: undefined,
+ question: "question-slug",
+ })
+ );
});
});
diff --git a/packages/form-builder/translations/de.yaml b/packages/form-builder/translations/de.yaml
index 5bf27b1c0..481dde30f 100644
--- a/packages/form-builder/translations/de.yaml
+++ b/packages/form-builder/translations/de.yaml
@@ -175,3 +175,6 @@ caluma:
question:
slug: "Eine Frage mit diesem Slug existiert bereits"
+
+ option:
+ slug: "Eine Option mit diesem Slug existiert bereits"
diff --git a/packages/form-builder/translations/en.yaml b/packages/form-builder/translations/en.yaml
index d97e70b32..ea817958f 100644
--- a/packages/form-builder/translations/en.yaml
+++ b/packages/form-builder/translations/en.yaml
@@ -175,3 +175,6 @@ caluma:
question:
slug: "A question with this slug already exists"
+
+ option:
+ slug: "An option with this slug already exists"
diff --git a/packages/form-builder/translations/fr.yaml b/packages/form-builder/translations/fr.yaml
index be5b659ee..f210797ad 100644
--- a/packages/form-builder/translations/fr.yaml
+++ b/packages/form-builder/translations/fr.yaml
@@ -175,3 +175,6 @@ caluma:
question:
slug: "Une question avec ce slug existe déjà"
+
+ option:
+ slug: "Une option avec ce slug existe déjà"