Added simple tests for design modal

refs https://github.com/TryGhost/Team/issues/3354
This commit is contained in:
Jono Mingard 2023-06-12 11:04:19 +12:00
parent 90c269275e
commit c3f6d77fd9
10 changed files with 202 additions and 36 deletions

View File

@ -15,6 +15,7 @@ export interface ModalProps {
*/ */
size?: ModalSize; size?: ModalSize;
testId?: string;
title?: string; title?: string;
okLabel?: string; okLabel?: string;
okColor?: string; okColor?: string;
@ -32,6 +33,7 @@ export interface ModalProps {
const Modal: React.FC<ModalProps> = ({ const Modal: React.FC<ModalProps> = ({
size = 'md', size = 'md',
testId,
title, title,
okLabel = 'OK', okLabel = 'OK',
cancelLabel = 'Cancel', cancelLabel = 'Cancel',
@ -179,7 +181,7 @@ const Modal: React.FC<ModalProps> = ({
'pointer-events-none fixed inset-0 z-0', 'pointer-events-none fixed inset-0 z-0',
backDrop && 'bg-[rgba(98,109,121,0.15)] backdrop-blur-[3px]' backDrop && 'bg-[rgba(98,109,121,0.15)] backdrop-blur-[3px]'
)}></div> )}></div>
<section className={modalClasses} style={modalStyles}> <section className={modalClasses} data-testid={testId} style={modalStyles}>
<div className={contentClasses}> <div className={contentClasses}>
<div className='h-full'> <div className='h-full'>
{title && <Heading level={4}>{title}</Heading>} {title && <Heading level={4}>{title}</Heading>}

View File

@ -10,6 +10,7 @@ import {IButton} from './Button';
import {SelectOption} from './Select'; import {SelectOption} from './Select';
export interface PreviewModalProps { export interface PreviewModalProps {
testId?: string;
title?: string; title?: string;
sidebar?: React.ReactNode; sidebar?: React.ReactNode;
preview?: React.ReactNode; preview?: React.ReactNode;
@ -31,6 +32,7 @@ export interface PreviewModalProps {
} }
export const PreviewModalContent: React.FC<PreviewModalProps> = ({ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
testId,
title, title,
sidebar, sidebar,
preview, preview,
@ -138,6 +140,7 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
customFooter={(<></>)} customFooter={(<></>)}
noPadding={true} noPadding={true}
size='full' size='full'
testId={testId}
title='' title=''
> >
<div className='flex h-full grow'> <div className='flex h-full grow'>

View File

@ -1,4 +1,5 @@
import React, {useState} from 'react'; import React, {useState} from 'react';
import clsx from 'clsx';
export type Tab = { export type Tab = {
id: string, id: string,
@ -29,13 +30,25 @@ const TabView: React.FC<TabViewProps> = ({tabs, defaultSelected}) => {
return ( return (
<section> <section>
<div className='flex gap-5 border-b border-grey-300'> <div className='flex gap-5 border-b border-grey-300' role='tablist'>
{tabs.map(tab => ( {tabs.map(tab => (
<button key={tab.id} className={`-m-b-px cursor-pointer appearance-none border-b-[3px] py-1 text-sm transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)] ${selectedTab === tab.id ? 'border-black font-bold' : 'border-transparent hover:border-grey-500'}`} id={tab.id} title={tab.title} type="button" onClick={handleTabChange}>{tab.title}</button> <button
key={tab.id}
aria-selected={selectedTab === tab.id}
className={clsx(
'-m-b-px cursor-pointer appearance-none border-b-[3px] py-1 text-sm transition-all after:invisible after:block after:h-px after:overflow-hidden after:font-bold after:text-transparent after:content-[attr(title)]',
selectedTab === tab.id ? 'border-black font-bold' : 'border-transparent hover:border-grey-500'
)}
id={tab.id}
role='tab'
title={tab.title}
type="button"
onClick={handleTabChange}
>{tab.title}</button>
))} ))}
</div> </div>
{tabs.map(tab => ( {tabs.map(tab => (
<div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'}`}> <div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'}`} role='tabpanel'>
<div>{tab.contents}</div> <div>{tab.contents}</div>
</div> </div>
))} ))}

View File

@ -5,7 +5,7 @@ import {Setting, SiteData} from '../../types/api';
// Define the Settings Context // Define the Settings Context
interface SettingsContextProps { interface SettingsContextProps {
settings: Setting[] | null; settings: Setting[] | null;
saveSettings: (updatedSettings: Setting[]) => Promise<void>; saveSettings: (updatedSettings: Setting[]) => Promise<Setting[]>;
siteData: SiteData | null; siteData: SiteData | null;
} }
@ -16,7 +16,7 @@ interface SettingsProviderProps {
const SettingsContext = createContext<SettingsContextProps>({ const SettingsContext = createContext<SettingsContextProps>({
settings: null, settings: null,
siteData: null, siteData: null,
saveSettings: async () => {} saveSettings: async () => []
}); });
function serialiseSettingsData(settings: Setting[]): Setting[] { function serialiseSettingsData(settings: Setting[]): Setting[] {
@ -100,16 +100,20 @@ const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
fetchSettings(); fetchSettings();
}, [api]); }, [api]);
const saveSettings = useCallback(async (updatedSettings: Setting[]): Promise<void> => { const saveSettings = useCallback(async (updatedSettings: Setting[]) => {
try { try {
// handle transformation for settings before save // handle transformation for settings before save
updatedSettings = deserializeSettings(updatedSettings); updatedSettings = deserializeSettings(updatedSettings);
// Make an API call to save the updated settings // Make an API call to save the updated settings
const data = await api.settings.edit(updatedSettings); const data = await api.settings.edit(updatedSettings);
const newSettings = serialiseSettingsData(data.settings);
setSettings(serialiseSettingsData(data.settings)); setSettings(newSettings);
return newSettings;
} catch (error) { } catch (error) {
// Log error in settings API // Log error in settings API
return [];
} }
}, [api]); }, [api]);

View File

@ -60,7 +60,7 @@ const DesignModal: React.FC = () => {
}); });
}, [api]); }, [api]);
const {settings} = useContext(SettingsContext); const {settings, saveSettings} = useContext(SettingsContext);
const { const {
formState, formState,
@ -80,8 +80,8 @@ const DesignModal: React.FC = () => {
} }
if (formState.settings.some(setting => setting.dirty)) { if (formState.settings.some(setting => setting.dirty)) {
const response = await api.settings.edit(formState.settings); const newSettings = await saveSettings(formState.settings.filter(setting => setting.dirty));
updateForm(state => ({...state, settings: response.settings})); updateForm(state => ({...state, settings: newSettings}));
} }
} }
}); });
@ -127,7 +127,7 @@ const DesignModal: React.FC = () => {
return <PreviewModalContent return <PreviewModalContent
buttonsDisabled={saveState === 'saving'} buttonsDisabled={saveState === 'saving'}
cancelLabel='Close' cancelLabel='Close'
okLabel='Save' okLabel={saveState === 'saved' ? 'Saved' : (saveState === 'saving' ? 'Saving ...' : 'Save')}
preview={ preview={
<ThemePreview <ThemePreview
settings={{ settings={{
@ -148,6 +148,7 @@ const DesignModal: React.FC = () => {
updateThemeSetting={updateThemeSetting} updateThemeSetting={updateThemeSetting}
/>} />}
sidebarPadding={false} sidebarPadding={false}
testId='design-modal'
title='Design' title='Design'
onCancel={() => { onCancel={() => {
if (saveState === 'unsaved') { if (saveState === 'unsaved') {

View File

@ -14,6 +14,7 @@ const DesignSetting: React.FC = () => {
customButtons={<Button color='green' label='Customize' link onClick={openPreviewModal}/>} customButtons={<Button color='green' label='Customize' link onClick={openPreviewModal}/>}
description="Customize your site and manage themes" description="Customize your site and manage themes"
navid='branding-and-design' navid='branding-and-design'
testId='design'
title="Branding and design" title="Branding and design"
/> />
); );

View File

@ -115,7 +115,10 @@ const ThemePreview: React.FC<ThemePreviewProps> = ({settings}) => {
return ( return (
<> <>
<iframe <iframe
ref={iframeRef} height="100%" title="Site Preview" ref={iframeRef}
data-testid="theme-preview"
height="100%"
title="Site Preview"
width="100%" width="100%"
></iframe> ></iframe>
</> </>

View File

@ -0,0 +1,62 @@
import {expect, test} from '@playwright/test';
import {mockApi} from '../../utils/e2e';
test.describe('Theme settings', async () => {
test('Editing brand settings', async ({page}) => {
const lastApiRequest = await mockApi({page, responses: {
previewHtml: {
homepage: '<html><head><style></style></head><body><div>homepage preview</div></body></html>'
}
}});
await page.goto('/');
const section = page.getByTestId('design');
await section.getByRole('button', {name: 'Customize'}).click();
const modal = page.getByTestId('design-modal');
await expect(modal.frameLocator('[data-testid="theme-preview"]').getByText('homepage preview')).toHaveCount(1);
await modal.getByLabel('Site description').fill('new description');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal.getByRole('button', {name: 'Saved'})).toHaveCount(1);
expect(lastApiRequest.body).toEqual({
settings: [
{key: 'description', value: 'new description'}
]
});
});
test('Editing custom theme settings', async ({page}) => {
const lastApiRequest = await mockApi({page});
await page.goto('/');
const section = page.getByTestId('design');
await section.getByRole('button', {name: 'Customize'}).click();
const modal = page.getByTestId('design-modal');
await modal.getByRole('tab', {name: 'Site wide'}).click();
await modal.getByLabel('Navigation layout').selectOption('Logo in the middle');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal.getByRole('button', {name: 'Saved'})).toHaveCount(1);
expect(lastApiRequest.body).toMatchObject({
custom_theme_settings: [
{key: 'navigation_color'},
{key: 'navigation_background_image'},
{key: 'navigation_layout', value: 'Logo in the middle'},
{key: 'show_publication_cover'},
{key: 'email_signup_text'}
]
});
});
});

View File

@ -8,7 +8,8 @@ type LastApiRequest = {
const responseFixtures = { const responseFixtures = {
settings: JSON.parse(readFileSync(`${__dirname}/responses/settings.json`).toString()), settings: JSON.parse(readFileSync(`${__dirname}/responses/settings.json`).toString()),
site: JSON.parse(readFileSync(`${__dirname}/responses/site.json`).toString()) site: JSON.parse(readFileSync(`${__dirname}/responses/site.json`).toString()),
custom_theme_settings: JSON.parse(readFileSync(`${__dirname}/responses/custom_theme_settings.json`).toString())
}; };
interface Responses { interface Responses {
@ -22,6 +23,13 @@ interface Responses {
images?: { images?: {
upload?: any upload?: any
} }
custom_theme_settings?: {
browse?: any
edit?: any
}
previewHtml: {
homepage?: string
}
} }
export async function mockApi({page,responses}: {page: Page, responses?: Responses}) { export async function mockApi({page,responses}: {page: Page, responses?: Responses}) {
@ -32,42 +40,66 @@ export async function mockApi({page,responses}: {page: Page, responses?: Respons
await mockApiResponse({ await mockApiResponse({
page, page,
path: /\/ghost\/api\/admin\/settings\/\?group=.+/, path: /\/ghost\/api\/admin\/settings\//,
method: 'GET', responses: {
response: responses?.settings?.browse ?? responseFixtures.settings, GET: {body: responses?.settings?.browse ?? responseFixtures.settings},
PUT: {body: responses?.settings?.edit ?? responseFixtures.settings}
},
lastApiRequest lastApiRequest
}); });
await mockApiResponse({ await mockApiResponse({
page, page,
path: /\/ghost\/api\/admin\/site\//, path: /\/ghost\/api\/admin\/site\//,
method: 'GET', responses: {
response: responses?.site?.browse ?? responseFixtures.site, GET: {body: responses?.site?.browse ?? responseFixtures.site}
lastApiRequest },
});
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/settings\/$/,
method: 'PUT',
response: responses?.settings?.edit ?? responseFixtures.settings,
lastApiRequest lastApiRequest
}); });
await mockApiResponse({ await mockApiResponse({
page, page,
path: /\/ghost\/api\/admin\/images\/upload\/$/, path: /\/ghost\/api\/admin\/images\/upload\/$/,
method: 'POST', responses: {
response: responses?.images?.upload ?? {images: [{url: 'http://example.com/image.png', ref: null}]}, POST: {body: responses?.images?.upload ?? {images: [{url: 'http://example.com/image.png', ref: null}]}}
},
lastApiRequest lastApiRequest
}); });
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/custom_theme_settings\/$/,
responses: {
GET: {body: responses?.custom_theme_settings?.browse ?? responseFixtures.custom_theme_settings},
PUT: {body: responses?.custom_theme_settings?.edit ?? responseFixtures.custom_theme_settings}
},
lastApiRequest
});
await page.route(responseFixtures.site.site.url, async (route) => {
if (!route.request().headers()['x-ghost-preview']) {
return route.continue();
}
await route.fulfill({
status: 200,
body: responses?.previewHtml?.homepage ?? '<html><head><style></style></head><body><div>test</div></body></html>'
});
});
return lastApiRequest; return lastApiRequest;
} }
async function mockApiResponse({page, path, method, response, lastApiRequest}: { page: Page; path: string | RegExp; method: string; response: any; lastApiRequest: LastApiRequest }) { interface MockResponse {
body: any
status?: number
}
async function mockApiResponse({page, path, lastApiRequest, responses}: { page: Page; path: string | RegExp; lastApiRequest: LastApiRequest, responses: { [method: string]: MockResponse } }) {
await page.route(path, async (route) => { await page.route(path, async (route) => {
if (route.request().method() !== method) { const response = responses[route.request().method()];
if (!response) {
return route.continue(); return route.continue();
} }
@ -76,8 +108,8 @@ async function mockApiResponse({page, path, method, response, lastApiRequest}: {
lastApiRequest.url = route.request().url(); lastApiRequest.url = route.request().url();
await route.fulfill({ await route.fulfill({
status: 200, status: response.status || 200,
body: JSON.stringify(response) body: JSON.stringify(response.body)
}); });
}); });
} }

View File

@ -0,0 +1,45 @@
{
"custom_theme_settings": [
{
"type": "color",
"default": "#ff0000",
"id": "648047658d265b0c8b33c58f",
"value": "#c73333",
"key": "navigation_color"
},
{
"type": "image",
"id": "648047658d265b0c8b33c590",
"value": null,
"key": "navigation_background_image"
},
{
"type": "select",
"options": [
"Logo on cover",
"Logo in the middle",
"Stacked"
],
"default": "Logo on cover",
"id": "648047658d265b0c8b33c591",
"value": "Stacked",
"key": "navigation_layout"
},
{
"type": "boolean",
"default": true,
"group": "homepage",
"id": "648047658d265b0c8b33c594",
"value": true,
"key": "show_publication_cover"
},
{
"type": "text",
"default": "Sign up for more like this.",
"group": "post",
"id": "648047658d265b0c8b33c599",
"value": "Sign up for more like this.",
"key": "email_signup_text"
}
]
}