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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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