+ {{#each this.parsedTemplate as |content|}}
+ {{component
+ (concat "form-template-field/" content.type)
+ attributes=content.attributes
+ choices=content.choices
+ validations=content.validations
+ }}
+ {{/each}}
+
{{else}}
{{this.error}}
diff --git a/app/assets/javascripts/discourse/app/components/form-template-field/wrapper.js b/app/assets/javascripts/discourse/app/components/form-template-field/wrapper.js
index f181dd88c47..cafcbda848e 100644
--- a/app/assets/javascripts/discourse/app/components/form-template-field/wrapper.js
+++ b/app/assets/javascripts/discourse/app/components/form-template-field/wrapper.js
@@ -1,17 +1,45 @@
import Component from "@glimmer/component";
import Yaml from "js-yaml";
import { tracked } from "@glimmer/tracking";
+import FormTemplate from "discourse/models/form-template";
+import { action } from "@ember/object";
export default class FormTemplateFieldWrapper extends Component {
@tracked error = null;
+ @tracked parsedTemplate = null;
- get canShowContent() {
+ constructor() {
+ super(...arguments);
+
+ if (this.args.content) {
+ // Content used when no id exists yet
+ // (i.e. previewing while creating a new template)
+ this._loadTemplate(this.args.content);
+ } else if (this.args.id) {
+ this._fetchTemplate(this.args.id);
+ }
+ }
+
+ _loadTemplate(templateContent) {
try {
- const parsedContent = Yaml.load(this.args.content);
- this.parsedContent = parsedContent;
- return true;
+ this.parsedTemplate = Yaml.load(templateContent);
} catch (e) {
this.error = e;
}
}
+
+ @action
+ refreshTemplate() {
+ if (Array.isArray(this.args?.id) && this.args?.id.length === 0) {
+ return;
+ }
+
+ return this._fetchTemplate(this.args.id);
+ }
+
+ async _fetchTemplate(id) {
+ const response = await FormTemplate.findById(id);
+ const templateContent = await response.form_template.template;
+ return this._loadTemplate(templateContent);
+ }
}
diff --git a/app/assets/javascripts/discourse/app/models/form-template.js b/app/assets/javascripts/discourse/app/models/form-template.js
new file mode 100644
index 00000000000..b46c8de45e9
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/models/form-template.js
@@ -0,0 +1,13 @@
+import { ajax } from "discourse/lib/ajax";
+import RestModel from "discourse/models/rest";
+
+export default class FormTemplate extends RestModel {
+ static async findAll() {
+ const result = await ajax("/form-templates.json");
+ return result.form_templates;
+ }
+
+ static async findById(id) {
+ return await ajax(`/form-templates/${id}.json`);
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/services/composer.js b/app/assets/javascripts/discourse/app/services/composer.js
index 66f256fe882..a4511d21333 100644
--- a/app/assets/javascripts/discourse/app/services/composer.js
+++ b/app/assets/javascripts/discourse/app/services/composer.js
@@ -158,6 +158,14 @@ export default class ComposerController extends Controller {
return this.set("_disableSubmit", value);
}
+ get formTemplateIds() {
+ if (!this.siteSettings.experimental_form_templates) {
+ return null;
+ }
+
+ return this.model.category?.get("form_template_ids");
+ }
+
@discourseComputed("showPreview")
toggleText(showPreview) {
return showPreview
@@ -498,6 +506,11 @@ export default class ComposerController extends Controller {
});
}
+ @action
+ updateCategory(categoryId) {
+ this.model.categoryId = categoryId;
+ }
+
@action
openIfDraft(event) {
if (!this.get("model.viewDraft")) {
diff --git a/app/assets/javascripts/discourse/app/templates/modal/customize-form-template-view.hbs b/app/assets/javascripts/discourse/app/templates/modal/customize-form-template-view.hbs
index b27d7740b46..dac821bbd70 100644
--- a/app/assets/javascripts/discourse/app/templates/modal/customize-form-template-view.hbs
+++ b/app/assets/javascripts/discourse/app/templates/modal/customize-form-template-view.hbs
@@ -8,7 +8,7 @@
/>
{{#if this.showPreview}}
-
+
{{else}}
{{/if}}
diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/wrapper-test.js b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/wrapper-test.js
index 9597db2c4e2..908ee7ca53e 100644
--- a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/wrapper-test.js
+++ b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/wrapper-test.js
@@ -3,6 +3,7 @@ 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";
+import pretender, { response } from "discourse/tests/helpers/create-pretender";
module(
"Integration | Component | form-template-field | wrapper",
@@ -22,7 +23,7 @@ module(
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) {
+ test("renders a component based on the component type found in the content YAML", 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",
@@ -45,5 +46,28 @@ module(
);
});
});
+
+ test("renders a component based on the component type found in the content YAML when passed ids", async function (assert) {
+ pretender.get("/form-templates/1.json", () => {
+ return response({
+ form_template: {
+ id: 1,
+ name: "Bug Reports",
+ 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',
+ },
+ });
+ });
+
+ this.set("formTemplateId", [1]);
+ await render(
+ hbs`
`
+ );
+
+ assert.ok(
+ exists(`.form-template-field[data-field-type='checkbox']`),
+ `Checkbox component renders`
+ );
+ });
}
);
diff --git a/app/assets/javascripts/discourse/tests/integration/components/select-kit/form-template-chooser-test.js b/app/assets/javascripts/discourse/tests/integration/components/select-kit/form-template-chooser-test.js
index 015ec4ad281..09d248f862d 100644
--- a/app/assets/javascripts/discourse/tests/integration/components/select-kit/form-template-chooser-test.js
+++ b/app/assets/javascripts/discourse/tests/integration/components/select-kit/form-template-chooser-test.js
@@ -12,7 +12,7 @@ module(
hooks.beforeEach(function () {
this.set("subject", selectKit());
- pretender.get("/admin/customize/form-templates.json", () => {
+ pretender.get("/form-templates.json", () => {
return response({
form_templates: [
{ id: 1, name: "template 1", template: "test: true" },
@@ -40,7 +40,7 @@ module(
});
test("when no templates are available, the select is disabled", async function (assert) {
- pretender.get("/admin/customize/form-templates.json", () => {
+ pretender.get("/form-templates.json", () => {
return response({ form_templates: [] });
});
diff --git a/app/assets/javascripts/select-kit/addon/components/form-template-chooser.js b/app/assets/javascripts/select-kit/addon/components/form-template-chooser.js
index 45bbc849aee..9120819d008 100644
--- a/app/assets/javascripts/select-kit/addon/components/form-template-chooser.js
+++ b/app/assets/javascripts/select-kit/addon/components/form-template-chooser.js
@@ -1,12 +1,12 @@
import MultiSelectComponent from "select-kit/components/multi-select";
-import FormTemplate from "admin/models/form-template";
+import FormTemplate from "discourse/models/form-template";
import { computed } from "@ember/object";
export default MultiSelectComponent.extend({
pluginApiIdentifiers: ["form-template-chooser"],
classNames: ["form-template-chooser"],
selectKitOptions: {
- none: "admin.form_templates.edit_category.select_template",
+ none: "form_template_chooser.select_template",
},
init() {
@@ -17,6 +17,11 @@ export default MultiSelectComponent.extend({
}
},
+ didUpdateAttrs() {
+ this._super(...arguments);
+ this._fetchTemplates();
+ },
+
@computed("templates")
get content() {
if (!this.templates) {
@@ -28,7 +33,14 @@ export default MultiSelectComponent.extend({
_fetchTemplates() {
FormTemplate.findAll().then((result) => {
- const sortedTemplates = this._sortTemplatesByName(result);
+ let sortedTemplates = this._sortTemplatesByName(result);
+
+ if (this.filteredIds) {
+ sortedTemplates = sortedTemplates.filter((t) =>
+ this.filteredIds.includes(t.id)
+ );
+ }
+
if (sortedTemplates.length > 0) {
return this.set("templates", sortedTemplates);
} else {
diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss
index e216b95a44f..f556c5b26d1 100644
--- a/app/assets/stylesheets/common/base/compose.scss
+++ b/app/assets/stylesheets/common/base/compose.scss
@@ -394,6 +394,17 @@ html.composer-open {
#file-uploader {
display: none;
}
+
+ .composer-select-form-template {
+ margin-bottom: 8px;
+ width: 100%;
+
+ .name,
+ .formatted-selection,
+ .d-icon {
+ color: var(--primary-high);
+ }
+ }
}
.autocomplete {
diff --git a/app/assets/stylesheets/common/d-editor.scss b/app/assets/stylesheets/common/d-editor.scss
index 48fa3c633fb..55cb7be7234 100644
--- a/app/assets/stylesheets/common/d-editor.scss
+++ b/app/assets/stylesheets/common/d-editor.scss
@@ -344,3 +344,10 @@
justify-content: center;
}
}
+
+.d-editor .form-template-form__wrapper {
+ overflow: auto;
+ background: var(--primary-very-low);
+ padding: 1rem;
+ border: 1px solid var(--primary-medium);
+}
diff --git a/app/controllers/admin/form_templates_controller.rb b/app/controllers/admin/form_templates_controller.rb
index 9cf39f5861e..968bc521ba2 100644
--- a/app/controllers/admin/form_templates_controller.rb
+++ b/app/controllers/admin/form_templates_controller.rb
@@ -4,7 +4,7 @@ class Admin::FormTemplatesController < Admin::StaffController
before_action :ensure_form_templates_enabled
def index
- form_templates = FormTemplate.all
+ form_templates = FormTemplate.all.order(:id)
render_serialized(form_templates, AdminFormTemplateSerializer, root: "form_templates")
end
diff --git a/app/controllers/form_templates_controller.rb b/app/controllers/form_templates_controller.rb
new file mode 100644
index 00000000000..8a8d3865c7a
--- /dev/null
+++ b/app/controllers/form_templates_controller.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class FormTemplatesController < ApplicationController
+ requires_login
+ before_action :ensure_form_templates_enabled
+
+ def index
+ form_templates = FormTemplate.all.order(:id)
+ render_serialized(form_templates, FormTemplateSerializer, root: "form_templates")
+ end
+
+ def show
+ params.require(:id)
+
+ template = FormTemplate.find_by(id: params[:id])
+ raise Discourse::NotFound if template.nil?
+
+ render_serialized(template, FormTemplateSerializer, root: "form_template")
+ end
+
+ private
+
+ def ensure_form_templates_enabled
+ raise Discourse::InvalidAccess.new unless SiteSetting.experimental_form_templates
+ end
+end
diff --git a/app/serializers/form_template_serializer.rb b/app/serializers/form_template_serializer.rb
new file mode 100644
index 00000000000..6b7418c1752
--- /dev/null
+++ b/app/serializers/form_template_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class FormTemplateSerializer < ApplicationSerializer
+ attributes :id, :name, :template
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index d9b52114b52..d1ef7242b9c 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -4525,6 +4525,9 @@ en:
char_counter:
exceeded: "The maximum number of characters allowed has been exceeded."
+ form_template_chooser:
+ select_template: "Select form templates"
+
# This section is exported to the javascript for i18n in the admin section
admin_js:
type_to_filter: "type to filter..."
diff --git a/config/routes.rb b/config/routes.rb
index 0e114877734..b81de7b22dd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1597,5 +1597,8 @@ Discourse::Application.routes.draw do
put "/sidebar_sections/reset/:id" => "sidebar_sections#reset"
get "*url", to: "permalinks#show", constraints: PermalinkConstraint.new
+
+ get "/form-templates/:id" => "form_templates#show"
+ get "/form-templates" => "form_templates#index"
end
end
diff --git a/spec/requests/form_templates_controller_spec.rb b/spec/requests/form_templates_controller_spec.rb
new file mode 100644
index 00000000000..25803c3c789
--- /dev/null
+++ b/spec/requests/form_templates_controller_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+RSpec.describe FormTemplatesController do
+ fab!(:user) { Fabricate(:user) }
+
+ before { SiteSetting.experimental_form_templates = true }
+
+ describe "#index" do
+ fab!(:form_template) { Fabricate(:form_template, id: 2) }
+ fab!(:form_template_2) { Fabricate(:form_template, id: 1) }
+ fab!(:form_template_3) { Fabricate(:form_template, id: 3) }
+
+ context "when logged in as a user" do
+ before { sign_in(user) }
+
+ it "should return all form templates ordered by its ids" do
+ get "/form-templates.json"
+ expect(response.status).to eq(200)
+ json = response.parsed_body
+ expect(json["form_templates"]).to be_present
+ expect(json["form_templates"].length).to eq(3)
+
+ templates = json["form_templates"]
+ expect(templates[0]["id"]).to eq(form_template_2.id)
+ expect(templates[1]["id"]).to eq(form_template.id)
+ expect(templates[2]["id"]).to eq(form_template_3.id)
+ end
+ end
+
+ context "when you are not logged in" do
+ it "should deny access" do
+ get "/form-templates.json"
+ expect(response.status).to eq(403)
+ end
+ end
+
+ context "when experimental form templates is disabled" do
+ before do
+ sign_in(user)
+ SiteSetting.experimental_form_templates = false
+ end
+
+ it "should not work if you are a logged in user" do
+ get "/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 a user" do
+ before { sign_in(user) }
+
+ it "should return a single template" do
+ get "/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
+
+ context "when you are not logged in" do
+ it "should deny access" do
+ get "/form-templates/#{form_template.id}.json"
+ expect(response.status).to eq(403)
+ end
+ end
+
+ context "when experimental form templates is disabled" do
+ before do
+ sign_in(user)
+ SiteSetting.experimental_form_templates = false
+ end
+
+ it "should not work if you are a logged in user" do
+ get "/form-templates/#{form_template.id}.json"
+ expect(response.status).to eq(403)
+ end
+ end
+ end
+end
diff --git a/spec/system/admin_customize_form_templates_spec.rb b/spec/system/admin_customize_form_templates_spec.rb
index f57d4092a39..525e5a43952 100644
--- a/spec/system/admin_customize_form_templates_spec.rb
+++ b/spec/system/admin_customize_form_templates_spec.rb
@@ -119,7 +119,8 @@ describe "Admin Customize Form Templates", type: :system, js: true do
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")
+ # TODO(@keegan): Add this back when upload functionality is added
+ # expect(form_template_page).to have_input_field("upload")
expect(form_template_page).to have_input_field("multi-select")
end
@@ -175,7 +176,8 @@ describe "Admin Customize Form Templates", type: :system, js: true do
)
end
- it "should allow quick insertion of upload field" do
+ # TODO(@keegan): Unskip this test when Upload functionality is added
+ xit "should allow quick insertion of upload field" do
quick_insertion_test(
"upload",
'- type: upload
diff --git a/spec/system/composer/category_templates_spec.rb b/spec/system/composer/category_templates_spec.rb
new file mode 100644
index 00000000000..e09266535eb
--- /dev/null
+++ b/spec/system/composer/category_templates_spec.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+describe "Composer Form Templates", type: :system, js: true do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:form_template_1) do
+ Fabricate(:form_template, name: "Bug Reports", template: "- type: checkbox")
+ end
+ fab!(:form_template_2) do
+ Fabricate(:form_template, name: "Feature Request", template: "- type: input")
+ end
+ fab!(:form_template_3) do
+ Fabricate(:form_template, name: "Awesome Possum", template: "- type: dropdown")
+ end
+ fab!(:form_template_4) do
+ Fabricate(:form_template, name: "Biography", template: "- type: textarea")
+ end
+ fab!(:category_with_template_1) do
+ Fabricate(
+ :category,
+ name: "Reports",
+ slug: "reports",
+ topic_count: 2,
+ form_template_ids: [form_template_1.id],
+ )
+ end
+ fab!(:category_with_template_2) do
+ Fabricate(
+ :category,
+ name: "Features",
+ slug: "features",
+ topic_count: 3,
+ form_template_ids: [form_template_2.id],
+ )
+ end
+ fab!(:category_with_multiple_templates_1) do
+ Fabricate(
+ :category,
+ name: "Multiple",
+ slug: "mulitple",
+ topic_count: 10,
+ form_template_ids: [form_template_1.id, form_template_2.id],
+ )
+ end
+ fab!(:category_with_multiple_templates_2) do
+ Fabricate(
+ :category,
+ name: "More Stuff",
+ slug: "more-stuff",
+ topic_count: 10,
+ form_template_ids: [form_template_3.id, form_template_4.id],
+ )
+ end
+ fab!(:category_no_template) do
+ Fabricate(:category, name: "Staff", slug: "staff", topic_count: 2, form_template_ids: [])
+ end
+ fab!(:category_topic_template) do
+ Fabricate(
+ :category,
+ name: "Random",
+ slug: "random",
+ topic_count: 5,
+ form_template_ids: [],
+ topic_template: "Testing",
+ )
+ end
+ let(:category_page) { PageObjects::Pages::Category.new }
+ let(:composer) { PageObjects::Components::Composer.new }
+ let(:form_template_chooser) { PageObjects::Components::SelectKit.new(".form-template-chooser") }
+
+ before do
+ SiteSetting.experimental_form_templates = true
+ sign_in user
+ end
+
+ it "shows a textarea when no form template is assigned to the category" do
+ category_page.visit(category_no_template)
+ category_page.new_topic_button.click
+ expect(composer).to have_composer_input
+ end
+
+ it "shows a textarea filled in with topic template when a topic template is assigned to the category" do
+ category_page.visit(category_topic_template)
+ category_page.new_topic_button.click
+ expect(composer).to have_composer_input
+ expect(composer).to have_content(category_topic_template.topic_template)
+ end
+
+ it "shows a form when a form template is assigned to the category" do
+ category_page.visit(category_with_template_1)
+ category_page.new_topic_button.click
+ expect(composer).not_to have_composer_input
+ expect(composer).to have_form_template
+ expect(composer).to have_form_template_field("checkbox")
+ end
+
+ it "shows the correct template when switching categories" do
+ category_page.visit(category_no_template)
+ category_page.new_topic_button.click
+ # first category has no template
+ expect(composer).to have_composer_input
+ # switch to category with topic template
+ composer.switch_category(category_topic_template.name)
+ expect(composer).to have_composer_input
+ expect(composer).to have_content(category_topic_template.topic_template)
+ # switch to category with form template
+ composer.switch_category(category_with_template_1.name)
+ expect(composer).to have_form_template
+ expect(composer).to have_form_template_field("checkbox")
+ # switch to category with a different form template
+ composer.switch_category(category_with_template_2.name)
+ expect(composer).to have_form_template
+ expect(composer).to have_form_template_field("input")
+ end
+
+ it "does not show form template chooser when a category only has form template" do
+ category_page.visit(category_with_template_1)
+ category_page.new_topic_button.click
+ expect(composer).not_to have_form_template_chooser
+ end
+
+ it "shows form template chooser when a category has multiple form templates" do
+ category_page.visit(category_with_multiple_templates_1)
+ category_page.new_topic_button.click
+ expect(composer).to have_form_template_chooser
+ end
+
+ it "updates the form template when a different template is selected" do
+ category_page.visit(category_with_multiple_templates_1)
+ category_page.new_topic_button.click
+ expect(composer).to have_form_template_field("checkbox")
+ form_template_chooser.select_row_by_name(form_template_2.name)
+ expect(composer).to have_form_template_field("input")
+ end
+
+ it "shows the correct template options when switching categories" do
+ category_page.visit(category_with_multiple_templates_1)
+ category_page.new_topic_button.click
+ expect(composer).to have_form_template_chooser
+ form_template_chooser.expand
+ expect(form_template_chooser).to have_selected_choice_name(form_template_1.name)
+ expect(form_template_chooser).to have_option_name(form_template_2.name)
+ composer.switch_category(category_with_multiple_templates_2.name)
+ form_template_chooser.expand
+ expect(form_template_chooser).to have_selected_choice_name(form_template_3.name)
+ expect(form_template_chooser).to have_option_name(form_template_4.name)
+ end
+
+ it "shows the correct template name in the dropdown header after switching templates" do
+ category_page.visit(category_with_multiple_templates_1)
+ category_page.new_topic_button.click
+ expect(form_template_chooser).to have_selected_name(form_template_1.name)
+ form_template_chooser.select_row_by_name(form_template_2.name)
+ expect(form_template_chooser).to have_selected_name(form_template_2.name)
+ end
+end
diff --git a/spec/system/page_objects/components/composer.rb b/spec/system/page_objects/components/composer.rb
index f81fbe92e97..af667bb8461 100644
--- a/spec/system/page_objects/components/composer.rb
+++ b/spec/system/page_objects/components/composer.rb
@@ -72,6 +72,11 @@ module PageObjects
find(AUTOCOMPLETE_MENU)
end
+ def switch_category(category_name)
+ find(".category-chooser").click
+ find(".category-row[data-name='#{category_name}']").click
+ end
+
def has_emoji_autocomplete?
has_css?(AUTOCOMPLETE_MENU)
end
@@ -98,6 +103,22 @@ module PageObjects
page.has_no_css?(emoji_preview_selector(emoji))
end
+ def has_composer_input?
+ page.has_css?("#{COMPOSER_ID} .d-editor .d-editor-input")
+ end
+
+ def has_form_template?
+ page.has_css?(".form-template-form__wrapper")
+ end
+
+ def has_form_template_field?(field)
+ page.has_css?(".form-template-field[data-field-type='#{field}']")
+ end
+
+ def has_form_template_chooser?
+ page.has_css?(".composer-select-form-template")
+ end
+
def composer_input
find("#{COMPOSER_ID} .d-editor .d-editor-input")
end
diff --git a/spec/system/page_objects/components/select_kit.rb b/spec/system/page_objects/components/select_kit.rb
index 19f6842b919..8228d65b9be 100644
--- a/spec/system/page_objects/components/select_kit.rb
+++ b/spec/system/page_objects/components/select_kit.rb
@@ -34,8 +34,16 @@ module PageObjects
component.find(".select-kit-header[data-value='#{value}']")
end
- def has_selected_name?(value)
- component.find(".select-kit-header[data-name='#{value}']")
+ def has_selected_name?(name)
+ component.find(".select-kit-header[data-name='#{name}']")
+ end
+
+ def has_selected_choice_name?(name)
+ component.find(".selected-choice[data-name='#{name}']")
+ end
+
+ def has_option_name?(name)
+ component.find(".select-kit-collection li[data-name='#{name}']")
end
def expand