+ <@component
+ @id={{@content.id}}
+ @attributes={{@content.attributes}}
+ @choices={{@content.choices}}
+ @validations={{@content.validations}}
+ @value={{@initialValue}}
+ />
+;
+
+export default class FormTemplateFieldWrapper extends Component {
+
+ {{#if this.parsedTemplate}}
+
+ {{#each this.parsedTemplate as |content|}}
+
+ {{/each}}
+
+ {{else}}
+
+ {{this.error}}
+
+ {{/if}}
+
+
+ @tracked error = null;
+ @tracked parsedTemplate = null;
+
+ initialValues = this.args.initialValues ?? {};
+
+ fieldTypes = {
+ checkbox: CheckboxField,
+ input: InputField,
+ dropdown: DropdownField,
+ "multi-select": MultiSelectField,
+ textarea: TextareaField,
+ upload: UploadField,
+ };
+
+ 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 {
+ this.parsedTemplate = Yaml.load(templateContent);
+
+ this.args.onSelectFormTemplate?.(this.parsedTemplate);
+ } 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/components/form-template-field/wrapper.hbs b/app/assets/javascripts/discourse/app/components/form-template-field/wrapper.hbs
deleted file mode 100644
index 9866a2e74bf..00000000000
--- a/app/assets/javascripts/discourse/app/components/form-template-field/wrapper.hbs
+++ /dev/null
@@ -1,21 +0,0 @@
-{{#if this.parsedTemplate}}
-
- {{#each this.parsedTemplate as |content|}}
- {{component
- (concat "form-template-field/" content.type)
- id=content.id
- attributes=content.attributes
- choices=content.choices
- validations=content.validations
- value=(get @initialValues content.id)
- }}
- {{/each}}
-
-{{else}}
-
- {{this.error}}
-
-{{/if}}
\ No newline at end of file
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
deleted file mode 100644
index 043c9aec1a2..00000000000
--- a/app/assets/javascripts/discourse/app/components/form-template-field/wrapper.js
+++ /dev/null
@@ -1,47 +0,0 @@
-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;
-
- 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 {
- this.parsedTemplate = Yaml.load(templateContent);
-
- this.args.onSelectFormTemplate?.(this.parsedTemplate);
- } 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/tests/integration/components/form-template-field/input-test.js b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/input-test.js
index 6c835794e9b..e09c2527285 100644
--- a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/input-test.js
+++ b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/input-test.js
@@ -57,5 +57,18 @@ module(
assert.dom(".form-template-field__label").doesNotExist();
});
+
+ test("renders a description if present", async function (assert) {
+ const attributes = {
+ description: "Your full name",
+ };
+ this.set("attributes", attributes);
+
+ await render(
+ hbs``
+ );
+
+ assert.dom(".form-template-field__description").hasText("Your full name");
+ });
}
);
diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/textarea-test.js b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/textarea-test.js
index b79e4730f2d..6b129c2c64f 100644
--- a/app/assets/javascripts/discourse/tests/integration/components/form-template-field/textarea-test.js
+++ b/app/assets/javascripts/discourse/tests/integration/components/form-template-field/textarea-test.js
@@ -53,5 +53,20 @@ module(
assert.dom(".form-template-field__label").doesNotExist();
});
+
+ test("renders a description if present", async function (assert) {
+ const attributes = {
+ description: "Write your bio here",
+ };
+ this.set("attributes", attributes);
+
+ await render(
+ hbs``
+ );
+
+ assert
+ .dom(".form-template-field__description")
+ .hasText("Write your bio here");
+ });
}
);
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 399a0bf5224..90dfa50f2ce 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
@@ -89,7 +89,7 @@ module(
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',
+ '- type: checkbox\n id: options\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',
},
});
});
diff --git a/app/assets/stylesheets/common/components/form-template-field.scss b/app/assets/stylesheets/common/components/form-template-field.scss
index 445cb21cc11..ce49fd27c0f 100644
--- a/app/assets/stylesheets/common/components/form-template-field.scss
+++ b/app/assets/stylesheets/common/components/form-template-field.scss
@@ -63,4 +63,11 @@
&__textarea {
min-height: 100px;
}
+
+ &__description {
+ display: inline-block;
+ margin-bottom: 0.25rem;
+ font-size: var(--font-down-1);
+ color: var(--primary-medium);
+ }
}
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 9f4e90faccc..6823a6b8918 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -5313,3 +5313,4 @@ en:
missing_id: "is missing a field id"
duplicate_ids: "has duplicate ids"
reserved_id: "has a reserved keyword as id: %{id}"
+ unsafe_description: "has an unsafe HTML description"
diff --git a/lib/validators/form_template_yaml_validator.rb b/lib/validators/form_template_yaml_validator.rb
index 18ffa79367d..5931bbaa8fd 100644
--- a/lib/validators/form_template_yaml_validator.rb
+++ b/lib/validators/form_template_yaml_validator.rb
@@ -3,62 +3,72 @@
class FormTemplateYamlValidator < ActiveModel::Validator
RESERVED_KEYWORDS = %w[title body category category_id tags]
ALLOWED_TYPES = %w[checkbox dropdown input multi-select textarea upload]
+ HTML_SANITIZATION_OPTIONS = { elements: ["a"], attributes: { "a" => %w[href target] } }
def validate(record)
begin
yaml = Psych.safe_load(record.template)
- check_missing_fields(record, yaml)
- check_allowed_types(record, yaml)
- check_ids(record, yaml)
+
+ unless yaml.is_a?(Array)
+ record.errors.add(:template, I18n.t("form_templates.errors.invalid_yaml"))
+ return
+ end
+
+ existing_ids = []
+ yaml.each do |field|
+ check_missing_fields(record, field)
+ check_allowed_types(record, field)
+ check_ids(record, field, existing_ids)
+ check_descriptions_html(record, field)
+ end
rescue Psych::SyntaxError
record.errors.add(:template, I18n.t("form_templates.errors.invalid_yaml"))
end
end
- def check_allowed_types(record, yaml)
- yaml.each do |field|
- if !ALLOWED_TYPES.include?(field["type"])
- return(
- record.errors.add(
- :template,
- I18n.t(
- "form_templates.errors.invalid_type",
- type: field["type"],
- valid_types: ALLOWED_TYPES.join(", "),
- ),
- )
- )
- end
+ def check_allowed_types(record, field)
+ if !ALLOWED_TYPES.include?(field["type"])
+ record.errors.add(
+ :template,
+ I18n.t(
+ "form_templates.errors.invalid_type",
+ type: field["type"],
+ valid_types: ALLOWED_TYPES.join(", "),
+ ),
+ )
end
end
- def check_missing_fields(record, yaml)
- yaml.each do |field|
- if field["type"].blank?
- return(record.errors.add(:template, I18n.t("form_templates.errors.missing_type")))
- end
- if field["id"].blank?
- return(record.errors.add(:template, I18n.t("form_templates.errors.missing_id")))
- end
+ def check_missing_fields(record, field)
+ if field["type"].blank?
+ record.errors.add(:template, I18n.t("form_templates.errors.missing_type"))
+ end
+ record.errors.add(:template, I18n.t("form_templates.errors.missing_id")) if field["id"].blank?
+ end
+
+ def check_descriptions_html(record, field)
+ description = field.dig("attributes", "description")
+
+ return if description.blank?
+
+ sanitized_html = Sanitize.fragment(description, HTML_SANITIZATION_OPTIONS)
+
+ is_safe_html = sanitized_html == Loofah.html5_fragment(description).to_s
+
+ unless is_safe_html
+ record.errors.add(:template, I18n.t("form_templates.errors.unsafe_description"))
end
end
- def check_ids(record, yaml)
- ids = []
- yaml.each do |field|
- next if field["id"].blank?
-
- if RESERVED_KEYWORDS.include?(field["id"])
- return(
- record.errors.add(:template, I18n.t("form_templates.errors.reserved_id", id: field["id"]))
- )
- end
-
- if ids.include?(field["id"])
- return(record.errors.add(:template, I18n.t("form_templates.errors.duplicate_ids")))
- end
-
- ids << field["id"]
+ def check_ids(record, field, existing_ids)
+ if RESERVED_KEYWORDS.include?(field["id"])
+ record.errors.add(:template, I18n.t("form_templates.errors.reserved_id", id: field["id"]))
end
+
+ if existing_ids.include?(field["id"])
+ record.errors.add(:template, I18n.t("form_templates.errors.duplicate_ids"))
+ end
+
+ existing_ids << field["id"] unless field["id"].blank?
end
end
diff --git a/spec/lib/validators/form_template_yaml_validator_spec.rb b/spec/lib/validators/form_template_yaml_validator_spec.rb
new file mode 100644
index 00000000000..305874c578e
--- /dev/null
+++ b/spec/lib/validators/form_template_yaml_validator_spec.rb
@@ -0,0 +1,174 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe FormTemplateYamlValidator, type: :validator do
+ subject(:validator) { described_class.new }
+
+ let(:form_template) { FormTemplate.new(template: yaml_content) }
+
+ describe "#validate" do
+ context "with valid YAML" do
+ let(:yaml_content) { <<~YAML }
+ - type: input
+ id: name
+ attributes:
+ label: "Full name"
+ placeholder: "eg. John Smith"
+ description: "What is your full name?"
+ validations:
+ required: true
+ minimum: 2
+ maximum: 100
+ YAML
+
+ it "does not add any errors" do
+ validator.validate(form_template)
+ expect(form_template.errors).to be_empty
+ end
+ end
+
+ context "with invalid YAML" do
+ let(:yaml_content) { "invalid_yaml_string" }
+
+ it "adds an error message for invalid YAML" do
+ validator.validate(form_template)
+ expect(form_template.errors[:template]).to include(
+ I18n.t("form_templates.errors.invalid_yaml"),
+ )
+ end
+ end
+ end
+
+ describe "#check_missing_fields" do
+ context "when type field is missing" do
+ let(:yaml_content) { <<~YAML }
+ - id: name
+ attributes:
+ label: "Full name"
+ YAML
+
+ it "adds an error for missing type field" do
+ validator.validate(form_template)
+ expect(form_template.errors[:template]).to include(
+ I18n.t("form_templates.errors.missing_type"),
+ )
+ end
+ end
+
+ context "when id field is missing" do
+ let(:yaml_content) { <<~YAML }
+ - type: input
+ attributes:
+ label: "Full name"
+ YAML
+
+ it "adds an error for missing id field" do
+ validator.validate(form_template)
+ expect(form_template.errors[:template]).to include(
+ I18n.t("form_templates.errors.missing_id"),
+ )
+ end
+ end
+ end
+
+ describe "#check_allowed_types" do
+ context "when YAML has invalid field types" do
+ let(:yaml_content) { <<~YAML }
+ - type: invalid_type
+ id: name
+ attributes:
+ label: "Full name"
+ YAML
+
+ it "adds an error for invalid field types" do
+ validator.validate(form_template)
+ expect(form_template.errors[:template]).to include(
+ I18n.t(
+ "form_templates.errors.invalid_type",
+ type: "invalid_type",
+ valid_types: FormTemplateYamlValidator::ALLOWED_TYPES.join(", "),
+ ),
+ )
+ end
+ end
+
+ context "when field type is allowed" do
+ let(:yaml_content) { <<~YAML }
+ - type: input
+ id: name
+ YAML
+
+ it "does not add an error for valid field type" do
+ validator.validate(form_template)
+ expect(form_template.errors[:template]).to be_empty
+ end
+ end
+ end
+
+ describe "#check_descriptions_html" do
+ context "when description field has safe HTML" do
+ let(:yaml_content) { <<~YAML }
+ - type: input
+ id: name
+ attributes:
+ label: "Full name"
+ description: "What is your full name? Details here."
+ YAML
+
+ it "does not add an error" do
+ validator.validate(form_template)
+ expect(form_template.errors[:template]).to be_empty
+ end
+ end
+
+ context "when description field has unsafe HTML" do
+ let(:yaml_content) { <<~YAML }
+ - type: input
+ id: name
+ attributes:
+ label: "Full name"
+ description: "What is your full name? Details ."
+ YAML
+
+ it "adds a validation error" do
+ validator.validate(form_template)
+ expect(form_template.errors[:template]).to include(
+ I18n.t("form_templates.errors.unsafe_description"),
+ )
+ end
+ end
+ end
+
+ describe "#check_ids" do
+ context "when YAML has duplicate ids" do
+ let(:yaml_content) { <<~YAML }
+ - type: input
+ id: name
+ - type: input
+ id: name
+ YAML
+
+ it "adds an error for duplicate ids" do
+ validator.validate(form_template)
+ expect(form_template.errors[:template]).to include(
+ I18n.t("form_templates.errors.duplicate_ids"),
+ )
+ end
+ end
+
+ context "when YAML has reserved ids" do
+ let(:yaml_content) { <<~YAML }
+ - type: input
+ id: title
+ YAML
+
+ it "adds an error for reserved ids" do
+ validator.validate(form_template)
+ expect(form_template.errors[:template]).to include(
+ I18n.t("form_templates.errors.reserved_id", id: "title"),
+ )
+ end
+ end
+ end
+end
diff --git a/spec/system/composer/category_templates_spec.rb b/spec/system/composer/category_templates_spec.rb
index d87bc70f84e..819400c63d4 100644
--- a/spec/system/composer/category_templates_spec.rb
+++ b/spec/system/composer/category_templates_spec.rb
@@ -56,6 +56,31 @@ describe "Composer Form Templates", type: :system do
required: false"),
)
end
+ fab!(:form_template_6) do
+ Fabricate(
+ :form_template,
+ name: "Descriptions",
+ template:
+ %Q(
+ - type: input
+ id: full-name
+ attributes:
+ label: "Full name"
+ description: "What is your full name?"
+ placeholder: "John Smith"
+ validations:
+ required: false
+ - type: upload
+ id: prescription
+ attributes:
+ file_types: ".jpg, .png"
+ allow_multiple: false
+ label: "Prescription"
+ description: "Upload your prescription"
+ validations:
+ required: true"),
+ )
+ end
fab!(:category_with_template_1) do
Fabricate(
:category,
@@ -114,6 +139,15 @@ describe "Composer Form Templates", type: :system do
topic_template: "Testing",
)
end
+ fab!(:category_with_template_6) do
+ Fabricate(
+ :category,
+ name: "Descriptions",
+ slug: "descriptions",
+ topic_count: 2,
+ form_template_ids: [form_template_6.id],
+ )
+ end
let(:category_page) { PageObjects::Pages::Category.new }
let(:composer) { PageObjects::Components::Composer.new }
@@ -293,4 +327,19 @@ describe "Composer Form Templates", type: :system do
expect(find("#{topic_page.post_by_number_selector(1)} .cooked")).to have_css("audio")
expect(find("#{topic_page.post_by_number_selector(1)} .cooked")).to have_css("video")
end
+
+ it "shows labels and descriptions when a form template is assigned to the category" do
+ category_page.visit(category_with_template_6)
+ category_page.new_topic_button.click
+ expect(composer).to have_no_composer_input
+ expect(composer).to have_form_template
+
+ expect(composer).to have_form_template_field("input")
+ expect(composer).to have_form_template_field_label("Full name")
+ expect(composer).to have_form_template_field_description("What is your full name?")
+
+ expect(composer).to have_form_template_field("upload")
+ expect(composer).to have_form_template_field_label("Prescription")
+ expect(composer).to have_form_template_field_description("Upload your prescription")
+ end
end
diff --git a/spec/system/page_objects/components/composer.rb b/spec/system/page_objects/components/composer.rb
index 580841f5272..83b640011d1 100644
--- a/spec/system/page_objects/components/composer.rb
+++ b/spec/system/page_objects/components/composer.rb
@@ -170,6 +170,14 @@ module PageObjects
page.has_css?(".form-template-field__error", text: error)
end
+ def has_form_template_field_label?(label)
+ page.has_css?(".form-template-field__label", text: label)
+ end
+
+ def has_form_template_field_description?(description)
+ page.has_css?(".form-template-field__description", text: description)
+ end
+
def composer_input
find("#{COMPOSER_ID} .d-editor .d-editor-input")
end