diff --git a/frontend/app/common/fetcher.test.ts b/frontend/app/common/fetcher.test.ts index d45be04766..4a6f97dd92 100644 --- a/frontend/app/common/fetcher.test.ts +++ b/frontend/app/common/fetcher.test.ts @@ -56,9 +56,8 @@ describe('fetcher', () => { fail(data); }) .catch(e => { - expect(e.code).toBe(-1); + expect(e.code).toBe(401); expect(e.error).toBe('Not authorized.'); - expect(e.details).toBe('Not authorized.'); }); }); it('should throw "Something went wrong." object on unknown status', async () => { @@ -77,9 +76,8 @@ describe('fetcher', () => { fail(data); }) .catch(e => { - expect(e.code).toBe(-1); + expect(e.code).toBe(500); expect(e.error).toBe('Something went wrong.'); - expect(e.details).toBe('you given me something wrong'); }); }); }); diff --git a/frontend/app/common/fetcher.ts b/frontend/app/common/fetcher.ts index d25b6175c9..229bc6fef5 100644 --- a/frontend/app/common/fetcher.ts +++ b/frontend/app/common/fetcher.ts @@ -2,7 +2,7 @@ import { BASE_URL, API_BASE } from './constants'; import { siteId } from './settings'; import { StaticStore } from './static_store'; import { getCookie } from './cookies'; -import { httpErrorMap, isFailedFetch } from '@app/utils/errorUtils'; +import { httpErrorMap, isFailedFetch, httpMessages } from '@app/utils/errorUtils'; export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head']; @@ -82,11 +82,10 @@ const fetcher = methods.reduce>((acc, method) => { if (res.status >= 400) { if (httpErrorMap.has(res.status)) { - const errString = httpErrorMap.get(res.status)!; + const descriptor = httpErrorMap.get(res.status) || httpMessages.unexpectedError; throw { - code: -1, - error: errString, - details: errString, + code: descriptor ? res.status : 500, + error: descriptor.defaultMessage, }; } return res.text().then(text => { @@ -99,9 +98,8 @@ const fetcher = methods.reduce>((acc, method) => { console.error(err); } throw { - code: -1, - error: 'Something went wrong.', - details: text, + code: 500, + error: httpMessages.unexpectedError.defaultMessage, }; } throw err; diff --git a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx index 7cebf6d28b..97cddf638a 100644 --- a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx +++ b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.test.tsx @@ -5,6 +5,8 @@ import { EmailLoginForm, Props, State } from './auth-panel__email-login-form'; import { User } from '@app/common/types'; import { sleep } from '@app/utils/sleep'; import { validToken } from '@app/testUtils/mocks/jwt'; +import { createIntl } from 'react-intl'; +import enMessages from '../../../locales/en.json'; jest.mock('@app/utils/jwt', () => ({ isJwtExpired: jest @@ -13,6 +15,11 @@ jest.mock('@app/utils/jwt', () => ({ .mockImplementationOnce(() => true), })); +const intl = createIntl({ + locale: `en`, + messages: enMessages, +}); + describe('EmailLoginForm', () => { const testUser = ({} as any) as User; const onSuccess = jest.fn(async () => {}); @@ -27,6 +34,7 @@ describe('EmailLoginForm', () => { onSignIn={onSignIn} onSuccess={onSuccess} theme="light" + intl={intl} /> ); @@ -57,6 +65,7 @@ describe('EmailLoginForm', () => { onSignIn={onSignIn} onSuccess={onSuccess} theme="light" + intl={intl} /> ); await new Promise(resolve => @@ -82,6 +91,7 @@ describe('EmailLoginForm', () => { onSignIn={onSignIn} onSuccess={onSuccess} theme="light" + intl={intl} /> ); await new Promise(resolve => diff --git a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx index 598c6c934a..d62a34df74 100644 --- a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx +++ b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx @@ -12,6 +12,7 @@ import TextareaAutosize from '@app/components/comment-form/textarea-autosize'; import { Input } from '@app/components/input'; import { Button } from '@app/components/button'; import { isJwtExpired } from '@app/utils/jwt'; +import { IntlShape, useIntl } from 'react-intl'; const mapStateToProps = () => ({ sendEmailVerification: sendEmailVerificationRequest, @@ -24,7 +25,7 @@ interface OwnProps { className?: string; } -export type Props = OwnProps & ReturnType; +export type Props = OwnProps & ReturnType & { intl: IntlShape }; export interface State { usernameValue: string; @@ -70,7 +71,7 @@ export class EmailLoginForm extends Component { this.tokenRef.current && this.tokenRef.current.focus(); }, 100); } catch (e) { - this.setState({ error: extractErrorMessageFromResponse(e) }); + this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) }); } finally { this.setState({ loading: false }); } @@ -87,7 +88,7 @@ export class EmailLoginForm extends Component { this.setState({ verificationSent: false, tokenValue: '' }); this.props.onSuccess && this.props.onSuccess(user); } catch (e) { - this.setState({ error: extractErrorMessageFromResponse(e) }); + this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) }); } finally { this.setState({ loading: false }); } @@ -233,5 +234,6 @@ export type EmailLoginFormRef = EmailLoginForm; export const EmailLoginFormConnected = forwardRef((props, ref) => { const connectedProps = useSelector(mapStateToProps); - return ; + const intl = useIntl(); + return ; }); diff --git a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx index 78a760dd5d..784eadb5f1 100644 --- a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx +++ b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.test.tsx @@ -16,6 +16,8 @@ import { Input } from '@app/components/input'; import { Button } from '@app/components/button'; import { Dropdown } from '@app/components/dropdown'; import TextareaAutosize from '@app/components/comment-form/textarea-autosize'; +import { IntlProvider } from 'react-intl'; +import enMessages from '../../../locales/en.json'; import { SubscribeByEmail, SubscribeByEmailForm } from './'; @@ -40,9 +42,11 @@ jest.mock('@app/utils/jwt', () => ({ describe('', () => { const createWrapper = (store: ReturnType = mockStore(initialStore)) => mount( - - - + + + + + ); it('should be rendered with disabled email button when user is anonymous', () => { @@ -67,9 +71,11 @@ describe('', () => { describe('', () => { const createWrapper = (store: ReturnType = mockStore(initialStore)) => mount( - - - + + + + + ); it('should render email form by default', () => { const store = mockStore(initialStore); 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 fca3961675..5452a7abec 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 @@ -23,6 +23,7 @@ import { Preloader } from '@app/components/preloader'; import TextareaAutosize from '@app/components/comment-form/textarea-autosize'; import { isUserAnonymous } from '@app/utils/isUserAnonymous'; import { isJwtExpired } from '@app/utils/jwt'; +import { useIntl } from 'react-intl'; const emailRegex = /[^@]+@[^.]+\..+/; @@ -78,6 +79,7 @@ const renderTokenPart = ( export const SubscribeByEmailForm: FunctionComponent = () => { const theme = useTheme(); const dispatch = useDispatch(); + const intl = useIntl(); const subscribed = useSelector(({ user }) => user === null ? false : Boolean(user.email_subscription) ); @@ -114,7 +116,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => { break; } } catch (e) { - setError(extractErrorMessageFromResponse(e)); + setError(extractErrorMessageFromResponse(e, intl)); } finally { setLoading(false); } @@ -189,7 +191,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => { previousStep.current = Step.Subscribed; setStep(Step.Unsubscribed); } catch (e) { - setError(extractErrorMessageFromResponse(e)); + setError(extractErrorMessageFromResponse(e, intl)); } finally { setLoading(false); } diff --git a/frontend/app/components/comment-form/comment-form.tsx b/frontend/app/components/comment-form/comment-form.tsx index 92968a6b19..48ca41c181 100644 --- a/frontend/app/components/comment-form/comment-form.tsx +++ b/frontend/app/components/comment-form/comment-form.tsx @@ -192,7 +192,7 @@ export class CommentForm extends Component { this.setState({ preview: null, text: '' }); }) .catch(e => { - const errorMessage = extractErrorMessageFromResponse(e); + const errorMessage = extractErrorMessageFromResponse(e, this.props.intl); this.setState({ isErrorShown: true, errorMessage }); }) .finally(() => this.setState({ isDisabled: false })); @@ -259,7 +259,7 @@ export class CommentForm extends Component { return new Error( intl.formatMessage(messages.uploadFileFail, { fileName: file.name, - errorMessage: extractErrorMessageFromResponse(e), + errorMessage: extractErrorMessageFromResponse(e, this.props.intl), }) ); }); diff --git a/frontend/app/components/comment-form/markdown-toolbar.tsx b/frontend/app/components/comment-form/markdown-toolbar.tsx index 06bf514fbe..fd96ac03a9 100644 --- a/frontend/app/components/comment-form/markdown-toolbar.tsx +++ b/frontend/app/components/comment-form/markdown-toolbar.tsx @@ -91,6 +91,7 @@ export default class MarkdownToolbar extends Component { const unorderedListLabel = intl.formatMessage(messages.unorderedList); const orderedListLabel = intl.formatMessage(messages.orderedList); const attachImageLabel = intl.formatMessage(messages.attachImage); + return (
diff --git a/frontend/app/components/comment/comment.tsx b/frontend/app/components/comment/comment.tsx index 9146dcbb2f..a207074ec3 100644 --- a/frontend/app/components/comment/comment.tsx +++ b/frontend/app/components/comment/comment.tsx @@ -316,7 +316,7 @@ class Comment extends Component { this.setState({ scoreDelta: originalDelta, cachedScore: originalScore, - voteErrorMessage: extractErrorMessageFromResponse(e), + voteErrorMessage: extractErrorMessageFromResponse(e, this.props.intl), }); }; diff --git a/frontend/app/locales/en.json b/frontend/app/locales/en.json index 2ba8f33896..8ef3e02c2b 100644 --- a/frontend/app/locales/en.json +++ b/frontend/app/locales/en.json @@ -106,5 +106,29 @@ "toolbar.link": "Add a link ", "toolbar.unordered-list": "Add a bulleted list", "toolbar.ordered-list": "Add a numbered list", - "toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard" + "toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard", + "errors.failed-fetch": "Failed to fetch. Please check your internet connection or try again a bit later", + "errors.0": "Something went wrong. Please try again a bit later.", + "errors.1": "Comment cannot be found. Please refresh the page and try again.", + "errors.2": "Failed to unmarshal incoming request.", + "errors.3": "You don't have permission for this operation.", + "errors.4": "Invalid comment data.", + "errors.5": "Comment cannot be found. Please refresh the page and try again.", + "errors.6": "Site cannot be found. Please refresh the page and try again.", + "errors.7": "User has been blocked.", + "errors.8": "User has been blocked.", + "errors.9": "Comment changing failed. Please try again a bit later.", + "errors.10": "It is too late to edit the comment.", + "errors.11": "Comment already has reply, editing is not possible.", + "errors.12": "Cannot save voting result. Please try again a bit later.", + "errors.13": "You cannot vote for your own comment.", + "errors.14": "You have already voted for the comment.", + "errors.15": "Too many votes for the comment.", + "errors.16": "Min score reached for the comment.", + "errors.17": "Action rejected. Please try again a bit later.", + "errors.18": "Requested file cannot be found.", + "errors.not-authorized": "Not authorized.", + "errors.forbidden": "Forbidden.", + "errors.to-many-request": "You have reached maximum request limit.", + "errors.unexpected-error": "Something went wrong." } diff --git a/frontend/app/locales/ru.json b/frontend/app/locales/ru.json index 8e7575e06f..0ba7e202ee 100644 --- a/frontend/app/locales/ru.json +++ b/frontend/app/locales/ru.json @@ -106,5 +106,29 @@ "toolbar.link": "Ссылка ", "toolbar.unordered-list": "Неупорядоченный список", "toolbar.ordered-list": "Упорядоченный список", - "toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard" + "toolbar.attach-image": "Attach the image, drag & drop or paste from clipboard", + "errors.failed-fetch": "Нет ответа с сервера. Проверьте ваше соеденение с интернетом или попробуйте позже.", + "errors.0": "Something went wrong. Please try again a bit later.", + "errors.1": "Comment cannot be found. Please refresh the page and try again.", + "errors.2": "Failed to unmarshal incoming request.", + "errors.3": "You don't have permission for this operation.", + "errors.4": "Invalid comment data.", + "errors.5": "Comment cannot be found. Please refresh the page and try again.", + "errors.6": "Site cannot be found. Please refresh the page and try again.", + "errors.7": "User has been blocked.", + "errors.8": "User has been blocked.", + "errors.9": "Comment changing failed. Please try again a bit later.", + "errors.10": "It is too late to edit the comment.", + "errors.11": "Comment already has reply, editing is not possible.", + "errors.12": "Cannot save voting result. Please try again a bit later.", + "errors.13": "You cannot vote for your own comment.", + "errors.14": "Вы уже голосовали за этот комментарий.", + "errors.15": "Too many votes for the comment.", + "errors.16": "Min score reached for the comment.", + "errors.17": "Action rejected. Please try again a bit later.", + "errors.18": "Requested file cannot be found.", + "errors.not-authorized": "Not authorized.", + "errors.forbidden": "Forbidden.", + "errors.to-many-request": "Слишком много запросов.", + "errors.unexpected-error": "Что-то пошло не так." } diff --git a/frontend/app/utils/errorUtils.ts b/frontend/app/utils/errorUtils.ts index 6db4495414..c084ee5bf3 100644 --- a/frontend/app/utils/errorUtils.ts +++ b/frontend/app/utils/errorUtils.ts @@ -1,37 +1,184 @@ +import { IntlShape, defineMessages, MessageDescriptor } from 'react-intl'; + +const messages = defineMessages({ + failedFetch: { + id: 'errors.failed-fetch', + defaultMessage: 'Failed to fetch. Please check your internet connection or try again a bit later', + description: { + code: -2, + }, + }, + 0: { + id: 'errors.0', + defaultMessage: 'Something went wrong. Please try again a bit later.', + description: { + code: 0, + }, + }, + 1: { + id: 'errors.1', + defaultMessage: 'Comment cannot be found. Please refresh the page and try again.', + description: { + code: 1, + }, + }, + 2: { + id: 'errors.2', + defaultMessage: 'Failed to unmarshal incoming request.', + description: { + code: 2, + }, + }, + 3: { + id: 'errors.3', + defaultMessage: `You don't have permission for this operation.`, + description: { + code: 3, + }, + }, + 4: { + id: 'errors.4', + defaultMessage: `Invalid comment data.`, + description: { + code: 4, + }, + }, + 5: { + id: 'errors.5', + defaultMessage: `Comment cannot be found. Please refresh the page and try again.`, + description: { + code: 5, + }, + }, + 6: { + id: 'errors.6', + defaultMessage: `Site cannot be found. Please refresh the page and try again.`, + description: { + code: 6, + }, + }, + 7: { + id: 'errors.7', + defaultMessage: `User has been blocked.`, + description: { + code: 7, + }, + }, + 8: { + id: 'errors.8', + defaultMessage: `User has been blocked.`, + description: { + code: 8, + }, + }, + 9: { + id: 'errors.9', + defaultMessage: `Comment changing failed. Please try again a bit later.`, + description: { + code: 9, + }, + }, + 10: { + id: 'errors.10', + defaultMessage: `It is too late to edit the comment.`, + description: { + code: 10, + }, + }, + 11: { + id: 'errors.11', + defaultMessage: `Comment already has reply, editing is not possible.`, + description: { + code: 11, + }, + }, + 12: { + id: 'errors.12', + defaultMessage: `Cannot save voting result. Please try again a bit later.`, + description: { + code: 12, + }, + }, + 13: { + id: 'errors.13', + defaultMessage: `You cannot vote for your own comment.`, + description: { + code: 13, + }, + }, + 14: { + id: 'errors.14', + defaultMessage: `You have already voted for the comment.`, + description: { + code: 14, + }, + }, + 15: { + id: 'errors.15', + defaultMessage: `Too many votes for the comment.`, + description: { + code: 15, + }, + }, + 16: { + id: 'errors.16', + defaultMessage: `Min score reached for the comment.`, + description: { + code: 16, + }, + }, + 17: { + id: 'errors.17', + defaultMessage: `Action rejected. Please try again a bit later.`, + description: { + code: 17, + }, + }, + 18: { + id: 'errors.18', + defaultMessage: `Requested file cannot be found.`, + description: { + code: 18, + }, + }, +}); + /** * map of codes that server returns in its response in case of error * to client readable version */ -const errorMessageForCodes = new Map([ - [-2, 'Failed to fetch. Please check your internet connection.'], - [0, 'Something went wrong. Please try again a bit later.'], - [1, 'Comment cannot be found. Please refresh the page and try again.'], - [2, 'Failed to unmarshal incoming request.'], - [3, "You don't have permission for this operaton."], - [4, 'Invalid comment data.'], - [5, 'Comment cannot be found. Please refresh the page and try again.'], - [6, 'Site cannot be found. Please refresh the page and try again.'], - [7, 'User has been blocked.'], - [8, 'This post is read only.'], - [9, 'Comment changing failed. Please try again a bit later.'], - [10, 'It is too late to edit the comment.'], - [11, 'Comment already has reply, editing is not possible.'], - [12, 'Cannot save voting result. Please try again a bit later.'], - [13, 'You cannot vote for your own comment.'], - [14, 'You have already voted for the comment.'], - [15, 'Too many votes for the comment.'], - [16, 'Min score reached for the comment.'], - [17, 'Action rejected. Please try again a bit later.'], - [18, 'Requested file cannot be found.'], -]); +const errorMessageForCodes = new Map(); + +Object.entries(messages).forEach(([, messageDescriptor]) => { + errorMessageForCodes.set(messageDescriptor.description.code, messageDescriptor); +}); + +export const httpMessages = defineMessages({ + notAuthorized: { + id: 'errors.not-authorized', + defaultMessage: 'Not authorized.', + }, + forbidden: { + id: 'errors.forbidden', + defaultMessage: 'Forbidden.', + }, + toManyRequest: { + id: 'errors.to-many-request', + defaultMessage: 'You have reached maximum request limit.', + }, + unexpectedError: { + id: 'errors.unexpected-error', + defaultMessage: 'Something went wrong.', + }, +}); /** * map of http rest codes to ui label, used by fetcher to generate error with `-1` code */ export const httpErrorMap = new Map([ - [401, 'Not authorized.'], - [403, 'Forbidden.'], - [429, 'You have reached maximum request limit.'], + [401, httpMessages.notAuthorized], + [403, httpMessages.forbidden], + [429, httpMessages.toManyRequest], ]); export function isFailedFetch(e?: Error): boolean { @@ -51,9 +198,8 @@ export type FetcherError = error: string; }; -export function extractErrorMessageFromResponse(response: FetcherError): string { - const defaultErrorMessage = errorMessageForCodes.get(0) as string; - +export function extractErrorMessageFromResponse(response: FetcherError, intl: IntlShape): string { + const defaultErrorMessage = intl.formatMessage(errorMessageForCodes.get(0) || messages['0']); if (!response) { return defaultErrorMessage; } @@ -62,16 +208,13 @@ export function extractErrorMessageFromResponse(response: FetcherError): string return response; } - if (response.code === -1) { - return response.error; - } - - if (typeof response.details === 'string') { - return response.details.charAt(0).toUpperCase() + response.details.substring(1); - } - - if (typeof response.code === 'number' && errorMessageForCodes.has(response.code)) { - return errorMessageForCodes.get(response.code)!; + if ( + typeof response.code === 'number' && + (errorMessageForCodes.has(response.code) || httpErrorMap.has(response.code)) + ) { + const messageDescriptor = + errorMessageForCodes.get(response.code) || httpErrorMap.get(response.code) || messages['0']; + return intl.formatMessage(messageDescriptor); } return defaultErrorMessage;