Added tests for tier edit modal (#17369)

refs https://github.com/TryGhost/Product/issues/3580
This commit is contained in:
Jono M 2023-07-14 22:04:34 +09:00 committed by GitHub
parent 704fc18856
commit 0137f498d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 262 additions and 36 deletions

View File

@ -43,7 +43,8 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
const [errors, setErrors] = useState<{ [key in keyof Tier]?: string }>({}); // eslint-disable-line no-unused-vars
const setError = (field: keyof Tier, error: string | undefined) => {
setErrors({...errors, [field]: error});
setErrors(errs => ({...errs, [field]: error}));
return error;
};
const {formState, updateForm, handleSave} = useForm<TierFormState>({
@ -55,14 +56,6 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
currency: tier?.currency || currencies[0].isoCode
},
onSave: async () => {
if (Object.values(errors).some(error => error)) {
showToast({
type: 'pageError',
message: 'One or more fields have errors'
});
return;
}
const {monthly_price: monthlyPrice, yearly_price: yearlyPrice, trial_days: trialDays, currency, ...rest} = formState;
const values: Partial<Tier> = rest;
@ -85,6 +78,14 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
}
});
const currencySymbol = formState.currency ? getSymbol(formState.currency) : '$';
const validators = {
name: () => setError('name', formState.name ? undefined : 'You must specify a name'),
monthly_price: () => setError('monthly_price', (isFreeTier || (formState.monthly_price && parseFloat(formState.monthly_price) >= 1)) ? undefined : `Subscription amount must be at least ${currencySymbol}1.00`),
yearly_price: () => setError('yearly_price', (isFreeTier || (formState.yearly_price && parseFloat(formState.yearly_price) >= 1)) ? undefined : `Subscription amount must be at least ${currencySymbol}1.00`)
};
const benefits = useSortableIndexedList({
items: formState.benefits || [],
setItems: newBenefits => updateForm(state => ({...state, benefits: newBenefits})),
@ -96,17 +97,26 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
return value.match(/[\d]+\.?[\d]{0,2}/)?.[0] || '';
};
const currencySymbol = formState.currency ? getSymbol(formState.currency) : '$';
return <Modal
afterClose={() => {
updateRoute('tiers');
}}
okLabel='Save & close'
size='lg'
testId='tier-detail-modal'
title='Tier'
stickyFooter
onOk={handleSave}
onOk={() => {
if (Object.values(validators).filter(validator => validator()).length) {
showToast({
type: 'pageError',
message: 'One or more fields have errors'
});
return;
}
handleSave();
}}
>
<div className='mt-8 flex items-start gap-16'>
<div className='flex grow flex-col gap-5'>
@ -118,7 +128,7 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
placeholder='Bronze'
title='Name'
value={formState.name || ''}
onBlur={e => setError('name', e.target.value ? undefined : 'You must specify a name')}
onBlur={() => validators.name()}
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
/>}
<TextField
@ -155,8 +165,10 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
hint={errors.monthly_price}
placeholder='1'
rightPlaceholder={`${formState.currency}/month`}
title='Monthly price'
value={formState.monthly_price}
onBlur={e => setError('monthly_price', e.target.value ? undefined : `Subscription amount must be at least ${currencySymbol}1.00`)}
hideTitle
onBlur={() => validators.monthly_price()}
onChange={e => updateForm(state => ({...state, monthly_price: forceCurrencyValue(e.target.value)}))}
/>
<TextField
@ -164,16 +176,17 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
hint={errors.yearly_price}
placeholder='10'
rightPlaceholder={`${formState.currency}/year`}
title='Yearly price'
value={formState.yearly_price}
onBlur={e => setError('yearly_price', e.target.value ? undefined : `Subscription amount must be at least ${currencySymbol}1.00`)}
hideTitle
onBlur={() => validators.yearly_price()}
onChange={e => updateForm(state => ({...state, yearly_price: forceCurrencyValue(e.target.value)}))}
/>
</div>
</div>
<div className='basis-1/2'>
<div className='mb-1 flex h-6 items-center justify-between'>
<Heading level={6}>Add a free trial</Heading>
<Toggle onChange={e => setHasFreeTrial(e.target.checked)} />
<div className='mb-1 flex h-6 flex-col justify-center'>
<Toggle label='Add a free trial' labelStyle='heading' onChange={e => setHasFreeTrial(e.target.checked)} />
</div>
<TextField
disabled={!hasFreeTrial}
@ -182,7 +195,9 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
</>}
placeholder='0'
rightPlaceholder='days'
title='Trial days'
value={formState.trial_days}
hideTitle
onChange={e => updateForm(state => ({...state, trial_days: e.target.value.replace(/[^\d]/, '')}))}
/>
</div>
@ -210,10 +225,21 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
<TextField
className='grow'
placeholder='Expert analysis'
title='New benefit'
value={benefits.newItem}
hideTitle
onChange={e => benefits.setNewItem(e.target.value)}
/>
<Button className='absolute right-0 top-1' color='green' icon="add" iconColorClass='text-white' size='sm' onClick={() => benefits.addItem()} />
<Button
className='absolute right-0 top-1'
color='green'
icon='add'
iconColorClass='text-white'
label='Add'
size='sm'
hideLabel
onClick={() => benefits.addItem()}
/>
</div>
</Form>
</div>

View File

@ -29,7 +29,7 @@ const TierCard: React.FC<TierCardProps> = ({
const currencySymbol = currency ? getSymbol(currency) : '$';
return (
<div className={cardContainerClasses}>
<div className={cardContainerClasses} data-testid='tier-card'>
<div className='w-full grow cursor-pointer' onClick={() => {
NiceModal.show(TierDetailModal, {tier});
}}>
@ -81,7 +81,7 @@ const TiersList: React.FC<TiersListProps> = ({
return <TierCard tier={tier} updateTier={updateTier} />;
})}
{tab === 'active-tiers' && (
<div className={`${cardContainerClasses} group cursor-pointer`} onClick={() => {
<button className={`${cardContainerClasses} group cursor-pointer`} type='button' onClick={() => {
openTierModal();
}}>
<div className='flex h-full w-full flex-col items-center justify-center'>
@ -90,7 +90,7 @@ const TiersList: React.FC<TiersListProps> = ({
<div className='mt-2 translate-y-[-10px] text-sm font-semibold text-green opacity-0 transition-all group-hover:translate-y-0 group-hover:opacity-100'>Add tier</div>
</div>
</div>
</div>
</button>
)}
</div>
);

View File

@ -64,7 +64,7 @@ test.describe('Access settings', async () => {
await section.getByLabel('Select tiers').click();
await section.locator('[data-testid="multiselect-option"]', {hasText: 'Basic Supporter'}).click();
await section.locator('[data-testid="multiselect-option"]', {hasText: 'Ultimate Starlight Diamond Supporter'}).click();
await section.locator('[data-testid="multiselect-option"]', {hasText: 'Ultimate Starlight Diamond Tier'}).click();
await section.getByRole('button', {name: 'Save'}).click();
@ -73,7 +73,7 @@ test.describe('Access settings', async () => {
expect(lastApiRequests.settings.edit.body).toEqual({
settings: [
{key: 'default_content_visibility', value: 'tiers'},
{key: 'default_content_visibility_tiers', value: JSON.stringify(responseFixtures.tiers.tiers.map(tier => tier.id))}
{key: 'default_content_visibility_tiers', value: JSON.stringify(responseFixtures.tiers.tiers.slice(1).map(tier => tier.id))}
]
});
});

View File

@ -0,0 +1,168 @@
import {expect, test} from '@playwright/test';
import {mockApi, responseFixtures} from '../../utils/e2e';
test.describe('Tier settings', async () => {
test('Supports creating a new tier', async ({page}) => {
const lastApiRequests = await mockApi({page, responses: {
tiers: {
add: {
tiers: [{
id: 'new-tier',
type: 'paid',
active: true,
name: 'Plus tier',
slug: 'plus-tier',
description: null,
monthly_price: 800,
yearly_price: 8000,
benefits: [],
welcome_page_url: null,
trial_days: 0,
visibility: 'public',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}]
}
}
}});
await page.goto('/');
const section = page.getByTestId('tiers');
await section.getByRole('button', {name: 'Add tier'}).click();
const modal = page.getByTestId('tier-detail-modal');
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(page.getByTestId('toast')).toHaveText(/One or more fields have errors/);
await expect(modal).toHaveText(/You must specify a name/);
await expect(modal).toHaveText(/Subscription amount must be at least \$1\.00/);
await modal.getByLabel('Name').fill('Plus tier');
await modal.getByLabel('Monthly price').fill('8');
await modal.getByLabel('Yearly price').fill('80');
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/Plus tier/);
await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/\$8\/month/);
expect(lastApiRequests.tiers.add.body).toMatchObject({
tiers: [{
name: 'Plus tier',
monthly_price: 800,
yearly_price: 8000,
trial_days: null
}]
});
});
test('Supports updating a tier', async ({page}) => {
const lastApiRequests = await mockApi({page, responses: {
tiers: {
edit: {
tiers: [{
...responseFixtures.tiers.tiers[1],
name: 'Supporter updated',
description: 'Supporter description',
monthly_price: 1001,
trial_days: 7,
benefits: [
'Simple benefit',
'New benefit'
]
}]
}
}
}});
await page.goto('/');
const section = page.getByTestId('tiers');
await section.getByTestId('tier-card').filter({hasText: /Supporter/}).click();
const modal = page.getByTestId('tier-detail-modal');
await modal.getByLabel('Name').fill('');
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(page.getByTestId('toast')).toHaveText(/One or more fields have errors/);
await expect(modal).toHaveText(/You must specify a name/);
await modal.getByLabel('Name').fill('Supporter updated');
await modal.getByLabel('Description').fill('Supporter description');
await modal.getByLabel('Monthly price').fill('10.01');
await modal.getByLabel('Add a free trial').check();
await modal.getByLabel('Trial days').fill('7');
await modal.getByLabel('New benefit').fill('New benefit');
await modal.getByRole('button', {name: 'Add'}).click();
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(section.getByTestId('tier-card').filter({hasText: /Supporter/})).toHaveText(/Supporter updated/);
await expect(section.getByTestId('tier-card').filter({hasText: /Supporter/})).toHaveText(/Supporter description/);
await expect(section.getByTestId('tier-card').filter({hasText: /Supporter/})).toHaveText(/\$10\.01\/month/);
expect(lastApiRequests.tiers.edit.body).toMatchObject({
tiers: [{
id: responseFixtures.tiers.tiers[1].id,
name: 'Supporter updated',
description: 'Supporter description',
monthly_price: 1001,
trial_days: 7,
benefits: [
'Simple benefit',
'New benefit'
]
}]
});
});
test('Supports editing the free tier', async ({page}) => {
const lastApiRequests = await mockApi({page, responses: {
tiers: {
edit: {
tiers: [{
...responseFixtures.tiers.tiers[0],
description: 'Free tier description',
benefits: [
'First benefit',
'Second benefit'
]
}]
}
}
}});
await page.goto('/');
const section = page.getByTestId('tiers');
await section.getByTestId('tier-card').filter({hasText: /Free/}).click();
const modal = page.getByTestId('tier-detail-modal');
await modal.getByLabel('Description').fill('Free tier description');
await modal.getByLabel('New benefit').fill('First benefit');
await modal.getByRole('button', {name: 'Add'}).click();
await modal.getByLabel('New benefit').fill('Second benefit');
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(section.getByTestId('tier-card').filter({hasText: /Free/})).toHaveText(/Free tier description/);
expect(lastApiRequests.tiers.edit.body).toMatchObject({
tiers: [{
id: responseFixtures.tiers.tiers[0].id,
description: 'Free tier description',
benefits: [
'First benefit',
'Second benefit'
]
}]
});
});
});

View File

@ -1,8 +0,0 @@
const assert = require('assert/strict');
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
assert.ok(require('../index'));
});
});

View File

@ -56,6 +56,8 @@ interface Responses {
}
tiers?: {
browse?: TiersResponseType
edit?: TiersResponseType
add?: TiersResponseType
}
labels?: {
browse?: LabelsResponseType
@ -121,6 +123,8 @@ type LastRequests = {
}
tiers: {
browse: RequestRecord
edit: RequestRecord
add: RequestRecord
}
labels: {
browse: RequestRecord
@ -152,7 +156,7 @@ export async function mockApi({page,responses}: {page: Page, responses?: Respons
images: {upload: {}},
customThemeSettings: {browse: {}, edit: {}},
latestPost: {browse: {}},
tiers: {browse: {}},
tiers: {browse: {}, edit: {}, add: {}},
labels: {browse: {}},
offers: {browse: {}},
themes: {browse: {}, activate: {}, delete: {}, install: {}, upload: {}},
@ -386,7 +390,29 @@ export async function mockApi({page,responses}: {page: Page, responses?: Respons
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/tiers\//,
path: /\/ghost\/api\/admin\/tiers\/\w{24}/,
respondTo: {
PUT: {
body: responses?.tiers?.edit ?? responseFixtures.tiers,
updateLastRequest: lastApiRequests.tiers.edit
}
}
});
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/tiers\/$/,
respondTo: {
POST: {
body: responses?.tiers?.add ?? responseFixtures.tiers,
updateLastRequest: lastApiRequests.tiers.add
}
}
});
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/tiers\/\?limit/,
respondTo: {
GET: {
body: responses?.tiers?.browse ?? responseFixtures.tiers,

View File

@ -1,5 +1,19 @@
{
"tiers": [
{
"id": "645453f4d254799990dd0e21",
"name": "Free",
"description": null,
"slug": "free",
"active": true,
"type": "free",
"welcome_page_url": null,
"created_at": "2023-05-05T00:55:16.000Z",
"updated_at": "2023-05-08T06:08:47.000Z",
"visibility": "public",
"benefits": [],
"trial_days": 0
},
{
"id": "645453f4d254799990dd0e22",
"name": "Basic Supporter",
@ -21,9 +35,9 @@
},
{
"id": "649a4f08e1de1c862cd79063",
"name": "Ultimate Starlight Diamond Supporter",
"name": "Ultimate Starlight Diamond Tier",
"description": null,
"slug": "ultimate-starlight-diamond-supporter",
"slug": "ultimate-starlight-diamond-tier",
"active": true,
"type": "paid",
"welcome_page_url": null,