diff --git a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx index c1af7295ff..6696331bea 100644 --- a/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/tiers/TierDetailModal.tsx @@ -43,7 +43,8 @@ const TierDetailModal: React.FC = ({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({ @@ -55,14 +56,6 @@ const TierDetailModal: React.FC = ({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 = rest; @@ -85,6 +78,14 @@ const TierDetailModal: React.FC = ({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 = ({tier}) => { return value.match(/[\d]+\.?[\d]{0,2}/)?.[0] || ''; }; - const currencySymbol = formState.currency ? getSymbol(formState.currency) : '$'; - return { 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(); + }} >
@@ -118,7 +128,7 @@ const TierDetailModal: React.FC = ({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}))} />} = ({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)}))} /> = ({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)}))} />
-
- Add a free trial - setHasFreeTrial(e.target.checked)} /> +
+ setHasFreeTrial(e.target.checked)} />
= ({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]/, '')}))} />
@@ -210,10 +225,21 @@ const TierDetailModal: React.FC = ({tier}) => { benefits.setNewItem(e.target.value)} /> -
diff --git a/apps/admin-x-settings/src/components/settings/membership/tiers/TiersList.tsx b/apps/admin-x-settings/src/components/settings/membership/tiers/TiersList.tsx index ac8b0f8250..ff510c4c13 100644 --- a/apps/admin-x-settings/src/components/settings/membership/tiers/TiersList.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/tiers/TiersList.tsx @@ -29,7 +29,7 @@ const TierCard: React.FC = ({ const currencySymbol = currency ? getSymbol(currency) : '$'; return ( -
+
{ NiceModal.show(TierDetailModal, {tier}); }}> @@ -81,7 +81,7 @@ const TiersList: React.FC = ({ return ; })} {tab === 'active-tiers' && ( -
{ +
-
+ )}
); diff --git a/apps/admin-x-settings/test/e2e/membership/access.test.ts b/apps/admin-x-settings/test/e2e/membership/access.test.ts index 9f28a0ec17..84aa2dba28 100644 --- a/apps/admin-x-settings/test/e2e/membership/access.test.ts +++ b/apps/admin-x-settings/test/e2e/membership/access.test.ts @@ -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))} ] }); }); diff --git a/apps/admin-x-settings/test/e2e/membership/tiers.test.ts b/apps/admin-x-settings/test/e2e/membership/tiers.test.ts new file mode 100644 index 0000000000..951c934b86 --- /dev/null +++ b/apps/admin-x-settings/test/e2e/membership/tiers.test.ts @@ -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' + ] + }] + }); + }); +}); diff --git a/apps/admin-x-settings/test/hello.test.js b/apps/admin-x-settings/test/hello.test.js deleted file mode 100644 index d13fa290bb..0000000000 --- a/apps/admin-x-settings/test/hello.test.js +++ /dev/null @@ -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')); - }); -}); diff --git a/apps/admin-x-settings/test/utils/e2e.ts b/apps/admin-x-settings/test/utils/e2e.ts index 04669e7335..1fe08fbb9a 100644 --- a/apps/admin-x-settings/test/utils/e2e.ts +++ b/apps/admin-x-settings/test/utils/e2e.ts @@ -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, diff --git a/apps/admin-x-settings/test/utils/responses/tiers.json b/apps/admin-x-settings/test/utils/responses/tiers.json index 2c2cf8ab1b..52bf7c419a 100644 --- a/apps/admin-x-settings/test/utils/responses/tiers.json +++ b/apps/admin-x-settings/test/utils/responses/tiers.json @@ -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,