DEV: Create posts from form templates (#21980)

This commit is contained in:
Keegan George 2023-06-08 12:49:18 -07:00 committed by GitHub
parent 4973f0ccde
commit 39efa4c32a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 432 additions and 56 deletions

View File

@ -6,7 +6,7 @@ export default class AdminFormTemplateValidationOptions extends Controller.exten
ModalFunctionality
) {
TABLE_HEADER_KEYS = ["key", "type", "description"];
VALIDATION_KEYS = ["required", "minimum", "maximum", "pattern"];
VALIDATION_KEYS = ["required", "minimum", "maximum", "pattern", "type"];
get tableHeaders() {
const translatedHeaders = [];

View File

@ -17,6 +17,7 @@
@onPopupMenuAction={{this.onPopupMenuAction}}
@popupMenuOptions={{this.popupMenuOptions}}
@formTemplateIds={{this.formTemplateIds}}
@replyingToTopic={{this.composer.replyingToTopic}}
@disabled={{this.disableTextarea}}
@outletArgs={{hash composer=this.composer editorType="composer"}}
>

View File

@ -183,6 +183,11 @@ export default class ComposerMessages extends Component {
return;
}
// We don't care about similar topics when creating with a form template
if (this.composer?.category?.form_template_ids.length > 0) {
return;
}
// TODO: pass the 200 in from somewhere
const raw = (this.composer.reply || "").slice(0, 200);
const title = this.composer.title || "";

View File

@ -1,7 +1,10 @@
<div class="d-editor-container">
<div
class="d-editor-container
{{if this.showFormTemplateForm 'has-form-template'}}"
>
<div class="d-editor-textarea-column">
{{yield}}
{{#if @formTemplateIds}}
{{#if this.showFormTemplateForm}}
{{#if (gt @formTemplateIds.length 1)}}
<FormTemplateChooser
@class="composer-select-form-template"
@ -11,7 +14,9 @@
@options={{hash maximum=1}}
/>
{{/if}}
<FormTemplateField::Wrapper @id={{this.selectedFormTemplateId}} />
<form id="form-template-form">
<FormTemplateField::Wrapper @id={{this.selectedFormTemplateId}} />
</form>
{{else}}
<div
class="d-editor-textarea-wrapper

View File

@ -247,6 +247,15 @@ export default Component.extend(TextareaTextManipulation, {
this.selectedFormTemplateId = formTemplateId;
},
@discourseComputed("formTemplateIds", "replyingToTopic")
showFormTemplateForm(formTemplateIds, replyingToTopic) {
if (formTemplateIds?.length > 0 && !replyingToTopic) {
return true;
}
return false;
},
@discourseComputed("placeholder")
placeholderTranslated(placeholder) {
if (placeholder) {

View File

@ -1,6 +1,11 @@
<div class="control-group form-template-field" data-field-type="checkbox">
<label class="form-template-field__label">
<Input class="form-template-field__checkbox" @type="checkbox" />
<Input
name={{@attributes.label}}
class="form-template-field__checkbox"
@type="checkbox"
required={{if @validations.required "required" ""}}
/>
{{@attributes.label}}
</label>
</div>

View File

@ -2,14 +2,25 @@
{{#if @attributes.label}}
<label class="form-template-field__label">{{@attributes.label}}</label>
{{/if}}
<ComboBox
@class="form-template-field__dropdown"
@content={{@choices}}
@nameProperty={{null}}
@valueProperty={{null}}
@options={{hash
translatedNone=@attributes.none_label
filterable=@attributes.filterable
}}
/>
{{! TODO(@keegan): Update implementation to use <ComboBox/> instead }}
{{! Current using <select> as it integrates easily with FormData (will update in v2) }}
<select
name={{@attributes.label}}
class="form-template-field__dropdown"
required={{if @validations.required "required" ""}}
>
{{#if @attributes.none_label}}
<option
class="form-template-field__dropdown-placeholder"
value=""
disabled
selected
hidden
>{{@attributes.none_label}}</option>
{{/if}}
{{#each @choices as |choice|}}
<option value={{choice}}>{{choice}}</option>
{{/each}}
</select>
</div>

View File

@ -1,10 +1,17 @@
<div class="control-group form-template-field" data-field-type="input">
{{! TODO(@keegan): Make label required }}
{{#if @attributes.label}}
<label class="form-template-field__label">{{@attributes.label}}</label>
{{/if}}
<Input
name={{@attributes.label}}
class="form-template-field__input"
@type="text"
@type={{if @validations.type @validations.type "text"}}
placeholder={{@attributes.placeholder}}
required={{if @validations.required "required" ""}}
pattern={{@validations.pattern}}
minLength={{@validations.min}}
maxLength={{@validations.max}}
/>
</div>

View File

@ -2,14 +2,25 @@
{{#if @attributes.label}}
<label class="form-template-field__label">{{@attributes.label}}</label>
{{/if}}
<MultiSelect
@class="form-template-field__multi-select"
@content={{@choices}}
@nameProperty={{null}}
@valueProperty={{null}}
@options={{hash
translatedNone=@attributes.none_label
maximum=@validations.maximum
}}
/>
{{! TODO(@keegan): Update implementation to use <MultiSelect/> instead }}
{{! Current using <select multiple> as it integrates easily with FormData (will update in v2) }}
<select
name={{@attributes.label}}
class="form-template-field__multi-select"
required={{if @validations.required "required" ""}}
multiple="multiple"
>
{{#if @attributes.none_label}}
<option
class="form-template-field__multi-select-placeholder"
value=""
disabled
hidden
>{{@attributes.none_label}}</option>
{{/if}}
{{#each @choices as |choice|}}
<option value={{choice}}>{{choice}}</option>
{{/each}}
</select>
</div>

View File

@ -3,7 +3,12 @@
<label class="form-template-field__label">{{@attributes.label}}</label>
{{/if}}
<Textarea
name={{@attributes.label}}
class="form-template-field__textarea"
placeholder={{@attributes.placeholder}}
pattern={{@validations.pattern}}
minLength={{@validations.min}}
maxLength={{@validations.max}}
required={{if @validations.required "required" ""}}
/>
</div>

View File

@ -0,0 +1,82 @@
export default function prepareFormTemplateData(form) {
const formData = new FormData(form);
// Validate the form template
_validateFormTemplateData(form);
if (!form.checkValidity()) {
return false;
}
// Gather form template data
const formDetails = [];
for (let [key, value] of formData.entries()) {
formDetails.push({
[key]: value,
});
}
const mergedData = [];
const mergedKeys = new Set();
for (const item of formDetails) {
const key = Object.keys(item)[0]; // Get the key of the current item
if (mergedKeys.has(key)) {
// If the key has already been merged, append the value to the existing key
mergedData[mergedData.length - 1][key] += "\n" + item[key];
} else {
mergedData.push(item);
mergedKeys.add(key);
}
}
// Construct formatted post output
const formattedOutput = mergedData.map((item) => {
const key = Object.keys(item)[0];
const value = item[key];
if (value) {
return `### ${key}\n${value}`;
}
});
return formattedOutput.join("\n\n");
}
function _validateFormTemplateData(form) {
const fields = Array.from(form.elements);
fields.forEach((field) => {
field.setAttribute("aria-invalid", false);
const errorBox = document.createElement("div");
errorBox.classList.add("form-template-field__error", "popup-tip");
const errorId = field.id + "-error";
field.addEventListener("invalid", () => {
field.setAttribute("aria-invalid", true);
errorBox.classList.add("bad");
errorBox.setAttribute("id", errorId);
field.setAttribute("aria-describedby", errorId);
if (!field.nextElementSibling) {
field.insertAdjacentElement("afterend", errorBox);
}
errorBox.textContent = field.validationMessage;
});
// Mark the field as valid as changed:
field.addEventListener("input", () => {
const valid = field.checkValidity();
if (valid) {
field.setAttribute("aria-invalid", false);
errorBox.classList.remove("bad");
errorBox.textContent = "";
}
});
field.addEventListener("blur", () => {
field.checkValidity();
});
});
}

View File

@ -33,6 +33,7 @@ import { categoryBadgeHTML } from "discourse/helpers/category-link";
import renderTags from "discourse/lib/render-tags";
import { htmlSafe } from "@ember/template";
import { iconHTML } from "discourse-common/lib/icon-library";
import prepareFormTemplateData from "discourse/lib/form-template-validation";
async function loadDraft(store, opts = {}) {
let { draft, draftKey, draftSequence } = opts;
@ -929,6 +930,22 @@ export default class ComposerController extends Controller {
this.set("showPreview", false);
}
if (this.siteSettings.experimental_form_templates) {
if (
this.formTemplateIds?.length > 0 &&
!this.get("model.replyingToTopic")
) {
const formTemplateData = prepareFormTemplateData(
document.querySelector("#form-template-form")
);
if (formTemplateData) {
this.model.set("reply", formTemplateData);
} else {
return;
}
}
}
const composer = this.model;
if (composer?.cantSubmitPost) {
@ -1246,6 +1263,7 @@ export default class ComposerController extends Controller {
"id",
opts.prioritizedCategoryId
);
if (category) {
this.set("prioritizedCategoryId", opts.prioritizedCategoryId);
}

View File

@ -3,7 +3,7 @@ import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { exists } from "discourse/tests/helpers/qunit-helpers";
import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | form-template-field | dropdown",
@ -16,7 +16,6 @@ module(
test("renders a dropdown with choices", async function (assert) {
const choices = ["Choice 1", "Choice 2", "Choice 3"];
this.set("choices", choices);
await render(
@ -27,22 +26,22 @@ module(
"A dropdown component exists"
);
await this.subject.expand();
const dropdown = this.subject.displayedContent();
const dropdown = queryAll(
".form-template-field__dropdown option:not(.form-template-field__dropdown-placeholder)"
);
assert.strictEqual(dropdown.length, 3, "it has 3 choices");
assert.strictEqual(
dropdown[0].name,
dropdown[0].value,
"Choice 1",
"it has the correct name for choice 1"
);
assert.strictEqual(
dropdown[1].name,
dropdown[1].value,
"Choice 2",
"it has the correct name for choice 2"
);
assert.strictEqual(
dropdown[2].name,
dropdown[2].value,
"Choice 3",
"it has the correct name for choice 3"
);
@ -66,9 +65,8 @@ module(
"A dropdown component exists"
);
await this.subject.expand();
assert.strictEqual(
this.subject.header().label(),
query(".form-template-field__dropdown-placeholder").innerText,
attributes.none_label,
"None label is correct"
);

View File

@ -3,7 +3,7 @@ import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { exists } from "discourse/tests/helpers/qunit-helpers";
import { exists, query, queryAll } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | form-template-field | multi-select",
@ -27,22 +27,22 @@ module(
"A multiselect component exists"
);
await this.subject.expand();
const dropdown = this.subject.displayedContent();
const dropdown = queryAll(
".form-template-field__multi-select option:not(.form-template-field__multi-select-placeholder)"
);
assert.strictEqual(dropdown.length, 3, "it has 3 choices");
assert.strictEqual(
dropdown[0].name,
dropdown[0].value,
"Choice 1",
"it has the correct name for choice 1"
);
assert.strictEqual(
dropdown[1].name,
dropdown[1].value,
"Choice 2",
"it has the correct name for choice 2"
);
assert.strictEqual(
dropdown[2].name,
dropdown[2].value,
"Choice 3",
"it has the correct name for choice 3"
);
@ -66,9 +66,8 @@ module(
"A multiselect dropdown component exists"
);
await this.subject.expand();
assert.strictEqual(
this.subject.header().label(),
query(".form-template-field__multi-select-placeholder").innerText,
attributes.none_label,
"None label is correct"
);

View File

@ -16,9 +16,19 @@ html.composer-open {
margin-right: auto;
max-width: $reply-area-max-width;
width: 100%;
&.hide-preview {
max-width: 740px;
}
&:has(.has-form-template) {
max-width: 740px;
.toggle-preview {
display: none;
}
}
@media screen and (max-width: 1200px) {
min-width: 0;
}
@ -356,6 +366,10 @@ html.composer-open {
color: var(--danger);
}
}
.preview-template {
margin-left: 0.5rem;
}
}
#draft-status,

View File

@ -14,6 +14,7 @@
@import "date-time-input-range";
@import "date-time-input";
@import "footer-nav";
@import "form-template-field";
@import "group-member-dropdown";
@import "groups-form-membership-fields";
@import "hashtag";

View File

@ -0,0 +1,16 @@
.form-template-field {
// TODO(@keegan): Remove after updating dropdown/multi-select to use select-kit
&__dropdown {
border: 1px solid var(--primary-medium);
border-radius: var(--d-input-border-radius);
padding: 0.5em 0.65em;
font-size: var(--font-0);
}
&__multi-select {
border: 1px solid var(--primary-medium);
border-radius: var(--d-input-border-radius);
padding: 0.5em 0.65em;
font-size: var(--font-0);
}
}

View File

@ -2,6 +2,13 @@
display: flex;
flex-grow: 1;
max-width: 100%;
&.has-form-template {
.d-editor-preview-wrapper {
display: none;
flex: 0;
}
}
}
.d-editor {
@ -353,7 +360,7 @@
}
}
.d-editor .form-template-form__wrapper {
.d-editor #form-template-form {
overflow: auto;
background: var(--primary-very-low);
padding: 1rem;

View File

@ -5641,15 +5641,19 @@ en:
minimum:
key: "minimum"
type: "integer"
description: "In text fields, specifies the minimum number of characters allowed."
description: "For text fields, specifies the minimum number of characters allowed."
maximum:
key: "maximum"
type: "integer"
description: "In text fields, specifies the maximum number of characters allowed. In multi select dropdowns, specifies the maximum number of options that can be selected."
description: "For text fields, specifies the maximum number of characters allowed."
pattern:
key: "pattern"
type: "regex string"
description: "In text fields, a regular expression specifying the allowed input."
description: "For text fields, a regular expression specifying the allowed input."
type:
key: "type"
type: "string"
description: "For input fields, you can specify the type of input that should be expected (text|email|date|number|url|tel|color)"
preview_modal:
title: "Preview Template"
field_placeholders:

View File

@ -82,6 +82,7 @@ module SvgSprite
exclamation-circle
exclamation-triangle
external-link-alt
eye
fab-android
fab-apple
fab-chrome

View File

@ -3,10 +3,20 @@
describe "Composer Form Templates", type: :system do
fab!(:user) { Fabricate(:user) }
fab!(:form_template_1) do
Fabricate(:form_template, name: "Bug Reports", template: "- type: checkbox")
Fabricate(
:form_template,
name: "Bug Reports",
template:
"- type: input
attributes:
label: What is your full name?
placeholder: John Doe
validations:
required: true",
)
end
fab!(:form_template_2) do
Fabricate(:form_template, name: "Feature Request", template: "- type: input")
Fabricate(:form_template, name: "Feature Request", template: "- type: checkbox")
end
fab!(:form_template_3) do
Fabricate(:form_template, name: "Awesome Possum", template: "- type: dropdown")
@ -66,6 +76,7 @@ describe "Composer Form Templates", type: :system do
let(:category_page) { PageObjects::Pages::Category.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:form_template_chooser) { PageObjects::Components::SelectKit.new(".form-template-chooser") }
let(:topic_page) { PageObjects::Pages::Topic.new }
before do
SiteSetting.experimental_form_templates = true
@ -90,7 +101,21 @@ describe "Composer Form Templates", type: :system do
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("checkbox")
expect(composer).to have_form_template_field("input")
end
it "shows the preview when a category without a form template is selected" do
category_page.visit(category_no_template)
category_page.new_topic_button.click
expect(composer).to have_composer_preview
expect(composer).to have_composer_preview_toggle
end
it "hides the preivew when a category with a form template is selected" do
category_page.visit(category_with_template_1)
category_page.new_topic_button.click
expect(composer).to have_no_composer_preview
expect(composer).to have_no_composer_preview_toggle
end
it "shows the correct template when switching categories" do
@ -105,11 +130,11 @@ describe "Composer Form Templates", type: :system do
# 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")
expect(composer).to have_form_template_field("input")
# 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")
expect(composer).to have_form_template_field("checkbox")
end
it "does not show form template chooser when a category only has form template" do
@ -127,9 +152,9 @@ describe "Composer Form Templates", type: :system do
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")
form_template_chooser.select_row_by_name(form_template_2.name)
expect(composer).to have_form_template_field("checkbox")
end
it "shows the correct template options when switching categories" do
@ -152,4 +177,22 @@ describe "Composer Form Templates", type: :system do
form_template_chooser.select_row_by_name(form_template_2.name)
expect(form_template_chooser).to have_selected_name(form_template_2.name)
end
it "forms a post when template fields are filled in" do
topic_title = "A topic about Batman"
category_page.visit(category_with_template_1)
category_page.new_topic_button.click
composer.fill_title(topic_title)
composer.fill_form_template_field("input", "Bruce Wayne")
composer.create
topic = Topic.where(user: user, title: topic_title)
topic_id = Topic.where(user: user, title: topic_title).pluck(:id)
post = Post.where(topic_id: topic_id).first
expect(topic_page).to have_topic_title(topic_title)
expect(find("#{topic_page.post_by_number_selector(1)} .cooked p")).to have_content(
"Bruce Wayne",
)
end
end

View File

@ -0,0 +1,105 @@
# frozen_string_literal: true
describe "Composer Form Template Validations", type: :system, js: true do
fab!(:user) { Fabricate(:user) }
fab!(:form_template) do
Fabricate(
:form_template,
name: "Bug Reports",
template:
"- type: input
attributes:
label: What is your full name?
placeholder: John Doe
validations:
required: true
type: email
min: 10",
)
end
fab!(:form_template_2) do
Fabricate(
:form_template,
name: "Websites",
template:
"- type: input
attributes:
label: What is your website name?
placeholder: https://www.example.com
validations:
pattern: https?://.+",
)
end
fab!(:category_with_template) do
Fabricate(
:category,
name: "Reports",
slug: "reports",
topic_count: 2,
form_template_ids: [form_template.id],
)
end
fab!(:category_with_template_2) do
Fabricate(
:category,
name: "Websites",
slug: "websites",
topic_count: 2,
form_template_ids: [form_template_2.id],
)
end
let(:category_page) { PageObjects::Pages::Category.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:error_types) do
{
required_error: "Please fill out this field.",
type_error: "Please include an '@' in the email address. 'Bruce Wayne' is missing an '@'.",
min_error:
"Please lengthen this text to 10 characters or more (you are currently using 7 characters).",
pattern_error: "Please match the requested format.",
}
end
let(:topic_title) { "A topic about Batman" }
before do
SiteSetting.experimental_form_templates = true
sign_in user
end
it "shows an error when a required input is not filled in" do
category_page.visit(category_with_template)
category_page.new_topic_button.click
composer.fill_title(topic_title)
composer.create
expect(composer).to have_form_template_field_error(error_types[:required_error])
end
it "shows an error when an input filled doesn't satisfy the type expected" do
category_page.visit(category_with_template)
category_page.new_topic_button.click
composer.fill_title(topic_title)
composer.create
composer.fill_form_template_field("input", "Bruce Wayne")
expect(composer).to have_form_template_field_error(error_types[:type_error])
end
it "shows an error when an input doesn't satisfy the min length expected" do
category_page.visit(category_with_template)
category_page.new_topic_button.click
composer.fill_title(topic_title)
composer.create
composer.fill_form_template_field("input", "b@b.com")
expect(composer).to have_form_template_field_error(error_types[:min_error])
end
it "shows an error when an input doesn't satisfy the requested pattern" do
category_page.visit(category_with_template_2)
category_page.new_topic_button.click
composer.fill_title(topic_title)
composer.fill_form_template_field("input", "www.example.com")
composer.create
expect(composer).to have_form_template_field_error(error_types[:pattern_error])
end
end

View File

@ -30,6 +30,11 @@ module PageObjects
self
end
def fill_form_template_field(field, content)
form_template_field(field).fill_in(with: content)
self
end
def type_content(content)
composer_input.send_keys(content)
self
@ -113,6 +118,22 @@ module PageObjects
page.has_css?(COMPOSER_INPUT_SELECTOR)
end
def has_composer_preview?
page.has_css?("#{COMPOSER_ID} .d-editor-preview-wrapper")
end
def has_no_composer_preview?
page.has_no_css?("#{COMPOSER_ID} .d-editor-preview-wrapper")
end
def has_composer_preview_toggle?
page.has_css?("#{COMPOSER_ID} .toggle-preview")
end
def has_no_composer_preview_toggle?
page.has_no_css?("#{COMPOSER_ID} .toggle-preview")
end
def has_form_template?
page.has_css?(".form-template-form__wrapper")
end
@ -131,6 +152,10 @@ module PageObjects
page.has_css?(FORM_TEMPLATE_CHOOSER_SELECTOR)
end
def has_form_template_field_error?(error)
page.has_css?(".form-template-field__error", text: error)
end
def composer_input
find("#{COMPOSER_ID} .d-editor .d-editor-input")
end
@ -139,6 +164,10 @@ module PageObjects
find("#{COMPOSER_ID} .composer-popup")
end
def form_template_field(field)
find(".form-template-field[data-field-type='#{field}']")
end
private
def emoji_preview_selector(emoji)