DEV: Define form template field inputs (#20430)

This commit is contained in:
Keegan George 2023-03-01 11:07:13 -08:00 committed by GitHub
parent 8b67a534a0
commit 666b4a7e6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 963 additions and 130 deletions

View File

@ -1,4 +1,4 @@
<div class="form-templates--form">
<div class="form-templates__form">
<div class="control-group">
<label for="template-name">
{{i18n "admin.form_templates.new_template_form.name.label"}}
@ -6,24 +6,38 @@
<TextField
@value={{this.templateName}}
@name="template-name"
@class="form-templates--form-name-input"
@class="form-templates__form-name-input"
@placeholderKey="admin.form_templates.new_template_form.name.placeholder"
/>
</div>
<div class="control-group form-templates--quick-insert-field-buttons">
<span>
{{I18n "admin.form_templates.quick_insert_fields.add_new_field"}}
</span>
{{#each this.quickInsertFields as |field|}}
<div class="control-group form-templates__editor">
<div class="form-templates__quick-insert-field-buttons">
<span>
{{I18n "admin.form_templates.quick_insert_fields.add_new_field"}}
</span>
{{#each this.quickInsertFields as |field|}}
<DButton
@class="btn-flat btn-icon-text quick-insert-{{field.type}}"
@icon={{field.icon}}
@label="admin.form_templates.quick_insert_fields.{{field.type}}"
@action={{this.onInsertField}}
@actionParam={{field.type}}
/>
{{/each}}
<DButton
@class="btn-flat btn-icon-text quick-insert-{{field.type}}"
@icon={{field.icon}}
@label="admin.form_templates.quick_insert_fields.{{field.type}}"
@action={{this.onInsertField}}
@actionParam={{field.type}}
class="btn-flat btn-icon-text form-templates__validations-modal-button"
@label="admin.form_templates.validations_modal.button_title"
@icon="check-circle"
@action={{this.showValidationOptionsModal}}
/>
{{/each}}
</div>
<DButton
@class="form-templates__preview-button"
@icon="eye"
@label="admin.form_templates.new_template_form.preview"
@action={{this.showPreview}}
@disabled={{this.disablePreviewButton}}
/>
</div>
<div class="control-group">
@ -36,7 +50,7 @@
@label="admin.form_templates.new_template_form.submit"
@icon="check"
@action={{this.onSubmit}}
@disabled={{this.formSubmitted}}
@disabled={{this.disableSubmitButton}}
/>
<DButton

View File

@ -6,14 +6,15 @@ import I18n from "I18n";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { templateFormFields } from "admin/lib/template-form-fields";
import FormTemplate from "admin/models/form-template";
import showModal from "discourse/lib/show-modal";
export default class FormTemplateForm extends Component {
@service router;
@service dialog;
@tracked formSubmitted = false;
@tracked templateContent = this.args.model?.template || "";
@tracked templateName = this.args.model?.name || "";
isEditing = this.args.model?.id ? true : false;
templateName = this.args.model?.name;
quickInsertFields = [
{
type: "checkbox",
@ -41,6 +42,17 @@ export default class FormTemplateForm extends Component {
},
];
get disablePreviewButton() {
return Boolean(!this.templateName.length || !this.templateContent.length);
}
get disableSubmitButton() {
return (
Boolean(!this.templateName.length || !this.templateContent.length) ||
this.formSubmitted
);
}
@action
onSubmit() {
if (!this.formSubmitted) {
@ -54,27 +66,17 @@ export default class FormTemplateForm extends Component {
if (this.isEditing) {
postData["id"] = this.args.model.id;
FormTemplate.updateTemplate(this.args.model.id, postData)
.then(() => {
this.formSubmitted = false;
this.router.transitionTo("adminCustomizeFormTemplates.index");
})
.catch((e) => {
popupAjaxError(e);
this.formSubmitted = false;
});
} else {
FormTemplate.createTemplate(postData)
.then(() => {
this.formSubmitted = false;
this.router.transitionTo("adminCustomizeFormTemplates.index");
})
.catch((e) => {
popupAjaxError(e);
this.formSubmitted = false;
});
}
FormTemplate.createOrUpdateTemplate(postData)
.then(() => {
this.formSubmitted = false;
this.router.transitionTo("adminCustomizeFormTemplates.index");
})
.catch((e) => {
popupAjaxError(e);
this.formSubmitted = false;
});
}
@action
@ -106,4 +108,33 @@ export default class FormTemplateForm extends Component {
this.templateContent += `\n${structure}`;
}
}
@action
showValidationOptionsModal() {
return showModal("admin-form-template-validation-options", {
admin: true,
});
}
@action
showPreview() {
const data = {
name: this.templateName,
template: this.templateContent,
};
if (this.isEditing) {
data["id"] = this.args.model.id;
}
FormTemplate.validateTemplate(data)
.then(() => {
return showModal("form-template-form-preview", {
model: {
content: this.templateContent,
},
});
})
.catch(popupAjaxError);
}
}

View File

@ -12,8 +12,7 @@ export default class FormTemplateRowItem extends Component {
@action
viewTemplate() {
showModal("admin-customize-form-template-view", {
admin: true,
showModal("customize-form-template-view", {
model: this.args.template,
refreshModel: this.args.refreshModel,
});

View File

@ -0,0 +1,35 @@
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import I18n from "I18n";
export default class AdminFormTemplateValidationOptions extends Controller.extend(
ModalFunctionality
) {
TABLE_HEADER_KEYS = ["key", "type", "description"];
VALIDATION_KEYS = ["required", "minimum", "maximum", "pattern"];
get tableHeaders() {
const translatedHeaders = [];
this.TABLE_HEADER_KEYS.forEach((header) => {
translatedHeaders.push(
I18n.t(`admin.form_templates.validations_modal.table_headers.${header}`)
);
});
return translatedHeaders;
}
get validations() {
const translatedValidations = [];
const prefix = "admin.form_templates.validations_modal.validations";
this.VALIDATION_KEYS.forEach((validation) => {
translatedValidations.push({
key: I18n.t(`${prefix}.${validation}.key`),
type: I18n.t(`${prefix}.${validation}.type`),
description: I18n.t(`${prefix}.${validation}.description`),
});
});
return translatedValidations;
}
}

View File

@ -1,70 +1,75 @@
// TODO(@keegan): Add translations for template strings
import I18n from "I18n";
export const templateFormFields = [
{
type: "checkbox",
structure: `- type: checkbox
choices:
- "Option 1"
- "Option 2"
- "Option 3"
attributes:
label: "Enter question here"
description: "Enter description here"
validations:
required: true`,
label: "${I18n.t("admin.form_templates.field_placeholders.label")}"
validations:
# ${I18n.t("admin.form_templates.field_placeholders.validations")}`,
},
{
type: "input",
structure: `- type: input
attributes:
label: "Enter input label here"
description: "Enter input description here"
placeholder: "Enter input placeholder here"
validations:
required: true`,
label: "${I18n.t("admin.form_templates.field_placeholders.label")}"
placeholder: "${I18n.t(
"admin.form_templates.field_placeholders.placeholder"
)}"
validations:
# ${I18n.t("admin.form_templates.field_placeholders.validations")}`,
},
{
type: "textarea",
structure: `- type: textarea
attributes:
label: "Enter textarea label here"
description: "Enter textarea description here"
placeholder: "Enter textarea placeholder here"
validations:
required: true`,
label: "${I18n.t("admin.form_templates.field_placeholders.label")}"
placeholder: "${I18n.t(
"admin.form_templates.field_placeholders.placeholder"
)}"
validations:
# ${I18n.t("admin.form_templates.field_placeholders.validations")}`,
},
{
type: "dropdown",
structure: `- type: dropdown
choices:
- "Option 1"
- "Option 2"
- "Option 3"
- "${I18n.t("admin.form_templates.field_placeholders.choices.first")}"
- "${I18n.t("admin.form_templates.field_placeholders.choices.second")}"
- "${I18n.t("admin.form_templates.field_placeholders.choices.third")}"
attributes:
label: "Enter dropdown label here"
description: "Enter dropdown description here"
validations:
required: true`,
none_label: "${I18n.t(
"admin.form_templates.field_placeholders.none_label"
)}"
label: "${I18n.t("admin.form_templates.field_placeholders.label")}"
filterable: false
validations:
# ${I18n.t("admin.form_templates.field_placeholders.validations")}`,
},
{
type: "upload",
structure: `- type: upload
attributes:
file_types: "jpg, png, gif"
label: "Enter upload label here"
description: "Enter upload description here"`,
allow_multiple: false
label: "${I18n.t("admin.form_templates.field_placeholders.label")}"
validations:
# ${I18n.t("admin.form_templates.field_placeholders.validations")}`,
},
{
type: "multiselect",
structure: `- type: multiple_choice
structure: `- type: multi-select
choices:
- "Option 1"
- "Option 2"
- "Option 3"
- "${I18n.t("admin.form_templates.field_placeholders.choices.first")}"
- "${I18n.t("admin.form_templates.field_placeholders.choices.second")}"
- "${I18n.t("admin.form_templates.field_placeholders.choices.third")}"
attributes:
label: "Enter multiple choice label here"
description: "Enter multiple choice description here"
validations:
required: true`,
none_label: "${I18n.t(
"admin.form_templates.field_placeholders.none_label"
)}"
label: "${I18n.t("admin.form_templates.field_placeholders.label")}"
validations:
# ${I18n.t("admin.form_templates.field_placeholders.validations")}`,
},
];

View File

@ -18,6 +18,14 @@ FormTemplate.reopenClass({
});
},
createOrUpdateTemplate(data) {
if (data.id) {
return this.updateTemplate(data.id, data);
} else {
return this.createTemplate(data);
}
},
deleteTemplate(id) {
return ajax(`/admin/customize/form-templates/${id}.json`, {
type: "DELETE",
@ -35,4 +43,11 @@ FormTemplate.reopenClass({
return model.form_template;
});
},
validateTemplate(data) {
return ajax(`/admin/customize/form-templates/preview.json`, {
type: "GET",
data,
});
},
});

View File

@ -2,7 +2,7 @@
<FormTemplate::InfoHeader />
{{#if this.model}}
<table class="form-templates--table grid">
<table class="form-templates__table grid">
<thead>
<th class="col heading">
{{i18n "admin.form_templates.list_table.headings.name"}}

View File

@ -0,0 +1,23 @@
<DModalBody
@class="form-templates__validation-options"
@title="admin.form_templates.validations_modal.modal_title"
>
<table>
<thead>
<tr>
{{#each this.tableHeaders as |header|}}
<th>{{header}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.validations as |item|}}
<tr>
<td><pre>{{item.key}}</pre></td>
<td>{{item.type}}</td>
<td>{{item.description}}</td>
</tr>
{{/each}}
</tbody>
</table>
</DModalBody>

View File

@ -0,0 +1,6 @@
<div class="control-group form-template-field" data-field-type="checkbox">
<label class="form-template-field__label">
<Input class="form-template-field__checkbox" @type="checkbox" />
{{@attributes.label}}
</label>
</div>

View File

@ -0,0 +1,15 @@
<div class="control-group form-template-field" data-field-type="dropdown">
{{#if @attributes.label}}
<label class="form-template-field__label">{{@attributes.label}}</label>
{{/if}}
<ComboBox
@class="form-template-field__dropdown"
@content={{@choices}}
@nameProperty={{null}}
@valueProperty={{null}}
@options={{hash
translatedNone=@attributes.none_label
filterable=@attributes.filterable
}}
/>
</div>

View File

@ -0,0 +1,10 @@
<div class="control-group form-template-field" data-field-type="input">
{{#if @attributes.label}}
<label class="form-template-field__label">{{@attributes.label}}</label>
{{/if}}
<Input
class="form-template-field__input"
@type="text"
placeholder={{@attributes.placeholder}}
/>
</div>

View File

@ -0,0 +1,15 @@
<div class="control-group form-template-field" data-field-type="multi-select">
{{#if @attributes.label}}
<label class="form-template-field__label">{{@attributes.label}}</label>
{{/if}}
<MultiSelect
@class="form-template-field__multi-select"
@content={{@choices}}
@nameProperty={{null}}
@valueProperty={{null}}
@options={{hash
translatedNone=@attributes.none_label
maximum=@validations.maximum
}}
/>
</div>

View File

@ -0,0 +1,9 @@
<div class="control-group form-template-field" data-field-type="textarea">
{{#if @attributes.label}}
<label class="form-template-field__label">{{@attributes.label}}</label>
{{/if}}
<Textarea
class="form-template-field__textarea"
placeholder={{@attributes.placeholder}}
/>
</div>

View File

@ -0,0 +1,11 @@
<div class="control-group form-template-field" data-field-type="upload">
{{#if @attributes.label}}
<label class="form-template-field__label">{{@attributes.label}}</label>
{{/if}}
<input
type="file"
accept={{@attributes.file_types}}
class="form-template-field__upload"
multiple={{@attributes.allow_multiple}}
/>
</div>

View File

@ -0,0 +1,14 @@
{{#if this.canShowContent}}
{{#each this.parsedContent as |content|}}
{{component
(concat "form-template-field/" content.type)
attributes=content.attributes
choices=content.choices
validations=content.validations
}}
{{/each}}
{{else}}
<div class="alert alert-error">
{{this.error}}
</div>
{{/if}}

View File

@ -0,0 +1,17 @@
import Component from "@glimmer/component";
import Yaml from "js-yaml";
import { tracked } from "@glimmer/tracking";
export default class FormTemplateFieldWrapper extends Component {
@tracked error = null;
get canShowContent() {
try {
const parsedContent = Yaml.load(this.args.content);
this.parsedContent = parsedContent;
return true;
} catch (e) {
this.error = e;
}
}
}

View File

@ -5,12 +5,19 @@ import { inject as service } from "@ember/service";
import I18n from "I18n";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { ajax } from "discourse/lib/ajax";
import { tracked } from "@glimmer/tracking";
export default class AdminCustomizeFormTemplateView extends Controller.extend(
ModalFunctionality
) {
@service router;
@service dialog;
@tracked showPreview = false;
@action
togglePreview() {
this.showPreview = !this.showPreview;
}
@action
editTemplate() {

View File

@ -0,0 +1,6 @@
import Controller from "@ember/controller";
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default class AdminFormTemplateValidationOptions extends Controller.extend(
ModalFunctionality
) {}

View File

@ -1,6 +1,17 @@
<DModalBody @rawTitle={{this.model.name}}>
<HighlightedCode @lang="yaml" @code={{this.model.template}} />
{{! ? TODO(@keegan): Perhaps add what places (ex. categories) the templates are active in }}
<div class="control-group">
<DToggleSwitch
class="form-templates__preview-toggle"
@state={{this.showPreview}}
@label="admin.form_templates.view_template.toggle_preview"
{{on "click" this.togglePreview}}
/>
</div>
{{#if this.showPreview}}
<FormTemplateField::Wrapper @content={{this.model.template}} />
{{else}}
<HighlightedCode @lang="yaml" @code={{this.model.template}} />
{{/if}}
</DModalBody>
<div class="modal-footer">
<DButton

View File

@ -0,0 +1,6 @@
<DModalBody
@class="form-templates__validation-options"
@title="admin.form_templates.preview_modal.title"
>
<FormTemplateField::Wrapper @content={{this.model.content}} />
</DModalBody>

View File

@ -0,0 +1,43 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { exists } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | form-template-field | checkbox",
function (hooks) {
setupRenderingTest(hooks);
test("renders a checkbox input", async function (assert) {
await render(hbs`<FormTemplateField::Checkbox />`);
assert.ok(
exists(
".form-template-field[data-field-type='checkbox'] input[type='checkbox']"
),
"A checkbox component exists"
);
});
test("renders a checkbox with a label", async function (assert) {
const attributes = {
label: "Click this box",
};
this.set("attributes", attributes);
await render(
hbs`<FormTemplateField::Checkbox @attributes={{this.attributes}} />`
);
assert.ok(
exists(
".form-template-field[data-field-type='checkbox'] input[type='checkbox']"
),
"A checkbox component exists"
);
assert.dom(".form-template-field__label").hasText("Click this box");
});
}
);

View File

@ -0,0 +1,88 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { exists } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | form-template-field | dropdown",
function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.set("subject", selectKit());
});
test("renders a dropdown with choices", async function (assert) {
const choices = ["Choice 1", "Choice 2", "Choice 3"];
this.set("choices", choices);
await render(
hbs`<FormTemplateField::Dropdown @choices={{this.choices}}/>`
);
assert.ok(
exists(".form-template-field__dropdown"),
"A dropdown component exists"
);
await this.subject.expand();
const dropdown = this.subject.displayedContent();
assert.strictEqual(dropdown.length, 3, "it has 3 choices");
assert.strictEqual(
dropdown[0].name,
"Choice 1",
"it has the correct name for choice 1"
);
assert.strictEqual(
dropdown[1].name,
"Choice 2",
"it has the correct name for choice 2"
);
assert.strictEqual(
dropdown[2].name,
"Choice 3",
"it has the correct name for choice 3"
);
});
test("renders a dropdown with choices and attributes", async function (assert) {
const choices = ["Choice 1", "Choice 2", "Choice 3"];
const attributes = {
none_label: "Select a choice",
filterable: true,
};
this.set("choices", choices);
this.set("attributes", attributes);
await render(
hbs`<FormTemplateField::Dropdown @choices={{this.choices}} @attributes={{this.attributes}} />`
);
assert.ok(
exists(".form-template-field__dropdown"),
"A dropdown component exists"
);
await this.subject.expand();
assert.strictEqual(
this.subject.header().label(),
attributes.none_label,
"None label is correct"
);
});
test("doesn't render a label when attribute is missing", async function (assert) {
const choices = ["Choice 1", "Choice 2", "Choice 3"];
this.set("choices", choices);
await render(
hbs`<FormTemplateField::Dropdown @choices={{this.choices}} />`
);
assert.dom(".form-template-field__label").doesNotExist();
});
}
);

View File

@ -0,0 +1,61 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | form-template-field | input",
function (hooks) {
setupRenderingTest(hooks);
test("renders a text input", async function (assert) {
await render(hbs`<FormTemplateField::Input />`);
assert.ok(
exists(
".form-template-field[data-field-type='input'] input[type='text']"
),
"A text input component exists"
);
});
test("renders a text input with attributes", async function (assert) {
const attributes = {
label: "My text label",
placeholder: "Enter text here",
};
this.set("attributes", attributes);
await render(
hbs`<FormTemplateField::Input @attributes={{this.attributes}} />`
);
assert.ok(
exists(
".form-template-field[data-field-type='input'] input[type='text']"
),
"A text input component exists"
);
assert.dom(".form-template-field__label").hasText("My text label");
assert.strictEqual(
query(".form-template-field__input").placeholder,
"Enter text here"
);
});
test("doesn't render a label when attribute is missing", async function (assert) {
const attributes = {
placeholder: "Enter text here",
};
this.set("attributes", attributes);
await render(
hbs`<FormTemplateField::Input @attributes={{this.attributes}} />`
);
assert.dom(".form-template-field__label").doesNotExist();
});
}
);

View File

@ -0,0 +1,88 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { exists } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | form-template-field | multi-select",
function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.set("subject", selectKit());
});
test("renders a multi-select dropdown with choices", async function (assert) {
const choices = ["Choice 1", "Choice 2", "Choice 3"];
this.set("choices", choices);
await render(
hbs`<FormTemplateField::MultiSelect @choices={{this.choices}}/>`
);
assert.ok(
exists(".form-template-field__multi-select"),
"A multiselect component exists"
);
await this.subject.expand();
const dropdown = this.subject.displayedContent();
assert.strictEqual(dropdown.length, 3, "it has 3 choices");
assert.strictEqual(
dropdown[0].name,
"Choice 1",
"it has the correct name for choice 1"
);
assert.strictEqual(
dropdown[1].name,
"Choice 2",
"it has the correct name for choice 2"
);
assert.strictEqual(
dropdown[2].name,
"Choice 3",
"it has the correct name for choice 3"
);
});
test("renders a multi-select with choices and attributes", async function (assert) {
const choices = ["Choice 1", "Choice 2", "Choice 3"];
const attributes = {
none_label: "Select a choice",
filterable: true,
};
this.set("choices", choices);
this.set("attributes", attributes);
await render(
hbs`<FormTemplateField::MultiSelect @choices={{this.choices}} @attributes={{this.attributes}} />`
);
assert.ok(
exists(".form-template-field__multi-select"),
"A multiselect dropdown component exists"
);
await this.subject.expand();
assert.strictEqual(
this.subject.header().label(),
attributes.none_label,
"None label is correct"
);
});
test("doesn't render a label when attribute is missing", async function (assert) {
const choices = ["Choice 1", "Choice 2", "Choice 3"];
this.set("choices", choices);
await render(
hbs`<FormTemplateField::MultiSelect @choices={{this.choices}} />`
);
assert.dom(".form-template-field__label").doesNotExist();
});
}
);

View File

@ -0,0 +1,57 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { exists, query } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | form-template-field | textarea",
function (hooks) {
setupRenderingTest(hooks);
test("renders a textarea input", async function (assert) {
await render(hbs`<FormTemplateField::Textarea />`);
assert.ok(
exists(".form-template-field__textarea"),
"A textarea input component exists"
);
});
test("renders a text input with attributes", async function (assert) {
const attributes = {
label: "My text label",
placeholder: "Enter text here",
};
this.set("attributes", attributes);
await render(
hbs`<FormTemplateField::Textarea @attributes={{this.attributes}} />`
);
assert.ok(
exists(".form-template-field__textarea"),
"A textarea input component exists"
);
assert.dom(".form-template-field__label").hasText("My text label");
assert.strictEqual(
query(".form-template-field__textarea").placeholder,
"Enter text here"
);
});
test("doesn't render a label when attribute is missing", async function (assert) {
const attributes = {
placeholder: "Enter text here",
};
this.set("attributes", attributes);
await render(
hbs`<FormTemplateField::Textarea @attributes={{this.attributes}} />`
);
assert.dom(".form-template-field__label").doesNotExist();
});
}
);

View File

@ -0,0 +1,49 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { exists } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | form-template-field | wrapper",
function (hooks) {
setupRenderingTest(hooks);
test("does not render a component when template content has invalid YAML", async function (assert) {
this.set("content", `- type: checkbox\n attributes;invalid`);
await render(
hbs`<FormTemplateField::Wrapper @content={{this.content}} />`
);
assert.notOk(
exists(".form-template-field"),
"A form template field should not exist"
);
assert.ok(exists(".alert"), "An alert message should exist");
});
test("renders a component based on the component type in the template content", async function (assert) {
const content = `- type: checkbox\n- type: input\n- type: textarea\n- type: dropdown\n- type: upload\n- type: multi-select`;
const componentTypes = [
"checkbox",
"input",
"textarea",
"dropdown",
"upload",
"multi-select",
];
this.set("content", content);
await render(
hbs`<FormTemplateField::Wrapper @content={{this.content}} />`
);
componentTypes.forEach((componentType) => {
assert.ok(
exists(`.form-template-field[data-field-type='${componentType}']`),
`${componentType} component exists`
);
});
});
}
);

View File

@ -925,11 +925,11 @@ table.permalinks {
}
.form-templates {
&--info {
&__info {
margin-top: 1rem;
}
&--table {
&__table {
margin-bottom: 1rem;
.admin-list-item .action {
@ -937,7 +937,7 @@ table.permalinks {
}
}
&--form {
&__form {
input {
width: 300px;
}
@ -975,7 +975,13 @@ table.permalinks {
}
}
&--quick-insert-field-buttons {
&__editor {
display: flex;
align-items: center;
flex-wrap: wrap;
}
&__quick-insert-field-buttons {
display: flex;
align-items: center;
flex-wrap: wrap;
@ -986,14 +992,22 @@ table.permalinks {
}
.btn {
&:not(:last-child) {
&:not(:last-of-type) {
border-right: 1px solid var(--primary-low);
}
}
}
&__validation-options td {
padding: 0.75rem;
}
&__preview-button {
margin-left: auto;
}
}
.admin-customize-form-template-view-modal {
.customize-form-template-view-modal {
.modal-footer {
.btn:last-child {
margin-left: auto;

View File

@ -11,6 +11,25 @@ class Admin::FormTemplatesController < Admin::StaffController
def new
end
def preview
params.require(:name)
params.require(:template)
if params[:id].present?
template = FormTemplate.find(params[:id])
template.assign_attributes(name: params[:name], template: params[:template])
else
template = FormTemplate.new(name: params[:name], template: params[:template])
end
begin
template.validate!
render json: success_json
rescue FormTemplate::NotAllowed => err
render_json_error(err.message)
end
end
def create
params.require(:name)
params.require(:template)

View File

@ -5547,6 +5547,7 @@ en:
close: "Close"
edit: "Edit"
delete: "Delete"
toggle_preview: "Toggle Preview"
new_template_form:
submit: "Save"
cancel: "Cancel"
@ -5556,6 +5557,7 @@ en:
template:
label: "Template"
placeholder: "Create a YAML template here..."
preview: "Preview"
delete_confirm: "Are you sure you would like to delete this template?"
quick_insert_fields:
add_new_field: "Add"
@ -5565,6 +5567,41 @@ en:
dropdown: "Dropdown"
upload: "Upload a file"
multiselect: "Multiple choice"
validations_modal:
button_title: "Validations"
modal_title: "Validation Options"
table_headers:
key: "Key"
type: "Type"
description: "Description"
validations:
required:
key: "required"
type: "boolean"
description: "Requires the field to be completed to submit the form."
minimum:
key: "minimum"
type: "integer"
description: "In text fields, specifies the minimum number of characters allowed."
maximum:
key: "maximum"
type: "integer"
description: "In text fields, specifies the maximum number of characters allowed. In multi select dropdowns, specifies the maximum number of options that can be selected."
pattern:
key: "pattern"
type: "regex string"
description: "In text fields, a regular expression specifying the allowed input."
preview_modal:
title: "Preview Template"
field_placeholders:
validations: "enter validations here"
label: "Enter label here"
placeholder: "Enter placeholder here"
none_label: "Select an item"
choices:
first: "Option 1"
second: "Option 2"
third: "Option 3"
edit_category:
toggle_freeform: "form template disabled"
toggle_form_template: "form template enabled"

View File

@ -5253,3 +5253,5 @@ en:
form_templates:
errors:
invalid_yaml: "is not a valid YAML string"
invalid_type: "contains an invalid template type: %{type} (valid types are: %{valid_types})"
missing_type: "is missing a field type"

View File

@ -233,7 +233,9 @@ Discourse::Application.routes.draw do
scope "/customize", constraints: AdminConstraint.new do
resources :user_fields, constraints: AdminConstraint.new
resources :emojis, constraints: AdminConstraint.new
resources :form_templates, constraints: AdminConstraint.new, path: "/form-templates"
resources :form_templates, constraints: AdminConstraint.new, path: "/form-templates" do
collection { get "preview" => "form_templates#preview" }
end
get "themes/:id/:target/:field_name/edit" => "themes#index"
get "themes/:id" => "themes#index"

View File

@ -4,8 +4,36 @@ class FormTemplateYamlValidator < ActiveModel::Validator
def validate(record)
begin
yaml = Psych.safe_load(record.template)
check_missing_type(record, yaml)
check_allowed_types(record, yaml)
rescue Psych::SyntaxError
record.errors.add(:template, I18n.t("form_templates.errors.invalid_yaml"))
end
end
def check_allowed_types(record, yaml)
allowed_types = %w[checkbox dropdown input multi-select textarea upload]
yaml.each do |field|
if !allowed_types.include?(field["type"])
return(
record.errors.add(
:template,
I18n.t(
"form_templates.errors.invalid_type",
type: field["type"],
valid_types: allowed_types.join(", "),
),
)
)
end
end
end
def check_missing_type(record, yaml)
yaml.each do |field|
if field["type"].blank?
return record.errors.add(:template, I18n.t("form_templates.errors.missing_type"))
end
end
end
end

View File

@ -2,5 +2,5 @@
Fabricator(:form_template) do
name { sequence(:name) { |i| "template_#{i}" } }
template "some yaml template: value"
template "- type: input"
end

View File

@ -4,17 +4,29 @@ require "rails_helper"
RSpec.describe FormTemplate, type: :model do
it "can't have duplicate names" do
Fabricate(:form_template, name: "Bug Report", template: "some yaml: true")
t = Fabricate.build(:form_template, name: "Bug Report", template: "some yaml: true")
Fabricate(:form_template, name: "Bug Report", template: "- type: input")
t = Fabricate.build(:form_template, name: "Bug Report", template: "- type: input")
expect(t.save).to eq(false)
t = Fabricate.build(:form_template, name: "Bug Report", template: "some yaml: true")
t = Fabricate.build(:form_template, name: "Bug Report", template: "- type: input")
expect(t.save).to eq(false)
expect(described_class.count).to eq(1)
end
it "can't have an invalid yaml template" do
template = "first: good\nsecond; bad"
template = "- type: checkbox\nattributes; bad"
t = Fabricate.build(:form_template, name: "Feature Request", template: template)
expect(t.save).to eq(false)
end
it "must have a supported type" do
template = "- type: fancy"
t = Fabricate.build(:form_template, name: "Fancy Template", template: template)
expect(t.save).to eq(false)
end
it "must have a type propety" do
template = "- hello: world"
t = Fabricate.build(:form_template, name: "Basic Template", template: template)
expect(t.save).to eq(false)
end
end

View File

@ -74,7 +74,7 @@ RSpec.describe Admin::FormTemplatesController do
params: {
name: "Bug Reports",
template:
"body:\n- type: input\n attributes:\n label: Website or apps\n description: |\n Which website or app were you using when the bug happened?\n placeholder: |\n e.g. website URL, name of the app\n validations:\n required: true",
"- type: input\n attributes:\n label: Website or apps\n description: |\n Which website or app were you using when the bug happened?\n placeholder: |\n e.g. website URL, name of the app\n validations:\n required: true",
}
expect(response.status).to eq(200)
@ -112,13 +112,13 @@ RSpec.describe Admin::FormTemplatesController do
params: {
id: form_template.id,
name: "Updated Template",
template: "New yaml: true",
template: "- type: checkbox",
}
expect(response.status).to eq(200)
form_template.reload
expect(form_template.name).to eq("Updated Template")
expect(form_template.template).to eq("New yaml: true")
expect(form_template.template).to eq("- type: checkbox")
end
end

View File

@ -21,7 +21,14 @@ describe "Admin Customize Form Templates", type: :system, js: true do
it "should show the form template structure in a modal" do
visit("/admin/customize/form-templates")
form_template_page.click_view_form_template
expect(form_template_page).to have_template_structure("some yaml template: value")
expect(form_template_page).to have_template_structure("- type: input")
end
it "should show a preview of the template in a modal when toggling the preview" do
visit("/admin/customize/form-templates")
form_template_page.click_view_form_template
form_template_page.click_toggle_preview
expect(form_template_page).to have_input_field("input")
end
end
@ -45,7 +52,7 @@ describe "Admin Customize Form Templates", type: :system, js: true do
visit("/admin/customize/form-templates/new")
sample_name = "My First Template"
sample_template = "test: true"
sample_template = "- type: input"
form_template_page.type_in_template_name(sample_name)
ace_editor.type_input(sample_template)
@ -53,19 +60,62 @@ describe "Admin Customize Form Templates", type: :system, js: true do
expect(form_template_page).to have_form_template(sample_name)
end
it "should disable the save button until form is filled out" do
visit("/admin/customize/form-templates/new")
expect(form_template_page).to have_save_button_with_state(true)
form_template_page.type_in_template_name("New Template")
expect(form_template_page).to have_save_button_with_state(true)
ace_editor.type_input("- type: input")
expect(form_template_page).to have_save_button_with_state(false)
end
it "should disable the preview button until form is filled out" do
visit("/admin/customize/form-templates/new")
expect(form_template_page).to have_preview_button_with_state(true)
form_template_page.type_in_template_name("New Template")
expect(form_template_page).to have_preview_button_with_state(true)
ace_editor.type_input("- type: input")
expect(form_template_page).to have_preview_button_with_state(false)
end
it "should show validation options in a modal when clicking the validations button" do
visit("/admin/customize/form-templates/new")
form_template_page.click_validations_button
expect(form_template_page).to have_validations_modal
end
it "should show a preview of the template in a modal when clicking the preview button" do
visit("/admin/customize/form-templates/new")
form_template_page.type_in_template_name("New Template")
ace_editor.type_input("- type: input")
form_template_page.click_preview_button
expect(form_template_page).to have_preview_modal
expect(form_template_page).to have_input_field("input")
end
it "should render all the input field types in the preview" do
visit("/admin/customize/form-templates/new")
form_template_page.type_in_template_name("New Template")
ace_editor.type_input(
"- type: input\n- type: textarea\n- type: checkbox\n- type: dropdown\n- type: upload\n- type: multi-select",
)
form_template_page.click_preview_button
expect(form_template_page).to have_input_field("input")
expect(form_template_page).to have_input_field("textarea")
expect(form_template_page).to have_input_field("checkbox")
expect(form_template_page).to have_input_field("dropdown")
expect(form_template_page).to have_input_field("upload")
expect(form_template_page).to have_input_field("multi-select")
end
it "should allow quick insertion of checkbox field" do
quick_insertion_test(
"checkbox",
'- type: checkbox
choices:
- "Option 1"
- "Option 2"
- "Option 3"
attributes:
label: "Enter question here"
description: "Enter description here"
validations:
required: true',
label: "Enter label here"
validations:
# enter validations here',
)
end
@ -74,11 +124,10 @@ describe "Admin Customize Form Templates", type: :system, js: true do
"input",
'- type: input
attributes:
label: "Enter input label here"
description: "Enter input description here"
placeholder: "Enter input placeholder here"
validations:
required: true',
label: "Enter label here"
placeholder: "Enter placeholder here"
validations:
# enter validations here',
)
end
@ -87,11 +136,10 @@ describe "Admin Customize Form Templates", type: :system, js: true do
"textarea",
'- type: textarea
attributes:
label: "Enter textarea label here"
description: "Enter textarea description here"
placeholder: "Enter textarea placeholder here"
validations:
required: true',
label: "Enter label here"
placeholder: "Enter placeholder here"
validations:
# enter validations here',
)
end
@ -104,10 +152,11 @@ describe "Admin Customize Form Templates", type: :system, js: true do
- "Option 2"
- "Option 3"
attributes:
label: "Enter dropdown label here"
description: "Enter dropdown description here"
validations:
required: true',
none_label: "Select an item"
label: "Enter label here"
filterable: false
validations:
# enter validations here',
)
end
@ -117,24 +166,26 @@ describe "Admin Customize Form Templates", type: :system, js: true do
'- type: upload
attributes:
file_types: "jpg, png, gif"
label: "Enter upload label here"
description: "Enter upload description here"',
allow_multiple: false
label: "Enter label here"
validations:
# enter validations here',
)
end
it "should allow quick insertion of multiple choice field" do
quick_insertion_test(
"multiselect",
'- type: multiple_choice
'- type: multi-select
choices:
- "Option 1"
- "Option 2"
- "Option 3"
attributes:
label: "Enter multiple choice label here"
description: "Enter multiple choice description here"
validations:
required: true',
none_label: "Select an item"
label: "Enter label here"
validations:
# enter validations here',
)
end
end

View File

@ -5,15 +5,20 @@ module PageObjects
class FormTemplate < PageObjects::Pages::Base
# Form Template Index
def has_form_template_table?
page.has_selector?("table.form-templates--table")
page.has_selector?("table.form-templates__table")
end
def click_view_form_template
find(".form-templates--table tr:first-child .btn-view-template").click
find(".form-templates__table tr:first-child .btn-view-template").click
end
def click_toggle_preview
find(".d-toggle-switch .d-toggle-switch__checkbox-slider", visible: false).click
self
end
def has_form_template?(name)
find(".form-templates--table tbody tr td", text: name).present?
find(".form-templates__table tbody tr td", text: name).present?
end
def has_template_structure?(structure)
@ -22,20 +27,48 @@ module PageObjects
# Form Template new/edit form related
def type_in_template_name(input)
find(".form-templates--form-name-input").send_keys(input)
find(".form-templates__form-name-input").send_keys(input)
self
end
def click_save_button
find(".form-templates--form .footer-buttons .btn-primary").click
find(".form-templates__form .footer-buttons .btn-primary").click
end
def click_quick_insert(field_type)
find(".form-templates--form .quick-insert-#{field_type}").click
find(".form-templates__form .quick-insert-#{field_type}").click
end
def click_validations_button
find(".form-templates__validations-modal-button").click
end
def click_preview_button
find(".form-templates__preview-button").click
end
def has_input_field?(type)
find(".form-template-field__#{type}").present?
end
def has_preview_modal?
find(".form-template-form-preview-modal").present?
end
def has_validations_modal?
find(".admin-form-template-validation-options-modal").present?
end
def has_name_value?(name)
find(".form-templates--form-name-input").value == name
find(".form-templates__form-name-input").value == name
end
def has_save_button_with_state?(state)
find_button("Save", disabled: state)
end
def has_preview_button_with_state?(state)
find_button("Preview", disabled: state)
end
end
end