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

View File

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

View File

@ -1,4 +1,5 @@
import React, {useState} from 'react';
import clsx from 'clsx';
export type Tab = {
id: string,
@ -29,13 +30,25 @@ const TabView: React.FC<TabViewProps> = ({tabs, defaultSelected}) => {
return (
<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 => (
<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>
{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>
))}

View File

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

View File

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

View File

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

View File

@ -115,7 +115,10 @@ const ThemePreview: React.FC<ThemePreviewProps> = ({settings}) => {
return (
<>
<iframe
ref={iframeRef} height="100%" title="Site Preview"
ref={iframeRef}
data-testid="theme-preview"
height="100%"
title="Site Preview"
width="100%"
></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 = {
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 {
@ -22,6 +23,13 @@ interface Responses {
images?: {
upload?: any
}
custom_theme_settings?: {
browse?: any
edit?: any
}
previewHtml: {
homepage?: string
}
}
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({
page,
path: /\/ghost\/api\/admin\/settings\/\?group=.+/,
method: 'GET',
response: responses?.settings?.browse ?? responseFixtures.settings,
path: /\/ghost\/api\/admin\/settings\//,
responses: {
GET: {body: responses?.settings?.browse ?? responseFixtures.settings},
PUT: {body: responses?.settings?.edit ?? responseFixtures.settings}
},
lastApiRequest
});
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/site\//,
method: 'GET',
response: responses?.site?.browse ?? responseFixtures.site,
lastApiRequest
});
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/settings\/$/,
method: 'PUT',
response: responses?.settings?.edit ?? responseFixtures.settings,
responses: {
GET: {body: responses?.site?.browse ?? responseFixtures.site}
},
lastApiRequest
});
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/images\/upload\/$/,
method: 'POST',
response: responses?.images?.upload ?? {images: [{url: 'http://example.com/image.png', ref: null}]},
responses: {
POST: {body: responses?.images?.upload ?? {images: [{url: 'http://example.com/image.png', ref: null}]}}
},
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;
}
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) => {
if (route.request().method() !== method) {
const response = responses[route.request().method()];
if (!response) {
return route.continue();
}
@ -76,8 +108,8 @@ async function mockApiResponse({page, path, method, response, lastApiRequest}: {
lastApiRequest.url = route.request().url();
await route.fulfill({
status: 200,
body: JSON.stringify(response)
status: response.status || 200,
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"
}
]
}