diff --git a/frontend/app/__mocks__/localstorage.ts b/frontend/app/__mocks__/localstorage.ts new file mode 100644 index 00000000..107cbe0b --- /dev/null +++ b/frontend/app/__mocks__/localstorage.ts @@ -0,0 +1,5 @@ +import 'jest-localstorage-mock'; + +afterEach(() => { + localStorage.clear(); +}); diff --git a/frontend/app/components/auth/__email-login-form/auth__email-login-form.tsx b/frontend/app/components/auth/__email-login-form/auth__email-login-form.tsx index d8c0c7f2..bfc652b7 100644 --- a/frontend/app/components/auth/__email-login-form/auth__email-login-form.tsx +++ b/frontend/app/components/auth/__email-login-form/auth__email-login-form.tsx @@ -7,7 +7,7 @@ import { sendEmailVerificationRequest } from 'common/api'; import { extractErrorMessageFromResponse } from 'utils/errorUtils'; import { getHandleClickProps } from 'common/accessibility'; import { sleep } from 'utils/sleep'; -import TextareaAutosize from 'components/comment-form/textarea-autosize'; +import TextareaAutosize from 'components/textarea-autosize'; import { Input } from 'components/input'; import { Button } from 'components/button'; import { isJwtExpired } from 'utils/jwt'; @@ -69,7 +69,7 @@ export class EmailLoginForm extends Component { static emailRegex = /[^@]+@[^.]+\..+/; usernameInputRef = createRef(); - tokenRef = createRef(); + tokenRef = createRef(); state = { usernameValue: '', @@ -82,13 +82,13 @@ export class EmailLoginForm extends Component { focus = async () => { await sleep(100); + if (this.usernameInputRef.current) { this.usernameInputRef.current.focus(); return; } - if (this.tokenRef.current?.textareaRef?.current) { - this.tokenRef.current.textareaRef.current.select(); - } + + this.tokenRef.current?.select(); }; onVerificationSubmit = async (e: Event) => { @@ -97,9 +97,8 @@ export class EmailLoginForm extends Component { try { await this.props.sendEmailVerification(this.state.usernameValue, this.state.addressValue); this.setState({ verificationSent: true }); - setTimeout(() => { - this.tokenRef.current && this.tokenRef.current.focus(); - }, 100); + await sleep(100); + this.tokenRef.current?.focus(); } catch (e) { this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) }); } finally { diff --git a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx index 8f2f16b2..ba9491e2 100644 --- a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx +++ b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx @@ -17,7 +17,7 @@ import { Input } from 'components/input'; import { Button } from 'components/button'; import { Dropdown } from 'components/dropdown'; import Preloader from 'components/preloader'; -import TextareaAutosize from 'components/comment-form/textarea-autosize'; +import TextareaAutosize from 'components/textarea-autosize'; import { isUserAnonymous } from 'utils/isUserAnonymous'; import { isJwtExpired } from 'utils/jwt'; diff --git a/frontend/app/components/comment-form/comment-form.test.tsx b/frontend/app/components/comment-form/comment-form.test.tsx index c72dc70a..5aef2df7 100644 --- a/frontend/app/components/comment-form/comment-form.test.tsx +++ b/frontend/app/components/comment-form/comment-form.test.tsx @@ -5,10 +5,10 @@ import { user, anonymousUser } from '__stubs__/user'; import { StaticStore } from 'common/static-store'; import { LS_SAVED_COMMENT_VALUE } from 'common/constants'; import * as localStorageModule from 'common/local-storage'; +import TextareaAutosize from 'components/textarea-autosize'; import { CommentForm, CommentFormProps, messages } from './comment-form'; import { SubscribeByEmail } from './__subscribe-by-email'; -import TextareaAutosize from './textarea-autosize'; import { IntlShape } from 'react-intl'; function createEvent(type: string, value: T): E { diff --git a/frontend/app/components/comment-form/comment-form.tsx b/frontend/app/components/comment-form/comment-form.tsx index 16b2e553..a8e396a9 100644 --- a/frontend/app/components/comment-form/comment-form.tsx +++ b/frontend/app/components/comment-form/comment-form.tsx @@ -10,6 +10,7 @@ import { isUserAnonymous } from 'utils/isUserAnonymous'; import { sleep } from 'utils/sleep'; import { replaceSelection } from 'utils/replaceSelection'; import { Button } from 'components/button'; +import TextareaAutosize from 'components/textarea-autosize'; import Auth from 'components/auth'; import { getJsonItem, updateJsonItem } from 'common/local-storage'; import { LS_SAVED_COMMENT_VALUE } from 'common/constants'; @@ -18,7 +19,6 @@ import { SubscribeByEmail } from './__subscribe-by-email'; import { SubscribeByRSS } from './__subscribe-by-rss'; import MarkdownToolbar from './markdown-toolbar'; -import TextareaAutosize from './textarea-autosize'; import { TextExpander } from './text-expander'; let textareaId = 0; @@ -100,7 +100,7 @@ export const messages = defineMessages({ export class CommentForm extends Component { /** reference to textarea element */ - textAreaRef = createRef(); + textareaRef = createRef(); textareaId: string; constructor(props: CommentFormProps) { @@ -138,7 +138,6 @@ export class CommentForm extends Component { componentWillReceiveProps(nextProps: CommentFormProps) { if (nextProps.value !== this.props.value) { this.setState({ text: nextProps.value || '' }); - this.props.autofocus && this.textAreaRef.current && this.textAreaRef.current.focus(); } if (nextProps.user && !this.props.value) { this.setState({ @@ -231,7 +230,7 @@ export class CommentForm extends Component { }; getPreview() { - const text = this.textAreaRef.current ? this.textAreaRef.current.getValue() : this.state.text; + const text = this.textareaRef.current?.value ?? this.state.text; if (!text || !text.trim()) return; @@ -264,7 +263,7 @@ export class CommentForm extends Component { if (!this.props.user) e.preventDefault(); if (!this.props.uploadImage) return; if (StaticStore.config.max_image_size === 0) return; - if (!this.textAreaRef) return; + if (!this.textareaRef.current) return; if (!e.dataTransfer) return; const items = Array.from(e.dataTransfer.items); if (Array.from(items).filter((i) => i.kind === 'file' && ImageMimeRegex.test(i.type)).length === 0) return; @@ -295,6 +294,30 @@ export class CommentForm extends Component { this.uploadImages(data); } + /** returns selection range of a textarea */ + getSelection(): [number, number] { + const textarea = this.textareaRef.current; + + if (textarea) { + return [textarea.selectionStart, textarea.selectionEnd]; + } + + throw new Error('No textarea element reference exists'); + } + + /** sets selection range of a textarea */ + setSelection(selection: [number, number]) { + const textarea = this.textareaRef.current; + + if (textarea) { + textarea.selectionStart = selection[0]; + textarea.selectionEnd = selection[1]; + return; + } + + throw new Error('No textarea element reference exists'); + } + /** wrapper with error handling for props.uploadImage */ uploadImage(file: File): Promise { const intl = this.props.intl; @@ -312,15 +335,13 @@ export class CommentForm extends Component { async uploadImages(files: File[]) { const intl = this.props.intl; if (!this.props.uploadImage) return; - if (!this.textAreaRef.current) return; + if (!this.textareaRef.current) return; /** Human readable image size limit, i.e 5MB */ const maxImageSizeString = `${(StaticStore.config.max_image_size / 1024 / 1024).toFixed(2)}MB`; /** upload delay to avoid server rate limiter */ const uploadDelay = 5000; - const isSelectionSupported = this.textAreaRef.current.isSelectionSupported(); - this.setState({ errorLock: true, errorMessage: null, @@ -329,43 +350,6 @@ export class CommentForm extends Component { buttonText: intl.formatMessage(messages.uploading), }); - // TODO: remove legacy code, now we don't support IE - // fallback for ie < 9 - if (!isSelectionSupported) { - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const isFirst = i === 0; - const placeholderStart = this.state.text.length === 0 ? '' : '\n'; - - if (file.size > StaticStore.config.max_image_size) { - this.appendError( - intl.formatMessage(messages.exceededSize, { - fileName: file.name, - maxImageSize: maxImageSizeString, - }) - ); - continue; - } - - !isFirst && (await sleep(uploadDelay)); - - const result = await this.uploadImage(file); - - if (result instanceof Error) { - this.appendError(result.message); - continue; - } - - const markdownString = `${placeholderStart}![${result.name}](${result.url})`; - this.setState({ - text: this.state.text + markdownString, - }); - } - - this.setState({ errorLock: false, isDisabled: false, buttonText: null }); - return; - } - for (let i = 0; i < files.length; i++) { const file = files[i]; const isFirst = i === 0; @@ -375,7 +359,7 @@ export class CommentForm extends Component { fileName: file.name, })}]()`; const uploadPlaceholderLength = uploadPlaceholder.length; - const selection = this.textAreaRef.current.getSelection(); + const selection = this.getSelection(); /** saved selection in case of error */ const originalText = this.state.text; const restoreSelection = async () => { @@ -384,7 +368,7 @@ export class CommentForm extends Component { }); /** sleeping awhile so textarea catch state change and its selection */ await sleep(100); - this.textAreaRef.current!.setSelection(selection); + this.setSelection(selection); }; if (file.size > StaticStore.config.max_image_size) { @@ -418,7 +402,7 @@ export class CommentForm extends Component { /** sleeping awhile so textarea catch state change and its selection */ await sleep(100); const selectionPointer = selection[0] + markdownString.length; - this.textAreaRef.current.setSelection([selectionPointer, selectionPointer]); + this.setSelection([selectionPointer, selectionPointer]); } this.setState({ errorLock: false, isDisabled: false, buttonText: null }); @@ -481,8 +465,8 @@ export class CommentForm extends Component { ; -} & Omit, 'ref'>; - -// TODO: rewrite it to functional component and add ref forwarding -export default class TextareaAutosize extends Component { - textareaRef = createRef(); - - componentDidMount() { - this.autoResize(); - - if (this.props.autofocus) this.focus(); - } - - componentDidUpdate(prevProps: TextareaAutosizeProps) { - if (prevProps.value !== this.props.value) { - this.autoResize(); - } - } - - focus(): void { - setTimeout(() => { - const { current: textarea } = this.textareaRef; - - if (textarea) { - textarea.focus(); - textarea.selectionStart = textarea.value.length; - textarea.selectionEnd = textarea.value.length; - } - }, 100); - } - - /** returns whether selectionStart api supported */ - isSelectionSupported() { - const { current: textarea } = this.textareaRef; - - if (textarea) { - return 'selectionStart' in textarea; - } - - throw new Error('No textarea element reference exists'); - } - - /** returns selection range of a textarea */ - getSelection(): [number, number] { - const { current: textarea } = this.textareaRef; - - if (textarea) { - return [textarea.selectionStart, textarea.selectionEnd]; - } - - throw new Error('No textarea element reference exists'); - } - - /** sets selection range of a textarea */ - setSelection(selection: [number, number]) { - const { current: textarea } = this.textareaRef; - - if (textarea) { - textarea.selectionStart = selection[0]; - textarea.selectionEnd = selection[1]; - return; - } - - throw new Error('No textarea element reference exists'); - } - - getValue() { - const { current: textarea } = this.textareaRef; - - return textarea ? textarea.value : ''; - } - - autoResize() { - const { current: textarea } = this.textareaRef; - - if (textarea) { - textarea.style.height = ''; - textarea.style.height = `${textarea.scrollHeight}px`; - } - } - - render() { - return