mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-28 14:03:48 +03:00
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:
parent
353c565739
commit
343535116c
@ -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
|
||||||
@ -64,4 +63,4 @@ export const CustomToolbar: Story = {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>}
|
||||||
</>
|
</>
|
||||||
|
@ -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={() => {}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -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: () => {}
|
|
||||||
}
|
|
||||||
};
|
|
@ -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;
|
|
@ -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
|
||||||
|
@ -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) => {
|
||||||
setDefaultRecipientValue(value);
|
if (value) {
|
||||||
|
setDefaultRecipientValue(value);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{(selectedOption === 'segment') && (
|
{(selectedOption === 'segment') && (
|
||||||
|
@ -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
|
||||||
|
@ -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 = (
|
||||||
|
@ -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>
|
||||||
|
@ -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'
|
||||||
|
@ -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'>
|
||||||
|
@ -83,7 +83,9 @@ const PortalLinks: React.FC = () => {
|
|||||||
options={tierOptions}
|
options={tierOptions}
|
||||||
selectedOption={selectedTier}
|
selectedOption={selectedTier}
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
setSelectedTier(value);
|
if (value) {
|
||||||
|
setSelectedTier(value);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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}))}
|
||||||
|
@ -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':
|
||||||
|
@ -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/);
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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'}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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'};
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user