Updated select to use react-select (#18164)

refs https://github.com/TryGhost/Product/issues/3832

---

### <samp>🤖 Generated by Copilot at adffb67</samp>

This file updates the global form select component to use a custom
`ReactSelect` component with better performance and style.
This commit is contained in:
Jono M 2023-09-18 14:51:59 +01:00 committed by GitHub
parent 353c565739
commit 343535116c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 203 additions and 170 deletions

View File

@ -3,8 +3,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import Button from '../Button'; import Button from '../Button';
import ButtonGroup from '../ButtonGroup'; import ButtonGroup from '../ButtonGroup';
import DesktopChromeHeader from './DesktopChromeHeader'; import DesktopChromeHeader from './DesktopChromeHeader';
import URLSelect from '../form/URLSelect'; import Select, {SelectOption} from '../form/Select';
import {SelectOption} from '../form/Select';
const meta = { const meta = {
title: 'Global / Chrome / Desktop Header', title: 'Global / Chrome / Desktop Header',
@ -54,7 +53,7 @@ const selectOptions: SelectOption[] = [
export const CustomToolbar: Story = { export const CustomToolbar: Story = {
args: { args: {
toolbarLeft: <Button icon='arrow-left' link={true} size='sm' />, toolbarLeft: <Button icon='arrow-left' link={true} size='sm' />,
toolbarCenter: <URLSelect options={selectOptions} onSelect={(value: string) => { toolbarCenter: <Select options={selectOptions} onSelect={(value) => {
alert(value); alert(value);
}} />, }} />,
toolbarRight: <ButtonGroup toolbarRight: <ButtonGroup

View File

@ -48,7 +48,7 @@ const DropdownIndicator: React.FC<DropdownIndicatorProps<MultiSelectOption, true
const Option: React.FC<OptionProps<MultiSelectOption, true>> = ({children, ...optionProps}) => ( const Option: React.FC<OptionProps<MultiSelectOption, true>> = ({children, ...optionProps}) => (
<components.Option {...optionProps}> <components.Option {...optionProps}>
<span data-testid="multiselect-option">{children}</span> <span data-testid="select-option">{children}</span>
</components.Option> </components.Option>
); );

View File

@ -86,7 +86,7 @@ export const WithSelectedOption: Story = {
export const WithCallback: Story = { export const WithCallback: Story = {
args: { args: {
options: selectOptions, options: selectOptions,
onSelect: (value: string) => { onSelect: (value) => {
alert(value); alert(value);
} }
} }

View File

@ -1,4 +1,5 @@
import React, {useId} from 'react'; import React, {useId, useMemo} from 'react';
import ReactSelect, {DropdownIndicatorProps, OptionProps, Props, components} from 'react-select';
import Heading from '../Heading'; import Heading from '../Heading';
import Hint from '../Hint'; import Hint from '../Hint';
@ -7,6 +8,7 @@ import clsx from 'clsx';
export interface SelectOption { export interface SelectOption {
value: string; value: string;
label: string; label: string;
hint?: string;
key?: string; key?: string;
className?: string; className?: string;
} }
@ -17,25 +19,48 @@ export interface SelectOptionGroup {
options: SelectOption[]; options: SelectOption[];
} }
export interface SelectProps { export interface SelectControlClasses {
control?: string;
valueContainer?: string;
placeHolder?: string;
menu?: string;
option?: string;
noOptionsMessage?: string;
groupHeading?: string;
}
export interface SelectProps extends Props<SelectOption, false> {
title?: string; title?: string;
hideTitle?: boolean; hideTitle?: boolean;
size?: 'xs' | 'md'; size?: 'xs' | 'md';
prompt?: string; prompt?: string;
options: SelectOption[] | SelectOptionGroup[]; options: SelectOption[] | SelectOptionGroup[];
selectedOption?: string selectedOption?: string
onSelect: (value: string) => void; onSelect: (value: string | undefined) => void;
error?:boolean; error?:boolean;
hint?: React.ReactNode; hint?: React.ReactNode;
clearBg?: boolean; clearBg?: boolean;
border?: boolean; border?: boolean;
fullWidth?: boolean;
containerClassName?: string; containerClassName?: string;
selectClassName?: string; controlClasses?: SelectControlClasses;
optionClassName?: string;
unstyled?: boolean; unstyled?: boolean;
disabled?: boolean; disabled?: boolean;
} }
const DropdownIndicator: React.FC<DropdownIndicatorProps<SelectOption, false> & {clearBg: boolean}> = ({clearBg, ...props}) => (
<components.DropdownIndicator {...props}>
<div className={`absolute top-[14px] block h-2 w-2 rotate-45 border-[1px] border-l-0 border-t-0 border-grey-900 content-[''] dark:border-grey-400 ${clearBg ? 'right-0' : 'right-4'} `}></div>
</components.DropdownIndicator>
);
const Option: React.FC<OptionProps<SelectOption, false>> = ({children, ...optionProps}) => (
<components.Option {...optionProps}>
<span data-testid="select-option">{children}</span>
{optionProps.data.hint && <span className="block text-xs text-grey-700 dark:text-grey-300">{optionProps.data.hint}</span>}
</components.Option>
);
const Select: React.FC<SelectProps> = ({ const Select: React.FC<SelectProps> = ({
title, title,
hideTitle, hideTitle,
@ -48,25 +73,20 @@ const Select: React.FC<SelectProps> = ({
hint, hint,
clearBg = true, clearBg = true,
border = true, border = true,
fullWidth = true,
containerClassName, containerClassName,
selectClassName, controlClasses,
optionClassName,
unstyled, unstyled,
disabled = false disabled = false,
...props
}) => { }) => {
const id = useId(); const id = useId();
const handleOptionChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
onSelect(event.target.value);
};
let containerClasses = ''; let containerClasses = '';
if (!unstyled) { if (!unstyled) {
containerClasses = clsx( containerClasses = clsx(
'relative w-full after:pointer-events-none dark:text-white', 'dark:text-white',
`after:absolute after:block after:h-2 after:w-2 after:rotate-45 after:border-[1px] after:border-l-0 after:border-t-0 after:border-grey-900 after:content-[''] dark:after:border-grey-500`, fullWidth && 'w-full',
size === 'xs' ? 'after:top-[6px]' : 'after:top-[14px]',
clearBg ? 'after:right-0' : 'after:right-4',
disabled && 'opacity-40' disabled && 'opacity-40'
); );
} }
@ -75,53 +95,66 @@ const Select: React.FC<SelectProps> = ({
containerClassName containerClassName
); );
let selectClasses = ''; const customClasses = {
if (!unstyled) { control: clsx(
selectClasses = clsx( controlClasses?.control,
size === 'xs' ? 'h-6 py-0 pr-3 text-xs' : 'h-10 py-2 pr-5', 'min-h-[40px] w-full cursor-pointer appearance-none outline-none dark:text-white',
'w-full appearance-none outline-none', size === 'xs' ? 'py-0 text-xs' : 'py-2',
border && 'border-b', border && 'border-b',
!clearBg && 'bg-grey-75 px-[10px]', !clearBg && 'bg-grey-75 px-[10px] dark:bg-grey-950',
error ? '!border-red' : 'border-grey-500 focus:border-black dark:border-grey-800 dark:focus:border-grey-500', error ? 'border-red' : 'border-grey-500 hover:border-grey-700 dark:border-grey-800 dark:hover:border-grey-700',
disabled ? 'cursor-auto' : 'cursor-pointer hover:border-grey-700',
(title && !clearBg) && 'mt-2' (title && !clearBg) && 'mt-2'
); ),
} valueContainer: clsx('gap-1', controlClasses?.valueContainer),
selectClasses = clsx( placeHolder: clsx('text-grey-500 dark:text-grey-800', controlClasses?.placeHolder),
selectClasses, menu: clsx(
selectClassName 'z-50 rounded-b bg-white py-2 shadow dark:border dark:border-grey-900 dark:bg-black',
); size === 'xs' && 'text-xs',
controlClasses?.menu
),
option: clsx('px-3 py-[6px] hover:cursor-pointer hover:bg-grey-100 dark:text-white dark:hover:bg-grey-900', controlClasses?.option),
noOptionsMessage: clsx('p-3 text-grey-600', controlClasses?.noOptionsMessage),
groupHeading: clsx('px-3 py-[6px] text-2xs font-semibold uppercase tracking-wide text-grey-700', controlClasses?.groupHeading)
};
const optionClasses = optionClassName; const dropdownIndicatorComponent = useMemo(() => {
return function DropdownIndicatorComponent(ddiProps: DropdownIndicatorProps<SelectOption, false>) {
return <DropdownIndicator {...ddiProps} clearBg={clearBg} />;
};
}, [clearBg]);
const individualOptions = options.flatMap((option) => {
if ('options' in option) {
return option.options;
}
return option;
});
const select = ( const select = (
<> <>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={selectedOption || !prompt ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>} {title && <Heading className={hideTitle ? 'sr-only' : ''} grey={selectedOption || !prompt ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
<div className={containerClasses}> <div className={containerClasses}>
<select className={selectClasses} disabled={disabled} id={id} value={selectedOption} onChange={handleOptionChange}> <ReactSelect<SelectOption, false>
{prompt && <option className={optionClasses} value="" disabled selected>{prompt}</option>} classNames={{
{options.map(option => ( menuList: () => 'z-50',
'options' in option ? valueContainer: () => customClasses.valueContainer,
<optgroup key={option.key || option.label} label={option.label}> control: () => customClasses.control,
{option.options.map(child => ( placeholder: () => customClasses.placeHolder,
<option menu: () => customClasses.menu,
key={child.key || child.value} option: () => customClasses.option,
className={clsx(optionClasses, child.className)} noOptionsMessage: () => customClasses.noOptionsMessage,
value={child.value} groupHeading: () => customClasses.groupHeading
> }}
{child.label} components={{DropdownIndicator: dropdownIndicatorComponent, Option}}
</option> inputId={id}
))} isClearable={false}
</optgroup> : options={options}
<option placeholder={prompt ? prompt : ''}
key={option.key || option.value} value={individualOptions.find(option => option.value === selectedOption)}
className={clsx(optionClasses, option.className)} unstyled
value={option.value} onChange={option => onSelect(option?.value)}
> {...props}
{option.label} />
</option>
))}
</select>
</div> </div>
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>} {hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</> </>

View File

@ -97,11 +97,12 @@ export const WithDropdown: Story = {
rightPlaceholder: ( rightPlaceholder: (
<Select <Select
border={false} border={false}
containerClassName='w-14'
fullWidth={false}
options={[ options={[
{label: 'USD', value: 'usd'}, {label: 'USD', value: 'usd'},
{label: 'EUR', value: 'eur'} {label: 'EUR', value: 'eur'}
]} ]}
selectClassName='w-auto'
onSelect={() => {}} onSelect={() => {}}
/> />
) )

View File

@ -1,28 +0,0 @@
import type {Meta, StoryObj} from '@storybook/react';
import URLSelect from './URLSelect';
import {SelectOption} from './Select';
const meta = {
title: 'Experimental / URL Select',
component: URLSelect,
tags: ['autodocs']
} satisfies Meta<typeof URLSelect>;
export default meta;
type Story = StoryObj<typeof URLSelect>;
const selectOptions: SelectOption[] = [
{value: 'homepage', label: 'Homepage'},
{value: 'post', label: 'Post'},
{value: 'page', label: 'Page'},
{value: 'tag-archive', label: 'Tag archive'},
{value: 'author-archive', label: 'Author archive'}
];
export const Default: Story = {
args: {
options: selectOptions,
onSelect: () => {}
}
};

View File

@ -1,25 +0,0 @@
import React from 'react';
import Select, {SelectProps} from './Select';
import clsx from 'clsx';
const URLSelect: React.FC<SelectProps> = (props) => {
const selectClasses = clsx(
`!h-[unset] w-full appearance-none rounded-full border border-grey-100 bg-white px-3 py-1 text-sm`
);
const containerClasses = clsx(
'relative w-full max-w-[380px] self-center after:pointer-events-none',
`after:absolute after:right-4 after:top-[9px] after:block after:h-2 after:w-2 after:rotate-45 after:border-[1px] after:border-l-0 after:border-t-0 after:border-grey-900 after:content-['']`
);
return (
<Select
containerClassName={containerClasses}
selectClassName={selectClasses}
unstyled={true}
{...props}
/>
);
};
export default URLSelect;

View File

@ -108,7 +108,7 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
let toolbarLeft = (<></>); let toolbarLeft = (<></>);
if (previewToolbarURLs) { if (previewToolbarURLs) {
toolbarLeft = ( toolbarLeft = (
<Select options={previewToolbarURLs!} selectedOption={selectedURL} onSelect={onSelectURL!} /> <Select options={previewToolbarURLs!} selectedOption={selectedURL} onSelect={url => url && onSelectURL?.(url)} />
); );
} else if (previewToolbarTabs) { } else if (previewToolbarTabs) {
toolbarLeft = <TabView toolbarLeft = <TabView

View File

@ -18,18 +18,23 @@ type RefipientValueArgs = {
const RECIPIENT_FILTER_OPTIONS = [{ const RECIPIENT_FILTER_OPTIONS = [{
label: 'Whoever has access to the post', label: 'Whoever has access to the post',
hint: 'Free posts to everyone, premium posts sent to paid members',
value: 'visibility' value: 'visibility'
}, { }, {
label: 'All members', label: 'All members',
hint: 'Everyone who is subscribed to newsletter updates, whether free or paid members',
value: 'all-members' value: 'all-members'
}, { }, {
label: 'Paid-members only', label: 'Paid-members only',
hint: 'People who have a premium subscription',
value: 'paid-only' value: 'paid-only'
}, { }, {
label: 'Specific people', label: 'Specific people',
hint: 'Only people with any of the selected tiers or labels',
value: 'segment' value: 'segment'
}, { }, {
label: 'Usually nobody', label: 'Usually nobody',
hint: 'Newsletters are off for new posts, but can be enabled when needed',
value: 'none' value: 'none'
}]; }];
@ -166,7 +171,9 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
selectedOption={selectedOption} selectedOption={selectedOption}
title="Default Newsletter recipients" title="Default Newsletter recipients"
onSelect={(value) => { onSelect={(value) => {
if (value) {
setDefaultRecipientValue(value); setDefaultRecipientValue(value);
}
}} }}
/> />
{(selectedOption === 'segment') && ( {(selectedOption === 'segment') && (

View File

@ -6,8 +6,7 @@ import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent'; import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField'; import TextField from '../../../admin-x-ds/global/form/TextField';
import useSettingGroup from '../../../hooks/useSettingGroup'; import useSettingGroup from '../../../hooks/useSettingGroup';
import {getSettingValues} from '../../../api/settings'; import {getSettingValues, useEditSettings} from '../../../api/settings';
import {useEditSettings} from '../../../api/settings';
const MAILGUN_REGIONS = [ const MAILGUN_REGIONS = [
{label: '🇺🇸 US', value: 'https://api.mailgun.net/v3'}, {label: '🇺🇸 US', value: 'https://api.mailgun.net/v3'},
@ -67,7 +66,7 @@ const MailGun: React.FC<{ keywords: string[] }> = ({keywords}) => {
selectedOption={mailgunRegion} selectedOption={mailgunRegion}
title="Mailgun region" title="Mailgun region"
onSelect={(value) => { onSelect={(value) => {
updateSetting('mailgun_base_url', value); updateSetting('mailgun_base_url', value || null);
}} }}
/> />
<TextField <TextField

View File

@ -55,8 +55,8 @@ const TimeZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
}; };
}); });
const handleTimezoneChange = (value: string) => { const handleTimezoneChange = (value?: string) => {
updateSetting('timezone', value); updateSetting('timezone', value || null);
}; };
const viewContent = ( const viewContent = (

View File

@ -10,22 +10,62 @@ import {getSettingValues} from '../../../api/settings';
import {useBrowseTiers} from '../../../api/tiers'; import {useBrowseTiers} from '../../../api/tiers';
const MEMBERS_SIGNUP_ACCESS_OPTIONS = [ const MEMBERS_SIGNUP_ACCESS_OPTIONS = [
{value: 'all', label: 'Anyone can sign up'}, {
{value: 'invite', label: 'Only people I invite'}, value: 'all',
{value: 'none', label: 'Nobody'} label: 'Anyone can sign up',
hint: 'All visitors will be able to subscribe and sign in'
},
{
value: 'invite',
label: 'Only people I invite',
hint: 'People can sign in from your site but won\'t be able to sign up'
},
{
value: 'none',
label: 'Nobody',
hint: 'Disable all member features, including newsletters'
}
]; ];
const DEFAULT_CONTENT_VISIBILITY_OPTIONS = [ const DEFAULT_CONTENT_VISIBILITY_OPTIONS = [
{value: 'public', label: 'Public'}, {
{value: 'members', label: 'Members only'}, value: 'public',
{value: 'paid', label: 'Paid-members only'}, label: 'Public',
{value: 'tiers', label: 'Specific tiers'} hint: 'All site visitors to your site, no login required'
},
{
value: 'members',
label: 'Members only',
hint: 'All logged-in members'
},
{
value: 'paid',
label: 'Paid-members only',
hint: 'Only logged-in members with an active Stripe subscription'
},
{
value: 'tiers',
label: 'Specific tiers',
hint: 'Members with any of the selected tiers'
}
]; ];
const COMMENTS_ENABLED_OPTIONS = [ const COMMENTS_ENABLED_OPTIONS = [
{value: 'all', label: 'All members'}, {
{value: 'paid', label: 'Paid-members only'}, value: 'all',
{value: 'off', label: 'Nobody'} label: 'All members',
hint: 'Logged-in members'
},
{
value: 'paid',
label: 'Paid-members only',
hint: 'Only logged-in members with an active Stripe subscription'
},
{
value: 'off',
label: 'Nobody',
hint: 'Disable commenting completely'
}
]; ];
const Access: React.FC<{ keywords: string[] }> = ({keywords}) => { const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
@ -98,7 +138,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
selectedOption={membersSignupAccess} selectedOption={membersSignupAccess}
title="Subscription access" title="Subscription access"
onSelect={(value) => { onSelect={(value) => {
updateSetting('members_signup_access', value); updateSetting('members_signup_access', value || null);
}} }}
/> />
<Select <Select
@ -107,7 +147,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
selectedOption={defaultContentVisibility} selectedOption={defaultContentVisibility}
title="Default post access" title="Default post access"
onSelect={(value) => { onSelect={(value) => {
updateSetting('default_content_visibility', value); updateSetting('default_content_visibility', value || null);
}} }}
/> />
{defaultContentVisibility === 'tiers' && ( {defaultContentVisibility === 'tiers' && (
@ -126,7 +166,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
selectedOption={commentsEnabled} selectedOption={commentsEnabled}
title="Commenting" title="Commenting"
onSelect={(value) => { onSelect={(value) => {
updateSetting('comments_enabled', value); updateSetting('comments_enabled', value || null);
}} }}
/> />
</SettingGroupContent> </SettingGroupContent>

View File

@ -98,10 +98,11 @@ const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
rightPlaceholder={( rightPlaceholder={(
<Select <Select
border={false} border={false}
containerClassName='w-14'
fullWidth={false}
options={currencySelectGroups()} options={currencySelectGroups()}
selectClassName='w-auto'
selectedOption={donationsCurrency} selectedOption={donationsCurrency}
onSelect={currency => updateSetting('donations_currency', currency)} onSelect={currency => updateSetting('donations_currency', currency || 'USD')}
/> />
)} )}
title='Suggested amount' title='Suggested amount'

View File

@ -77,7 +77,7 @@ const LookAndFeel: React.FC<{
]} ]}
selectedOption={portalButtonStyle as string} selectedOption={portalButtonStyle as string}
title='Portal button style' title='Portal button style'
onSelect={option => updateSetting('portal_button_style', option)} onSelect={option => updateSetting('portal_button_style', option || null)}
/> />
{portalButtonStyle?.toString()?.includes('icon') && {portalButtonStyle?.toString()?.includes('icon') &&
<div className='flex flex-col gap-2'> <div className='flex flex-col gap-2'>

View File

@ -83,7 +83,9 @@ const PortalLinks: React.FC = () => {
options={tierOptions} options={tierOptions}
selectedOption={selectedTier} selectedOption={selectedTier}
onSelect={(value) => { onSelect={(value) => {
if (value) {
setSelectedTier(value); setSelectedTier(value);
}
}} }}
/> />
</div> </div>

View File

@ -163,8 +163,9 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
<div className='w-10'> <div className='w-10'>
<Select <Select
border={false} border={false}
containerClassName='font-medium'
controlClasses={{menu: 'w-14'}}
options={currencySelectGroups()} options={currencySelectGroups()}
selectClassName='font-medium'
selectedOption={formState.currency} selectedOption={formState.currency}
size='xs' size='xs'
onSelect={currency => updateForm(state => ({...state, currency}))} onSelect={currency => updateForm(state => ({...state, currency}))}

View File

@ -49,7 +49,7 @@ const ThemeSetting: React.FC<{
options={setting.options.map(option => ({label: option, value: option}))} options={setting.options.map(option => ({label: option, value: option}))}
selectedOption={setting.value} selectedOption={setting.value}
title={humanizeSettingKey(setting.key)} title={humanizeSettingKey(setting.key)}
onSelect={value => setSetting(value)} onSelect={value => setSetting(value || null)}
/> />
); );
case 'color': case 'color':

View File

@ -1,7 +1,7 @@
import {Integration, IntegrationsResponseType} from '../../../../src/api/integrations'; import {Integration, IntegrationsResponseType} from '../../../../src/api/integrations';
import {Webhook, WebhooksResponseType} from '../../../../src/api/webhooks'; import {Webhook, WebhooksResponseType} from '../../../../src/api/webhooks';
import {chooseOptionInSelect, globalDataRequests, limitRequests, mockApi, responseFixtures} from '../../../utils/e2e';
import {expect, test} from '@playwright/test'; import {expect, test} from '@playwright/test';
import {globalDataRequests, limitRequests, mockApi, responseFixtures} from '../../../utils/e2e';
test.describe('Custom integrations', async () => { test.describe('Custom integrations', async () => {
test('Supports creating an integration and adding webhooks', async ({page}) => { test('Supports creating an integration and adding webhooks', async ({page}) => {
@ -146,10 +146,8 @@ test.describe('Custom integrations', async () => {
await webhookModal.getByLabel('Name').fill('My webhook'); await webhookModal.getByLabel('Name').fill('My webhook');
await webhookModal.getByLabel('Target URL').fill('https://example.com'); await webhookModal.getByLabel('Target URL').fill('https://example.com');
await webhookModal.getByLabel('Event').selectOption('Post created'); await chooseOptionInSelect(webhookModal.getByLabel('Event'), 'Post created');
// Playwright fails unless you click twice, for some reason (timing issue with validations?)
await webhookModal.getByRole('button', {name: 'Add'}).click();
await webhookModal.getByRole('button', {name: 'Add'}).click(); await webhookModal.getByRole('button', {name: 'Add'}).click();
await expect(modal).toHaveText(/My webhook/); await expect(modal).toHaveText(/My webhook/);

View File

@ -1,5 +1,5 @@
import {chooseOptionInSelect, globalDataRequests, mockApi, responseFixtures, updatedSettingsResponse} from '../../utils/e2e';
import {expect, test} from '@playwright/test'; import {expect, test} from '@playwright/test';
import {globalDataRequests, mockApi, responseFixtures, updatedSettingsResponse} from '../../utils/e2e';
test.describe('Default recipient settings', async () => { test.describe('Default recipient settings', async () => {
test('Supports editing default recipients', async ({page}) => { test('Supports editing default recipients', async ({page}) => {
@ -18,7 +18,7 @@ test.describe('Default recipient settings', async () => {
await expect(section.getByText('Whoever has access to the post')).toHaveCount(1); await expect(section.getByText('Whoever has access to the post')).toHaveCount(1);
await section.getByRole('button', {name: 'Edit'}).click(); await section.getByRole('button', {name: 'Edit'}).click();
await section.getByLabel('Default newsletter recipients').selectOption('All members'); await chooseOptionInSelect(section.getByLabel('Default newsletter recipients'), 'All members');
await section.getByRole('button', {name: 'Save'}).click(); await section.getByRole('button', {name: 'Save'}).click();
expect(lastApiRequests.editSettings?.body).toEqual({ expect(lastApiRequests.editSettings?.body).toEqual({
@ -29,7 +29,7 @@ test.describe('Default recipient settings', async () => {
}); });
await section.getByRole('button', {name: 'Edit'}).click(); await section.getByRole('button', {name: 'Edit'}).click();
await section.getByLabel('Default newsletter recipients').selectOption('Usually nobody'); await chooseOptionInSelect(section.getByLabel('Default newsletter recipients'), 'Usually nobody');
await section.getByRole('button', {name: 'Save'}).click(); await section.getByRole('button', {name: 'Save'}).click();
expect(lastApiRequests.editSettings?.body).toEqual({ expect(lastApiRequests.editSettings?.body).toEqual({
@ -40,7 +40,7 @@ test.describe('Default recipient settings', async () => {
}); });
await section.getByRole('button', {name: 'Edit'}).click(); await section.getByRole('button', {name: 'Edit'}).click();
await section.getByLabel('Default newsletter recipients').selectOption('Paid-members only'); await chooseOptionInSelect(section.getByLabel('Default newsletter recipients'), 'Paid-members only');
await section.getByRole('button', {name: 'Save'}).click(); await section.getByRole('button', {name: 'Save'}).click();
await expect(section.getByLabel('Default newsletter recipients')).toHaveCount(0); await expect(section.getByLabel('Default newsletter recipients')).toHaveCount(0);
@ -79,12 +79,12 @@ test.describe('Default recipient settings', async () => {
await section.getByRole('button', {name: 'Edit'}).click(); await section.getByRole('button', {name: 'Edit'}).click();
await section.getByLabel('Default newsletter recipients').selectOption({label: 'Specific people'}); await chooseOptionInSelect(section.getByLabel('Default newsletter recipients'), 'Specific people');
await section.getByLabel('Filter').click(); await section.getByLabel('Filter').click();
await section.locator('[data-testid="multiselect-option"]', {hasText: 'Basic Supporter'}).click(); await section.locator('[data-testid="select-option"]', {hasText: 'Basic Supporter'}).click();
await section.locator('[data-testid="multiselect-option"]', {hasText: 'first-label'}).click(); await section.locator('[data-testid="select-option"]', {hasText: 'first-label'}).click();
await section.locator('[data-testid="multiselect-option"]', {hasText: 'First offer'}).click(); await section.locator('[data-testid="select-option"]', {hasText: 'First offer'}).click();
await section.getByRole('button', {name: 'Save'}).click(); await section.getByRole('button', {name: 'Save'}).click();

View File

@ -1,5 +1,5 @@
import {chooseOptionInSelect, globalDataRequests, limitRequests, mockApi, responseFixtures} from '../../utils/e2e';
import {expect, test} from '@playwright/test'; import {expect, test} from '@playwright/test';
import {globalDataRequests, limitRequests, mockApi, responseFixtures} from '../../utils/e2e';
test.describe('Newsletter settings', async () => { test.describe('Newsletter settings', async () => {
test('Supports creating a new newsletter', async ({page}) => { test('Supports creating a new newsletter', async ({page}) => {
@ -75,7 +75,7 @@ test.describe('Newsletter settings', async () => {
await modal.getByPlaceholder('Weekly Roundup').fill('Updated newsletter'); await modal.getByPlaceholder('Weekly Roundup').fill('Updated newsletter');
await modal.getByRole('tab', {name: 'Design'}).click(); await modal.getByRole('tab', {name: 'Design'}).click();
await modal.getByLabel('Body style').selectOption({value: 'sans_serif'}); await chooseOptionInSelect(modal.getByLabel('Body style'), 'Clean sans-serif');
await modal.getByRole('button', {name: 'Save & close'}).click(); await modal.getByRole('button', {name: 'Save & close'}).click();

View File

@ -1,12 +1,12 @@
import {chooseOptionInSelect, globalDataRequests, mockApi, updatedSettingsResponse} from '../../utils/e2e';
import {expect, test} from '@playwright/test'; import {expect, test} from '@playwright/test';
import {globalDataRequests, mockApi, updatedSettingsResponse} from '../../utils/e2e';
test.describe('Time zone settings', async () => { test.describe('Time zone settings', async () => {
test('Supports editing the time zone', async ({page}) => { test('Supports editing the time zone', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([ editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{key: 'timezone', value: 'Asia/Tokyo'} {key: 'timezone', value: 'America/Anchorage'}
])} ])}
}}); }});
@ -18,17 +18,17 @@ test.describe('Time zone settings', async () => {
await section.getByRole('button', {name: 'Edit'}).click(); await section.getByRole('button', {name: 'Edit'}).click();
await section.getByLabel('Site timezone').selectOption('Asia/Tokyo'); await chooseOptionInSelect(section.getByLabel('Site timezone'), '(GMT -9:00) Alaska');
await section.getByRole('button', {name: 'Save'}).click(); await section.getByRole('button', {name: 'Save'}).click();
await expect(section.getByLabel('Site timezone')).toHaveCount(0); await expect(section.getByLabel('Site timezone')).toHaveCount(0);
await expect(section.getByText('Asia/Tokyo')).toHaveCount(1); await expect(section.getByText('America/Anchorage')).toHaveCount(1);
expect(lastApiRequests.editSettings?.body).toEqual({ expect(lastApiRequests.editSettings?.body).toEqual({
settings: [ settings: [
{key: 'timezone', value: 'Asia/Tokyo'} {key: 'timezone', value: 'America/Anchorage'}
] ]
}); });
}); });

View File

@ -1,5 +1,5 @@
import {chooseOptionInSelect, globalDataRequests, mockApi, responseFixtures, updatedSettingsResponse} from '../../utils/e2e';
import {expect, test} from '@playwright/test'; import {expect, test} from '@playwright/test';
import {globalDataRequests, mockApi, responseFixtures, updatedSettingsResponse} from '../../utils/e2e';
test.describe('Access settings', async () => { test.describe('Access settings', async () => {
test('Supports editing access', async ({page}) => { test('Supports editing access', async ({page}) => {
@ -22,9 +22,9 @@ test.describe('Access settings', async () => {
await section.getByRole('button', {name: 'Edit'}).click(); await section.getByRole('button', {name: 'Edit'}).click();
await section.getByLabel('Subscription access').selectOption({label: 'Only people I invite'}); await chooseOptionInSelect(section.getByLabel('Subscription access'), 'Only people I invite');
await section.getByLabel('Default post access').selectOption({label: 'Members only'}); await chooseOptionInSelect(section.getByLabel('Default post access'), /^Members only$/);
await section.getByLabel('Commenting').selectOption({label: 'All members'}); await chooseOptionInSelect(section.getByLabel('Commenting'), 'All members');
await section.getByRole('button', {name: 'Save'}).click(); await section.getByRole('button', {name: 'Save'}).click();
@ -59,11 +59,11 @@ test.describe('Access settings', async () => {
await section.getByRole('button', {name: 'Edit'}).click(); await section.getByRole('button', {name: 'Edit'}).click();
await section.getByLabel('Default post access').selectOption({label: 'Specific tiers'}); await chooseOptionInSelect(section.getByLabel('Default post access'), 'Specific tiers');
await section.getByLabel('Select tiers').click(); await section.getByLabel('Select tiers').click();
await section.locator('[data-testid="multiselect-option"]', {hasText: 'Basic Supporter'}).click(); await section.locator('[data-testid="select-option"]', {hasText: 'Basic Supporter'}).click();
await section.locator('[data-testid="multiselect-option"]', {hasText: 'Ultimate Starlight Diamond Tier'}).click(); await section.locator('[data-testid="select-option"]', {hasText: 'Ultimate Starlight Diamond Tier'}).click();
await section.getByRole('button', {name: 'Save'}).click(); await section.getByRole('button', {name: 'Save'}).click();

View File

@ -1,5 +1,5 @@
import {chooseOptionInSelect, globalDataRequests, mockApi, mockSitePreview, responseFixtures} from '../../utils/e2e';
import {expect, test} from '@playwright/test'; import {expect, test} from '@playwright/test';
import {globalDataRequests, mockApi, mockSitePreview, responseFixtures} from '../../utils/e2e';
test.describe('Design settings', async () => { test.describe('Design settings', async () => {
test('Working with the preview', async ({page}) => { test('Working with the preview', async ({page}) => {
@ -129,7 +129,7 @@ test.describe('Design settings', async () => {
await modal.getByRole('tab', {name: 'Site wide'}).click(); await modal.getByRole('tab', {name: 'Site wide'}).click();
await modal.getByLabel('Navigation layout').selectOption('Logo in the middle'); await chooseOptionInSelect(modal.getByLabel('Navigation layout'), 'Logo in the middle');
await modal.getByRole('button', {name: 'Save'}).click(); await modal.getByRole('button', {name: 'Save'}).click();
const expectedSettings = {navigation_layout: 'Logo in the middle'}; const expectedSettings = {navigation_layout: 'Logo in the middle'};

View File

@ -3,9 +3,9 @@ import {ConfigResponseType} from '../../src/api/config';
import {CustomThemeSettingsResponseType} from '../../src/api/customThemeSettings'; import {CustomThemeSettingsResponseType} from '../../src/api/customThemeSettings';
import {InvitesResponseType} from '../../src/api/invites'; import {InvitesResponseType} from '../../src/api/invites';
import {LabelsResponseType} from '../../src/api/labels'; import {LabelsResponseType} from '../../src/api/labels';
import {Locator, Page} from '@playwright/test';
import {NewslettersResponseType} from '../../src/api/newsletters'; import {NewslettersResponseType} from '../../src/api/newsletters';
import {OffersResponseType} from '../../src/api/offers'; import {OffersResponseType} from '../../src/api/offers';
import {Page} from '@playwright/test';
import {RolesResponseType} from '../../src/api/roles'; import {RolesResponseType} from '../../src/api/roles';
import {SettingsResponseType} from '../../src/api/settings'; import {SettingsResponseType} from '../../src/api/settings';
import {SiteResponseType} from '../../src/api/site'; import {SiteResponseType} from '../../src/api/site';
@ -146,3 +146,8 @@ export async function mockSitePreview({page, url, response}: {page: Page, url: s
return lastRequest; return lastRequest;
} }
export async function chooseOptionInSelect(select: Locator, optionText: string | RegExp) {
await select.click();
await select.page().locator('[data-testid="select-option"]', {hasText: optionText}).click();
}