diff --git a/packages/-ember-caluma/app/services/caluma-options.js b/packages/-ember-caluma/app/services/caluma-options.js index c82011543..771a402ee 100644 --- a/packages/-ember-caluma/app/services/caluma-options.js +++ b/packages/-ember-caluma/app/services/caluma-options.js @@ -9,6 +9,8 @@ export default class CustomCalumaOptionsService extends CalumaOptionsService { @service intl; @service store; + namespace = "demo"; + constructor(...args) { super(...args); diff --git a/packages/form-builder/addon/-private/application.js b/packages/form-builder/addon/-private/application.js new file mode 100644 index 000000000..72d475007 --- /dev/null +++ b/packages/form-builder/addon/-private/application.js @@ -0,0 +1,6 @@ +/** + * This module holds the application instance which is needed in the validations + * to allow context of the ember container in order to allow injections of + * services. The `instance` property will be set in an instance initializer. + */ +export default { instance: null }; diff --git a/packages/form-builder/addon/components/cfb-form-editor/general.hbs b/packages/form-builder/addon/components/cfb-form-editor/general.hbs index 0dc5e2702..c684760b5 100644 --- a/packages/form-builder/addon/components/cfb-form-editor/general.hbs +++ b/packages/form-builder/addon/components/cfb-form-editor/general.hbs @@ -16,40 +16,17 @@ @on-update={{this.updateName}} /> - {{#if (or @slug (not this.prefix))}} - - {{else}} - -
- {{this.prefix}} - -
-
- {{/if}} + diff --git a/packages/form-builder/addon/components/cfb-form-editor/general.js b/packages/form-builder/addon/components/cfb-form-editor/general.js index b61f329f9..7176ec868 100644 --- a/packages/form-builder/addon/components/cfb-form-editor/general.js +++ b/packages/form-builder/addon/components/cfb-form-editor/general.js @@ -1,16 +1,14 @@ import { action } from "@ember/object"; import { inject as service } from "@ember/service"; -import { macroCondition, isTesting } from "@embroider/macros"; import Component from "@glimmer/component"; import { queryManager } from "ember-apollo-client"; -import { timeout, restartableTask, dropTask } from "ember-concurrency"; +import { restartableTask, dropTask } from "ember-concurrency"; import { trackedTask } from "ember-resources/util/ember-concurrency"; import FormValidations from "../../validations/form"; import slugify from "@projectcaluma/ember-core/utils/slugify"; import saveFormMutation from "@projectcaluma/ember-form-builder/gql/mutations/save-form.graphql"; -import checkFormSlugQuery from "@projectcaluma/ember-form-builder/gql/queries/check-form-slug.graphql"; import formEditorGeneralQuery from "@projectcaluma/ember-form-builder/gql/queries/form-editor-general.graphql"; export default class CfbFormEditorGeneral extends Component { @@ -61,16 +59,13 @@ export default class CfbFormEditorGeneral extends Component { @dropTask *submit(changeset) { try { - const slug = - ((!this.args.slug && this.prefix) || "") + changeset.get("slug"); - const form = yield this.apollo.mutate( { mutation: saveFormMutation, variables: { input: { name: changeset.get("name"), - slug, + slug: changeset.get("slug"), description: changeset.get("description"), isArchived: changeset.get("isArchived"), isPublished: changeset.get("isPublished"), @@ -100,47 +95,15 @@ export default class CfbFormEditorGeneral 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: checkFormSlugQuery, - variables: { slug }, - }, - "allForms.edges" - ); - - if (res && res.length) { - changeset.pushErrors( - "slug", - this.intl.t("caluma.form-builder.validations.form.slug") - ); - } - } - @action updateName(value, changeset) { changeset.set("name", value); - if (!this.args.slug) { - const slug = slugify(value, { locale: this.intl.primaryLocale }); - changeset.set("slug", slug); + if (!this.args.slug && !this.slugUnlinked) { + const slugifiedName = slugify(value, { locale: this.intl.primaryLocale }); + const slug = slugifiedName ? this.prefix + slugifiedName : ""; - this.validateSlug.perform(this.prefix + slug, changeset); + changeset.set("slug", slug); } } - - @action - updateSlug(value, changeset) { - changeset.set("slug", value); - - this.validateSlug.perform(this.prefix + value, changeset); - } } diff --git a/packages/form-builder/addon/components/cfb-form-editor/question.hbs b/packages/form-builder/addon/components/cfb-form-editor/question.hbs index 82d4582f2..5821f4437 100644 --- a/packages/form-builder/addon/components/cfb-form-editor/question.hbs +++ b/packages/form-builder/addon/components/cfb-form-editor/question.hbs @@ -34,7 +34,7 @@ @hint={{t "caluma.form-builder.question.type-disabled"}} @name="__typename" @required={{true}} - @disabled={{@slug}} + @disabled={{not (is-empty @slug)}} @on-update={{changeset-set f.model "__typename"}} /> @@ -47,39 +47,16 @@
- {{#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 /> + +
    +
    + {{#if (and (not (or @disabled @hidePrefix)) this.prefix)}} + + {{this.prefix}} + + {{/if}} + +
    +
    + + <@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à"