DEV: Show form templates in the composer (#21190)

This commit is contained in:
Keegan George 2023-05-29 14:47:18 -07:00 committed by GitHub
parent 5abe98afb5
commit c74c90bae5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 548 additions and 96 deletions

View File

@ -32,10 +32,11 @@ export default class FormTemplateForm extends Component {
type: "dropdown",
icon: "chevron-circle-down",
},
{
type: "upload",
icon: "cloud-upload-alt",
},
// TODO(@keegan): add support for uploads
// {
// type: "upload",
// icon: "cloud-upload-alt",
// },
{
type: "multiselect",
icon: "bullseye",

View File

@ -30,16 +30,14 @@ export default class FormTemplate extends RestModel {
});
}
static findAll() {
return ajax(`/admin/customize/form-templates.json`).then((model) => {
return model.form_templates.sort((a, b) => a.id - b.id);
});
static async findAll() {
const result = await ajax("/admin/customize/form-templates.json");
return result.form_templates;
}
static findById(id) {
return ajax(`/admin/customize/form-templates/${id}.json`).then((model) => {
return model.form_template;
});
static async findById(id) {
const result = await ajax(`/admin/customize/form-templates/${id}.json`);
return result.form_template;
}
static validateTemplate(data) {

View File

@ -121,6 +121,7 @@
@afterRefresh={{this.composer.afterRefresh}}
@focusTarget={{this.composer.focusTarget}}
@disableTextarea={{this.composer.disableTextarea}}
@formTemplateIds={{this.composer.formTemplateIds}}
>
<div class="composer-fields">
<PluginOutlet
@ -167,9 +168,7 @@
<div class="category-input">
<CategoryChooser
@value={{this.composer.model.categoryId}}
@onChange={{action
(mut this.composer.model.categoryId)
}}
@onChange={{this.composer.updateCategory}}
@options={{hash
disabled=this.composer.disableCategoryChooser
scopedCategoryId=this.composer.scopedCategoryId

View File

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

View File

@ -1,7 +1,18 @@
<div class="d-editor-container">
<div class="d-editor-textarea-column">
{{yield}}
{{#if @formTemplateIds}}
{{#if (gt @formTemplateIds.length 1)}}
<FormTemplateChooser
@class="composer-select-form-template"
@filteredIds={{@formTemplateIds}}
@value={{this.selectedFormTemplateId}}
@onChange={{this.updateSelectedFormTemplateId}}
@options={{hash maximum=1}}
/>
{{/if}}
<FormTemplateField::Wrapper @id={{this.selectedFormTemplateId}} />
{{else}}
<div
class="d-editor-textarea-wrapper
{{if this.disabled 'disabled'}}
@ -58,6 +69,7 @@
@outletArgs={{this.outletArgs}}
/>
</div>
{{/if}}
</div>
<div

View File

@ -33,7 +33,7 @@ import showModal from "discourse/lib/show-modal";
import { siteDir } from "discourse/lib/text-direction";
import { translations } from "pretty-text/emoji/data";
import { wantsNewWindow } from "discourse/lib/intercept-click";
import { action } from "@ember/object";
import { action, computed } from "@ember/object";
import TextareaTextManipulation, {
getHead,
} from "discourse/mixins/textarea-text-manipulation";
@ -228,6 +228,25 @@ export default Component.extend(TextareaTextManipulation, {
processPreview: true,
composerFocusSelector: "#reply-control .d-editor-input",
selectedFormTemplateId: computed("formTemplateIds", {
get() {
if (this._selectedFormTemplateId) {
return this._selectedFormTemplateId;
}
return this.formTemplateIds?.[0];
},
set(key, value) {
return (this._selectedFormTemplateId = value);
},
}),
@action
updateSelectedFormTemplateId(formTemplateId) {
this.selectedFormTemplateId = formTemplateId;
},
@discourseComputed("placeholder")
placeholderTranslated(placeholder) {
if (placeholder) {

View File

@ -1,5 +1,9 @@
{{#if this.canShowContent}}
{{#each this.parsedContent as |content|}}
{{#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)
attributes=content.attributes
@ -7,6 +11,7 @@
validations=content.validations
}}
{{/each}}
</div>
{{else}}
<div class="alert alert-error">
{{this.error}}

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@
/>
</div>
{{#if this.showPreview}}
<FormTemplateField::Wrapper @content={{this.model.template}} />
<FormTemplateField::Wrapper @id={{this.model.id}} />
{{else}}
<HighlightedCode @lang="yaml" @code={{this.model.template}} />
{{/if}}

View File

@ -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`<FormTemplateField::Wrapper @id={{this.formTemplateId}} />`
);
assert.ok(
exists(`.form-template-field[data-field-type='checkbox']`),
`Checkbox component renders`
);
});
}
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class FormTemplateSerializer < ApplicationSerializer
attributes :id, :name, :template
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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