From 4a43092fb50391bbadc0945d81a27be0454a7430 Mon Sep 17 00:00:00 2001 From: Pavel Mineev Date: Sat, 13 Feb 2021 19:20:32 +0300 Subject: [PATCH 1/5] ignore autogenerated loadLocale.ts in coverage report --- frontend/jest.config.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 2b583129..8072c4aa 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -22,7 +22,13 @@ module.exports = { '/app/__stubs__/remark-config.ts', '/app/__stubs__/static-config.ts', ], - collectCoverageFrom: ['app/**/*.{ts,tsx}', '!**/__mocks__/**', '!**/__stubs__/**', '!app/locales/**'], + collectCoverageFrom: [ + 'app/**/*.{ts,tsx}', + '!**/__mocks__/**', + '!**/__stubs__/**', + '!app/locales/**', + '!app/utils/loadLocale.ts', + ], globals: { 'ts-jest': { babelConfig: true, From 0a4c8dbe45310bf0211af2db940b9322672468dc Mon Sep 17 00:00:00 2001 From: Pavel Mineev Date: Sun, 21 Feb 2021 23:55:46 +0300 Subject: [PATCH 2/5] clear localstorage-mock after each test --- frontend/app/__mocks__/localstorage.ts | 5 +++++ frontend/app/typings/jest-localstorage-mock.d.ts | 3 +++ frontend/jest.config.js | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 frontend/app/__mocks__/localstorage.ts create mode 100644 frontend/app/typings/jest-localstorage-mock.d.ts 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/typings/jest-localstorage-mock.d.ts b/frontend/app/typings/jest-localstorage-mock.d.ts new file mode 100644 index 00000000..10b3192c --- /dev/null +++ b/frontend/app/typings/jest-localstorage-mock.d.ts @@ -0,0 +1,3 @@ +declare module 'jest-localstorage-mock' { + export const clear: () => void; +} diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 8072c4aa..7409aa81 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -17,7 +17,7 @@ module.exports = { }, setupFiles: ['/jest.setup.ts'], setupFilesAfterEnv: [ - 'jest-localstorage-mock', + '/app/__mocks__/localstorage.ts', '/app/__mocks__/headers.ts', '/app/__stubs__/remark-config.ts', '/app/__stubs__/static-config.ts', From 719b07ec6ce192fc49edb5e1824ca09bca68d06b Mon Sep 17 00:00:00 2001 From: Pavel Mineev Date: Mon, 22 Feb 2021 13:04:09 +0300 Subject: [PATCH 3/5] remove unused hook --- frontend/app/hooks/useAction.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/frontend/app/hooks/useAction.ts b/frontend/app/hooks/useAction.ts index 0363773c..3f051588 100644 --- a/frontend/app/hooks/useAction.ts +++ b/frontend/app/hooks/useAction.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useCallback, useMemo } from 'preact/compat'; +import { useMemo } from 'preact/compat'; import { useDispatch } from 'react-redux'; import { BoundActionCreator, BoundActionCreators } from 'utils/actionBinder'; @@ -19,10 +19,3 @@ export const useActions = ( [dispatch, ...Object.values(actions)] ) as any; }; - -export const useAction = (action: Action): BoundActionCreator => { - const dispatch = useDispatch(); - - // @ts-ignore - return useCallback((...args) => dispatch(action(...args)), [dispatch, action]); -}; From 23b5a0c6bb5c901fad6fa5e4a89825aa32c3c09d Mon Sep 17 00:00:00 2001 From: Pavel Mineev Date: Mon, 22 Feb 2021 11:43:10 +0300 Subject: [PATCH 4/5] fix webpack config --- frontend/package-lock.json | 8 ++++++- frontend/package.json | 1 + frontend/webpack.config.js | 44 ++++++++++++++------------------------ 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b5956e53..69f4c0df 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1719,6 +1719,12 @@ "integrity": "sha512-3NsZsJIA/22P3QUyrEDNA2D133H4j224twJrdipXN38dpnIOzAbUDtOwkcJ5pXmn75w7LSQDjA4tO9dm1XlqlA==", "dev": true }, + "@prefresh/babel-plugin": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.4.0.tgz", + "integrity": "sha512-fFwyfIHm/B8BBY7HL4j9iJl7KFk/5yVIWE+aozRRPPxI8lRFkyXMAgUFtTSmP3/jiMA6jyOcBeYUhWsyEUynpQ==", + "dev": true + }, "@prefresh/core": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.3.0.tgz", @@ -7288,7 +7294,7 @@ }, "ignore-walk": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz", + "resolved": false, "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==", "dev": true, "requires": { diff --git a/frontend/package.json b/frontend/package.json index 6353eb63..601f74a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "@babel/preset-react": "^7.12.10", "@formatjs/cli": "^3.0.1", "@mavrin/stylelint-declaration-use-css-custom-properties": "1.0.0-alpha.2", + "@prefresh/babel-plugin": "^0.4.0", "@prefresh/webpack": "^3.0.0", "@size-limit/file": "^4.9.1", "@types/classnames": "^2.2.11", diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index bcc48529..eff7b852 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -12,7 +12,7 @@ const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const babelConfig = require('./.babelrc.js'); const NODE_ID = 'remark42'; -const PUBLIC_PATH = '/web'; +const PUBLIC_PATH = '/web/'; const PORT = process.env.PORT || 9000; const REMARK_API_BASE_URL = process.env.REMARK_API_BASE_URL || 'http://127.0.0.1:8080'; const DEVSERVER_BASE_PATH = process.env.DEVSERVER_BASE_PATH || 'http://127.0.0.1:9000'; @@ -78,15 +78,7 @@ module.exports = (_, { mode, analyze }) => { publicPath: PUBLIC_PATH, }; - const optimization = { - chunkIds: 'named', - moduleIds: 'named', - splitChunks: { - minChunks: 3, - }, - }; - - const getTsRule = (babelEnvConfig = {}) => { + const getTsRule = (babelConfig = {}) => { return { test: /\.tsx?$/, exclude: /node_modules/, @@ -96,7 +88,7 @@ module.exports = (_, { mode, analyze }) => { options: { exclude, cacheDirectory: true, - ...babelEnvConfig, + ...babelConfig, }, }, { @@ -178,13 +170,7 @@ module.exports = (_, { mode, analyze }) => { port: PORT, contentBase: PUBLIC_FOLDER_PATH, disableHostCheck: true, - historyApiFallback: true, - quiet: true, - inline: true, hot: true, - compress: true, - clientLogLevel: 'none', - overlay: false, stats: 'minimal', watchOptions: { ignored: [PUBLIC_FOLDER_PATH, path.resolve(__dirname, 'node_modules')], @@ -196,7 +182,7 @@ module.exports = (_, { mode, analyze }) => { }; const plugins = [ - ...(isDev ? [new CleanWebpackPlugin(), new RefreshPlugin(), new webpack.HotModuleReplacementPlugin()] : []), + ...(isDev ? [new CleanWebpackPlugin(), new webpack.HotModuleReplacementPlugin(), new RefreshPlugin()] : []), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(mode), 'process.env.REMARK_NODE': JSON.stringify(NODE_ID), @@ -211,7 +197,6 @@ module.exports = (_, { mode, analyze }) => { entry, devtool: 'source-map', resolve, - optimization, }; const legacyConfig = { @@ -246,7 +231,13 @@ module.exports = (_, { mode, analyze }) => { chunkFilename: '[name].mjs', }, module: { - rules: [getTsRule(babelConfig.env.modern), ...rules], + rules: [ + getTsRule({ + ...babelConfig.env.modern, + plugins: [...babelConfig.env.modern.plugins, ...(isDev ? ['@prefresh/babel-plugin'] : [])], + }), + ...rules, + ], }, plugins: [ ...plugins, @@ -272,14 +263,6 @@ module.exports = (_, { mode, analyze }) => { REMARK_URL, minify: htmlMinifyOptions, }), - // new HtmlWebpackPlugin({ - // template: path.resolve(__dirname, 'templates/comments.ejs'), - // filename: 'comments.html', - // inject: false, - // env: mode, - // REMARK_URL, - // minify: htmlMinifyOptions, - // }), new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'templates/last-comments.ejs'), filename: 'last-comments.html', @@ -320,7 +303,12 @@ module.exports = (_, { mode, analyze }) => { devServer, }; + if (isDev) { + return modernConfig; + } + return [legacyConfig, modernConfig]; }; + module.exports.CUSTOM_PROPERTIES_PATH = CUSTOM_PROPERTIES_PATH; module.exports.exclude = exclude; From ec62b1a572babed1abdbcd8b8003e71ee69747c5 Mon Sep 17 00:00:00 2001 From: Pavel Mineev Date: Sun, 21 Feb 2021 22:38:31 +0300 Subject: [PATCH 5/5] refac textarea autosize --- .../auth__email-login-form.tsx | 15 ++-- .../comment-form__subscribe-by-email.tsx | 2 +- .../comment-form/comment-form.test.tsx | 2 +- .../components/comment-form/comment-form.tsx | 82 +++++++---------- .../comment-form/textarea-autosize.tsx | 88 ------------------- frontend/app/components/textarea-autosize.tsx | 33 +++++++ 6 files changed, 75 insertions(+), 147 deletions(-) delete mode 100644 frontend/app/components/comment-form/textarea-autosize.tsx create mode 100644 frontend/app/components/textarea-autosize.tsx 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