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:
parent
be97f2fc07
commit
d0dea2228c
15 changed files with 959 additions and 200 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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?.(``);
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<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 : (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ describe('<MarkdownToolbar />', () => {
|
|||
beforeEach(() => {
|
||||
global.Runtime = {
|
||||
getOSKeyboardModifierKeyString: jest.fn(() => 'cmd'),
|
||||
isNativeIOS: jest.fn(() => {
|
||||
return false;
|
||||
}),
|
||||
};
|
||||
|
||||
global.window.matchMedia = jest.fn((query) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue