FEATURE: support a description attribute on form template fields (#23744)

* FEATURE: support a description attribute on form template fields
This commit is contained in:
Renato Atilio 2023-10-04 17:51:53 -03:00 committed by GitHub
parent a1aedc9ce1
commit 1d70cf455e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 453 additions and 110 deletions

View File

@ -12,4 +12,10 @@
{{d-icon "asterisk" class="form-template-field__required-indicator"}}
{{/if}}
</label>
{{#if @attributes.description}}
<span class="form-template-field__description">
{{html-safe @attributes.description}}
</span>
{{/if}}
</div>

View File

@ -8,6 +8,12 @@
</label>
{{/if}}
{{#if @attributes.description}}
<span class="form-template-field__description">
{{html-safe @attributes.description}}
</span>
{{/if}}
{{! TODO(@keegan): Update implementation to use <ComboBox/> instead }}
{{! Current using <select> as it integrates easily with FormData (will update in v2) }}
<select

View File

@ -8,6 +8,12 @@
</label>
{{/if}}
{{#if @attributes.description}}
<span class="form-template-field__description">
{{html-safe @attributes.description}}
</span>
{{/if}}
<Input
name={{@id}}
class="form-template-field__input"

View File

@ -8,6 +8,12 @@
</label>
{{/if}}
{{#if @attributes.description}}
<span class="form-template-field__description">
{{html-safe @attributes.description}}
</span>
{{/if}}
{{! TODO(@keegan): Update implementation to use <MultiSelect/> instead }}
{{! Current using <select multiple> as it integrates easily with FormData (will update in v2) }}
<select

View File

@ -7,6 +7,13 @@
{{/if}}
</label>
{{/if}}
{{#if @attributes.description}}
<span class="form-template-field__description">
{{html-safe @attributes.description}}
</span>
{{/if}}
<Textarea
name={{@id}}
@value={{@value}}

View File

@ -8,6 +8,12 @@
</label>
{{/if}}
{{#if @attributes.description}}
<span class="form-template-field__description">
{{html-safe @attributes.description}}
</span>
{{/if}}
<input type="hidden" name={{@id}} value={{this.uploadValue}} />
<PickFilesButton

View File

@ -0,0 +1,97 @@
import Component from "@glimmer/component";
import Yaml from "js-yaml";
import { tracked } from "@glimmer/tracking";
import FormTemplate from "discourse/models/form-template";
import { action, get } from "@ember/object";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import CheckboxField from "./checkbox";
import InputField from "./input";
import DropdownField from "./dropdown";
import MultiSelectField from "./multi-select";
import TextareaField from "./textarea";
import UploadField from "./upload";
const FormTemplateField = <template>
<@component
@id={{@content.id}}
@attributes={{@content.attributes}}
@choices={{@content.choices}}
@validations={{@content.validations}}
@value={{@initialValue}}
/>
</template>;
export default class FormTemplateFieldWrapper extends Component {
<template>
{{#if this.parsedTemplate}}
<div
class="form-template-form__wrapper"
{{! template-lint-disable modifier-name-case }}
{{didUpdate this.refreshTemplate @id}}
>
{{#each this.parsedTemplate as |content|}}
<FormTemplateField
@component={{get this.fieldTypes content.type}}
@content={{content}}
@initialValue={{get this.initialValues content.id}}
/>
{{/each}}
</div>
{{else}}
<div class="alert alert-error">
{{this.error}}
</div>
{{/if}}
</template>
@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);
}
}

View File

@ -1,21 +0,0 @@
{{#if this.parsedTemplate}}
<div
class="form-template-form__wrapper"
{{did-update this.refreshTemplate @id}}
>
{{#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}}
</div>
{{else}}
<div class="alert alert-error">
{{this.error}}
</div>
{{/if}}

View File

@ -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);
}
}

View File

@ -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`<FormTemplateField::Input @attributes={{this.attributes}} />`
);
assert.dom(".form-template-field__description").hasText("Your full name");
});
}
);

View File

@ -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`<FormTemplateField::Input @attributes={{this.attributes}} />`
);
assert
.dom(".form-template-field__description")
.hasText("Write your bio here");
});
}
);

View File

@ -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',
},
});
});

View File

@ -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);
}
}

View File

@ -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"

View File

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

View File

@ -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 <a href='https://test.com'>here</a>."
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 <script>window.alert('hey');</script>."
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

View File

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

View File

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