fetchSearch('usernames', { username })}
diff --git a/app/javascript/article-form/components/ImageUploader.jsx b/app/javascript/article-form/components/ImageUploader.jsx
index 996a1393f..b3e1741a7 100644
--- a/app/javascript/article-form/components/ImageUploader.jsx
+++ b/app/javascript/article-form/components/ImageUploader.jsx
@@ -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 = () => (
);
+const CancelIcon = () => (
+
+);
+
+const SpinnerOrCancel = () => (
+
+
+
+
+);
+
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 }) => (
{!uploadingImage && (
@@ -82,60 +104,133 @@ const NativeIosImageUpload = ({ uploadingImage, extraProps }) => (
);
-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 (
-
+ )}
+ {uploadingImage ? (
+
+ ) : (
+
);
+};
-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 (
+
+ {uploadingImage && (
+
+ Uploading...
+
+ )}
+
+ {useNativeUpload ? (
+
+ ) : uploadingImage ? null : (
+
+
+
+ )}
+
+ {insertionImageUrls.length > 0 && (
+
+ )}
+
+ {uploadErrorMessage ? (
+ {uploadErrorMessage}
+ ) : null}
+
+ );
+};
+
+/**
+ * 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?.(``);
+
+ 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?.(``);
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 (
-
- {uploadingImage && (
-
- Uploading...
-
- )}
+
+
- {useNativeUpload ? (
-
- ) : (
-
+ ) : (
+
)}
-
- {insertionImageUrls.length > 0 && (
-
- )}
-
- {uploadError && (
- {uploadErrorMessage}
- )}
-
+
);
};
diff --git a/app/javascript/article-form/components/Toolbar.jsx b/app/javascript/article-form/components/Toolbar.jsx
index 6bd8650cd..1ed9dd3fe 100644
--- a/app/javascript/article-form/components/Toolbar.jsx
+++ b/app/javascript/article-form/components/Toolbar.jsx
@@ -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 (
-
+ {version === 'v1' ? (
+
+ ) : (
+
+ )}
);
};
diff --git a/app/javascript/article-form/components/__tests__/Form.test.jsx b/app/javascript/article-form/components/__tests__/Form.test.jsx
index 35c16decd..e2eda6689 100644
--- a/app/javascript/article-form/components/__tests__/Form.test.jsx
+++ b/app/javascript/article-form/components/__tests__/Form.test.jsx
@@ -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('', () => {
isNativeIOS: jest.fn(() => {
return false;
}),
+ getOSKeyboardModifierKeyString: jest.fn(() => 'cmd'),
};
global.window.matchMedia = jest.fn((query) => {
@@ -153,6 +156,127 @@ describe('', () => {
// Ensure max file size
expect(Number(coverImageInput.dataset.maxFileSizeMb)).toEqual(25);
});
+
+ it('renders a toolbar of markdown formatters', () => {
+ const { getByRole, getByLabelText } = render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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('');
+ 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', () => {
diff --git a/app/javascript/article-form/components/__tests__/ImageUploader.test.jsx b/app/javascript/article-form/components/__tests__/ImageUploader.test.jsx
index 37e917a1a..11cdc85a5 100644
--- a/app/javascript/article-form/components/__tests__/ImageUploader.test.jsx
+++ b/app/javascript/article-form/components/__tests__/ImageUploader.test.jsx
@@ -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('', () => {
- 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();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('displays an upload input', () => {
+ const { getByLabelText } = render();
+ 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(
+ ,
+ );
+
+ 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();
+ 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(
+ ,
+ );
+ 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();
- const results = await axe(container);
- expect(results).toHaveNoViolations();
- });
-
- it('displays an upload input', () => {
- const { getByLabelText } = render();
- 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('', () => {
});
it('does not display the file input', async () => {
- const { queryByLabelText } = render();
+ const { queryByLabelText } = render();
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();
+ const { queryByLabelText } = render();
const uploadButton = queryByLabelText(/Upload an image/i);
uploadButton.click();
expect(
@@ -60,7 +150,7 @@ describe('', () => {
});
it('handles a native bridge message correctly', async () => {
- const { container, findByTitle } = render(); // eslint-disable-line no-unused-vars
+ const { findByTitle } = render();
// Fire a change event in the hidden input with JSON payload for success
const fakeSuccessMessage = JSON.stringify({
@@ -80,88 +170,173 @@ describe('', () => {
});
});
- it('displays the upload spinner during upload', async () => {
- fetch.mockResponse(
- JSON.stringify({
- links: ['/i/fake-link.jpg'],
- }),
- );
-
- const { getByLabelText, queryByText } = render();
-
- 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();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
- const uploadingImage = queryByText(/uploading.../i);
+ it('displays an upload image button with input', () => {
+ const { getAllByLabelText } = render(
+ ,
+ );
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+ 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(
+ '',
+ );
+ });
+
+ it('invokes error callback when error occurs', async () => {
+ fetch.mockReject({
+ message: 'Some Fake Error',
+ });
+
+ const uploadErrorCallback = jest.fn();
+
+ const { getAllByLabelText, queryByText } = render(
+ ,
+ );
+ 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();
- 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();
+ 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(
- ,
- );
- 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(
+ ,
+ );
- 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(
+ '',
+ ),
+ );
});
-
- 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);
});
});
diff --git a/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx b/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx
index 1fcfee9d0..3e97d4358 100644
--- a/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx
+++ b/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx
@@ -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 = '';
+
+/**
+ * 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 }) => {
/>
);
})}
+
+ handleToolbarButtonKeyPress(e, 'toolbar-btn'),
+ tooltip: smallScreen ? null : (
+ Upload image
+ ),
+ key: 'image-btn',
+ variant: 'ghost',
+ contentType: 'icon',
+ className: 'toolbar-btn formatter-btn',
+ tabindex: '-1',
+ }}
+ />
+
{smallScreen ? getSecondaryFormatterButtons(false) : null}
{smallScreen ? null : (
diff --git a/app/javascript/crayons/MarkdownToolbar/__tests__/MarkdownToolbar.test.jsx b/app/javascript/crayons/MarkdownToolbar/__tests__/MarkdownToolbar.test.jsx
index c02e8c867..cd3c10699 100644
--- a/app/javascript/crayons/MarkdownToolbar/__tests__/MarkdownToolbar.test.jsx
+++ b/app/javascript/crayons/MarkdownToolbar/__tests__/MarkdownToolbar.test.jsx
@@ -8,6 +8,9 @@ describe('', () => {
beforeEach(() => {
global.Runtime = {
getOSKeyboardModifierKeyString: jest.fn(() => 'cmd'),
+ isNativeIOS: jest.fn(() => {
+ return false;
+ }),
};
global.window.matchMedia = jest.fn((query) => {
diff --git a/app/javascript/utilities/dragAndUpload.js b/app/javascript/utilities/dragAndUpload.js
index 7c2bd1788..18dcbf0c8 100644
--- a/app/javascript/utilities/dragAndUpload.js
+++ b/app/javascript/utilities/dragAndUpload.js
@@ -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,
+ });
}
};
diff --git a/cypress/integration/seededFlows/publishingFlows/markdownToolbar.spec.js b/cypress/integration/seededFlows/publishingFlows/markdownToolbar.spec.js
new file mode 100644
index 000000000..375ae2990
--- /dev/null
+++ b/cypress/integration/seededFlows/publishingFlows/markdownToolbar.spec.js
@@ -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', '');
+ });
+
+ 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');
+ });
+});
diff --git a/cypress/integration/seededFlows/publishingFlows/uploadImage.spec.js b/cypress/integration/seededFlows/publishingFlows/uploadImage.spec.js
index 661db4011..7beb67c9f 100644
--- a/cypress/integration/seededFlows/publishingFlows/uploadImage.spec.js
+++ b/cypress/integration/seededFlows/publishingFlows/uploadImage.spec.js
@@ -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', () => {