Skip to content

Commit

Permalink
🌐 Updated Portal error handling to be i18n-friendly (#21176)
Browse files Browse the repository at this point in the history
no ref
- Portal was not set up in a way to allow for easy use of the i18n
module for errors, as they weren't a React component
- moved away from the class model to a functional component that could
utilize React state (AppContext)

I'm working on a different refactor that would convert more of Portal to
hooks & functional components so that the codebase is more consistent
and easier to read. This will have to work for the moment while that is
being done, as that's no small task.
  • Loading branch information
9larsons authored Oct 1, 2024
1 parent b9547cc commit 49debef
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 36 deletions.
4 changes: 2 additions & 2 deletions apps/portal/src/actions.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import setupGhostApi from './utils/api';
import {HumanReadableError} from './utils/errors';
import {getMessageFromError} from './utils/errors';
import {createPopupNotification, getMemberEmail, getMemberName, getProductCadenceFromPrice, removePortalLinkFromUrl, getRefDomain} from './utils/helpers';

function switchPage({data, state}) {
Expand Down Expand Up @@ -90,7 +90,7 @@ async function signin({data, api, state}) {
action: 'signin:failed',
popupNotification: createPopupNotification({
type: 'signin:failed', autoHide: false, closeable: true, state, status: 'error',
message: HumanReadableError.getMessageFromError(e, 'Failed to log in, please try again')
message: getMessageFromError(e, 'Failed to log in, please try again')
})
};
}
Expand Down
4 changes: 2 additions & 2 deletions apps/portal/src/components/pages/FeedbackPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {ReactComponent as ThumbDownIcon} from '../../images/icons/thumbs-down.sv
import {ReactComponent as ThumbUpIcon} from '../../images/icons/thumbs-up.svg';
import {ReactComponent as ThumbErrorIcon} from '../../images/icons/thumbs-error.svg';
import setupGhostApi from '../../utils/api';
import {HumanReadableError} from '../../utils/errors';
import {getMessageFromError} from '../../utils/errors';
import ActionButton from '../common/ActionButton';
import CloseButton from '../common/CloseButton';
import LoadingPage from './LoadingPage';
Expand Down Expand Up @@ -317,7 +317,7 @@ export default function FeedbackPage() {
await sendFeedback({siteUrl: site.url, uuid, key, postId, score: selectedScore}, api);
setScore(selectedScore);
} catch (e) {
const text = HumanReadableError.getMessageFromError(e, t('There was a problem submitting your feedback. Please try again a little later.'));
const text = getMessageFromError(e, t('There was a problem submitting your feedback. Please try again a little later.'));
setError(text);
}
setLoading(false);
Expand Down
6 changes: 3 additions & 3 deletions apps/portal/src/data-attributes.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable no-console */
import {getCheckoutSessionDataFromPlanAttribute, getUrlHistory} from './utils/helpers';
import {HumanReadableError} from './utils/errors';
import {getErrorFromApiResponse, getMessageFromError} from './utils/errors';

export function formSubmitHandler({event, form, errorEl, siteUrl, submitHandler}) {
form.removeEventListener('submit', submitHandler);
Expand Down Expand Up @@ -77,14 +77,14 @@ export function formSubmitHandler({event, form, errorEl, siteUrl, submitHandler}
if (res.ok) {
form.classList.add('success');
} else {
return HumanReadableError.fromApiResponse(res).then((e) => {
return getErrorFromApiResponse(res).then((e) => {
throw e;
});
}
}).catch((err) => {
if (errorEl) {
// This theme supports a custom error element
errorEl.innerText = HumanReadableError.getMessageFromError(err, 'There was an error sending the email, please try again');
errorEl.innerText = getMessageFromError(err, 'There was an error sending the email, please try again');
}
form.classList.add('error');
});
Expand Down
93 changes: 93 additions & 0 deletions apps/portal/src/tests/unit/errors.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react';
import {render} from '@testing-library/react';
import AppContext from '../../AppContext';
import {GetMessage, getErrorFromApiResponse, getMessageFromError} from '../../utils/errors';

// Mock AppContext
jest.mock('../../AppContext', () => ({
__esModule: true,
default: React.createContext({t: jest.fn()})
}));

describe('GetMessage', () => {
it('returns translated message when t function is available', () => {
const tMock = jest.fn().mockImplementation(msg => `translated: ${msg}`);
const {getByText} = render(
<AppContext.Provider value={{t: tMock}}>
<GetMessage message="Hello" />
</AppContext.Provider>
);
expect(getByText('translated: Hello')).toBeInTheDocument();
expect(tMock).toHaveBeenCalledWith('Hello');
});

it('returns original message when t function is not available', () => {
const {getByText} = render(
<AppContext.Provider value={{}}>
<GetMessage message="Hello" />
</AppContext.Provider>
);
expect(getByText('Hello')).toBeInTheDocument();
});
});

describe('getErrorFromApiResponse', () => {
// These tests are a little goofy because we're not testing the translation, we're testing that the function
// returns an Error with the correct message. We're not testing the message itself because that's handled in the
// getMessage function. And this doesn't run in react so the react component gets odd.
it('returns Error with translated message for 400 status', async () => {
const res = {
status: 400,
json: jest.fn().mockResolvedValue({errors: [{message: 'Bad Request'}]})
};
const error = await getErrorFromApiResponse(res);
expect(error).toBeInstanceOf(Error);
});

// These tests are a little goofy because we're not testing the translation, we're testing that the function
// returns an Error with the correct message. We're not testing the message itself because that's handled in the
// getMessage function. And this doesn't run in react so the react component gets odd.
it('returns Error with translated message for 429 status', async () => {
const res = {
status: 429,
json: jest.fn().mockResolvedValue({errors: [{message: 'Too Many Requests'}]})
};
const error = await getErrorFromApiResponse(res);
expect(error).toBeInstanceOf(Error);
});

it('returns undefined for other status codes', async () => {
const res = {status: 200};
const error = await getErrorFromApiResponse(res);
expect(error).toBeUndefined();
});

it('returns undefined when json parsing fails', async () => {
const res = {
status: 400,
json: jest.fn().mockRejectedValue(new Error('JSON parse error'))
};
const error = await getErrorFromApiResponse(res);
expect(error).toBeUndefined();
});
});

describe('getMessageFromError', () => {
it('returns GetMessage component with default message when provided', () => {
const result = getMessageFromError(new Error('Test error'), 'Default message');
const {getByText} = render(<AppContext.Provider value={{}}>{result}</AppContext.Provider>);
expect(getByText('Default message')).toBeInTheDocument();
});

it('returns GetMessage component with error message for Error instance', () => {
const result = getMessageFromError(new Error('Test error'));
const {getByText} = render(<AppContext.Provider value={{}}>{result}</AppContext.Provider>);
expect(getByText('Test error')).toBeInTheDocument();
});

it('returns GetMessage component with error for non-Error objects', () => {
const result = getMessageFromError('String error');
const {getByText} = render(<AppContext.Provider value={{}}>{result}</AppContext.Provider>);
expect(getByText('String error')).toBeInTheDocument();
});
});
6 changes: 3 additions & 3 deletions apps/portal/src/utils/api.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {HumanReadableError} from './errors';
import {getErrorFromApiResponse} from './errors';
import {transformApiSiteData, transformApiTiersData, getUrlHistory} from './helpers';

function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
Expand Down Expand Up @@ -159,7 +159,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
if (res.ok) {
return res.json();
} else {
throw (await HumanReadableError.fromApiResponse(res)) ?? new Error('Failed to save feedback');
throw (await getErrorFromApiResponse(res)) ?? new Error('Failed to save feedback');
}
}
};
Expand Down Expand Up @@ -291,7 +291,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
return 'Success';
} else {
// Try to read body error message that is human readable and should be shown to the user
const humanError = await HumanReadableError.fromApiResponse(res);
const humanError = await getErrorFromApiResponse(res);
if (humanError) {
throw humanError;
}
Expand Down
68 changes: 42 additions & 26 deletions apps/portal/src/utils/errors.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,48 @@
export class HumanReadableError extends Error {
/**
* Returns whether this response from the server is a human readable error and should be shown to the user.
* @param {Response} res
* @returns {HumanReadableError|undefined}
*/
static async fromApiResponse(res) {
// Bad request + Too many requests
if (res.status === 400 || res.status === 429) {
try {
const json = await res.json();
if (json.errors && Array.isArray(json.errors) && json.errors.length > 0 && json.errors[0].message) {
return new HumanReadableError(json.errors[0].message);
}
} catch (e) {
// Failed to decode: ignore
return false;
import {useContext} from 'react';
import AppContext from '../AppContext';

/**
* A component that gets the internationalized (i18n) message from the app context.
* @param {{message: string}} props - The props object containing the message to be translated.
* @returns {string} The translated message if AppContext.t is available, otherwise the original message.
*/
export const GetMessage = ({message}) => {
const {t} = useContext(AppContext);
return t ? t(message) : message;
};

/**
* Creates a human-readable error from an API response.
* @param {Response} res - The API response object.
* @returns {Promise<Error|undefined>} A promise that resolves to an Error if one can be created, or undefined otherwise.
*/
export async function getErrorFromApiResponse(res) {
// Bad request + Too many requests
if (res.status === 400 || res.status === 429) {
try {
const json = await res.json();
if (json.errors && Array.isArray(json.errors) && json.errors.length > 0 && json.errors[0].message) {
return new Error(<GetMessage message={json.errors[0].message} />);
}
} catch (e) {
// Failed to decode: ignore
return undefined;
}
}
return undefined;
}

/**
* Only output the message of an error if it is a human readable error and should be exposed to the user.
* Otherwise it returns a default generic message.
*/
static getMessageFromError(error, defaultMessage) {
if (error instanceof HumanReadableError) {
return error.message;
}
return defaultMessage;
/**
* Creates a human-readable error message from an error object.
* @param {Error} error - The error object.
* @param {string} defaultMessage - The default message to use if a human-readable message can't be extracted.
* @returns {React.ReactElement} A React element containing the human-readable error message.
*/
export function getMessageFromError(error, defaultMessage) {
if (defaultMessage) {
return <GetMessage message={defaultMessage} />;
} else if (error instanceof Error) {
return <GetMessage message={error.message} />;
}
return <GetMessage message={error} />;
}

0 comments on commit 49debef

Please sign in to comment.