From 871607a42019668abbf9b462a54eb44c0bdea786 Mon Sep 17 00:00:00 2001 From: Keegan George Date: Wed, 8 Feb 2023 11:21:39 -0800 Subject: [PATCH] DEV: Create form templates (#20189) --- .../addon/components/form-template/form.hbs | 57 ++++++ .../addon/components/form-template/form.js | 109 +++++++++++ .../components/form-template/info-header.hbs | 4 + .../components/form-template/row-item.hbs | 23 +++ .../components/form-template/row-item.js | 47 +++++ .../admin-customize-form-templates-index.js | 14 ++ .../admin-customize-form-template-view.js | 37 ++++ .../admin/addon/lib/template-form-fields.js | 70 +++++++ .../admin/addon/models/form-template.js | 38 ++++ .../admin-customize-form-templates-edit.js | 12 ++ .../admin-customize-form-templates-index.js | 17 ++ .../admin/addon/routes/admin-route-map.js | 8 + .../customize-form-templates-edit.hbs | 4 + .../customize-form-templates-index.hbs | 32 ++++ .../customize-form-templates-new.hbs | 4 + .../admin/addon/templates/customize.hbs | 7 + .../admin-customize-form-template-view.hbs | 22 +++ .../stylesheets/common/admin/customize.scss | 77 ++++++++ .../admin/form_templates_controller.rb | 58 ++++++ app/models/form_template.rb | 22 +++ .../admin_form_template_serializer.rb | 5 + config/locales/client.en.yml | 35 ++++ config/locales/server.en.yml | 4 + config/routes.rb | 1 + config/site_settings.yml | 4 + .../20230202173641_create_form_templates.rb | 14 ++ lib/svg_sprite.rb | 5 + .../form_template_yaml_validator.rb | 11 ++ spec/fabricators/form_template_fabricator.rb | 6 + spec/models/form_template_spec.rb | 20 ++ .../admin/form_templates_controller_spec.rb | 173 ++++++++++++++++++ .../admin_customize_form_templates_spec.rb | 141 ++++++++++++++ .../page_objects/components/ace_editor.rb | 25 +++ .../page_objects/pages/form_template.rb | 42 +++++ 34 files changed, 1148 insertions(+) create mode 100644 app/assets/javascripts/admin/addon/components/form-template/form.hbs create mode 100644 app/assets/javascripts/admin/addon/components/form-template/form.js create mode 100644 app/assets/javascripts/admin/addon/components/form-template/info-header.hbs create mode 100644 app/assets/javascripts/admin/addon/components/form-template/row-item.hbs create mode 100644 app/assets/javascripts/admin/addon/components/form-template/row-item.js create mode 100644 app/assets/javascripts/admin/addon/controllers/admin-customize-form-templates-index.js create mode 100644 app/assets/javascripts/admin/addon/controllers/modals/admin-customize-form-template-view.js create mode 100644 app/assets/javascripts/admin/addon/lib/template-form-fields.js create mode 100644 app/assets/javascripts/admin/addon/models/form-template.js create mode 100644 app/assets/javascripts/admin/addon/routes/admin-customize-form-templates-edit.js create mode 100644 app/assets/javascripts/admin/addon/routes/admin-customize-form-templates-index.js create mode 100644 app/assets/javascripts/admin/addon/templates/customize-form-templates-edit.hbs create mode 100644 app/assets/javascripts/admin/addon/templates/customize-form-templates-index.hbs create mode 100644 app/assets/javascripts/admin/addon/templates/customize-form-templates-new.hbs create mode 100644 app/assets/javascripts/admin/addon/templates/modal/admin-customize-form-template-view.hbs create mode 100644 app/controllers/admin/form_templates_controller.rb create mode 100644 app/models/form_template.rb create mode 100644 app/serializers/admin_form_template_serializer.rb create mode 100644 db/migrate/20230202173641_create_form_templates.rb create mode 100644 lib/validators/form_template_yaml_validator.rb create mode 100644 spec/fabricators/form_template_fabricator.rb create mode 100644 spec/models/form_template_spec.rb create mode 100644 spec/requests/admin/form_templates_controller_spec.rb create mode 100644 spec/system/admin_customize_form_templates_spec.rb create mode 100644 spec/system/page_objects/components/ace_editor.rb create mode 100644 spec/system/page_objects/pages/form_template.rb diff --git a/app/assets/javascripts/admin/addon/components/form-template/form.hbs b/app/assets/javascripts/admin/addon/components/form-template/form.hbs new file mode 100644 index 00000000000..fe94fdc6bd2 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/form.hbs @@ -0,0 +1,57 @@ +
+
+ + +
+ +
+ + {{I18n "admin.form_templates.quick_insert_fields.add_new_field"}} + + {{#each this.quickInsertFields as |field|}} + + {{/each}} +
+ +
+ +
+ + +
\ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/form-template/form.js b/app/assets/javascripts/admin/addon/components/form-template/form.js new file mode 100644 index 00000000000..c0212eea406 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/form.js @@ -0,0 +1,109 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import { tracked } from "@glimmer/tracking"; +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"; + +export default class FormTemplateForm extends Component { + @service router; + @service dialog; + @tracked formSubmitted = false; + @tracked templateContent = this.args.model?.template || ""; + isEditing = this.args.model?.id ? true : false; + templateName = this.args.model?.name; + quickInsertFields = [ + { + type: "checkbox", + icon: "check-square", + }, + { + type: "input", + icon: "grip-lines", + }, + { + type: "textarea", + icon: "align-left", + }, + { + type: "dropdown", + icon: "chevron-circle-down", + }, + { + type: "upload", + icon: "cloud-upload-alt", + }, + { + type: "multiselect", + icon: "bullseye", + }, + ]; + + @action + onSubmit() { + if (!this.formSubmitted) { + this.formSubmitted = true; + } + + const postData = { + name: this.templateName, + template: this.templateContent, + }; + + 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; + }); + } + } + + @action + onCancel() { + this.router.transitionTo("adminCustomizeFormTemplates.index"); + } + + @action + onDelete() { + return this.dialog.yesNoConfirm({ + message: I18n.t("admin.form_templates.delete_confirm"), + didConfirm: () => { + FormTemplate.deleteTemplate(this.args.model.id) + .then(() => { + this.router.transitionTo("adminCustomizeFormTemplates.index"); + }) + .catch(popupAjaxError); + }, + }); + } + + @action + onInsertField(type) { + const structure = templateFormFields.findBy("type", type).structure; + + if (this.templateContent.length === 0) { + this.templateContent += structure; + } else { + this.templateContent += `\n${structure}`; + } + } +} diff --git a/app/assets/javascripts/admin/addon/components/form-template/info-header.hbs b/app/assets/javascripts/admin/addon/components/form-template/info-header.hbs new file mode 100644 index 00000000000..e9821a56017 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/info-header.hbs @@ -0,0 +1,4 @@ +
+

{{i18n "admin.form_templates.title"}}

+

{{i18n "admin.form_templates.help"}}

+
\ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/form-template/row-item.hbs b/app/assets/javascripts/admin/addon/components/form-template/row-item.hbs new file mode 100644 index 00000000000..09a8738f2fe --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/row-item.hbs @@ -0,0 +1,23 @@ + + {{@template.name}} + + + + + + \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/form-template/row-item.js b/app/assets/javascripts/admin/addon/components/form-template/row-item.js new file mode 100644 index 00000000000..ef94e04a37e --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/form-template/row-item.js @@ -0,0 +1,47 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import showModal from "discourse/lib/show-modal"; +import { inject as service } from "@ember/service"; +import { ajax } from "discourse/lib/ajax"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import I18n from "I18n"; + +export default class FormTemplateRowItem extends Component { + @service router; + @service dialog; + + @action + viewTemplate() { + showModal("admin-customize-form-template-view", { + admin: true, + model: this.args.template, + refreshModel: this.args.refreshModel, + }); + } + + @action + editTemplate() { + this.router.transitionTo( + "adminCustomizeFormTemplates.edit", + this.args.template + ); + } + + @action + deleteTemplate() { + return this.dialog.yesNoConfirm({ + message: I18n.t("admin.form_templates.delete_confirm", { + template_name: this.args.template.name, + }), + didConfirm: () => { + ajax(`/admin/customize/form-templates/${this.args.template.id}.json`, { + type: "DELETE", + }) + .then(() => { + this.args.refreshModel(); + }) + .catch(popupAjaxError); + }, + }); + } +} diff --git a/app/assets/javascripts/admin/addon/controllers/admin-customize-form-templates-index.js b/app/assets/javascripts/admin/addon/controllers/admin-customize-form-templates-index.js new file mode 100644 index 00000000000..82106b4f5e8 --- /dev/null +++ b/app/assets/javascripts/admin/addon/controllers/admin-customize-form-templates-index.js @@ -0,0 +1,14 @@ +import Controller from "@ember/controller"; +import { action } from "@ember/object"; + +export default class AdminCustomizeFormTemplatesIndex extends Controller { + @action + newTemplate() { + this.transitionToRoute("adminCustomizeFormTemplates.new"); + } + + @action + reload() { + this.send("reloadModel"); + } +} diff --git a/app/assets/javascripts/admin/addon/controllers/modals/admin-customize-form-template-view.js b/app/assets/javascripts/admin/addon/controllers/modals/admin-customize-form-template-view.js new file mode 100644 index 00000000000..817621d485b --- /dev/null +++ b/app/assets/javascripts/admin/addon/controllers/modals/admin-customize-form-template-view.js @@ -0,0 +1,37 @@ +import Controller from "@ember/controller"; +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { action } from "@ember/object"; +import { inject as service } from "@ember/service"; +import I18n from "I18n"; +import { popupAjaxError } from "discourse/lib/ajax-error"; +import { ajax } from "discourse/lib/ajax"; + +export default class AdminCustomizeFormTemplateView extends Controller.extend( + ModalFunctionality +) { + @service router; + @service dialog; + + @action + editTemplate() { + this.router.transitionTo("adminCustomizeFormTemplates.edit", this.model); + } + + @action + deleteTemplate() { + return this.dialog.yesNoConfirm({ + message: I18n.t("admin.form_templates.delete_confirm", { + template_name: this.model.name, + }), + didConfirm: () => { + ajax(`/admin/customize/form-templates/${this.model.id}.json`, { + type: "DELETE", + }) + .then(() => { + this.refreshModel(); + }) + .catch(popupAjaxError); + }, + }); + } +} diff --git a/app/assets/javascripts/admin/addon/lib/template-form-fields.js b/app/assets/javascripts/admin/addon/lib/template-form-fields.js new file mode 100644 index 00000000000..c69cb0f711a --- /dev/null +++ b/app/assets/javascripts/admin/addon/lib/template-form-fields.js @@ -0,0 +1,70 @@ +// TODO(@keegan): Add translations for template strings +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`, + }, + { + type: "input", + structure: `- type: input + attributes: + label: "Enter input label here" + description: "Enter input description here" + placeholder: "Enter input placeholder here" + validations: + required: true`, + }, + { + type: "textarea", + structure: `- type: textarea + attributes: + label: "Enter textarea label here" + description: "Enter textarea description here" + placeholder: "Enter textarea placeholder here" + validations: + required: true`, + }, + { + type: "dropdown", + structure: `- type: dropdown + choices: + - "Option 1" + - "Option 2" + - "Option 3" + attributes: + label: "Enter dropdown label here" + description: "Enter dropdown description here" + validations: + required: true`, + }, + { + type: "upload", + structure: `- type: upload + attributes: + file_types: "jpg, png, gif" + label: "Enter upload label here" + description: "Enter upload description here"`, + }, + { + type: "multiselect", + structure: `- type: multiple_choice + choices: + - "Option 1" + - "Option 2" + - "Option 3" + attributes: + label: "Enter multiple choice label here" + description: "Enter multiple choice description here" + validations: + required: true`, + }, +]; diff --git a/app/assets/javascripts/admin/addon/models/form-template.js b/app/assets/javascripts/admin/addon/models/form-template.js new file mode 100644 index 00000000000..9893d9244a9 --- /dev/null +++ b/app/assets/javascripts/admin/addon/models/form-template.js @@ -0,0 +1,38 @@ +import RestModel from "discourse/models/rest"; +import { ajax } from "discourse/lib/ajax"; + +export default class FormTemplate extends RestModel {} + +FormTemplate.reopenClass({ + createTemplate(data) { + return ajax("/admin/customize/form-templates.json", { + type: "POST", + data, + }); + }, + + updateTemplate(id, data) { + return ajax(`/admin/customize/form-templates/${id}.json`, { + type: "PUT", + data, + }); + }, + + deleteTemplate(id) { + return ajax(`/admin/customize/form-templates/${id}.json`, { + type: "DELETE", + }); + }, + + findAll() { + return ajax(`/admin/customize/form-templates.json`).then((model) => { + return model.form_templates.sort((a, b) => a.id - b.id); + }); + }, + + findById(id) { + return ajax(`/admin/customize/form-templates/${id}.json`).then((model) => { + return model.form_template; + }); + }, +}); diff --git a/app/assets/javascripts/admin/addon/routes/admin-customize-form-templates-edit.js b/app/assets/javascripts/admin/addon/routes/admin-customize-form-templates-edit.js new file mode 100644 index 00000000000..073636f74fd --- /dev/null +++ b/app/assets/javascripts/admin/addon/routes/admin-customize-form-templates-edit.js @@ -0,0 +1,12 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import FormTemplate from "admin/models/form-template"; + +export default class AdminCustomizeFormTemplatesEdit extends DiscourseRoute { + model(params) { + return FormTemplate.findById(params.id); + } + + setupController(controller, model) { + controller.set("model", model); + } +} diff --git a/app/assets/javascripts/admin/addon/routes/admin-customize-form-templates-index.js b/app/assets/javascripts/admin/addon/routes/admin-customize-form-templates-index.js new file mode 100644 index 00000000000..062e5f91e06 --- /dev/null +++ b/app/assets/javascripts/admin/addon/routes/admin-customize-form-templates-index.js @@ -0,0 +1,17 @@ +import DiscourseRoute from "discourse/routes/discourse"; +import { action } from "@ember/object"; +import FormTemplate from "admin/models/form-template"; +export default class AdminCustomizeFormTemplatesIndex extends DiscourseRoute { + model() { + return FormTemplate.findAll(); + } + + setupController(controller, model) { + controller.set("model", model); + } + + @action + reloadModel() { + this.refresh(); + } +} diff --git a/app/assets/javascripts/admin/addon/routes/admin-route-map.js b/app/assets/javascripts/admin/addon/routes/admin-route-map.js index 37272c1c07a..ef5d507922d 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-route-map.js +++ b/app/assets/javascripts/admin/addon/routes/admin-route-map.js @@ -97,6 +97,14 @@ export default function () { this.route("edit", { path: "/:field_name" }); } ); + this.route( + "adminCustomizeFormTemplates", + { path: "/form-templates", resetNamespace: true }, + function () { + this.route("new", { path: "/new" }); + this.route("edit", { path: "/:id" }); + } + ); this.route( "adminWatchedWords", { path: "/watched_words", resetNamespace: true }, diff --git a/app/assets/javascripts/admin/addon/templates/customize-form-templates-edit.hbs b/app/assets/javascripts/admin/addon/templates/customize-form-templates-edit.hbs new file mode 100644 index 00000000000..38c5499bfb4 --- /dev/null +++ b/app/assets/javascripts/admin/addon/templates/customize-form-templates-edit.hbs @@ -0,0 +1,4 @@ +
+ + +
\ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/templates/customize-form-templates-index.hbs b/app/assets/javascripts/admin/addon/templates/customize-form-templates-index.hbs new file mode 100644 index 00000000000..e7f1fc3adc6 --- /dev/null +++ b/app/assets/javascripts/admin/addon/templates/customize-form-templates-index.hbs @@ -0,0 +1,32 @@ +
+ + + {{#if this.model}} + + + + + + + {{#each this.model as |template|}} + + {{/each}} + +
+ {{i18n "admin.form_templates.list_table.headings.name"}} + + {{i18n "admin.form_templates.list_table.headings.actions"}} +
+ {{/if}} + + +
\ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/templates/customize-form-templates-new.hbs b/app/assets/javascripts/admin/addon/templates/customize-form-templates-new.hbs new file mode 100644 index 00000000000..71515c6a54c --- /dev/null +++ b/app/assets/javascripts/admin/addon/templates/customize-form-templates-new.hbs @@ -0,0 +1,4 @@ +
+ + +
\ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/templates/customize.hbs b/app/assets/javascripts/admin/addon/templates/customize.hbs index 089b6587cc0..dac792775a4 100644 --- a/app/assets/javascripts/admin/addon/templates/customize.hbs +++ b/app/assets/javascripts/admin/addon/templates/customize.hbs @@ -45,6 +45,13 @@ @label="admin.embedding.title" @class="admin-customize-embedding" /> + {{#if this.siteSettings.experimental_form_templates}} + + {{/if}} {{/if}} + + {{! ? TODO(@keegan): Perhaps add what places (ex. categories) the templates are active in }} + + \ No newline at end of file diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index cd7405e9a12..b377c1bd1aa 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -923,3 +923,80 @@ table.permalinks { } } } + +.form-templates { + &--info { + margin-top: 1rem; + } + + &--table { + margin-bottom: 1rem; + + .admin-list-item .action { + text-align: right; + } + } + + &--form { + input { + width: 300px; + } + + .ace-wrapper { + position: relative; + height: calc(100vh - 450px); + min-height: 200px; + width: 100%; + box-shadow: shadow("footer-nav"); + border-radius: 4px; + } + + .ace_editor { + border-radius: 4px; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + } + + .ace_placeholder { + font-family: inherit; + font-size: var(--font-up-1); + color: var(--primary-high); + } + + .footer-buttons { + display: flex; + gap: 0.5rem; + .btn-danger { + margin-left: auto; + } + } + } + + &--quick-insert-field-buttons { + display: flex; + align-items: center; + flex-wrap: wrap; + margin-left: 1rem; + + span { + margin-right: 0.25rem; + } + + .btn { + &:not(:last-child) { + border-right: 1px solid var(--primary-low); + } + } + } +} + +.admin-customize-form-template-view-modal { + .modal-footer { + .btn:last-child { + margin-left: auto; + } + } +} diff --git a/app/controllers/admin/form_templates_controller.rb b/app/controllers/admin/form_templates_controller.rb new file mode 100644 index 00000000000..f076cba550e --- /dev/null +++ b/app/controllers/admin/form_templates_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class Admin::FormTemplatesController < Admin::StaffController + before_action :ensure_form_templates_enabled + + def index + form_templates = FormTemplate.all + render_serialized(form_templates, AdminFormTemplateSerializer, root: "form_templates") + end + + def new + end + + def create + params.require(:name) + params.require(:template) + + begin + template = FormTemplate.create!(name: params[:name], template: params[:template]) + render_serialized(template, AdminFormTemplateSerializer, root: "form_template") + rescue FormTemplate::NotAllowed => err + render_json_error(err.message) + end + end + + def show + template = FormTemplate.find(params[:id]) + render_serialized(template, AdminFormTemplateSerializer, root: "form_template") + end + + def edit + FormTemplate.find(params[:id]) + end + + def update + template = FormTemplate.find(params[:id]) + + begin + template.update!(name: params[:name], template: params[:template]) + render_serialized(template, AdminFormTemplateSerializer, root: "form_template") + rescue FormTemplate::NotAllowed => err + render_json_error(err.message) + end + end + + def destroy + template = FormTemplate.find(params[:id]) + template.destroy! + + render json: success_json + end + + private + + def ensure_form_templates_enabled + raise Discourse::InvalidAccess.new unless SiteSetting.experimental_form_templates + end +end diff --git a/app/models/form_template.rb b/app/models/form_template.rb new file mode 100644 index 00000000000..54971937e91 --- /dev/null +++ b/app/models/form_template.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class FormTemplate < ActiveRecord::Base + validates :name, presence: true, uniqueness: true, length: { maximum: 100 } + validates :template, presence: true, length: { maximum: 2000 } + validates_with FormTemplateYamlValidator +end + +# == Schema Information +# +# Table name: form_templates +# +# id :bigint not null, primary key +# name :string(100) not null +# template :text not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_form_templates_on_name (name) UNIQUE +# diff --git a/app/serializers/admin_form_template_serializer.rb b/app/serializers/admin_form_template_serializer.rb new file mode 100644 index 00000000000..e6bdf3c1d5a --- /dev/null +++ b/app/serializers/admin_form_template_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class AdminFormTemplateSerializer < ApplicationSerializer + attributes :id, :name, :template +end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 2d3c9ebb0f3..198fe481b2e 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -5508,6 +5508,41 @@ en: found_matches: "Found matches:" no_matches: "No matches found" + form_templates: + nav_title: "Templates" + title: "Form Templates" + help: "Create a template structure that can be used to create new topics, posts, and messages." + new_template: "New Template" + list_table: + headings: + name: "Name" + actions: "Actions" + actions: + view: "View Template" + edit: "Edit Template" + delete: "Delete Template" + view_template: + close: "Close" + edit: "Edit" + delete: "Delete" + new_template_form: + submit: "Save" + cancel: "Cancel" + name: + label: "Template Name" + placeholder: "Enter a name for this template..." + template: + label: "Template" + placeholder: "Create a YAML template here..." + delete_confirm: "Are you sure you would like to delete this template?" + quick_insert_fields: + add_new_field: "Add" + checkbox: "Checkbox" + input: "Short answer" + textarea: "Long answer" + dropdown: "Dropdown" + upload: "Upload a file" + multiselect: "Multiple choice" impersonate: title: "Impersonate" help: "Use this tool to impersonate a user account for debugging purposes. You will have to log out once finished." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 979fa339572..7702d217ff9 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -5250,3 +5250,7 @@ en: payload_url: blocked_or_internal: "Payload URL cannot be used because it resolves to a blocked or internal IP" unsafe: "Payload URL cannot be used because it's unsafe" + + form_templates: + errors: + invalid_yaml: "is not a valid YAML string" diff --git a/config/routes.rb b/config/routes.rb index cdc9c5a3093..eab836fb21b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -233,6 +233,7 @@ 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" get "themes/:id/:target/:field_name/edit" => "themes#index" get "themes/:id" => "themes#index" diff --git a/config/site_settings.yml b/config/site_settings.yml index 9636c5b83a1..436043f0c1f 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -2090,6 +2090,10 @@ developer: client: true default: false hidden: true + experimental_form_templates: + client: true + default: false + hidden: true navigation: navigation_menu: diff --git a/db/migrate/20230202173641_create_form_templates.rb b/db/migrate/20230202173641_create_form_templates.rb new file mode 100644 index 00000000000..7cdace0e443 --- /dev/null +++ b/db/migrate/20230202173641_create_form_templates.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateFormTemplates < ActiveRecord::Migration[7.0] + def change + create_table :form_templates do |t| + t.string :name, null: false, limit: 100 + t.text :template, null: false, limit: 2000 + + t.timestamps null: false + end + + add_index :form_templates, :name, unique: true + end +end diff --git a/lib/svg_sprite.rb b/lib/svg_sprite.rb index afb62d54574..25467472e19 100644 --- a/lib/svg_sprite.rb +++ b/lib/svg_sprite.rb @@ -6,6 +6,7 @@ module SvgSprite %w[ adjust address-book + align-left ambulance anchor angle-double-down @@ -34,6 +35,7 @@ module SvgSprite book-reader bookmark briefcase + bullseye calendar-alt caret-down caret-left @@ -45,11 +47,13 @@ module SvgSprite check check-circle check-square + chevron-circle-down chevron-down chevron-left chevron-right chevron-up circle + cloud-upload-alt code cog columns @@ -134,6 +138,7 @@ module SvgSprite gift globe globe-americas + grip-lines hand-point-right hands-helping heart diff --git a/lib/validators/form_template_yaml_validator.rb b/lib/validators/form_template_yaml_validator.rb new file mode 100644 index 00000000000..154ee8430a4 --- /dev/null +++ b/lib/validators/form_template_yaml_validator.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class FormTemplateYamlValidator < ActiveModel::Validator + def validate(record) + begin + yaml = Psych.safe_load(record.template) + rescue Psych::SyntaxError + record.errors.add(:template, I18n.t("form_templates.errors.invalid_yaml")) + end + end +end diff --git a/spec/fabricators/form_template_fabricator.rb b/spec/fabricators/form_template_fabricator.rb new file mode 100644 index 00000000000..5d561d99351 --- /dev/null +++ b/spec/fabricators/form_template_fabricator.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Fabricator(:form_template) do + name { sequence(:name) { |i| "template_#{i}" } } + template "some yaml template: value" +end diff --git a/spec/models/form_template_spec.rb b/spec/models/form_template_spec.rb new file mode 100644 index 00000000000..0445b44b0f1 --- /dev/null +++ b/spec/models/form_template_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +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") + expect(t.save).to eq(false) + t = Fabricate.build(:form_template, name: "Bug Report", template: "some yaml: true") + 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" + t = Fabricate.build(:form_template, name: "Feature Request", template: template) + expect(t.save).to eq(false) + end +end diff --git a/spec/requests/admin/form_templates_controller_spec.rb b/spec/requests/admin/form_templates_controller_spec.rb new file mode 100644 index 00000000000..9538ae397a2 --- /dev/null +++ b/spec/requests/admin/form_templates_controller_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +RSpec.describe Admin::FormTemplatesController do + fab!(:admin) { Fabricate(:admin) } + fab!(:user) { Fabricate(:user) } + + before { SiteSetting.experimental_form_templates = true } + + describe "#index" do + fab!(:form_template) { Fabricate(:form_template) } + + context "when logged in as an admin" do + before { sign_in(admin) } + + it "should work if you are an admin" do + get "/admin/customize/form-templates.json" + expect(response.status).to eq(200) + + json = response.parsed_body + expect(json["form_templates"]).to be_present + end + end + + context "when logged in as a non-admin user" do + before { sign_in(user) } + + it "should not work if you are not an admin" do + get "/admin/customize/form-templates.json" + + expect(response.status).to eq(404) + end + end + + context "when experiemental form templates is disabled" do + before do + sign_in(admin) + SiteSetting.experimental_form_templates = false + end + + it "should not work if you are an admin" do + get "/admin/customize/form-templates.json" + + expect(response.status).to eq(403) + end + end + end + + describe "#show" do + fab!(:form_template) { Fabricate(:form_template) } + + context "when logged in as an admin" do + before { sign_in(admin) } + + it "should work if you are an admin" do + get "/admin/customize/form-templates/#{form_template.id}.json" + expect(response.status).to eq(200) + + json = response.parsed_body + current_template = json["form_template"] + expect(current_template["id"]).to eq(form_template.id) + expect(current_template["name"]).to eq(form_template.name) + expect(current_template["template"]).to eq(form_template.template) + end + end + end + + describe "#create" do + context "when logged in as an admin" do + before { sign_in(admin) } + + it "creates a form template" do + expect { + post "/admin/customize/form-templates.json", + 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", + } + + expect(response.status).to eq(200) + }.to change(FormTemplate, :count).by(1) + end + end + + context "when logged in as a non-admin user" do + before { sign_in(user) } + + it "prevents creation with a 404 response" do + expect do + post "/admin/customize/form-templates.json", + params: { + name: "Feature Requests", + template: + " type: checkbox\n choices:\n - \"Option 1\"\n - \"Option 2\"\n - \"Option 3\"\n attributes:\n label: \"Enter question here\"\n description: \"Enter description here\"\n validations:\n required: true\n- type: input\n attributes:\n label: \"Enter input label here\"\n description: \"Enter input description here\"\n placeholder: \"Enter input placeholder here\"\n validations:\n required: true", + } + end.not_to change { FormTemplate.count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + end + + describe "#update" do + fab!(:form_template) { Fabricate(:form_template) } + + context "when logged in as an admin" do + before { sign_in(admin) } + + it "updates a form template" do + put "/admin/customize/form-templates/#{form_template.id}.json", + params: { + id: form_template.id, + name: "Updated Template", + template: "New yaml: true", + } + + 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") + end + end + + context "when logged in as a non-admin user" do + before { sign_in(user) } + + it "prevents update with a 404 response" do + form_template.reload + original_name = form_template.name + + put "/admin/customize/form-templates/#{form_template.id}.json", + params: { + name: "Updated Template", + template: "New yaml: true", + } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + + form_template.reload + expect(form_template.name).to eq(original_name) + end + end + end + + describe "#destroy" do + fab!(:form_template) { Fabricate(:form_template) } + + context "when logged in as an admin" do + before { sign_in(admin) } + + it "deletes a form template" do + expect { + delete "/admin/customize/form-templates/#{form_template.id}.json" + expect(response.status).to eq(200) + }.to change(FormTemplate, :count).by(-1) + end + end + + context "when logged in as a non-admin user" do + before { sign_in(user) } + it "prevents deletion with a 404 response" do + expect do + delete "/admin/customize/form-templates/#{form_template.id}.json" + end.not_to change { FormTemplate.count } + + expect(response.status).to eq(404) + expect(response.parsed_body["errors"]).to include(I18n.t("not_found")) + end + end + end +end diff --git a/spec/system/admin_customize_form_templates_spec.rb b/spec/system/admin_customize_form_templates_spec.rb new file mode 100644 index 00000000000..664448a1d3d --- /dev/null +++ b/spec/system/admin_customize_form_templates_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +describe "Admin Customize Form Templates", type: :system, js: true do + let(:form_template_page) { PageObjects::Pages::FormTemplate.new } + let(:ace_editor) { PageObjects::Components::AceEditor.new } + fab!(:admin) { Fabricate(:admin) } + fab!(:form_template) { Fabricate(:form_template) } + + before do + SiteSetting.experimental_form_templates = true + sign_in(admin) + end + + describe "when visiting the page to customize form templates" do + it "should show the existing form templates in a table" do + visit("/admin/customize/form-templates") + expect(form_template_page).to have_form_template_table + expect(form_template_page).to have_form_template(form_template.name) + end + + 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") + end + end + + describe "when visiting the page to edit a form template" do + it "should prefill form data" do + visit("/admin/customize/form-templates/#{form_template.id}") + expect(form_template_page).to have_name_value(form_template.name) + # difficult to test the ace editor content (todo later) + end + end + + def quick_insertion_test(field_type, content) + visit("/admin/customize/form-templates/new") + form_template_page.type_in_template_name("New Template") + form_template_page.click_quick_insert(field_type) + expect(ace_editor).to have_text(content) + end + + describe "when visiting the page to create a new form template" do + it "should allow admin to create a new form template" do + visit("/admin/customize/form-templates/new") + + sample_name = "My First Template" + sample_template = "test: true" + + form_template_page.type_in_template_name(sample_name) + ace_editor.type_input(sample_template) + form_template_page.click_save_button + expect(form_template_page).to have_form_template(sample_name) + 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', + ) + end + + it "should allow quick insertion of short answer field" do + quick_insertion_test( + "input", + '- type: input + attributes: + label: "Enter input label here" + description: "Enter input description here" + placeholder: "Enter input placeholder here" + validations: + required: true', + ) + end + + it "should allow quick insertion of long answer field" do + quick_insertion_test( + "textarea", + '- type: textarea + attributes: + label: "Enter textarea label here" + description: "Enter textarea description here" + placeholder: "Enter textarea placeholder here" + validations: + required: true', + ) + end + + it "should allow quick insertion of dropdown field" do + quick_insertion_test( + "dropdown", + '- type: dropdown + choices: + - "Option 1" + - "Option 2" + - "Option 3" + attributes: + label: "Enter dropdown label here" + description: "Enter dropdown description here" + validations: + required: true', + ) + end + + it "should allow quick insertion of upload field" do + quick_insertion_test( + "upload", + '- type: upload + attributes: + file_types: "jpg, png, gif" + label: "Enter upload label here" + description: "Enter upload description here"', + ) + end + + it "should allow quick insertion of multiple choice field" do + quick_insertion_test( + "multiselect", + '- type: multiple_choice + choices: + - "Option 1" + - "Option 2" + - "Option 3" + attributes: + label: "Enter multiple choice label here" + description: "Enter multiple choice description here" + validations: + required: true', + ) + end + end +end diff --git a/spec/system/page_objects/components/ace_editor.rb b/spec/system/page_objects/components/ace_editor.rb new file mode 100644 index 00000000000..d7aee298524 --- /dev/null +++ b/spec/system/page_objects/components/ace_editor.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module PageObjects + module Components + class AceEditor < PageObjects::Components::Base + def type_input(content) + editor_input.send_keys(content) + self + end + + def fill_input(content) + editor_input.fill_in(with: content) + self + end + + def clear_input + fill_input("") + end + + def editor_input + find(".ace-wrapper .ace_text-input", visible: false) + end + end + end +end diff --git a/spec/system/page_objects/pages/form_template.rb b/spec/system/page_objects/pages/form_template.rb new file mode 100644 index 00000000000..3f1c04195e0 --- /dev/null +++ b/spec/system/page_objects/pages/form_template.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module PageObjects + module Pages + class FormTemplate < PageObjects::Pages::Base + # Form Template Index + def has_form_template_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 + end + + def has_form_template?(name) + find(".form-templates--table tbody tr td", text: name).present? + end + + def has_template_structure?(structure) + find("code", text: structure).present? + end + + # Form Template new/edit form related + def type_in_template_name(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 + end + + def click_quick_insert(field_type) + find(".form-templates--form .quick-insert-#{field_type}").click + end + + def has_name_value?(name) + find(".form-templates--form-name-input").value == name + end + end + end +end