🐛 Fixed error rendering when using a duplicate offer code (#20156)

closes https://linear.app/tryghost/issue/ONC-15

- when adding or editing an offer, the backend throws an error if the
offer code is already in use. This error was not being surfaced
correctly in Admin
This commit is contained in:
Sag 2024-05-08 11:27:31 +02:00 committed by GitHub
parent e8e3447f15
commit 6281e63411
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 79 additions and 10 deletions

View File

@ -37,6 +37,7 @@ interface MockRequestConfig {
path: string | RegExp; path: string | RegExp;
response: unknown; response: unknown;
responseStatus?: number; responseStatus?: number;
responseHeaders?: {[key: string]: string};
} }
interface RequestRecord { interface RequestRecord {
@ -189,7 +190,8 @@ export async function mockApi<Requests extends Record<string, MockRequestConfig>
await route.fulfill({ await route.fulfill({
status: matchingMock.responseStatus || 200, status: matchingMock.responseStatus || 200,
body: typeof matchingMock.response === 'string' ? matchingMock.response : JSON.stringify(matchingMock.response) body: typeof matchingMock.response === 'string' ? matchingMock.response : JSON.stringify(matchingMock.response),
headers: matchingMock.responseHeaders || {}
}); });
}); });

View File

@ -1,7 +1,9 @@
import PortalFrame from '../../membership/portal/PortalFrame'; import PortalFrame from '../../membership/portal/PortalFrame';
import toast from 'react-hot-toast';
import {Button} from '@tryghost/admin-x-design-system'; import {Button} from '@tryghost/admin-x-design-system';
import {ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks';
import {Form, Icon, PreviewModalContent, Select, SelectOption, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system'; import {Form, Icon, PreviewModalContent, Select, SelectOption, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system';
import {JSONError} from '@tryghost/admin-x-framework/errors';
import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site';
import {getOfferPortalPreviewUrl, offerPortalPreviewUrlTypes} from '../../../../utils/getOffersPortalPreviewUrl'; import {getOfferPortalPreviewUrl, offerPortalPreviewUrlTypes} from '../../../../utils/getOffersPortalPreviewUrl';
import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers';
@ -636,16 +638,35 @@ const AddOfferModal = () => {
validate(); validate();
const isErrorsEmpty = Object.values(errors).every(error => !error); const isErrorsEmpty = Object.values(errors).every(error => !error);
if (!isErrorsEmpty) { if (!isErrorsEmpty) {
toast.remove();
showToast({ showToast({
type: 'pageError', type: 'pageError',
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields correctly' message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields correctly'
}); });
return; return;
} }
if (!(await handleSave())) {
try {
if (await handleSave({force: true})) {
return;
} else {
toast.remove();
showToast({
type: 'pageError',
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields correctly'
});
};
} catch (e) {
let message;
if (e instanceof JSONError && e.data && e.data.errors[0]) {
message = e.data.errors[0].context || e.data.errors[0].message;
}
toast.remove();
showToast({ showToast({
type: 'pageError', type: 'pageError',
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields.' message: message || 'Something went wrong while saving the offer, please try again'
}); });
} }
}} }}

View File

@ -1,8 +1,9 @@
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import PortalFrame from '../../membership/portal/PortalFrame'; import PortalFrame from '../../membership/portal/PortalFrame';
// import useFeatureFlag from '../../../../hooks/useFeatureFlag'; import toast from 'react-hot-toast';
import {Button, ConfirmationModal, Form, PreviewModalContent, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system'; import {Button, ConfirmationModal, Form, PreviewModalContent, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system';
import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
import {JSONError} from '@tryghost/admin-x-framework/errors';
import {Offer, useBrowseOffersById, useEditOffer} from '@tryghost/admin-x-framework/api/offers'; import {Offer, useBrowseOffersById, useEditOffer} from '@tryghost/admin-x-framework/api/offers';
import {createRedemptionFilterUrl} from './OffersIndex'; import {createRedemptionFilterUrl} from './OffersIndex';
import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site';
@ -280,10 +281,27 @@ const EditOfferModal: React.FC<{id: string}> = ({id}) => {
} }
}} }}
onOk={async () => { onOk={async () => {
if (!(await handleSave({fakeWhenUnchanged: true}))) { try {
if (await handleSave({force: true})) {
return;
} else {
toast.remove();
showToast({
type: 'pageError',
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields correctly'
});
}
} catch (e) {
let message;
if (e instanceof JSONError && e.data && e.data.errors[0]) {
message = e.data.errors[0].context || e.data.errors[0].message;
}
toast.remove();
showToast({ showToast({
type: 'pageError', type: 'pageError',
message: 'Can\'t save offer, please double check that you\'ve filled all mandatory fields.' message: message || 'Something went wrong while saving the offer, please try again'
}); });
} }
}} /> : null; }} /> : null;

View File

@ -3,10 +3,6 @@ import {globalDataRequests} from '../../utils/acceptance';
import {mockApi, responseFixtures, settingsWithStripe} from '@tryghost/admin-x-framework/test/acceptance'; import {mockApi, responseFixtures, settingsWithStripe} from '@tryghost/admin-x-framework/test/acceptance';
test.describe('Offers Modal', () => { test.describe('Offers Modal', () => {
// test.beforeEach(async () => {
// toggleLabsFlag('adminXOffers', true);
// });
test('Offers Modal is available', async ({page}) => { test('Offers Modal is available', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
@ -108,9 +104,41 @@ test.describe('Offers Modal', () => {
await modal.getByRole('button', {name: 'New offer'}).click(); await modal.getByRole('button', {name: 'New offer'}).click();
const addModal = page.getByTestId('add-offer-modal'); const addModal = page.getByTestId('add-offer-modal');
await addModal.getByRole('button', {name: 'Publish'}).click(); await addModal.getByRole('button', {name: 'Publish'}).click();
await expect(page.getByTestId('toast-error')).toContainText(/Can't save offer, please double check that you've filled all mandatory fields./); await expect(page.getByTestId('toast-error')).toContainText(/Can't save offer, please double check that you've filled all mandatory fields./);
}); });
test('Errors if the offer code is already taken', async ({page}) => {
await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
addOffer: {method: 'POST', path: `/offers/`, responseStatus: 400, responseHeaders: {'content-type': 'json'}, response: {
errors: [{
message: 'Validation error, cannot edit offer.',
context: 'Offer `code` must be unique. Please change and try again.'
}]
}}
}});
await page.goto('/');
const section = page.getByTestId('offers');
await section.getByRole('button', {name: 'Manage offers'}).click();
const modal = page.getByTestId('offers-modal');
await modal.getByRole('button', {name: 'New offer'}).click();
const addModal = page.getByTestId('add-offer-modal');
expect(addModal).toBeVisible();
const sidebar = addModal.getByTestId('add-offer-sidebar');
expect(sidebar).toBeVisible();
await sidebar.getByPlaceholder(/^Black Friday$/).fill('Coffee Tuesdays');
await sidebar.getByLabel('Amount off').fill('10');
await addModal.getByRole('button', {name: 'Publish'}).click();
await expect(page.getByTestId('toast-error')).toContainText(/Offer `code` must be unique. Please change and try again./);
});
test('Shows validation hints', async ({page}) => { test('Shows validation hints', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers}, browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},