FEATURE: Implement Form Template Preview (#32111)

![imagen](https://github.com/user-attachments/assets/db5cf334-6e92-40b6-b93a-5cfa12882e8f)
This commit is contained in:
Juan David Martínez Cubillos 2025-04-15 23:52:02 -05:00 committed by GitHub
parent a6a85a0241
commit c7d400eda2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 328 additions and 68 deletions

View File

@ -1,3 +1,4 @@
import { tracked } from "@glimmer/tracking";
import Component from "@ember/component";
import { hash } from "@ember/helper";
import EmberObject, { action, computed } from "@ember/object";
@ -11,12 +12,14 @@ import $ from "jquery";
import { resolveAllShortUrls } from "pretty-text/upload-short-url";
import { gt } from "truth-helpers";
import DEditor from "discourse/components/d-editor";
import DEditorPreview from "discourse/components/d-editor-preview";
import Wrapper from "discourse/components/form-template-field/wrapper";
import PickFilesButton from "discourse/components/pick-files-button";
import { ajax } from "discourse/lib/ajax";
import { tinyAvatar } from "discourse/lib/avatar-utils";
import { setupComposerPosition } from "discourse/lib/composer/composer-position";
import discourseComputed, { bind, debounce } from "discourse/lib/decorators";
import prepareFormTemplateData from "discourse/lib/form-template-validation";
import {
fetchUnseenHashtagsInContext,
linkSeenHashtagsInContext,
@ -28,6 +31,7 @@ import {
linkSeenMentions,
} from "discourse/lib/link-mentions";
import { loadOneboxes } from "discourse/lib/load-oneboxes";
import { generateCookFunction } from "discourse/lib/text";
import {
authorizesOneOrMoreImageExtensions,
IMAGE_MARKDOWN_REGEX,
@ -93,6 +97,8 @@ const DEBOUNCE_JIT_MS = 2000;
export default class ComposerEditor extends Component {
@service composer;
@tracked preview;
composerEventPrefix = "composer";
shouldBuildScrollMap = true;
scrollMap = null;
@ -941,6 +947,26 @@ export default class ComposerEditor extends Component {
this._selectedFormTemplateId = value;
}
@action
async updatePreviewFromForm() {
const formTemplateData = prepareFormTemplateData(
document.querySelector("#form-template-form"),
this.composer.selectedFormTemplate
);
if (formTemplateData) {
this.preview = await this.cachedCookAsync(
formTemplateData,
this.markdownOptions
);
}
}
async cachedCookAsync(text, options) {
this._cachedCookFunction ||= await generateCookFunction(options || {});
return await this._cachedCookFunction(text);
}
@action
updateSelectedFormTemplateId(formTemplateId) {
this.selectedFormTemplateId = formTemplateId;
@ -963,28 +989,35 @@ export default class ComposerEditor extends Component {
<template>
{{#if this.showFormTemplateForm}}
<div class="d-editor">
<div class="d-editor-container">
<div class="d-editor-textarea-column">
{{yield}}
<div class="d-editor-textarea-column">
{{yield}}
{{#if (gt this.composer.formTemplateIds.length 1)}}
<FormTemplateChooser
@filteredIds={{this.composer.formTemplateIds}}
@value={{this.selectedFormTemplateId}}
@onChange={{this.updateSelectedFormTemplateId}}
@options={{hash maximum=1}}
class="composer-select-form-template"
/>
{{/if}}
<form id="form-template-form">
<Wrapper
@id={{this.selectedFormTemplateId}}
@initialValues={{this.composer.formTemplateInitialValues}}
@onSelectFormTemplate={{this.composer.onSelectFormTemplate}}
/>
</form>
</div>
{{#if (gt this.composer.formTemplateIds.length 1)}}
<FormTemplateChooser
@filteredIds={{this.composer.formTemplateIds}}
@value={{this.selectedFormTemplateId}}
@onChange={{this.updateSelectedFormTemplateId}}
@options={{hash maximum=1}}
class="composer-select-form-template"
/>
{{/if}}
<form id="form-template-form">
<Wrapper
@id={{this.selectedFormTemplateId}}
@initialValues={{this.composer.formTemplateInitialValues}}
@onSelectFormTemplate={{this.composer.onSelectFormTemplate}}
@onChange={{this.updatePreviewFromForm}}
/>
</form>
</div>
{{#if this.siteSettings.show_preview_for_form_templates}}
<DEditorPreview
@preview={{this.preview}}
@forcePreview={{this.forcePreview}}
@onPreviewUpdated={{this.previewUpdated}}
@outletArgs={{this.outletArgs}}
/>
{{/if}}
</div>
{{else}}
<DEditor

View File

@ -0,0 +1,62 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { htmlSafe } from "@ember/template";
import DecoratedHtml from "discourse/components/decorated-html";
import PluginOutlet from "discourse/components/plugin-outlet";
import { wantsNewWindow } from "discourse/lib/intercept-click";
export default class DEditorPreview extends Component {
@action
handlePreviewClick(event) {
if (!event.target.closest(".d-editor-preview")) {
return;
}
if (wantsNewWindow(event)) {
return;
}
if (event.target.tagName === "A") {
if (event.target.classList.contains("mention")) {
this.appEvents.trigger(
"d-editor:preview-click-user-card",
event.target,
event
);
}
if (event.target.classList.contains("mention-group")) {
this.appEvents.trigger(
"d-editor:preview-click-group-card",
event.target,
event
);
}
event.preventDefault();
return false;
}
}
<template>
{{! template-lint-disable no-invalid-interactive }}
<div
class="d-editor-preview-wrapper {{if @forcePreview 'force-preview'}}"
{{on "click" this.handlePreviewClick}}
>
<DecoratedHtml
@className="d-editor-preview"
@html={{htmlSafe @preview}}
@decorate={{@onPreviewUpdated}}
/>
<span class="d-editor-plugin">
<PluginOutlet
@name="editor-preview"
@connectorTagName="div"
@outletArgs={{@outletArgs}}
/>
</span>
</div>
</template>
}

View File

@ -15,12 +15,11 @@ import TextareaEditor from "discourse/components/composer/textarea-editor";
import ToggleSwitch from "discourse/components/composer/toggle-switch";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import DecoratedHtml from "discourse/components/decorated-html";
import DEditorPreview from "discourse/components/d-editor-preview";
import EmojiPickerDetached from "discourse/components/emoji-picker/detached";
import InsertHyperlink from "discourse/components/modal/insert-hyperlink";
import PluginOutlet from "discourse/components/plugin-outlet";
import PopupInputTip from "discourse/components/popup-input-tip";
import htmlSafe from "discourse/helpers/html-safe";
import { SKIP } from "discourse/lib/autocomplete";
import renderEmojiAutocomplete from "discourse/lib/autocomplete/emoji";
import userAutocomplete from "discourse/lib/autocomplete/user";
@ -814,25 +813,12 @@ export default class DEditor extends Component {
</div>
</div>
{{! template-lint-disable no-invalid-interactive }}
<div
class="d-editor-preview-wrapper
{{if this.forcePreview 'force-preview'}}"
{{on "click" this.handlePreviewClick}}
>
<DecoratedHtml
@className="d-editor-preview"
@html={{htmlSafe this.preview}}
@decorate={{this.previewUpdated}}
/>
<span class="d-editor-plugin">
<PluginOutlet
@name="editor-preview"
@connectorTagName="div"
@outletArgs={{this.outletArgs}}
/>
</span>
</div>
<DEditorPreview
@preview={{this.preview}}
@forcePreview={{this.forcePreview}}
@onPreviewUpdated={{this.previewUpdated}}
@outletArgs={{this.outletArgs}}
/>
</div>
</template>
}

View File

@ -1,4 +1,5 @@
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { htmlSafe } from "@ember/template";
import icon from "discourse/helpers/d-icon";
@ -11,6 +12,7 @@ const Checkbox = <template>
@checked={{@value}}
@type="checkbox"
required={{if @validations.required "required" ""}}
{{on "input" @onChange}}
/>
{{@attributes.label}}
{{#if @validations.required}}

View File

@ -1,3 +1,4 @@
import { on } from "@ember/modifier";
import { htmlSafe } from "@ember/template";
import { eq } from "truth-helpers";
import icon from "discourse/helpers/d-icon";
@ -23,6 +24,7 @@ const Dropdown = <template>
name={{@id}}
class="form-template-field__dropdown"
required={{if @validations.required "required" ""}}
{{on "input" @onChange}}
>
{{#if @attributes.none_label}}
<option

View File

@ -1,4 +1,5 @@
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import { htmlSafe } from "@ember/template";
import icon from "discourse/helpers/d-icon";
@ -30,6 +31,7 @@ const Input0 = <template>
minlength={{@validations.minimum}}
maxlength={{@validations.maximum}}
disabled={{@attributes.disabled}}
{{on "input" @onChange}}
/>
</div>
</template>;

View File

@ -1,4 +1,5 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { htmlSafe } from "@ember/template";
import icon from "discourse/helpers/d-icon";
@ -34,6 +35,7 @@ export default class FormTemplateFieldMultiSelect extends Component {
required={{if @validations.required "required" ""}}
multiple="multiple"
class="form-template-field__multi-select"
{{on "input" @onChange}}
>
{{#if @attributes.none_label}}
<option

View File

@ -1,4 +1,5 @@
import { Textarea } from "@ember/component";
import { on } from "@ember/modifier";
import { htmlSafe } from "@ember/template";
import icon from "discourse/helpers/d-icon";
@ -28,6 +29,7 @@ const Textarea0 = <template>
minlength={{@validations.minimum}}
maxlength={{@validations.maximum}}
required={{if @validations.required "required" ""}}
{{on "input" @onChange}}
/>
</div>
</template>;

View File

@ -1,6 +1,7 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { getOwner } from "@ember/owner";
import { next } from "@ember/runloop";
import { dasherize } from "@ember/string";
import { htmlSafe } from "@ember/template";
import PickFilesButton from "discourse/components/pick-files-button";
@ -12,7 +13,8 @@ import UppyUpload from "discourse/lib/uppy/uppy-upload";
export default class FormTemplateFieldUpload extends Component {
@tracked uploadValue;
@tracked uploadedFiles = [];
@tracked fileUploadElementId = `${dasherize(this.args.id)}-uploader`;
@tracked
fileUploadElementId = `${dasherize(this.args.id.toString())}-uploader`;
@tracked fileInputSelector = `#${this.fileUploadElementId}`;
uppyUpload = new UppyUpload(getOwner(this), {
@ -70,6 +72,10 @@ export default class FormTemplateFieldUpload extends Component {
// single file upload
this.uploadValue = uploadMarkdown;
}
next(this, () => {
this.args.onChange(this.uploadValue);
});
}
buildMarkdown(upload) {

View File

@ -2,6 +2,8 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action, get } from "@ember/object";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import Yaml from "js-yaml";
import FormTemplate from "discourse/models/form-template";
import CheckboxField from "./checkbox";
@ -18,10 +20,14 @@ const FormTemplateField = <template>
@choices={{@content.choices}}
@validations={{@content.validations}}
@value={{@initialValue}}
@onChange={{@onChange}}
/>
</template>;
export default class FormTemplateFieldWrapper extends Component {
@service composer;
@service siteSettings;
@tracked error = null;
@tracked parsedTemplate = null;
@ -46,6 +52,13 @@ export default class FormTemplateFieldWrapper extends Component {
} else if (this.args.id) {
this._fetchTemplate(this.args.id);
}
next(this, () => {
this.composer.set(
"allowPreview",
this.siteSettings.show_preview_for_form_templates
);
});
}
_loadTemplate(templateContent) {
@ -84,6 +97,7 @@ export default class FormTemplateFieldWrapper extends Component {
@component={{get this.fieldTypes content.type}}
@content={{content}}
@initialValue={{get this.initialValues content.id}}
@onChange={{@onChange}}
/>
{{/each}}
</div>

View File

@ -1,6 +1,7 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import Checkbox from "discourse/components/form-template-field/checkbox";
import noop from "discourse/helpers/noop";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module(
@ -9,7 +10,7 @@ module(
setupRenderingTest(hooks);
test("renders a checkbox input", async function (assert) {
await render(<template><Checkbox /></template>);
await render(<template><Checkbox @onChange={{noop}} /></template>);
assert
.dom(
@ -24,7 +25,9 @@ module(
};
await render(
<template><Checkbox @attributes={{attributes}} /></template>
<template>
<Checkbox @attributes={{attributes}} @onChange={{noop}} />
</template>
);
assert

View File

@ -1,6 +1,7 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import Dropdown from "discourse/components/form-template-field/dropdown";
import noop from "discourse/helpers/noop";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { queryAll } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
@ -20,7 +21,11 @@ module(
const choices = ["Choice 1", "Choice 2", "Choice 3"];
this.set("choices", choices);
await render(<template><Dropdown @choices={{self.choices}} /></template>);
await render(
<template>
<Dropdown @choices={{self.choices}} @onChange={{noop}} />
</template>
);
assert
.dom(".form-template-field__dropdown")
.exists("a dropdown component exists");
@ -54,7 +59,11 @@ module(
await render(
<template>
<Dropdown @choices={{self.choices}} @attributes={{self.attributes}} />
<Dropdown
@choices={{self.choices}}
@attributes={{self.attributes}}
@onChange={{noop}}
/>
</template>
);
assert
@ -72,7 +81,11 @@ module(
const choices = ["Choice 1", "Choice 2", "Choice 3"];
this.set("choices", choices);
await render(<template><Dropdown @choices={{self.choices}} /></template>);
await render(
<template>
<Dropdown @choices={{self.choices}} @onChange={{noop}} />
</template>
);
assert.dom(".form-template-field__label").doesNotExist();
});

View File

@ -1,6 +1,7 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import FormInput from "discourse/components/form-template-field/input";
import noop from "discourse/helpers/noop";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module(
@ -9,7 +10,7 @@ module(
setupRenderingTest(hooks);
test("renders a text input", async function (assert) {
await render(<template><FormInput /></template>);
await render(<template><FormInput @onChange={{noop}} /></template>);
assert
.dom(".form-template-field[data-field-type='input'] input[type='text']")
@ -23,7 +24,9 @@ module(
};
await render(
<template><FormInput @attributes={{attributes}} /></template>
<template>
<FormInput @attributes={{attributes}} @onChange={{noop}} />
</template>
);
assert
@ -42,7 +45,9 @@ module(
};
await render(
<template><FormInput @attributes={{attributes}} /></template>
<template>
<FormInput @attributes={{attributes}} @onChange={{noop}} />
</template>
);
assert.dom(".form-template-field__label").doesNotExist();
@ -54,7 +59,9 @@ module(
};
await render(
<template><FormInput @attributes={{attributes}} /></template>
<template>
<FormInput @attributes={{attributes}} @onChange={{noop}} />
</template>
);
assert.dom(".form-template-field__description").hasText("Your full name");
@ -66,7 +73,9 @@ module(
};
await render(
<template><FormInput @attributes={{attributes}} /></template>
<template>
<FormInput @attributes={{attributes}} @onChange={{noop}} />
</template>
);
assert

View File

@ -1,6 +1,7 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import MultiSelect from "discourse/components/form-template-field/multi-select";
import noop from "discourse/helpers/noop";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { queryAll } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
@ -22,7 +23,9 @@ module(
this.set("choices", choices);
await render(
<template><MultiSelect @choices={{self.choices}} /></template>
<template>
<MultiSelect @choices={{self.choices}} @onChange={{noop}} />
</template>
);
assert
.dom(".form-template-field__multi-select")
@ -60,6 +63,7 @@ module(
<MultiSelect
@choices={{self.choices}}
@attributes={{self.attributes}}
@onChange={{noop}}
/>
</template>
);
@ -79,7 +83,9 @@ module(
this.set("choices", choices);
await render(
<template><MultiSelect @choices={{self.choices}} /></template>
<template>
<MultiSelect @choices={{self.choices}} @onChange={{noop}} />
</template>
);
assert.dom(".form-template-field__label").doesNotExist();

View File

@ -1,6 +1,7 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import FormTextarea from "discourse/components/form-template-field/textarea";
import noop from "discourse/helpers/noop";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module(
@ -9,7 +10,7 @@ module(
setupRenderingTest(hooks);
test("renders a textarea input", async function (assert) {
await render(<template><FormTextarea /></template>);
await render(<template><FormTextarea @onChange={{noop}} /></template>);
assert
.dom(".form-template-field__textarea")
@ -26,7 +27,9 @@ module(
this.set("attributes", attributes);
await render(
<template><FormTextarea @attributes={{self.attributes}} /></template>
<template>
<FormTextarea @attributes={{self.attributes}} @onChange={{noop}} />
</template>
);
assert
@ -48,7 +51,9 @@ module(
this.set("attributes", attributes);
await render(
<template><FormTextarea @attributes={{self.attributes}} /></template>
<template>
<FormTextarea @attributes={{self.attributes}} @onChange={{noop}} />
</template>
);
assert.dom(".form-template-field__label").doesNotExist();

View File

@ -1,6 +1,7 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import Wrapper from "discourse/components/form-template-field/wrapper";
import noop from "discourse/helpers/noop";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import pretender, { response } from "discourse/tests/helpers/create-pretender";
@ -13,7 +14,11 @@ module(
const self = this;
this.set("content", `- type: checkbox\n attributes;invalid`);
await render(<template><Wrapper @content={{self.content}} /></template>);
await render(
<template>
<Wrapper @content={{self.content}} @onChange={{noop}} />
</template>
);
assert
.dom(".form-template-field")
@ -40,7 +45,11 @@ module(
];
this.set("content", content);
await render(<template><Wrapper @content={{self.content}} /></template>);
await render(
<template>
<Wrapper @content={{self.content}} @onChange={{noop}} />
</template>
);
componentTypes.forEach((componentType) => {
assert
@ -72,6 +81,7 @@ module(
<Wrapper
@content={{self.content}}
@initialValues={{self.initialValues}}
@onChange={{noop}}
/>
</template>
);
@ -99,7 +109,9 @@ module(
this.set("formTemplateId", [1]);
await render(
<template><Wrapper @id={{self.formTemplateId}} /></template>
<template>
<Wrapper @id={{self.formTemplateId}} @onChange={{noop}} />
</template>
);
assert

View File

@ -63,16 +63,9 @@ html.composer-open {
overflow: auto;
flex: 1;
.toggle-preview,
#mobile-file-upload,
.submit-panel .mobile-preview {
#mobile-file-upload {
display: none;
}
.d-editor-preview-wrapper {
display: none;
flex: 0;
}
}
.saving-text,

View File

@ -2760,6 +2760,7 @@ en:
glimmer_post_stream_mode: "Control whether the new 'glimmer' post stream implementation is used. 'auto' will enable automatically once all your themes and plugins are ready. This implementation is under active development, and is not intended for production use. Do not develop themes/plugins against it until the implementation is finalized and announced."
glimmer_post_stream_mode_auto_groups: "Enable the new 'glimmer' post menu implementation in 'auto' mode for the specified user groups. This implementation is under active development, and is not intended for production use. Do not develop themes/plugins against it until the implementation is finalized and announced."
experimental_form_templates: "Enable the form templates feature. Manage the templates at <a href='%{base_path}/admin/customize/form-templates'>Customize / Templates</a>."
show_preview_for_form_templates: "Enable the preview for form templates feature"
lazy_load_categories_groups: "Lazy load category information only for users of these groups. This improves performance on sites with many categories."
experimental_auto_grid_images: "Automatically wraps images in [grid] tags when 3 or more images are uploaded in the composer."
experimental_admin_search_enabled_groups: "Enable the new admin search for users in these groups. This will enable the use of the keyboard shortcut to open the admin search, as well as visiting <a href='%{base_path}/admin/search'>the full page search</a>."

View File

@ -3925,6 +3925,9 @@ experimental:
experimental_form_templates:
client: true
default: false
show_preview_for_form_templates:
client: true
default: true
experimental_new_new_view_groups:
client: true
type: group_list

View File

@ -81,6 +81,54 @@ describe "Composer Form Templates", type: :system do
required: true"),
)
end
fab!(:form_template_7) do
Fabricate(
:form_template,
name: "Preview Test",
template:
%Q(
- type: checkbox
id: 1
attributes:
label: "checkbox"
- type: input
id: 2
attributes:
label: "input"
placeholder: "Enter placeholder here"
- type: textarea
id: 3
attributes:
label: "textarea"
placeholder: "Enter placeholder here"
- type: dropdown
id: 4
choices:
- "Option 1"
- "Option 2"
- "Option 3"
attributes:
none_label: "Select an item"
label: "dropdown"
- type: upload
id: 5
attributes:
file_types: ".jpg, .png, .gif"
allow_multiple: false
label: "upload"
- type: multi-select
id: 6
choices:
- "Option 4"
- "Option 5"
- "Option 6"
attributes:
none_label: "Select an item"
label: "multi-select"
),
)
end
fab!(:category_with_template_1) do
Fabricate(
:category,
@ -99,6 +147,15 @@ describe "Composer Form Templates", type: :system do
form_template_ids: [form_template_2.id],
)
end
fab!(:category_with_template_7) do
Fabricate(
:category,
name: "Preview Test",
slug: "preview_test",
topic_count: 2,
form_template_ids: [form_template_7.id],
)
end
fab!(:category_with_multiple_templates_1) do
Fabricate(
:category,
@ -260,12 +317,20 @@ describe "Composer Form Templates", type: :system do
end
it "hides the preview when a category with a form template is selected" do
SiteSetting.show_preview_for_form_templates = false
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 preview 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_composer_preview
expect(composer).to have_composer_preview_toggle
end
it "shows the correct template when switching categories" do
category_page.visit(category_no_template)
category_page.new_topic_button.click
@ -432,4 +497,43 @@ describe "Composer Form Templates", type: :system do
expect(composer).to have_form_template_field_label("Prescription")
expect(composer).to have_form_template_field_description("Upload your prescription")
end
it "shows preview of the form correctly for all input types" do
topic_title = "A topic about Batman"
category_page.visit(category_with_template_7)
category_page.new_topic_button.click
composer.fill_title(topic_title)
preview = find(".d-editor-preview")
composer.fill_form_template_field("input", "Peter Parker")
expect(preview).to have_content("Peter Parker")
dropdown = find("[name='4']")
dropdown.click
dropdown.send_keys(:arrow_down)
dropdown.send_keys(:enter)
dropdown.click
dropdown.send_keys(:arrow_up)
dropdown.send_keys(:enter)
expect(preview).to have_content("Option 1")
multi_select = find("[name='6']")
multi_select.find("option", text: "Option 4").click(:control)
expect(preview).to have_content("Option 4")
textarea = find("textarea")
message = "This is a test message!"
textarea.fill_in(with: message)
preview = find(".d-editor-preview")
expect(preview).to have_content(message)
attach_file "5-uploader", "#{Rails.root}/spec/fixtures/images/logo.png", make_visible: true
expect(preview).to have_css("img")
end
end