Add markdown toolbar to V2 editor (#15347)

* initial add of toolbar to v2 editor WIP

* fix Form test knocked out by rebase

* basic image upload functionality added to toolbar

* add cypress tests for toolbar

* move image tests to previous cypress spec

* test markdown insertion from buttons in jest

* small tidy up

* remove right padding in scrollable layout, hide scrollbar

* update tests following new line changes

* add more doc comments

* tweak padding css

* create constant for image placeholder

* add image uploader change missed in staging after merge conflict resolution
This commit is contained in:
Suzanne Aitchison 2021-11-29 07:50:17 +00:00 committed by GitHub
parent be97f2fc07
commit d0dea2228c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 959 additions and 200 deletions

View file

@ -1,4 +1,29 @@
.editor-toolbar {
display: flex;
background: var(--base-0);
&::-webkit-scrollbar {
background: transparent;
height: 0;
}
.toolbar-btn .spinner-or-cancel {
.cancel {
display: none;
}
}
.toolbar-btn:hover,
.toolbar-btn:focus {
.spinner-or-cancel {
.cancel {
display: block;
color: var(--accent-danger);
}
svg:not(.cancel) {
display: none;
}
}
}
}

View file

@ -168,6 +168,8 @@
top: 0;
background: var(--base-0);
padding: var(--su-2) var(--content-padding-x);
padding-right: var(--toolbar-padding-right, 0);
margin: calc(var(--content-padding-y) * -1)
calc(var(--content-padding-x) * -1) var(--su-6)
calc(var(--content-padding-x) * -1);
@ -175,6 +177,10 @@
> :first-child {
margin-left: calc(var(--su-2) * -1);
}
@media (min-width: $breakpoint-m) {
--toolbar-padding-right: var(--content-padding-x);
}
}
&__cover {

View file

@ -1,7 +1,11 @@
<script>
// MarkdownToolbar relies on these Runtime functions being available in stories
var Runtime = {
getOSKeyboardModifierKeyString: function () {
return 'cmd';
},
isNativeIOS: function () {
return false;
},
};
</script>

View file

@ -83,7 +83,7 @@ function generateUploadFormdata(payload) {
return formData;
}
export function generateMainImage(payload, successCb, failureCb) {
export function generateMainImage({ payload, successCb, failureCb, signal }) {
fetch('/image_uploads', {
method: 'POST',
headers: {
@ -91,6 +91,7 @@ export function generateMainImage(payload, successCb, failureCb) {
},
body: generateUploadFormdata(payload),
credentials: 'same-origin',
signal,
})
.then((response) => response.json())
.then((json) => {
@ -120,6 +121,10 @@ export function processImageUpload(
if (images.length > 0 && validateFileInputs()) {
const payload = { image: images };
generateMainImage(payload, handleImageSuccess, handleImageFailure);
generateMainImage({
payload,
successCb: handleImageSuccess,
failureCb: handleImageFailure,
});
}
}

View file

@ -71,7 +71,11 @@ export class ArticleCoverImage extends Component {
const { files: image } = event.dataTransfer || event.target;
const payload = { image };
generateMainImage(payload, this.onImageUploadSuccess, this.onUploadError);
generateMainImage({
payload,
successCb: this.onImageUploadSuccess,
failureCb: this.onUploadError,
});
}
};

View file

@ -73,7 +73,7 @@ export const EditorBody = ({
data-testid="article-form__body"
className="crayons-article-form__body drop-area text-padding"
>
<Toolbar version={version} />
<Toolbar version={version} textAreaId="article_body_markdown" />
<MentionAutocompleteTextArea
ref={textAreaRef}
fetchSuggestions={(username) => fetchSearch('usernames', { username })}

View file

@ -1,7 +1,7 @@
/* global Runtime */
import { Fragment, h } from 'preact';
import { useReducer } from 'preact/hooks';
import { useReducer, useEffect, useState } from 'preact/hooks';
import { generateMainImage } from '../actions';
import { validateFileInputs } from '../../packs/validateFileInputs';
import { addSnackbarItem } from '../../Snackbar';
@ -23,6 +23,28 @@ const ImageIcon = () => (
</svg>
);
const CancelIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="crayons-icon cancel"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-hidden="true"
>
<title id="as1mn15llu5e032u2pgzlc6yhvss2myk">Cancel</title>
<path d="M12 10.586l4.95-4.95 1.414 1.414-4.95 4.95 4.95 4.95-1.414 1.414-4.95-4.95-4.95 4.95-1.414-1.414 4.95-4.95-4.95-4.95L7.05 5.636l4.95 4.95z" />
</svg>
);
const SpinnerOrCancel = () => (
<span className="spinner-or-cancel">
<Spinner />
<CancelIcon />
</span>
);
ImageIcon.displayName = 'ImageIcon';
function imageUploaderReducer(state, action) {
@ -32,33 +54,25 @@ function imageUploaderReducer(state, action) {
case 'uploading_image':
return {
...state,
uploadError: false,
uploadingErrorMessage: null,
uploadErrorMessage: null,
uploadingImage: true,
insertionImageUrls: [],
showImageCopiedMessage: false,
};
case 'upload_error':
return {
...state,
insertionImageUrls: [],
uploadError: true,
uploadErrorMessage: payload.errorMessage,
uploadingImage: false,
};
case 'show_copied_image_message':
return {
...state,
showImageCopiedMessage: true,
};
case 'upload_image_success':
return {
...state,
insertionImageUrls: payload.insertionImageUrls,
uploadingImage: false,
uploadErrorMessage: null,
};
default:
@ -66,15 +80,23 @@ function imageUploaderReducer(state, action) {
}
}
const NativeIosImageUpload = ({ uploadingImage, extraProps }) => (
function initNativeImagePicker(e) {
e.preventDefault();
window.ForemMobile?.injectNativeMessage('imageUpload', {
action: 'imageUpload',
});
}
const NativeIosV1ImageUpload = ({ uploadingImage }) => (
<Fragment>
{!uploadingImage && (
<Button
aria-label="Upload an image"
className="mr-2 fw-normal"
variant="ghost"
contentType="icon-left"
icon={ImageIcon}
{...extraProps}
onClick={initNativeImagePicker}
>
Upload image
</Button>
@ -82,60 +104,133 @@ const NativeIosImageUpload = ({ uploadingImage, extraProps }) => (
</Fragment>
);
const StandardImageUpload = ({ handleInsertionImageUpload, uploadingImage }) =>
uploadingImage ? null : (
/**
* The V2 editor uses a toolbar button press to trigger a visually hidden file input.
*
* @param {object} props
* @param {object} props.buttonProps Any props to be added to the trigger button
* @param {function} props.handleInsertionImageUpload Callback to handle image upload
* @param {boolean} props.uploadingImage Is an image currently being uploaded
* @param {boolean} props.useNativeUpload Should iOS native upload functionality be used
* @param {function} props.handleNativeMessage Callback for iOS native upload message handling
* @param {string} props.uploadErrorMessage Error message to be displayed
*
*/
const V2EditorImageUpload = ({
buttonProps,
handleInsertionImageUpload,
uploadingImage,
useNativeUpload,
handleNativeMessage,
uploadErrorMessage,
}) => {
useEffect(() => {
if (uploadErrorMessage) {
addSnackbarItem({
message: uploadErrorMessage,
addCloseButton: true,
});
}
}, [uploadErrorMessage]);
const [abortRequestController, setAbortRequestController] = useState(null);
const startNewRequest = (e) => {
const controller = new AbortController();
setAbortRequestController(controller);
handleInsertionImageUpload(e, controller.signal);
};
const cancelRequest = () => {
abortRequestController.abort();
setAbortRequestController(null);
};
const { tooltip: actionTooltip } = buttonProps;
return (
<Fragment>
<label className="cursor-pointer crayons-btn crayons-btn--ghost">
<ImageIcon /> Upload image
{useNativeUpload ? (
<input
type="hidden"
id="native-image-upload-message"
value=""
onChange={handleNativeMessage}
/>
) : (
<input
type="file"
tabindex="-1"
aria-label="Upload image"
id="image-upload-field"
onChange={handleInsertionImageUpload}
onChange={startNewRequest}
className="screen-reader-only"
multiple
accept="image/*"
data-max-file-size-mb="25"
/>
</label>
)}
{uploadingImage ? (
<Button
{...buttonProps}
icon={SpinnerOrCancel}
onClick={cancelRequest}
aria-label="Cancel image upload"
tooltip="Cancel upload"
/>
) : (
<Button
{...buttonProps}
icon={ImageIcon}
onClick={(e) => {
useNativeUpload
? initNativeImagePicker(e)
: document.getElementById('image-upload-field').click();
}}
aria-label="Upload image"
tooltip={actionTooltip}
/>
)}
</Fragment>
);
};
export const ImageUploader = () => {
const [state, dispatch] = useReducer(imageUploaderReducer, {
insertionImageUrls: [],
uploadError: false,
uploadErrorMessage: null,
showImageCopiedMessage: false,
uploadingImage: false,
});
/**
* The V1 Editor uses a more detailed image upload UI, displaying errors and markdown text inline
*
* @param {object} props
* @param {boolean} props.uploadingImage Is an image currently being uploaded
* @param {boolean} props.useNativeUpload Should iOS native upload functionality be used
* @param {function} props.handleNativeMessage Callback for iOS native upload message handling
* @param {function} props.handleInsertionImageUpload Callback to handle image upload
* @param {string[]} props.insertionImageUrls URLs of successfully uploaded images
* @param {string} props.uploadErrorMessage Error message to be displayed
*
* @returns
*/
const V1EditorImageUpload = ({
uploadingImage,
useNativeUpload,
handleNativeMessage,
handleInsertionImageUpload,
insertionImageUrls,
uploadErrorMessage,
}) => {
const [showCopiedImageText, setShowCopiedImageText] = useState(false);
const {
uploadingImage,
showImageCopiedMessage,
uploadErrorMessage,
uploadError,
insertionImageUrls,
} = state;
useEffect(() => {
if (uploadingImage) {
setShowCopiedImageText(false);
}
}, [uploadingImage]);
let imageMarkdownInput = null;
function onUploadError(error) {
dispatch({
type: 'upload_error',
payload: { errorMessage: error.message },
});
}
function copyText() {
imageMarkdownInput = document.getElementById(
const copyText = () => {
const imageMarkdownInput = document.getElementById(
'image-markdown-copy-link-input',
);
Runtime.copyToClipboard(imageMarkdownInput.value)
.then(() => {
dispatch({
type: 'show_copied_image_message',
});
setShowCopiedImageText(true);
})
.catch((error) => {
addSnackbarItem({
@ -144,9 +239,87 @@ export const ImageUploader = () => {
});
Honeybadger.notify(error);
});
};
return (
<div className="flex items-center">
{uploadingImage && (
<span class="lh-base pl-3 border-0 py-2 inline-block">
<Spinner /> Uploading...
</span>
)}
{useNativeUpload ? (
<NativeIosV1ImageUpload
uploadingImage={uploadingImage}
handleNativeMessage={handleNativeMessage}
/>
) : uploadingImage ? null : (
<Fragment>
<label className="cursor-pointer crayons-btn crayons-btn--ghost">
<ImageIcon /> Upload image
<input
type="file"
id="image-upload-field"
onChange={handleInsertionImageUpload}
className="screen-reader-only"
multiple
accept="image/*"
data-max-file-size-mb="25"
/>
</label>
</Fragment>
)}
{insertionImageUrls.length > 0 && (
<ClipboardButton
onCopy={copyText}
imageUrls={insertionImageUrls}
showCopyMessage={showCopiedImageText}
/>
)}
{uploadErrorMessage ? (
<span className="color-accent-danger">{uploadErrorMessage}</span>
) : null}
</div>
);
};
/**
* Image Uploader feature for editor forms
*
* @param {object} props
* @param {string} props.editorVersion The current editor version being used
* @param {object} props.buttonProps Any additional props to be added to upload image button (v2 editor only)
* @param {function} props.onImageUploadStart Callback for when image upload begins
* @param {function} props.onImageUploadSuccess Callback for when image upload succeeds
* @param {function} props.onImageUploadError Callback for when image upload fails
*
*/
export const ImageUploader = ({
editorVersion = 'v2',
buttonProps = {},
onImageUploadStart,
onImageUploadSuccess,
onImageUploadError,
}) => {
const [state, dispatch] = useReducer(imageUploaderReducer, {
insertionImageUrls: [],
uploadErrorMessage: null,
uploadingImage: false,
});
const { uploadingImage, uploadErrorMessage, insertionImageUrls } = state;
function onUploadError(error) {
onImageUploadError?.();
dispatch({
type: 'upload_error',
payload: { errorMessage: error.message },
});
}
function handleInsertionImageUpload(e) {
function handleInsertionImageUpload(e, abortSignal) {
const { files } = e.target;
if (files.length > 0 && validateFileInputs()) {
@ -155,7 +328,13 @@ export const ImageUploader = () => {
type: 'uploading_image',
});
generateMainImage(payload, handleInsertImageUploadSuccess, onUploadError);
onImageUploadStart?.();
generateMainImage({
payload,
successCb: handleInsertImageUploadSuccess,
failureCb: onUploadError,
signal: abortSignal,
});
}
}
@ -164,6 +343,11 @@ export const ImageUploader = () => {
type: 'upload_image_success',
payload: { insertionImageUrls: response.links },
});
onImageUploadSuccess?.(`![Image description](${response.links})`);
document.getElementById('upload-success-info').innerText =
'image upload complete';
}
function handleNativeMessage(e) {
@ -174,6 +358,7 @@ export const ImageUploader = () => {
switch (message.action) {
case 'uploading':
onImageUploadStart?.();
dispatch({
type: 'uploading_image',
});
@ -185,6 +370,7 @@ export const ImageUploader = () => {
});
break;
case 'success':
onImageUploadSuccess?.(`![Image description](${message.link})`);
dispatch({
type: 'upload_image_success',
payload: { insertionImageUrls: [message.link] },
@ -193,13 +379,6 @@ export const ImageUploader = () => {
}
}
function initNativeImagePicker(e) {
e.preventDefault();
window.ForemMobile?.injectNativeMessage('imageUpload', {
action: 'imageUpload',
});
}
// When the component is rendered in an environment that supports a native
// image picker for image upload we want to add the aria-label attr and the
// onClick event to the UI button. This event will kick off the native UX.
@ -211,45 +390,34 @@ export const ImageUploader = () => {
// deploy our refactor and wait until our iOS app is approved by AppStore
// review. The old web implementation will be the fallback until then.
const useNativeUpload = Runtime.isNativeIOS('imageUpload_disabled');
const extraProps = useNativeUpload
? { onClick: initNativeImagePicker, 'aria-label': 'Upload an image' }
: { tabIndex: -1 };
// Native Bridge messages come through ForemMobile events
document.addEventListener('ForemMobile', handleNativeMessage);
return (
<div className="flex items-center">
{uploadingImage && (
<span class="lh-base pl-3 border-0 py-2 inline-block">
<Spinner /> Uploading...
</span>
)}
<Fragment>
<div id="upload-success-info" className="screen-reader-only" />
{useNativeUpload ? (
<NativeIosImageUpload
extraProps={extraProps}
uploadingImage={uploadingImage}
/>
) : (
<StandardImageUpload
{editorVersion === 'v2' ? (
<V2EditorImageUpload
buttonProps={buttonProps}
uploadingImage={uploadingImage}
handleInsertionImageUpload={handleInsertionImageUpload}
useNativeUpload={useNativeUpload}
handleNativeMessage={handleNativeMessage}
uploadErrorMessage={uploadErrorMessage}
/>
) : (
<V1EditorImageUpload
uploadingImage={uploadingImage}
useNativeUpload={useNativeUpload}
handleNativeMessage={handleNativeMessage}
handleInsertionImageUpload={handleInsertionImageUpload}
insertionImageUrls={insertionImageUrls}
uploadErrorMessage={uploadErrorMessage}
/>
)}
{insertionImageUrls.length > 0 && (
<ClipboardButton
onCopy={copyText}
imageUrls={insertionImageUrls}
showCopyMessage={showImageCopiedMessage}
/>
)}
{uploadError && (
<span className="color-accent-danger">{uploadErrorMessage}</span>
)}
</div>
</Fragment>
);
};

View file

@ -1,15 +1,20 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
import { ImageUploader } from './ImageUploader';
import { MarkdownToolbar } from '@crayons/MarkdownToolbar';
export const Toolbar = ({ version }) => {
export const Toolbar = ({ version, textAreaId }) => {
return (
<div
className={`crayons-article-form__toolbar ${
version === 'v1' && 'border-t-0'
version === 'v1' ? 'border-t-0' : ''
}`}
>
<ImageUploader />
{version === 'v1' ? (
<ImageUploader editorVersion={version} />
) : (
<MarkdownToolbar textAreaId={textAreaId} />
)}
</div>
);
};

View file

@ -1,5 +1,7 @@
import { h } from 'preact';
import { render } from '@testing-library/preact';
import { render, waitFor } from '@testing-library/preact';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { Form } from '../Form';
@ -19,6 +21,7 @@ describe('<Form />', () => {
isNativeIOS: jest.fn(() => {
return false;
}),
getOSKeyboardModifierKeyString: jest.fn(() => 'cmd'),
};
global.window.matchMedia = jest.fn((query) => {
@ -153,6 +156,127 @@ describe('<Form />', () => {
// Ensure max file size
expect(Number(coverImageInput.dataset.maxFileSizeMb)).toEqual(25);
});
it('renders a toolbar of markdown formatters', () => {
const { getByRole, getByLabelText } = render(
<Form
titleDefaultValue="Test Title v2"
titleOnChange={null}
tagsDefaultValue="javascript, career"
tagsOnInput={null}
bodyDefaultValue=""
bodyOnChange={null}
bodyHasFocus={false}
version="v2"
mainImage={mainImage}
onMainImageUrlChange={null}
errors={null}
switchHelpContext={null}
/>,
);
const textArea = getByLabelText('Post Content');
getByRole('button', { name: 'Bold' }).click();
expect(textArea.value).toEqual('****');
userEvent.clear(textArea);
getByRole('button', { name: 'Italic' }).click();
expect(textArea.value).toEqual('__');
userEvent.clear(textArea);
getByRole('button', { name: 'Link' }).click();
expect(textArea.value).toEqual('[](url)');
userEvent.clear(textArea);
getByRole('button', { name: 'Ordered list' }).click();
expect(textArea.value).toEqual('1. \n');
userEvent.clear(textArea);
getByRole('button', { name: 'Unordered list' }).click();
expect(textArea.value).toEqual('- \n');
userEvent.clear(textArea);
getByRole('button', { name: 'Heading' }).click();
expect(textArea.value).toEqual('## \n');
userEvent.clear(textArea);
getByRole('button', { name: 'Quote' }).click();
expect(textArea.value).toEqual('> \n');
userEvent.clear(textArea);
getByRole('button', { name: 'Code' }).click();
expect(textArea.value).toEqual('``');
userEvent.clear(textArea);
getByRole('button', { name: 'Code block' }).click();
expect(textArea.value).toEqual('```\n\n```\n');
userEvent.clear(textArea);
});
it('renders an overflow menu of markdown formatters', async () => {
const { getByRole, getByLabelText } = render(
<Form
titleDefaultValue="Test Title v2"
titleOnChange={null}
tagsDefaultValue="javascript, career"
tagsOnInput={null}
bodyDefaultValue=""
bodyOnChange={null}
bodyHasFocus={false}
version="v2"
mainImage={mainImage}
onMainImageUrlChange={null}
errors={null}
switchHelpContext={null}
/>,
);
const textArea = getByLabelText('Post Content');
const overflowMenuButton = getByRole('button', { name: 'More options' });
overflowMenuButton.click();
await waitFor(() =>
expect(overflowMenuButton).toHaveAttribute('aria-expanded', 'true'),
);
getByRole('menuitem', { name: 'Underline' }).click();
expect(textArea.value).toEqual('<u></u>');
userEvent.clear(textArea);
await waitFor(() =>
expect(overflowMenuButton).toHaveAttribute('aria-expanded', 'false'),
);
getByRole('button', { name: 'More options' }).click();
await waitFor(() =>
expect(overflowMenuButton).toHaveAttribute('aria-expanded', 'true'),
);
getByRole('menuitem', { name: 'Strikethrough' }).click();
expect(textArea.value).toEqual('~~~~');
userEvent.clear(textArea);
await waitFor(() =>
expect(overflowMenuButton).toHaveAttribute('aria-expanded', 'false'),
);
getByRole('button', { name: 'More options' }).click();
await waitFor(() =>
expect(overflowMenuButton).toHaveAttribute('aria-expanded', 'true'),
);
getByRole('menuitem', { name: 'Line divider' }).click();
expect(textArea.value).toEqual('---\n\n');
userEvent.clear(textArea);
await waitFor(() =>
expect(overflowMenuButton).toHaveAttribute('aria-expanded', 'false'),
);
getByRole('button', { name: 'More options' }).click();
await waitFor(() =>
expect(overflowMenuButton).toHaveAttribute('aria-expanded', 'true'),
);
expect(getByRole('menuitem', { name: 'Help' })).toBeInTheDocument();
});
});
it('shows errors if there are any', () => {

View file

@ -3,6 +3,7 @@ import {
render,
fireEvent,
waitForElementToBeRemoved,
waitFor,
createEvent,
} from '@testing-library/preact';
import { axe } from 'jest-axe';
@ -13,28 +14,117 @@ import '@testing-library/jest-dom';
global.fetch = fetch;
describe('<ImageUploader />', () => {
beforeEach(() => {
global.Runtime = {
isNativeIOS: jest.fn(() => {
return false;
}),
};
describe('Editor v1, not native iOS', () => {
beforeEach(() => {
global.Runtime = {
isNativeIOS: jest.fn(() => {
return false;
}),
};
});
it('should have no a11y violations', async () => {
const { container } = render(<ImageUploader editorVersion="v1" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('displays an upload input', () => {
const { getByLabelText } = render(<ImageUploader editorVersion="v1" />);
const uploadInput = getByLabelText(/Upload image/i);
expect(uploadInput.getAttribute('type')).toEqual('file');
});
it('displays the upload spinner during upload', async () => {
fetch.mockResponse(
JSON.stringify({
links: ['/i/fake-link.jpg'],
}),
);
const { getByLabelText, queryByText } = render(
<ImageUploader editorVersion="v1" />,
);
const inputEl = getByLabelText(/Upload image/i);
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
});
fireEvent.change(inputEl, { target: { files: [file] } });
const uploadingImage = queryByText(/uploading.../i);
expect(uploadingImage).toBeDefined();
});
it('displays text to copy after upload', async () => {
fetch.mockResponse(
JSON.stringify({
links: ['/i/fake-link.jpg'],
}),
);
const { findByTitle, getByDisplayValue, getByLabelText, queryByText } =
render(<ImageUploader editorVersion="v1" />);
const inputEl = getByLabelText(/Upload image/i);
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
});
fireEvent.change(inputEl, { target: { files: [file] } });
const uploadingImage = queryByText(/uploading.../i);
expect(uploadingImage).toBeDefined();
expect(inputEl.files[0]).toEqual(file);
expect(inputEl.files).toHaveLength(1);
waitForElementToBeRemoved(() => queryByText(/uploading.../i));
expect(await findByTitle(/copy markdown for image/i)).toBeDefined();
getByDisplayValue(/fake-link.jpg/i);
});
// TODO: 'Copied!' is always in the DOM, and so we cannot test that the visual implications of the copy when clicking on the copy icon
it('displays an error when one occurs', async () => {
fetch.mockReject({
message: 'Some Fake Error',
});
const { getByLabelText, findByText, queryByText } = render(
<ImageUploader editorVersion="v1" />,
);
const inputEl = getByLabelText(/Upload image/i);
// Check the input validation settings
expect(inputEl.getAttribute('accept')).toEqual('image/*');
expect(Number(inputEl.dataset.maxFileSizeMb)).toEqual(25);
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
});
fireEvent.change(inputEl, {
target: {
files: [file],
},
});
expect(await findByText(/uploading.../i)).not.toBeNull();
// Upload is finished, so the messsage has disappeared.
expect(queryByText(/uploading.../i)).toBeNull();
await findByText(/some fake error/i);
});
});
it('should have no a11y violations', async () => {
const { container } = render(<ImageUploader />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('displays an upload input', () => {
const { getByLabelText } = render(<ImageUploader />);
const uploadInput = getByLabelText(/Upload image/i);
expect(uploadInput.getAttribute('type')).toEqual('file');
});
describe('when rendered in native iOS with imageUpload_disabled support', () => {
describe('Editor v1, native iOS with imageUpload_disabled support', () => {
beforeEach(() => {
global.Runtime = {
isNativeIOS: jest.fn((namespace) => {
@ -44,14 +134,14 @@ describe('<ImageUploader />', () => {
});
it('does not display the file input', async () => {
const { queryByLabelText } = render(<ImageUploader />);
const { queryByLabelText } = render(<ImageUploader editorVersion="v1" />);
expect(queryByLabelText(/Upload image/i)).not.toBeInTheDocument();
});
it('triggers a webkit messageHandler call when isNativeIOS', async () => {
global.window.ForemMobile = { injectNativeMessage: jest.fn() };
const { queryByLabelText } = render(<ImageUploader />);
const { queryByLabelText } = render(<ImageUploader editorVersion="v1" />);
const uploadButton = queryByLabelText(/Upload an image/i);
uploadButton.click();
expect(
@ -60,7 +150,7 @@ describe('<ImageUploader />', () => {
});
it('handles a native bridge message correctly', async () => {
const { container, findByTitle } = render(<ImageUploader />); // eslint-disable-line no-unused-vars
const { findByTitle } = render(<ImageUploader editorVersion="v1" />);
// Fire a change event in the hidden input with JSON payload for success
const fakeSuccessMessage = JSON.stringify({
@ -80,88 +170,173 @@ describe('<ImageUploader />', () => {
});
});
it('displays the upload spinner during upload', async () => {
fetch.mockResponse(
JSON.stringify({
links: ['/i/fake-link.jpg'],
}),
);
const { getByLabelText, queryByText } = render(<ImageUploader />);
const inputEl = getByLabelText(/Upload image/i);
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
describe('Editor v2, not native iOS', () => {
beforeEach(() => {
global.Runtime = {
isNativeIOS: jest.fn(() => {
return false;
}),
};
});
fireEvent.change(inputEl, { target: { files: [file] } });
it('should have no a11y violations', async () => {
const { container } = render(<ImageUploader editorVersion="v2" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
const uploadingImage = queryByText(/uploading.../i);
it('displays an upload image button with input', () => {
const { getAllByLabelText } = render(
<ImageUploader editorVersion="v2" />,
);
const uploadControls = getAllByLabelText('Upload image');
expect(uploadingImage).toBeDefined();
expect(uploadControls.length).toEqual(2);
expect(uploadControls[0].getAttribute('type')).toEqual('file');
});
it('displays cancel upload tooltip during upload', () => {
fetch.mockResponse(
JSON.stringify({
links: ['/i/fake-link.jpg'],
}),
);
const { getAllByLabelText, queryByText } = render(
<ImageUploader editorVersion="v2" />,
);
expect(queryByText('Cancel upload')).toBeNull();
const inputEl = getAllByLabelText(/Upload image/i)[0];
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
});
fireEvent.change(inputEl, { target: { files: [file] } });
expect(queryByText('Cancel upload')).toBeInTheDocument();
});
it('invokes upload start and success callbacks when image is uploaded', async () => {
fetch.mockResponse(
JSON.stringify({
links: ['/i/fake-link.jpg'],
}),
);
const uploadStartCallback = jest.fn();
const uploadSuccessCallback = jest.fn();
const { getAllByLabelText, queryByText } = render(
<ImageUploader
editorVersion="v2"
onImageUploadStart={uploadStartCallback}
onImageUploadSuccess={uploadSuccessCallback}
/>,
);
const inputEl = getAllByLabelText(/Upload image/i)[0];
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
});
fireEvent.change(inputEl, { target: { files: [file] } });
expect(uploadStartCallback).toHaveBeenCalled();
await waitFor(() => expect(queryByText('Cancel upload')).toBeNull());
expect(uploadSuccessCallback).toHaveBeenCalledWith(
'![Image description](/i/fake-link.jpg)',
);
});
it('invokes error callback when error occurs', async () => {
fetch.mockReject({
message: 'Some Fake Error',
});
const uploadErrorCallback = jest.fn();
const { getAllByLabelText, queryByText } = render(
<ImageUploader
editorVersion="v2"
onImageUploadError={uploadErrorCallback}
/>,
);
const inputEl = getAllByLabelText(/Upload image/i)[0];
// Check the input validation settings
expect(inputEl.getAttribute('accept')).toEqual('image/*');
expect(Number(inputEl.dataset.maxFileSizeMb)).toEqual(25);
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
});
fireEvent.change(inputEl, {
target: {
files: [file],
},
});
await waitFor(() => expect(queryByText('Cancel upload')).toBeNull());
expect(uploadErrorCallback).toHaveBeenCalled();
});
});
it('displays text to copy after upload', async () => {
fetch.mockResponse(
JSON.stringify({
links: ['/i/fake-link.jpg'],
}),
);
const { findByTitle, getByDisplayValue, getByLabelText, queryByText } =
render(<ImageUploader />);
const inputEl = getByLabelText(/Upload image/i);
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
describe('Editor v2, native iOS with imageUpload_disabled support', () => {
beforeEach(() => {
global.Runtime = {
isNativeIOS: jest.fn((namespace) => {
return namespace === 'imageUpload_disabled';
}),
};
});
fireEvent.change(inputEl, { target: { files: [file] } });
const uploadingImage = queryByText(/uploading.../i);
it('triggers a webkit messageHandler call when isNativeIOS', async () => {
global.window.ForemMobile = { injectNativeMessage: jest.fn() };
expect(uploadingImage).toBeDefined();
const { getByRole } = render(<ImageUploader editorVersion="v2" />);
const uploadButton = getByRole('button', { name: /Upload image/i });
uploadButton.click();
expect(inputEl.files[0]).toEqual(file);
expect(inputEl.files).toHaveLength(1);
waitForElementToBeRemoved(() => queryByText(/uploading.../i));
expect(await findByTitle(/copy markdown for image/i)).toBeDefined();
getByDisplayValue(/fake-link.jpg/i);
});
// TODO: 'Copied!' is always in the DOM, and so we cannot test that the visual implications of the copy when clicking on the copy icon
it('displays an error when one occurs', async () => {
fetch.mockReject({
message: 'Some Fake Error',
await waitFor(() =>
expect(
global.window.ForemMobile.injectNativeMessage,
).toHaveBeenCalledTimes(1),
);
});
const { getByLabelText, findByText, queryByText } = render(
<ImageUploader />,
);
const inputEl = getByLabelText(/Upload image/i);
it('handles a native bridge message correctly', async () => {
const uploadSuccess = jest.fn();
// Check the input validation settings
expect(inputEl.getAttribute('accept')).toEqual('image/*');
expect(Number(inputEl.dataset.maxFileSizeMb)).toEqual(25);
render(
<ImageUploader
editorVersion="v2"
onImageUploadSuccess={uploadSuccess}
/>,
);
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png',
// Fire a change event in the hidden input with JSON payload for success
const fakeSuccessMessage = JSON.stringify({
action: 'success',
link: '/some-fake-image.jpg',
namespace: 'imageUpload',
});
const event = createEvent(
'ForemMobile',
document,
{ detail: fakeSuccessMessage },
{ EventType: 'CustomEvent' },
);
fireEvent(document, event);
await waitFor(() =>
expect(uploadSuccess).toHaveBeenCalledWith(
'![Image description](/some-fake-image.jpg)',
),
);
});
fireEvent.change(inputEl, {
target: {
files: [file],
},
});
expect(await findByText(/uploading.../i)).not.toBeNull();
// Upload is finished, so the messsage has disappeared.
expect(queryByText(/uploading.../i)).toBeNull();
await findByText(/some fake error/i);
});
});

View file

@ -1,5 +1,6 @@
import { h } from 'preact';
import { useState, useLayoutEffect } from 'preact/hooks';
import { ImageUploader } from '../../article-form/components/ImageUploader';
import {
coreSyntaxFormatters,
secondarySyntaxFormatters,
@ -8,7 +9,53 @@ import { Overflow, Help } from './icons';
import { Button } from '@crayons';
import { KeyboardShortcuts } from '@components/useKeyboardShortcuts';
import { BREAKPOINTS, useMediaQuery } from '@components/useMediaQuery';
import { getSelectionData } from '@utilities/textAreaUtils';
// Placeholder text displayed while an image is uploading
const UPLOADING_IMAGE_PLACEHOLDER = '![Uploading image](...)';
/**
* Returns the next sibling in the DOM which matches the given CSS selector.
* This makes sure that only toolbar buttons are cycled through on Arrow key press,
* and not e.g. the hidden file input from ImageUploader
*
* @param {HTMLElement} element The current HTML element
* @param {string} selector The CSS selector to match
* @returns
*/
const getNextMatchingSibling = (element, selector) => {
let sibling = element.nextElementSibling;
while (sibling) {
if (sibling.matches(selector)) return sibling;
sibling = sibling.nextElementSibling;
}
};
/**
* Returns the previous sibling in the DOM which matches the given CSS selector.
* This makes sure that only toolbar buttons are cycled through on Arrow key press,
* and not e.g. the hidden file input from ImageUploader
*
* @param {HTMLElement} element The current HTML element
* @param {string} selector The CSS selector to match
* @returns
*/
const getPreviousMatchingSibling = (element, selector) => {
let sibling = element.previousElementSibling;
while (sibling) {
if (sibling.matches(selector)) return sibling;
sibling = sibling.previousElementSibling;
}
};
/**
* UI component providing markdown shortcuts, to be inserted into the textarea with the given ID
*
* @param {object} props
* @param {string} props.textAreaId The ID of the textarea the markdown formatting should be added to
*/
export const MarkdownToolbar = ({ textAreaId }) => {
const [textArea, setTextArea] = useState(null);
const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
@ -85,10 +132,9 @@ export const MarkdownToolbar = ({ textAreaId }) => {
// Handles keyboard 'roving tabindex' pattern for toolbar
const handleToolbarButtonKeyPress = (event, className) => {
const { key, target } = event;
const {
nextElementSibling: nextButton,
previousElementSibling: previousButton,
} = target;
const nextButton = getNextMatchingSibling(target, `.${className}`);
const previousButton = getPreviousMatchingSibling(target, `.${className}`);
switch (key) {
case 'ArrowRight':
@ -132,10 +178,48 @@ export const MarkdownToolbar = ({ textAreaId }) => {
markdownSyntaxFormatters[syntaxName].getFormatting(textArea);
textArea.value = newTextAreaValue;
textArea.dispatchEvent(new Event('input'));
textArea.focus({ preventScroll: true });
textArea.setSelectionRange(newCursorStart, newCursorEnd);
};
const handleImageUploadStarted = () => {
const { textBeforeSelection, textAfterSelection, selectionEnd } =
getSelectionData(textArea);
const textWithPlaceholder = `${textBeforeSelection}\n${UPLOADING_IMAGE_PLACEHOLDER}${textAfterSelection}`;
textArea.value = textWithPlaceholder;
// Make sure Editor text area updates via linkstate
textArea.dispatchEvent(new Event('input'));
textArea.focus({ preventScroll: true });
// Set cursor to the end of the placeholder
const newCursorPosition =
selectionEnd + UPLOADING_IMAGE_PLACEHOLDER.length + 1;
textArea.setSelectionRange(newCursorPosition, newCursorPosition);
};
const handleImageUploadSuccess = (imageMarkdown) => {
const newTextValue = textArea.value.replace(
UPLOADING_IMAGE_PLACEHOLDER,
imageMarkdown,
);
textArea.value = newTextValue;
// Make sure Editor text area updates via linkstate
textArea.dispatchEvent(new Event('input'));
};
const handleImageUploadError = () => {
const newTextValue = textArea.value.replace(
UPLOADING_IMAGE_PLACEHOLDER,
'',
);
textArea.value = newTextValue;
// Make sure Editor text area updates via linkstate
textArea.dispatchEvent(new Event('input'));
};
const getSecondaryFormatterButtons = (isOverflow) =>
Object.keys(secondarySyntaxFormatters).map((controlName, index) => {
const { icon, label, getKeyboardShortcut } =
@ -214,6 +298,25 @@ export const MarkdownToolbar = ({ textAreaId }) => {
/>
);
})}
<ImageUploader
editorVersion="v2"
onImageUploadStart={handleImageUploadStarted}
onImageUploadSuccess={handleImageUploadSuccess}
onImageUploadError={handleImageUploadError}
buttonProps={{
onKeyUp: (e) => handleToolbarButtonKeyPress(e, 'toolbar-btn'),
tooltip: smallScreen ? null : (
<span aria-hidden="true">Upload image</span>
),
key: 'image-btn',
variant: 'ghost',
contentType: 'icon',
className: 'toolbar-btn formatter-btn',
tabindex: '-1',
}}
/>
{smallScreen ? getSecondaryFormatterButtons(false) : null}
{smallScreen ? null : (

View file

@ -8,6 +8,9 @@ describe('<MarkdownToolbar />', () => {
beforeEach(() => {
global.Runtime = {
getOSKeyboardModifierKeyString: jest.fn(() => 'cmd'),
isNativeIOS: jest.fn(() => {
return false;
}),
};
global.window.matchMedia = jest.fn((query) => {

View file

@ -8,6 +8,10 @@ export const dragAndUpload = (
) => {
if (files.length > 0 && validateFileInputs()) {
const payload = { image: files };
generateMainImage(payload, handleImageSuccess, handleImageFailure);
generateMainImage({
payload,
successCb: handleImageSuccess,
failureCb: handleImageFailure,
});
}
};

View file

@ -0,0 +1,83 @@
describe('Markdown toolbar', () => {
beforeEach(() => {
cy.testSetup();
cy.fixture('users/articleEditorV2User.json').as('user');
cy.get('@user').then((user) => {
cy.loginAndVisit(user, '/new');
});
});
it('expands and collapses overflow menu', () => {
cy.findByRole('button', { name: 'More options' }).as('overflowMenuButton');
// Check the menu opens on down arrow press
cy.get('@overflowMenuButton')
.should('have.attr', 'aria-expanded', 'false')
.focus()
.type('{downarrow}')
.should('have.attr', 'aria-expanded', 'true');
cy.findByRole('menuitem', { name: 'Underline' })
.should('be.visible')
.should('have.focus');
// Check Escape closes the menu
cy.get('body').type('{esc}');
cy.get('@overflowMenuButton')
.should('have.focus')
.should('have.attr', 'aria-expanded', 'false');
// Check clicking toggles the menu
cy.get('@overflowMenuButton')
.click()
.should('have.attr', 'aria-expanded', 'true');
cy.findByRole('menuitem', { name: 'Underline' })
.should('be.visible')
.should('have.focus');
cy.get('@overflowMenuButton')
.click()
.should('have.attr', 'aria-expanded', 'false');
});
it('closes overflow menu after formatter button press', () => {
cy.findByLabelText('Post Content').clear();
cy.findByRole('button', { name: 'More options' }).as('overflowMenuButton');
cy.get('@overflowMenuButton').click();
cy.findByRole('menuitem', { name: 'Underline' }).click();
cy.get('@overflowMenuButton').should('have.attr', 'aria-expanded', 'false');
cy.findByRole('menuitem', { name: 'Underline' }).should('not.exist');
cy.findByLabelText('Post Content')
.should('have.focus')
.should('have.value', '<u></u>');
});
it('inserts formatting on button press, returning focus to text area with correct cursor position', () => {
cy.findByLabelText('Post Content').clear();
cy.findByRole('button', { name: 'Bold' }).click();
cy.findByLabelText('Post Content')
.should('have.value', '****')
.should('have.focus')
.type('something')
.should('have.value', '**something**');
});
it('cycles button focus using arrow keys', () => {
cy.findByRole('button', { name: 'Bold' }).as('boldButton');
cy.get('@boldButton').focus().type('{leftarrow}');
cy.findByRole('button', { name: 'More options' })
.should('have.focus')
.type('{rightarrow}');
cy.get('@boldButton').should('have.focus');
});
});

View file

@ -35,17 +35,67 @@ describe('Upload image', () => {
});
});
it('Uploads an image in the editor', () => {
cy.findByRole('form', { name: 'Edit post' }).within(() => {
cy.findByLabelText(/Upload image/).attachFile(
'/images/admin-image.png',
);
it('successfully uploads an image and inserts image markdown', () => {
cy.findByLabelText('Post Content').clear();
cy.findByLabelText(/Upload image/, { selector: 'input' }).attachFile(
'/images/admin-image.png',
);
cy.findByLabelText('Post Content')
.invoke('val')
.should('match', /!\[Image description\]/);
});
it('cancels an in-progress image upload and displays an error', () => {
cy.intercept('/image_uploads', { delay: 2000 });
cy.findByLabelText('Post Content').as('editorBody');
cy.get('@editorBody').clear();
cy.findByLabelText(/Upload image/, { selector: 'input' }).attachFile(
'/images/admin-image.png',
);
// Check the uploading placeholder is shown
cy.get('@editorBody')
.invoke('val')
.should('match', /\n!\[Uploading image\]/);
// Cancel the upload and check the placeholder is removed
cy.findByRole('button', { name: 'Cancel image upload' }).click();
cy.get('@editorBody').should('have.value', '\n');
// Mouseover on snackbar ensures it remains readable for test duration
cy.findByTestId('snackbar')
.trigger('mouseover')
.findByRole('alert')
.should('have.text', 'The user aborted a request.');
});
it('shows an error for failed image upload', () => {
cy.intercept('/image_uploads', {
statusCode: 500,
body: {
error: 'Error message',
},
});
// Confirm the UI has updated to show the uploaded state
cy.findByRole('button', {
name: 'Copy Markdown for imageCopy...',
}).should('exist');
cy.findByLabelText('Post Content').as('editorBody');
cy.get('@editorBody').clear();
cy.findByLabelText(/Upload image/, { selector: 'input' }).attachFile(
'/images/admin-image.png',
);
// Check placeholder is removed from text area
cy.get('@editorBody').should('have.value', '\n');
// Mouseover on snackbar ensures it remains readable for test duration
cy.findByTestId('snackbar')
.trigger('mouseover')
.findByRole('alert')
.should('have.text', 'Error message');
});
it('Uploads a cover image in the editor', () => {