DEV: Create form templates (#20189)
This commit is contained in:
parent
7622dbcebf
commit
871607a420
|
@ -0,0 +1,57 @@
|
|||
<div class="form-templates--form">
|
||||
<div class="control-group">
|
||||
<label for="template-name">
|
||||
{{i18n "admin.form_templates.new_template_form.name.label"}}
|
||||
</label>
|
||||
<TextField
|
||||
@value={{this.templateName}}
|
||||
@name="template-name"
|
||||
@class="form-templates--form-name-input"
|
||||
@placeholderKey="admin.form_templates.new_template_form.name.placeholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="control-group form-templates--quick-insert-field-buttons">
|
||||
<span>
|
||||
{{I18n "admin.form_templates.quick_insert_fields.add_new_field"}}
|
||||
</span>
|
||||
{{#each this.quickInsertFields as |field|}}
|
||||
<DButton
|
||||
@class="btn-flat btn-icon-text quick-insert-{{field.type}}"
|
||||
@icon={{field.icon}}
|
||||
@label="admin.form_templates.quick_insert_fields.{{field.type}}"
|
||||
@action={{this.onInsertField}}
|
||||
@actionParam={{field.type}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<AceEditor @content={{this.templateContent}} @mode="yaml" />
|
||||
</div>
|
||||
|
||||
<div class="footer-buttons">
|
||||
<DButton
|
||||
@class="btn-primary"
|
||||
@label="admin.form_templates.new_template_form.submit"
|
||||
@icon="check"
|
||||
@action={{this.onSubmit}}
|
||||
@disabled={{this.formSubmitted}}
|
||||
/>
|
||||
|
||||
<DButton
|
||||
@label="admin.form_templates.new_template_form.cancel"
|
||||
@icon="times"
|
||||
@action={{this.onCancel}}
|
||||
/>
|
||||
|
||||
{{#if this.isEditing}}
|
||||
<DButton
|
||||
@class="btn-danger"
|
||||
@label="admin.form_templates.view_template.delete"
|
||||
@icon="trash-alt"
|
||||
@action={{this.onDelete}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,109 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import I18n from "I18n";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { templateFormFields } from "admin/lib/template-form-fields";
|
||||
import FormTemplate from "admin/models/form-template";
|
||||
|
||||
export default class FormTemplateForm extends Component {
|
||||
@service router;
|
||||
@service dialog;
|
||||
@tracked formSubmitted = false;
|
||||
@tracked templateContent = this.args.model?.template || "";
|
||||
isEditing = this.args.model?.id ? true : false;
|
||||
templateName = this.args.model?.name;
|
||||
quickInsertFields = [
|
||||
{
|
||||
type: "checkbox",
|
||||
icon: "check-square",
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
icon: "grip-lines",
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
icon: "align-left",
|
||||
},
|
||||
{
|
||||
type: "dropdown",
|
||||
icon: "chevron-circle-down",
|
||||
},
|
||||
{
|
||||
type: "upload",
|
||||
icon: "cloud-upload-alt",
|
||||
},
|
||||
{
|
||||
type: "multiselect",
|
||||
icon: "bullseye",
|
||||
},
|
||||
];
|
||||
|
||||
@action
|
||||
onSubmit() {
|
||||
if (!this.formSubmitted) {
|
||||
this.formSubmitted = true;
|
||||
}
|
||||
|
||||
const postData = {
|
||||
name: this.templateName,
|
||||
template: this.templateContent,
|
||||
};
|
||||
|
||||
if (this.isEditing) {
|
||||
postData["id"] = this.args.model.id;
|
||||
|
||||
FormTemplate.updateTemplate(this.args.model.id, postData)
|
||||
.then(() => {
|
||||
this.formSubmitted = false;
|
||||
this.router.transitionTo("adminCustomizeFormTemplates.index");
|
||||
})
|
||||
.catch((e) => {
|
||||
popupAjaxError(e);
|
||||
this.formSubmitted = false;
|
||||
});
|
||||
} else {
|
||||
FormTemplate.createTemplate(postData)
|
||||
.then(() => {
|
||||
this.formSubmitted = false;
|
||||
this.router.transitionTo("adminCustomizeFormTemplates.index");
|
||||
})
|
||||
.catch((e) => {
|
||||
popupAjaxError(e);
|
||||
this.formSubmitted = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onCancel() {
|
||||
this.router.transitionTo("adminCustomizeFormTemplates.index");
|
||||
}
|
||||
|
||||
@action
|
||||
onDelete() {
|
||||
return this.dialog.yesNoConfirm({
|
||||
message: I18n.t("admin.form_templates.delete_confirm"),
|
||||
didConfirm: () => {
|
||||
FormTemplate.deleteTemplate(this.args.model.id)
|
||||
.then(() => {
|
||||
this.router.transitionTo("adminCustomizeFormTemplates.index");
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
onInsertField(type) {
|
||||
const structure = templateFormFields.findBy("type", type).structure;
|
||||
|
||||
if (this.templateContent.length === 0) {
|
||||
this.templateContent += structure;
|
||||
} else {
|
||||
this.templateContent += `\n${structure}`;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
<div class="form-templates--info">
|
||||
<h2>{{i18n "admin.form_templates.title"}}</h2>
|
||||
<p class="desc">{{i18n "admin.form_templates.help"}}</p>
|
||||
</div>
|
|
@ -0,0 +1,23 @@
|
|||
<tr class="admin-list-item">
|
||||
<td class="col first">{{@template.name}}</td>
|
||||
<td class="col action">
|
||||
<DButton
|
||||
@title="admin.form_templates.list_table.actions.view"
|
||||
@icon="far-eye"
|
||||
@class="btn-view-template"
|
||||
@action={{this.viewTemplate}}
|
||||
/>
|
||||
<DButton
|
||||
@title="admin.form_templates.list_table.actions.edit"
|
||||
@icon="pencil-alt"
|
||||
@class="btn-edit-template"
|
||||
@action={{this.editTemplate}}
|
||||
/>
|
||||
<DButton
|
||||
@title="admin.form_templates.list_table.actions.delete"
|
||||
@icon="far-trash-alt"
|
||||
@class="btn-danger btn-delete-template"
|
||||
@action={{this.deleteTemplate}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
|
@ -0,0 +1,47 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { action } from "@ember/object";
|
||||
import showModal from "discourse/lib/show-modal";
|
||||
import { inject as service } from "@ember/service";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import I18n from "I18n";
|
||||
|
||||
export default class FormTemplateRowItem extends Component {
|
||||
@service router;
|
||||
@service dialog;
|
||||
|
||||
@action
|
||||
viewTemplate() {
|
||||
showModal("admin-customize-form-template-view", {
|
||||
admin: true,
|
||||
model: this.args.template,
|
||||
refreshModel: this.args.refreshModel,
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
editTemplate() {
|
||||
this.router.transitionTo(
|
||||
"adminCustomizeFormTemplates.edit",
|
||||
this.args.template
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
deleteTemplate() {
|
||||
return this.dialog.yesNoConfirm({
|
||||
message: I18n.t("admin.form_templates.delete_confirm", {
|
||||
template_name: this.args.template.name,
|
||||
}),
|
||||
didConfirm: () => {
|
||||
ajax(`/admin/customize/form-templates/${this.args.template.id}.json`, {
|
||||
type: "DELETE",
|
||||
})
|
||||
.then(() => {
|
||||
this.args.refreshModel();
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import Controller from "@ember/controller";
|
||||
import { action } from "@ember/object";
|
||||
|
||||
export default class AdminCustomizeFormTemplatesIndex extends Controller {
|
||||
@action
|
||||
newTemplate() {
|
||||
this.transitionToRoute("adminCustomizeFormTemplates.new");
|
||||
}
|
||||
|
||||
@action
|
||||
reload() {
|
||||
this.send("reloadModel");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import Controller from "@ember/controller";
|
||||
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||
import { action } from "@ember/object";
|
||||
import { inject as service } from "@ember/service";
|
||||
import I18n from "I18n";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
export default class AdminCustomizeFormTemplateView extends Controller.extend(
|
||||
ModalFunctionality
|
||||
) {
|
||||
@service router;
|
||||
@service dialog;
|
||||
|
||||
@action
|
||||
editTemplate() {
|
||||
this.router.transitionTo("adminCustomizeFormTemplates.edit", this.model);
|
||||
}
|
||||
|
||||
@action
|
||||
deleteTemplate() {
|
||||
return this.dialog.yesNoConfirm({
|
||||
message: I18n.t("admin.form_templates.delete_confirm", {
|
||||
template_name: this.model.name,
|
||||
}),
|
||||
didConfirm: () => {
|
||||
ajax(`/admin/customize/form-templates/${this.model.id}.json`, {
|
||||
type: "DELETE",
|
||||
})
|
||||
.then(() => {
|
||||
this.refreshModel();
|
||||
})
|
||||
.catch(popupAjaxError);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
// TODO(@keegan): Add translations for template strings
|
||||
export const templateFormFields = [
|
||||
{
|
||||
type: "checkbox",
|
||||
structure: `- type: checkbox
|
||||
choices:
|
||||
- "Option 1"
|
||||
- "Option 2"
|
||||
- "Option 3"
|
||||
attributes:
|
||||
label: "Enter question here"
|
||||
description: "Enter description here"
|
||||
validations:
|
||||
required: true`,
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
structure: `- type: input
|
||||
attributes:
|
||||
label: "Enter input label here"
|
||||
description: "Enter input description here"
|
||||
placeholder: "Enter input placeholder here"
|
||||
validations:
|
||||
required: true`,
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
structure: `- type: textarea
|
||||
attributes:
|
||||
label: "Enter textarea label here"
|
||||
description: "Enter textarea description here"
|
||||
placeholder: "Enter textarea placeholder here"
|
||||
validations:
|
||||
required: true`,
|
||||
},
|
||||
{
|
||||
type: "dropdown",
|
||||
structure: `- type: dropdown
|
||||
choices:
|
||||
- "Option 1"
|
||||
- "Option 2"
|
||||
- "Option 3"
|
||||
attributes:
|
||||
label: "Enter dropdown label here"
|
||||
description: "Enter dropdown description here"
|
||||
validations:
|
||||
required: true`,
|
||||
},
|
||||
{
|
||||
type: "upload",
|
||||
structure: `- type: upload
|
||||
attributes:
|
||||
file_types: "jpg, png, gif"
|
||||
label: "Enter upload label here"
|
||||
description: "Enter upload description here"`,
|
||||
},
|
||||
{
|
||||
type: "multiselect",
|
||||
structure: `- type: multiple_choice
|
||||
choices:
|
||||
- "Option 1"
|
||||
- "Option 2"
|
||||
- "Option 3"
|
||||
attributes:
|
||||
label: "Enter multiple choice label here"
|
||||
description: "Enter multiple choice description here"
|
||||
validations:
|
||||
required: true`,
|
||||
},
|
||||
];
|
|
@ -0,0 +1,38 @@
|
|||
import RestModel from "discourse/models/rest";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
|
||||
export default class FormTemplate extends RestModel {}
|
||||
|
||||
FormTemplate.reopenClass({
|
||||
createTemplate(data) {
|
||||
return ajax("/admin/customize/form-templates.json", {
|
||||
type: "POST",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
updateTemplate(id, data) {
|
||||
return ajax(`/admin/customize/form-templates/${id}.json`, {
|
||||
type: "PUT",
|
||||
data,
|
||||
});
|
||||
},
|
||||
|
||||
deleteTemplate(id) {
|
||||
return ajax(`/admin/customize/form-templates/${id}.json`, {
|
||||
type: "DELETE",
|
||||
});
|
||||
},
|
||||
|
||||
findAll() {
|
||||
return ajax(`/admin/customize/form-templates.json`).then((model) => {
|
||||
return model.form_templates.sort((a, b) => a.id - b.id);
|
||||
});
|
||||
},
|
||||
|
||||
findById(id) {
|
||||
return ajax(`/admin/customize/form-templates/${id}.json`).then((model) => {
|
||||
return model.form_template;
|
||||
});
|
||||
},
|
||||
});
|
|
@ -0,0 +1,12 @@
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
import FormTemplate from "admin/models/form-template";
|
||||
|
||||
export default class AdminCustomizeFormTemplatesEdit extends DiscourseRoute {
|
||||
model(params) {
|
||||
return FormTemplate.findById(params.id);
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
controller.set("model", model);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import DiscourseRoute from "discourse/routes/discourse";
|
||||
import { action } from "@ember/object";
|
||||
import FormTemplate from "admin/models/form-template";
|
||||
export default class AdminCustomizeFormTemplatesIndex extends DiscourseRoute {
|
||||
model() {
|
||||
return FormTemplate.findAll();
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
controller.set("model", model);
|
||||
}
|
||||
|
||||
@action
|
||||
reloadModel() {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
|
@ -97,6 +97,14 @@ export default function () {
|
|||
this.route("edit", { path: "/:field_name" });
|
||||
}
|
||||
);
|
||||
this.route(
|
||||
"adminCustomizeFormTemplates",
|
||||
{ path: "/form-templates", resetNamespace: true },
|
||||
function () {
|
||||
this.route("new", { path: "/new" });
|
||||
this.route("edit", { path: "/:id" });
|
||||
}
|
||||
);
|
||||
this.route(
|
||||
"adminWatchedWords",
|
||||
{ path: "/watched_words", resetNamespace: true },
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
<div class="edit-form-template">
|
||||
<FormTemplate::InfoHeader />
|
||||
<FormTemplate::Form @model={{this.model}} />
|
||||
</div>
|
|
@ -0,0 +1,32 @@
|
|||
<div class="form-templates">
|
||||
<FormTemplate::InfoHeader />
|
||||
|
||||
{{#if this.model}}
|
||||
<table class="form-templates--table grid">
|
||||
<thead>
|
||||
<th class="col heading">
|
||||
{{i18n "admin.form_templates.list_table.headings.name"}}
|
||||
</th>
|
||||
<th class="col heading sr-only">
|
||||
{{i18n "admin.form_templates.list_table.headings.actions"}}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each this.model as |template|}}
|
||||
<FormTemplate::RowItem
|
||||
@template={{template}}
|
||||
@refreshModel={{this.reload}}
|
||||
/>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{/if}}
|
||||
|
||||
<DButton
|
||||
@class="btn-primary"
|
||||
@label="admin.form_templates.new_template"
|
||||
@title="admin.form_templates.new_template"
|
||||
@icon="plus"
|
||||
@action={{this.newTemplate}}
|
||||
/>
|
||||
</div>
|
|
@ -0,0 +1,4 @@
|
|||
<div class="new-form-template">
|
||||
<FormTemplate::InfoHeader />
|
||||
<FormTemplate::Form />
|
||||
</div>
|
|
@ -45,6 +45,13 @@
|
|||
@label="admin.embedding.title"
|
||||
@class="admin-customize-embedding"
|
||||
/>
|
||||
{{#if this.siteSettings.experimental_form_templates}}
|
||||
<NavItem
|
||||
@route="adminCustomizeFormTemplates"
|
||||
@label="admin.form_templates.nav_title"
|
||||
@class="admin-customize-form-templates"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<NavItem
|
||||
@route="adminWatchedWords"
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<DModalBody @rawTitle={{this.model.name}}>
|
||||
<HighlightedCode @lang="yaml" @code={{this.model.template}} />
|
||||
{{! ? TODO(@keegan): Perhaps add what places (ex. categories) the templates are active in }}
|
||||
</DModalBody>
|
||||
<div class="modal-footer">
|
||||
<DButton
|
||||
class="btn-primary"
|
||||
@action={{this.editTemplate}}
|
||||
@icon="pencil-alt"
|
||||
@label="admin.form_templates.view_template.edit"
|
||||
/>
|
||||
<DButton
|
||||
@action={{route-action "closeModal"}}
|
||||
@label="admin.form_templates.view_template.close"
|
||||
/>
|
||||
<DButton
|
||||
class="btn-danger"
|
||||
@action={{this.deleteTemplate}}
|
||||
@icon="trash-alt"
|
||||
@label="admin.form_templates.view_template.delete"
|
||||
/>
|
||||
</div>
|
|
@ -923,3 +923,80 @@ table.permalinks {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-templates {
|
||||
&--info {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
&--table {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.admin-list-item .action {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
&--form {
|
||||
input {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.ace-wrapper {
|
||||
position: relative;
|
||||
height: calc(100vh - 450px);
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
box-shadow: shadow("footer-nav");
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ace_editor {
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.ace_placeholder {
|
||||
font-family: inherit;
|
||||
font-size: var(--font-up-1);
|
||||
color: var(--primary-high);
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
.btn-danger {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--quick-insert-field-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-left: 1rem;
|
||||
|
||||
span {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--primary-low);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-customize-form-template-view-modal {
|
||||
.modal-footer {
|
||||
.btn:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::FormTemplatesController < Admin::StaffController
|
||||
before_action :ensure_form_templates_enabled
|
||||
|
||||
def index
|
||||
form_templates = FormTemplate.all
|
||||
render_serialized(form_templates, AdminFormTemplateSerializer, root: "form_templates")
|
||||
end
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
params.require(:name)
|
||||
params.require(:template)
|
||||
|
||||
begin
|
||||
template = FormTemplate.create!(name: params[:name], template: params[:template])
|
||||
render_serialized(template, AdminFormTemplateSerializer, root: "form_template")
|
||||
rescue FormTemplate::NotAllowed => err
|
||||
render_json_error(err.message)
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
template = FormTemplate.find(params[:id])
|
||||
render_serialized(template, AdminFormTemplateSerializer, root: "form_template")
|
||||
end
|
||||
|
||||
def edit
|
||||
FormTemplate.find(params[:id])
|
||||
end
|
||||
|
||||
def update
|
||||
template = FormTemplate.find(params[:id])
|
||||
|
||||
begin
|
||||
template.update!(name: params[:name], template: params[:template])
|
||||
render_serialized(template, AdminFormTemplateSerializer, root: "form_template")
|
||||
rescue FormTemplate::NotAllowed => err
|
||||
render_json_error(err.message)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
template = FormTemplate.find(params[:id])
|
||||
template.destroy!
|
||||
|
||||
render json: success_json
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_form_templates_enabled
|
||||
raise Discourse::InvalidAccess.new unless SiteSetting.experimental_form_templates
|
||||
end
|
||||
end
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FormTemplate < ActiveRecord::Base
|
||||
validates :name, presence: true, uniqueness: true, length: { maximum: 100 }
|
||||
validates :template, presence: true, length: { maximum: 2000 }
|
||||
validates_with FormTemplateYamlValidator
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: form_templates
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# name :string(100) not null
|
||||
# template :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_form_templates_on_name (name) UNIQUE
|
||||
#
|
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AdminFormTemplateSerializer < ApplicationSerializer
|
||||
attributes :id, :name, :template
|
||||
end
|
|
@ -5508,6 +5508,41 @@ en:
|
|||
found_matches: "Found matches:"
|
||||
no_matches: "No matches found"
|
||||
|
||||
form_templates:
|
||||
nav_title: "Templates"
|
||||
title: "Form Templates"
|
||||
help: "Create a template structure that can be used to create new topics, posts, and messages."
|
||||
new_template: "New Template"
|
||||
list_table:
|
||||
headings:
|
||||
name: "Name"
|
||||
actions: "Actions"
|
||||
actions:
|
||||
view: "View Template"
|
||||
edit: "Edit Template"
|
||||
delete: "Delete Template"
|
||||
view_template:
|
||||
close: "Close"
|
||||
edit: "Edit"
|
||||
delete: "Delete"
|
||||
new_template_form:
|
||||
submit: "Save"
|
||||
cancel: "Cancel"
|
||||
name:
|
||||
label: "Template Name"
|
||||
placeholder: "Enter a name for this template..."
|
||||
template:
|
||||
label: "Template"
|
||||
placeholder: "Create a YAML template here..."
|
||||
delete_confirm: "Are you sure you would like to delete this template?"
|
||||
quick_insert_fields:
|
||||
add_new_field: "Add"
|
||||
checkbox: "Checkbox"
|
||||
input: "Short answer"
|
||||
textarea: "Long answer"
|
||||
dropdown: "Dropdown"
|
||||
upload: "Upload a file"
|
||||
multiselect: "Multiple choice"
|
||||
impersonate:
|
||||
title: "Impersonate"
|
||||
help: "Use this tool to impersonate a user account for debugging purposes. You will have to log out once finished."
|
||||
|
|
|
@ -5250,3 +5250,7 @@ en:
|
|||
payload_url:
|
||||
blocked_or_internal: "Payload URL cannot be used because it resolves to a blocked or internal IP"
|
||||
unsafe: "Payload URL cannot be used because it's unsafe"
|
||||
|
||||
form_templates:
|
||||
errors:
|
||||
invalid_yaml: "is not a valid YAML string"
|
||||
|
|
|
@ -233,6 +233,7 @@ Discourse::Application.routes.draw do
|
|||
scope "/customize", constraints: AdminConstraint.new do
|
||||
resources :user_fields, constraints: AdminConstraint.new
|
||||
resources :emojis, constraints: AdminConstraint.new
|
||||
resources :form_templates, constraints: AdminConstraint.new, path: "/form-templates"
|
||||
|
||||
get "themes/:id/:target/:field_name/edit" => "themes#index"
|
||||
get "themes/:id" => "themes#index"
|
||||
|
|
|
@ -2090,6 +2090,10 @@ developer:
|
|||
client: true
|
||||
default: false
|
||||
hidden: true
|
||||
experimental_form_templates:
|
||||
client: true
|
||||
default: false
|
||||
hidden: true
|
||||
|
||||
navigation:
|
||||
navigation_menu:
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateFormTemplates < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :form_templates do |t|
|
||||
t.string :name, null: false, limit: 100
|
||||
t.text :template, null: false, limit: 2000
|
||||
|
||||
t.timestamps null: false
|
||||
end
|
||||
|
||||
add_index :form_templates, :name, unique: true
|
||||
end
|
||||
end
|
|
@ -6,6 +6,7 @@ module SvgSprite
|
|||
%w[
|
||||
adjust
|
||||
address-book
|
||||
align-left
|
||||
ambulance
|
||||
anchor
|
||||
angle-double-down
|
||||
|
@ -34,6 +35,7 @@ module SvgSprite
|
|||
book-reader
|
||||
bookmark
|
||||
briefcase
|
||||
bullseye
|
||||
calendar-alt
|
||||
caret-down
|
||||
caret-left
|
||||
|
@ -45,11 +47,13 @@ module SvgSprite
|
|||
check
|
||||
check-circle
|
||||
check-square
|
||||
chevron-circle-down
|
||||
chevron-down
|
||||
chevron-left
|
||||
chevron-right
|
||||
chevron-up
|
||||
circle
|
||||
cloud-upload-alt
|
||||
code
|
||||
cog
|
||||
columns
|
||||
|
@ -134,6 +138,7 @@ module SvgSprite
|
|||
gift
|
||||
globe
|
||||
globe-americas
|
||||
grip-lines
|
||||
hand-point-right
|
||||
hands-helping
|
||||
heart
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class FormTemplateYamlValidator < ActiveModel::Validator
|
||||
def validate(record)
|
||||
begin
|
||||
yaml = Psych.safe_load(record.template)
|
||||
rescue Psych::SyntaxError
|
||||
record.errors.add(:template, I18n.t("form_templates.errors.invalid_yaml"))
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:form_template) do
|
||||
name { sequence(:name) { |i| "template_#{i}" } }
|
||||
template "some yaml template: value"
|
||||
end
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe FormTemplate, type: :model do
|
||||
it "can't have duplicate names" do
|
||||
Fabricate(:form_template, name: "Bug Report", template: "some yaml: true")
|
||||
t = Fabricate.build(:form_template, name: "Bug Report", template: "some yaml: true")
|
||||
expect(t.save).to eq(false)
|
||||
t = Fabricate.build(:form_template, name: "Bug Report", template: "some yaml: true")
|
||||
expect(t.save).to eq(false)
|
||||
expect(described_class.count).to eq(1)
|
||||
end
|
||||
|
||||
it "can't have an invalid yaml template" do
|
||||
template = "first: good\nsecond; bad"
|
||||
t = Fabricate.build(:form_template, name: "Feature Request", template: template)
|
||||
expect(t.save).to eq(false)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,173 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe Admin::FormTemplatesController do
|
||||
fab!(:admin) { Fabricate(:admin) }
|
||||
fab!(:user) { Fabricate(:user) }
|
||||
|
||||
before { SiteSetting.experimental_form_templates = true }
|
||||
|
||||
describe "#index" do
|
||||
fab!(:form_template) { Fabricate(:form_template) }
|
||||
|
||||
context "when logged in as an admin" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "should work if you are an admin" do
|
||||
get "/admin/customize/form-templates.json"
|
||||
expect(response.status).to eq(200)
|
||||
|
||||
json = response.parsed_body
|
||||
expect(json["form_templates"]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context "when logged in as a non-admin user" do
|
||||
before { sign_in(user) }
|
||||
|
||||
it "should not work if you are not an admin" do
|
||||
get "/admin/customize/form-templates.json"
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
|
||||
context "when experiemental form templates is disabled" do
|
||||
before do
|
||||
sign_in(admin)
|
||||
SiteSetting.experimental_form_templates = false
|
||||
end
|
||||
|
||||
it "should not work if you are an admin" do
|
||||
get "/admin/customize/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 an admin" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "should work if you are an admin" do
|
||||
get "/admin/customize/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
|
||||
end
|
||||
|
||||
describe "#create" do
|
||||
context "when logged in as an admin" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "creates a form template" do
|
||||
expect {
|
||||
post "/admin/customize/form-templates.json",
|
||||
params: {
|
||||
name: "Bug Reports",
|
||||
template:
|
||||
"body:\n- type: input\n attributes:\n label: Website or apps\n description: |\n Which website or app were you using when the bug happened?\n placeholder: |\n e.g. website URL, name of the app\n validations:\n required: true",
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
}.to change(FormTemplate, :count).by(1)
|
||||
end
|
||||
end
|
||||
|
||||
context "when logged in as a non-admin user" do
|
||||
before { sign_in(user) }
|
||||
|
||||
it "prevents creation with a 404 response" do
|
||||
expect do
|
||||
post "/admin/customize/form-templates.json",
|
||||
params: {
|
||||
name: "Feature Requests",
|
||||
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\n- type: input\n attributes:\n label: \"Enter input label here\"\n description: \"Enter input description here\"\n placeholder: \"Enter input placeholder here\"\n validations:\n required: true",
|
||||
}
|
||||
end.not_to change { FormTemplate.count }
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
expect(response.parsed_body["errors"]).to include(I18n.t("not_found"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#update" do
|
||||
fab!(:form_template) { Fabricate(:form_template) }
|
||||
|
||||
context "when logged in as an admin" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "updates a form template" do
|
||||
put "/admin/customize/form-templates/#{form_template.id}.json",
|
||||
params: {
|
||||
id: form_template.id,
|
||||
name: "Updated Template",
|
||||
template: "New yaml: true",
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
form_template.reload
|
||||
expect(form_template.name).to eq("Updated Template")
|
||||
expect(form_template.template).to eq("New yaml: true")
|
||||
end
|
||||
end
|
||||
|
||||
context "when logged in as a non-admin user" do
|
||||
before { sign_in(user) }
|
||||
|
||||
it "prevents update with a 404 response" do
|
||||
form_template.reload
|
||||
original_name = form_template.name
|
||||
|
||||
put "/admin/customize/form-templates/#{form_template.id}.json",
|
||||
params: {
|
||||
name: "Updated Template",
|
||||
template: "New yaml: true",
|
||||
}
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
expect(response.parsed_body["errors"]).to include(I18n.t("not_found"))
|
||||
|
||||
form_template.reload
|
||||
expect(form_template.name).to eq(original_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "#destroy" do
|
||||
fab!(:form_template) { Fabricate(:form_template) }
|
||||
|
||||
context "when logged in as an admin" do
|
||||
before { sign_in(admin) }
|
||||
|
||||
it "deletes a form template" do
|
||||
expect {
|
||||
delete "/admin/customize/form-templates/#{form_template.id}.json"
|
||||
expect(response.status).to eq(200)
|
||||
}.to change(FormTemplate, :count).by(-1)
|
||||
end
|
||||
end
|
||||
|
||||
context "when logged in as a non-admin user" do
|
||||
before { sign_in(user) }
|
||||
it "prevents deletion with a 404 response" do
|
||||
expect do
|
||||
delete "/admin/customize/form-templates/#{form_template.id}.json"
|
||||
end.not_to change { FormTemplate.count }
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
expect(response.parsed_body["errors"]).to include(I18n.t("not_found"))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,141 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
describe "Admin Customize Form Templates", type: :system, js: true do
|
||||
let(:form_template_page) { PageObjects::Pages::FormTemplate.new }
|
||||
let(:ace_editor) { PageObjects::Components::AceEditor.new }
|
||||
fab!(:admin) { Fabricate(:admin) }
|
||||
fab!(:form_template) { Fabricate(:form_template) }
|
||||
|
||||
before do
|
||||
SiteSetting.experimental_form_templates = true
|
||||
sign_in(admin)
|
||||
end
|
||||
|
||||
describe "when visiting the page to customize form templates" do
|
||||
it "should show the existing form templates in a table" do
|
||||
visit("/admin/customize/form-templates")
|
||||
expect(form_template_page).to have_form_template_table
|
||||
expect(form_template_page).to have_form_template(form_template.name)
|
||||
end
|
||||
|
||||
it "should show the form template structure in a modal" do
|
||||
visit("/admin/customize/form-templates")
|
||||
form_template_page.click_view_form_template
|
||||
expect(form_template_page).to have_template_structure("some yaml template: value")
|
||||
end
|
||||
end
|
||||
|
||||
describe "when visiting the page to edit a form template" do
|
||||
it "should prefill form data" do
|
||||
visit("/admin/customize/form-templates/#{form_template.id}")
|
||||
expect(form_template_page).to have_name_value(form_template.name)
|
||||
# difficult to test the ace editor content (todo later)
|
||||
end
|
||||
end
|
||||
|
||||
def quick_insertion_test(field_type, content)
|
||||
visit("/admin/customize/form-templates/new")
|
||||
form_template_page.type_in_template_name("New Template")
|
||||
form_template_page.click_quick_insert(field_type)
|
||||
expect(ace_editor).to have_text(content)
|
||||
end
|
||||
|
||||
describe "when visiting the page to create a new form template" do
|
||||
it "should allow admin to create a new form template" do
|
||||
visit("/admin/customize/form-templates/new")
|
||||
|
||||
sample_name = "My First Template"
|
||||
sample_template = "test: true"
|
||||
|
||||
form_template_page.type_in_template_name(sample_name)
|
||||
ace_editor.type_input(sample_template)
|
||||
form_template_page.click_save_button
|
||||
expect(form_template_page).to have_form_template(sample_name)
|
||||
end
|
||||
|
||||
it "should allow quick insertion of checkbox field" do
|
||||
quick_insertion_test(
|
||||
"checkbox",
|
||||
'- type: checkbox
|
||||
choices:
|
||||
- "Option 1"
|
||||
- "Option 2"
|
||||
- "Option 3"
|
||||
attributes:
|
||||
label: "Enter question here"
|
||||
description: "Enter description here"
|
||||
validations:
|
||||
required: true',
|
||||
)
|
||||
end
|
||||
|
||||
it "should allow quick insertion of short answer field" do
|
||||
quick_insertion_test(
|
||||
"input",
|
||||
'- type: input
|
||||
attributes:
|
||||
label: "Enter input label here"
|
||||
description: "Enter input description here"
|
||||
placeholder: "Enter input placeholder here"
|
||||
validations:
|
||||
required: true',
|
||||
)
|
||||
end
|
||||
|
||||
it "should allow quick insertion of long answer field" do
|
||||
quick_insertion_test(
|
||||
"textarea",
|
||||
'- type: textarea
|
||||
attributes:
|
||||
label: "Enter textarea label here"
|
||||
description: "Enter textarea description here"
|
||||
placeholder: "Enter textarea placeholder here"
|
||||
validations:
|
||||
required: true',
|
||||
)
|
||||
end
|
||||
|
||||
it "should allow quick insertion of dropdown field" do
|
||||
quick_insertion_test(
|
||||
"dropdown",
|
||||
'- type: dropdown
|
||||
choices:
|
||||
- "Option 1"
|
||||
- "Option 2"
|
||||
- "Option 3"
|
||||
attributes:
|
||||
label: "Enter dropdown label here"
|
||||
description: "Enter dropdown description here"
|
||||
validations:
|
||||
required: true',
|
||||
)
|
||||
end
|
||||
|
||||
it "should allow quick insertion of upload field" do
|
||||
quick_insertion_test(
|
||||
"upload",
|
||||
'- type: upload
|
||||
attributes:
|
||||
file_types: "jpg, png, gif"
|
||||
label: "Enter upload label here"
|
||||
description: "Enter upload description here"',
|
||||
)
|
||||
end
|
||||
|
||||
it "should allow quick insertion of multiple choice field" do
|
||||
quick_insertion_test(
|
||||
"multiselect",
|
||||
'- type: multiple_choice
|
||||
choices:
|
||||
- "Option 1"
|
||||
- "Option 2"
|
||||
- "Option 3"
|
||||
attributes:
|
||||
label: "Enter multiple choice label here"
|
||||
description: "Enter multiple choice description here"
|
||||
validations:
|
||||
required: true',
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PageObjects
|
||||
module Components
|
||||
class AceEditor < PageObjects::Components::Base
|
||||
def type_input(content)
|
||||
editor_input.send_keys(content)
|
||||
self
|
||||
end
|
||||
|
||||
def fill_input(content)
|
||||
editor_input.fill_in(with: content)
|
||||
self
|
||||
end
|
||||
|
||||
def clear_input
|
||||
fill_input("")
|
||||
end
|
||||
|
||||
def editor_input
|
||||
find(".ace-wrapper .ace_text-input", visible: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,42 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PageObjects
|
||||
module Pages
|
||||
class FormTemplate < PageObjects::Pages::Base
|
||||
# Form Template Index
|
||||
def has_form_template_table?
|
||||
page.has_selector?("table.form-templates--table")
|
||||
end
|
||||
|
||||
def click_view_form_template
|
||||
find(".form-templates--table tr:first-child .btn-view-template").click
|
||||
end
|
||||
|
||||
def has_form_template?(name)
|
||||
find(".form-templates--table tbody tr td", text: name).present?
|
||||
end
|
||||
|
||||
def has_template_structure?(structure)
|
||||
find("code", text: structure).present?
|
||||
end
|
||||
|
||||
# Form Template new/edit form related
|
||||
def type_in_template_name(input)
|
||||
find(".form-templates--form-name-input").send_keys(input)
|
||||
self
|
||||
end
|
||||
|
||||
def click_save_button
|
||||
find(".form-templates--form .footer-buttons .btn-primary").click
|
||||
end
|
||||
|
||||
def click_quick_insert(field_type)
|
||||
find(".form-templates--form .quick-insert-#{field_type}").click
|
||||
end
|
||||
|
||||
def has_name_value?(name)
|
||||
find(".form-templates--form-name-input").value == name
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue