Add markdown toolbar to comments (#18062)

* show the toolbar (WIP - includes some hacks and todos)

* allow calling component to pass additional toolbar elements

* add templates behaviour

* get templates working

* tweaks following mention autocomplete refactor

* remove image upload handler no longer needed

* rename method

* some small refactors

* only comment text area controls visibility of templates area

* fix error in role name

* fix mobile view

* update specs
This commit is contained in:
Suzanne Aitchison 2022-07-12 09:02:53 +01:00 committed by GitHub
parent 04dba9d05c
commit 2f436731a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 201 additions and 261 deletions

View file

@ -329,7 +329,7 @@ function handleFocus(event) {
form.classList.add("comment-form--initiated");
}
handleSizeChange(event);
window.Forem.initializeMentionAutocompleteTextArea(area);
window.Forem.initializeEnhancedCommentTextArea(area);
}
}
@ -487,71 +487,6 @@ function generateUploadFormdata(image) {
return formData;
}
function handleImageUpload(event, randomIdNumber) {
var commentableId = document.getElementById('comments-container').dataset.commentableId;
event.preventDefault();
document.getElementById('image-upload-' + randomIdNumber).click();
document.getElementById('image-upload-' + randomIdNumber).onchange = function (e) {
var image = document.getElementById('image-upload-' + randomIdNumber).files;
if (image.length > 0) {
document.getElementById("image-upload-file-label-" + randomIdNumber).style.color = '#888888';
document.getElementById("image-upload-file-label-" + randomIdNumber).innerHTML = "Uploading...";
document.getElementById("image-upload-submit-" + randomIdNumber).value = "uploading";
setTimeout(function () {
document.getElementById("image-upload-submit-" + randomIdNumber).click(function () { });
}, 50)
}
}
document.getElementById("image-upload-submit-" + randomIdNumber).onclick = function (e) {
e.preventDefault();
var image = document.getElementById('image-upload-' + randomIdNumber).files;
if (image.length > 0) {
getCsrfToken()
.then(sendFetch("image-upload", generateUploadFormdata(image)))
.then(function (response) {
if (response.status === 200) {
response.json().then(
function uploadImageCb(json) {
var address = document.getElementById("uploaded-image-" + randomIdNumber);
var button = document.getElementById("image-upload-button-" + randomIdNumber);
var messageContainer = document.getElementById("image-upload-file-label-" + randomIdNumber)
// button.style.display = "none";
messageContainer.style.display = "none";
address.value = json.links[0];
address.classList.remove("hidden");;
address.select();
var uploadedMessage = 'Uploaded! Paste into editor';
messageContainer.innerHTML = uploadedMessage;
messageContainer.style.color = '#00c673';
messageContainer.style.position = "relative";
messageContainer.style.top = "5px";
}
);
} else if (response.status === 429) {
showRateLimitModal({
response,
element: 'image',
action_ing: 'uploading',
action_past: 'uploaded',
});
} else {
response.json().then(function(responseBody) {
var errorMessage = responseBody.error || 'Invalid file!';
document.getElementById("image-upload-file-label-" + randomIdNumber).innerHTML = errorMessage;
document.getElementById("image-upload-file-label-" + randomIdNumber).style.color = '#e05252';
document.getElementById("image-upload-submit-" + randomIdNumber).style.display = 'none';
});
}
})
.catch(function (err) {
// there's currently no error handling
})
}
}
}
function updateItemSummaryHtml(item) {
var itemSummaryContent = item.getElementsByClassName("js-collapse-comment-content")[0];
var usernames = item.getElementsByClassName("js-comment-username");

View file

@ -19,25 +19,6 @@ function buildCommentFormHTML(commentableId, commentableType, parentId) {
<div class="comment-form__inner">
<div class="comment-form__field">
<textarea id="textarea-for-${parentId}" class="crayons-textfield crayons-textfield--ghost comment-textarea" name="comment[body_markdown]" data-tracking-name="comment_form_textfield" placeholder="Reply..." aria-label="Reply to a comment..." required="required" onkeydown="handleKeyDown(event)" onfocus="handleFocus(event)" oninput="handleChange(event)" onkeyup="handleKeyUp(event)"></textarea>
<div class="comment-form__toolbar">
<div class="editor-image-upload">
<input type="file" id="image-upload-${randomIdNumber}" name="file" accept="image/*" style="display:none">
<button type="button" class="crayons-btn crayons-btn--s crayons-btn--icon-left crayons-btn--ghost-dimmed" onclick="handleImageUpload(event, ${randomIdNumber})" id="image-upload-button-${randomIdNumber}">
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="crayons-icon"><path d="M20 5H4v14l9.292-9.294a1 1 0 011.414 0L20 15.01V5zM2 3.993A1 1 0 012.992 3h18.016c.548 0 .992.445.992.993v16.014a1 1 0 01-.992.993H2.992A.993.993 0 012 20.007V3.993zM8 11a2 2 0 110-4 2 2 0 010 4z"/></svg>
<span class="hidden s:inline-block">Upload image</span>
</button>
<label class="image-upload-file-label" id="image-upload-file-label-${randomIdNumber}"></label>
<input type="submit" id="image-upload-submit-${randomIdNumber}" value="Upload" style="display:none">
<input class="crayons-textfield fs-s w-auto uploaded-image hidden" type="text" id="uploaded-image-${randomIdNumber}" />
</div>
<button type="button" class="crayons-btn crayons-btn--s crayons-btn--icon-left crayons-btn--ghost-dimmed response-templates-button" title="Use a response template" data-has-listener="false">
<svg width="24" height="24" class="crayons-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 18.5V5a3 3 0 013-3h14a1 1 0 011 1v18a1 1 0 01-1 1H6.5A3.5 3.5 0 013 18.5zM19 20v-3H6.5a1.5 1.5 0 100 3H19zM10 4H6a1 1 0 00-1 1v10.337A3.485 3.485 0 016.5 15H19V4h-2v8l-3.5-2-3.5 2V4z"/></svg>
<span class="hidden s:inline-block">Templates</span>
</button>
<a href="/p/editor_guide" class="crayons-btn crayons-btn--ghost-dimmed crayons-btn--icon crayons-btn--s ml-auto" target="_blank" rel="noopener" title="Markdown Guide">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="crayons-icon"><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 100-16 8 8 0 000 16zM11 7h2v2h-2V7zm0 4h2v6h-2v-6z"/></svg>
</a>
</div>
</div>
<div class="response-templates-container crayons-card crayons-card--secondary p-4 mb-4 fs-base comment-form__templates hidden">
<header>

View file

@ -165,6 +165,10 @@
calc(var(--content-padding-x) * -1) var(--su-6)
calc(var(--content-padding-x) * -1);
.editor-toolbar {
position: relative;
}
> :first-child {
@media (min-width: $breakpoint-m) {
margin-left: calc(var(--su-2) * -1);

View file

@ -24,6 +24,16 @@
border-color: var(--form-border-focus);
box-shadow: 0 0 0 1px var(--form-border-focus);
}
.editor-toolbar {
border-top: 1px solid var(--form-border);
overflow-x: auto;
&::-webkit-scrollbar {
background: transparent;
height: 0;
}
}
}
&__inner {

View file

@ -1,7 +1,8 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { ImageUploader } from './ImageUploader';
import { MarkdownToolbar } from '@crayons/MarkdownToolbar';
import { MarkdownToolbar, Link } from '@crayons';
import HelpIcon from '@images/help.svg';
export const Toolbar = ({ version, textAreaId }) => {
return (
@ -13,7 +14,20 @@ export const Toolbar = ({ version, textAreaId }) => {
{version === 'v1' ? (
<ImageUploader editorVersion={version} />
) : (
<MarkdownToolbar textAreaId={textAreaId} />
<MarkdownToolbar
textAreaId={textAreaId}
additionalSecondaryToolbarElements={[
<Link
key="help-link"
block
href="/p/editor_guide"
target="_blank"
rel="noopener noreferrer"
icon={HelpIcon}
aria-label="Help"
/>,
]}
/>
)}
</div>
);

View file

@ -1,4 +1,4 @@
import { h } from 'preact';
import { h, cloneElement } from 'preact';
import { useState, useLayoutEffect, useRef } from 'preact/hooks';
import { ImageUploader } from '../../article-form/components/ImageUploader';
import {
@ -7,8 +7,7 @@ import {
getNewTextAreaValueWithEdits,
} from './markdownSyntaxFormatters';
import OverflowIcon from '@images/overflow-vertical.svg';
import HelpIcon from '@images/help.svg';
import { ButtonNew as Button, Link } from '@crayons';
import { ButtonNew as Button } from '@crayons';
import { KeyboardShortcuts } from '@components/useKeyboardShortcuts';
import { BREAKPOINTS, useMediaQuery } from '@components/useMediaQuery';
import { getSelectionData } from '@utilities/textAreaUtils';
@ -58,13 +57,27 @@ const getPreviousMatchingSibling = (element, selector) => {
* @param {object} props
* @param {string} props.textAreaId The ID of the textarea the markdown formatting should be added to
*/
export const MarkdownToolbar = ({ textAreaId }) => {
export const MarkdownToolbar = ({
textAreaId,
additionalSecondaryToolbarElements = [],
}) => {
const textAreaRef = useRef(null);
const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
const [storedCursorPosition, setStoredCursorPosition] = useState({});
const smallScreen = useMediaQuery(`(max-width: ${BREAKPOINTS.Medium - 1}px)`);
// Enhance any additional toolbar elements with the appropriate roles & listeners
const additionalSecondaryItems = additionalSecondaryToolbarElements.map(
(SecondaryItem) =>
cloneElement(SecondaryItem, {
role: 'menuitem',
className: 'overflow-menu-btn',
tabindex: '-1',
onKeyUp: (e) => handleToolbarButtonKeyPress(e, 'overflow-menu-btn'),
}),
);
const markdownSyntaxFormatters = {
...coreSyntaxFormatters,
...secondarySyntaxFormatters,
@ -325,7 +338,7 @@ export const MarkdownToolbar = ({ textAreaId }) => {
return (
<div
className="editor-toolbar relative"
className="editor-toolbar"
aria-label="Markdown formatting toolbar"
role="toolbar"
aria-controls={textAreaId}
@ -382,7 +395,9 @@ export const MarkdownToolbar = ({ textAreaId }) => {
{smallScreen ? getSecondaryFormatterButtons(false) : null}
{smallScreen ? null : (
{smallScreen ? (
additionalSecondaryItems
) : (
<Button
id="overflow-menu-button"
onClick={() => setOverflowMenuOpen(!overflowMenuOpen)}
@ -403,18 +418,7 @@ export const MarkdownToolbar = ({ textAreaId }) => {
className="crayons-dropdown flex p-2 min-w-unset right-0 top-100"
>
{getSecondaryFormatterButtons(true)}
<Link
block
role="menuitem"
href="/p/editor_guide"
target="_blank"
rel="noopener noreferrer"
icon={HelpIcon}
className="overflow-menu-btn"
tabindex="-1"
aria-label="Help"
onKeyUp={(e) => handleToolbarButtonKeyPress(e, 'overflow-menu-btn')}
/>
{additionalSecondaryItems}
</div>
)}
{textAreaRef.current && (

View file

@ -44,7 +44,7 @@ describe('<MarkdownToolbar />', () => {
expect(getByLabelText('Embed')).toBeInTheDocument();
});
it('should render an overflow menu with secondary formatters and help link', async () => {
it('should render an overflow menu with secondary formatters', async () => {
const { getByLabelText } = render(<MarkdownToolbar />);
getByLabelText('More options').click();
@ -54,6 +54,25 @@ describe('<MarkdownToolbar />', () => {
);
expect(getByLabelText('Strikethrough')).toBeInTheDocument();
expect(getByLabelText('Line divider')).toBeInTheDocument();
expect(getByLabelText('Help')).toBeInTheDocument();
});
it('should render any custom secondary toolbar elements', async () => {
const exampleLink = <a href="/something">Some link</a>;
const exampleButton = <button>Some button</button>;
const { getByLabelText, getByRole } = render(
<MarkdownToolbar
additionalSecondaryToolbarElements={[exampleButton, exampleLink]}
/>,
);
getByLabelText('More options').click();
await waitFor(() =>
expect(getByLabelText('Underline')).toBeInTheDocument(),
);
expect(getByRole('menuitem', { name: 'Some link' })).toBeInTheDocument();
expect(getByRole('menuitem', { name: 'Some button' })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,74 @@
import { h } from 'preact';
import { useState } from 'preact/hooks';
import { populateTemplates } from '../../responseTemplates/responseTemplates';
import {
AutocompleteTriggerTextArea,
MarkdownToolbar,
Link,
ButtonNew as Button,
} from '@crayons';
import { fetchSearch } from '@utilities/search';
import HelpIcon from '@images/help.svg';
import Templates from '@images/templates.svg';
const getClosestTemplatesContainer = (element) =>
element
.closest('.comment-form__inner')
?.querySelector('.response-templates-container');
export const CommentTextArea = ({ vanillaTextArea }) => {
const [templatesVisible, setTemplatesVisible] = useState(false);
// Templates appear outside of the comment textarea, but we only want to load this data if it's requested by the user
const handleTemplatesClick = ({ target }) => {
const templatesContainer = getClosestTemplatesContainer(target);
const relatedForm = target.closest('form');
if (templatesContainer && relatedForm) {
populateTemplates(relatedForm, () => {
setTemplatesVisible(false);
templatesContainer.classList.add('hidden');
});
templatesContainer.classList.toggle('hidden');
setTemplatesVisible(!templatesVisible);
}
};
return (
<div className="w-100 relative">
<AutocompleteTriggerTextArea
triggerCharacter="@"
maxSuggestions={6}
searchInstructionsMessage="Type to search for a user"
replaceElement={vanillaTextArea}
fetchSuggestions={(username) =>
fetchSearch('usernames', { username }).then(({ result }) =>
result.map((user) => ({ ...user, value: user.username })),
)
}
/>
<MarkdownToolbar
textAreaId={vanillaTextArea.id}
additionalSecondaryToolbarElements={[
<Button
key="templates-btn"
onClick={handleTemplatesClick}
icon={Templates}
aria-label="Show templates"
aria-pressed={templatesVisible}
/>,
<Link
key="help-link"
block
href="/p/editor_guide"
target="_blank"
rel="noopener noreferrer"
icon={HelpIcon}
aria-label="Help"
/>,
]}
/>
</div>
);
};

View file

@ -17,21 +17,19 @@ window.Forem = {
}
return this.preactImport;
},
mentionAutoCompleteImports: undefined,
getMentionAutoCompleteImports() {
if (!this.mentionAutoCompleteImports) {
this.mentionAutoCompleteImports = [
import('@crayons/AutocompleteTriggerTextArea'),
import('@utilities/search'),
this.getPreactImport(),
];
enhancedCommentTextAreaImport: undefined,
getEnhancedCommentTextAreaImports() {
if (!this.enhancedCommentTextAreaImport) {
this.enhancedCommentTextAreaImport = import(
'./CommentTextArea/CommentTextArea'
);
}
// We're still returning Promises, but if the they have already been imported
// they will now be fulfilled instead of pending, i.e. a network request is no longer made.
return Promise.all(this.mentionAutoCompleteImports);
return Promise.all([
this.enhancedCommentTextAreaImport,
this.getPreactImport(),
]);
},
initializeMentionAutocompleteTextArea: async (originalTextArea) => {
initializeEnhancedCommentTextArea: async (originalTextArea) => {
const parentContainer = originalTextArea.parentElement;
const alreadyInitialized =
@ -41,21 +39,11 @@ window.Forem = {
return;
}
const [{ AutocompleteTriggerTextArea }, { fetchSearch }, { render, h }] =
await window.Forem.getMentionAutoCompleteImports();
const [{ CommentTextArea }, { render, h }] =
await window.Forem.getEnhancedCommentTextAreaImports();
render(
<AutocompleteTriggerTextArea
replaceElement={originalTextArea}
maxSuggestions={6}
fetchSuggestions={(username) =>
fetchSearch('usernames', { username }).then(({ result }) =>
result.map((user) => ({ ...user, value: user.username })),
)
}
searchInstructionsMessage="Type to search for a user"
triggerCharacter="@"
/>,
<CommentTextArea vanillaTextArea={originalTextArea} />,
parentContainer,
originalTextArea,
);

View file

@ -1,7 +0,0 @@
import { loadResponseTemplates } from '../responseTemplates/responseTemplates';
window.InstantClick.on('change', () => {
loadResponseTemplates();
});
loadResponseTemplates();

View file

@ -1,7 +1,3 @@
/* eslint-disable no-alert */
/* eslint-disable no-restricted-globals */
/* global showLoginModal */
function toggleTemplateTypeButton(form, e) {
const { targetType } = e.target.dataset;
const activeType = targetType === 'personal' ? 'moderator' : 'personal';
@ -110,7 +106,7 @@ Make sure this is the appropriate comment for the situation.
This action is not reversible.`;
function addClickListeners(form) {
function addClickListeners(form, onTemplateSelected) {
const responsesContainer = form.getElementsByClassName(
'response-templates-container',
)[0];
@ -126,10 +122,8 @@ function addClickListeners(form) {
insertButtons.forEach((button) => {
button.addEventListener('click', (event) => {
const { content } = event.target.dataset;
// We need to grab the textarea that is not the comment mention auto-complete component
const textArea = event.target.form.querySelector(
'.comment-textarea:not([role=combobox])',
);
const textArea = event.target.form.querySelector('.comment-textarea');
const textAreaReplaceable =
textArea.value === null ||
textArea.value === '' ||
@ -139,7 +133,7 @@ function addClickListeners(form) {
textArea.value = content;
textArea.dispatchEvent(new Event('input', { target: textArea }));
textArea.focus();
responsesContainer.classList.toggle('hidden');
onTemplateSelected();
}
});
});
@ -155,7 +149,7 @@ function addClickListeners(form) {
});
}
function fetchResponseTemplates(typeOf, formId) {
function fetchResponseTemplates(formId, onTemplateSelected) {
const form = document.getElementById(formId);
const typesOf = [
@ -163,8 +157,6 @@ function fetchResponseTemplates(typeOf, formId) {
['mod_comment', 'moderator-responses-container'],
];
/* eslint-disable-next-line no-undef */
fetch(`/response_templates`, {
method: 'GET',
headers: {
@ -201,7 +193,7 @@ function fetchResponseTemplates(typeOf, formId) {
}
}
addClickListeners(form);
addClickListeners(form, onTemplateSelected);
});
}
@ -230,27 +222,29 @@ function copyData(responsesContainer) {
).innerHTML;
}
function loadData(form) {
function loadData(form, onTemplateSelected) {
form.querySelector('img.loading-img').classList.toggle('hidden');
fetchResponseTemplates('personal_comment', form.id);
fetchResponseTemplates(form.id, onTemplateSelected);
}
function openButtonCallback(form) {
/**
* This helper function makes sure the correct templates are inserted into the UI next to the given comment form.
*
* @param {HTMLElement} form The relevant comment form
* @param {Function} onTemplateSelected Callback for when a template is inserted
*/
export function populateTemplates(form, onTemplateSelected) {
const responsesContainer = form.getElementsByClassName(
'response-templates-container',
)[0];
const topLevelData = document.getElementById('response-templates-data');
const dataFetched = topLevelData.innerHTML !== '';
responsesContainer.classList.toggle('hidden');
const containerHidden = responsesContainer.classList.contains('hidden');
if (dataFetched && !containerHidden) {
if (dataFetched) {
copyData(responsesContainer);
addClickListeners(form);
} else if (!dataFetched && !containerHidden) {
loadData(form);
addClickListeners(form, onTemplateSelected);
} else if (!dataFetched) {
loadData(form, onTemplateSelected);
}
const hasBothTemplates =
@ -275,76 +269,3 @@ function openButtonCallback(form) {
.classList.add('hidden');
}
}
function prepareOpenButton(form) {
const button = form.getElementsByClassName('response-templates-button')[0];
if (!button) {
return;
}
button.addEventListener('click', () => {
openButtonCallback(form);
});
button.dataset.hasListener = 'true';
}
function observeForReplyClick() {
const config = { childList: true, subtree: true };
const callback = (mutations) => {
const form = Array.from(mutations[0].addedNodes).filter(
(node) => node.nodeName === 'FORM',
);
if (form.length > 0) {
prepareOpenButton(form[0]);
}
};
const observer = new MutationObserver(callback);
const commentTree = document.getElementById('comment-trees-container');
if (commentTree) {
observer.observe(commentTree, config);
}
window.addEventListener('beforeunload', () => {
observer.disconnect();
});
window.InstantClick.on('change', () => {
observer.disconnect();
});
}
function handleLoggedOut() {
document
.getElementsByClassName('response-templates-button')[0]
?.addEventListener(
'click',
// eslint-disable-next-line no-undef
showLoginModal,
);
}
/* eslint-enable no-alert */
/* eslint-enable no-restricted-globals */
export function loadResponseTemplates() {
const { userStatus } = document.body.dataset;
const form = document.getElementsByClassName('comment-form')[0];
if (document.getElementById('response-templates-data')) {
if (userStatus === 'logged-out') {
handleLoggedOut();
}
if (
form &&
form.getElementsByClassName('response-templates-button')[0].dataset
.hasListener === 'false'
) {
prepareOpenButton(form);
}
observeForReplyClick();
}
}

View file

@ -1,5 +1,5 @@
<div id="response-templates-data" class="hidden"></div>
<%= javascript_packs_with_chunks_tag "validateFileInputs", "responseTemplates", defer: true %>
<%= javascript_packs_with_chunks_tag "validateFileInputs", defer: true %>
<% if @comment.errors.any? %>
<div class="crayons-notice crayons-notice--danger" role="alert">
@ -43,27 +43,7 @@
required: true,
class: "crayons-textfield comment-textarea crayons-textfield--ghost",
"aria-label": t("views.comments.write.field.aria_label") %>
<div class="comment-form__toolbar">
<div class="editor-image-upload">
<input type="file" id="image-upload-main" name="file" accept="image/*" style="display:none">
<button type="button" class="crayons-btn crayons-btn--s crayons-btn--icon-left crayons-btn--ghost-dimmed" onclick="handleImageUpload(event,'main')">
<%= crayons_icon_tag(:image) %>
<span class="hidden s:inline-block" aria-hidden="false"><%= t("views.comments.write.field.upload") %></span>
</button>
<label class="image-upload-file-label" id="image-upload-file-label-main"></label>
<input type="submit" id='image-upload-submit-main' value="<%= t("views.comments.write.field.submit") %>" style="display:none">
<input class="hidden" id="uploaded-image-main" />
</div>
<button type="button" class="crayons-btn crayons-btn--s crayons-btn--icon-left crayons-btn--ghost-dimmed response-templates-button" title="<%= t("views.comments.write.field.use") %>" data-has-listener="false" data-form-id="new_comment">
<%= crayons_icon_tag(:templates) %>
<span class="hidden s:inline-block" aria-hidden="false"><%= t("views.comments.write.field.templates") %></span>
</button>
<a href="/p/editor_guide" class="crayons-btn crayons-btn--ghost-dimmed crayons-btn--icon crayons-btn--s ml-auto" target="_blank" rel="noopener" title="<%= t("views.comments.write.field.guide.title") %>">
<%= crayons_icon_tag(:info, title: t("views.comments.write.field.guide.icon")) %>
</a>
</div>
</div>
<div class="response-templates-container crayons-card crayons-card--secondary p-4 mb-4 comment-form__templates fs-base hidden">

View file

@ -89,7 +89,7 @@
</article>
<% else %>
<div id="response-templates-data" class="hidden"></div>
<%= javascript_packs_with_chunks_tag "responseTemplates", "commentsDisplay", defer: true %>
<%= javascript_packs_with_chunks_tag "commentsDisplay", defer: true %>
<header class="p-2 pb-4 m:p-6 m:pb-6 crayons-card crayons-card--secondary crayons-layout__content s:mx-2 m:mx-4 -mb-1 z-0">
<% if @commentable %>
<h1 class="crayons-subtitle-1 mb-4">

View file

@ -381,7 +381,8 @@ describe('Comment on articles', () => {
name: /^Add a comment to the discussion$/i,
}).focus(); // Focus activates the Submit button and mini toolbar below a comment textbox
cy.findByRole('button', { name: /^Use a response template$/i }).click();
cy.findByRole('button', { name: 'More options' }).click();
cy.findByRole('menuitem', { name: 'Show templates' }).click();
cy.findByRole('button', { name: /^Insert$/i }).click();
@ -617,4 +618,20 @@ describe('Comment on articles', () => {
});
});
});
it('should enhance the textarea with a markdown toolbar', () => {
cy.findByRole('main').within(() => {
cy.findByRole('heading', { name: 'Discussion (0)' });
cy.findByRole('textbox', {
name: /^Add a comment to the discussion$/i,
})
.as('textArea')
.focus();
cy.findByRole('toolbar').as('toolbar');
cy.get('@toolbar').findByRole('button', { name: 'Bold' }).click();
cy.get('@textArea').should('have.value', '****').should('have.focus');
});
});
});