2
1
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2023-12-13 21:00:40 +01:00

AdminX UX improvements (#18449)

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

- added `isSearchable` to Select component (default `false`) so that only specific selects are searchable
- added `onFocus` and `onBlur` to select so that it can search for "/" and doesn't jump to the searchfield
This commit is contained in:
Peter Zimon 2023-10-04 12:09:04 +02:00 committed by GitHub
parent 81a6f0a00f
commit 235b346fda
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 169 additions and 26 deletions

View file

@ -52,7 +52,7 @@ const Button: React.FC<ButtonProps> = ({
if (!unstyled) {
className = clsx(
'flex items-center justify-center whitespace-nowrap rounded-sm text-sm transition',
'inline-flex items-center justify-center whitespace-nowrap rounded-sm text-sm transition',
((link && color !== 'clear' && color !== 'black') || (!link && color !== 'clear')) ? 'font-bold' : 'font-semibold',
!link ? `${size === 'sm' ? ' h-7 px-3 ' : ' h-[34px] px-4 '}` : '',
(link && linkWithPadding) && '-m-1 p-1',

View file

@ -0,0 +1,69 @@
import type {Meta, StoryObj} from '@storybook/react';
import Button from './Button';
import Tooltip from './Tooltip';
const meta = {
title: 'Global / Tooltip',
component: Tooltip,
tags: ['autodocs']
} satisfies Meta<typeof Tooltip>;
export default meta;
type Story = StoryObj<typeof Tooltip>;
export const Default: Story = {
args: {
content: 'Hello tooltip',
children: <Button color='outline' label="Hover me" />
}
};
export const MediumSize: Story = {
args: {
content: 'Hello tooltip',
children: <Button color='outline' label="Hover me" />,
size: 'md'
}
};
export const Left: Story = {
args: {
content: 'Hello tooltip on the left',
children: <Button color='outline' label="Hover me" />,
origin: 'left'
}
};
export const Center: Story = {
args: {
content: 'Hello center tooltip',
children: <Button color='outline' label="Hover me" />,
origin: 'center'
}
};
export const Right: Story = {
args: {
content: 'Hello right tooltip',
children: <Button color='outline' label="Hover me" />,
origin: 'right'
}
};
export const Long: Story = {
args: {
content: `You're the best evil son an evil dad could ever ask for.`,
children: <Button color='outline' label="Hover me" />,
size: 'md',
origin: 'left'
}
};
export const OnText: Story = {
args: {
content: 'Hello center tooltip',
children: 'Just hover me',
origin: 'center'
}
};

View file

@ -0,0 +1,36 @@
import React from 'react';
import clsx from 'clsx';
interface TooltipProps {
content?: React.ReactNode;
size?: 'sm' | 'md';
children?: React.ReactNode;
containerClassName?: string;
tooltipClassName?: string;
origin?: 'right' | 'center' | 'left'
}
const Tooltip: React.FC<TooltipProps> = ({content, size = 'sm', children, containerClassName, tooltipClassName, origin = 'center'}) => {
containerClassName = clsx(
'group/tooltip relative',
containerClassName
);
tooltipClassName = clsx(
'absolute -mt-1 -translate-y-full whitespace-nowrap rounded-sm bg-black px-2 py-0.5 text-white opacity-0 transition-all group-hover/tooltip:opacity-100 dark:bg-grey-950',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
origin === 'center' && 'left-1/2 -translate-x-1/2',
origin === 'left' && 'left-0',
origin === 'right' && 'right-0'
);
return (
<span className={containerClassName}>
{children}
<span className={tooltipClassName}>{content}</span>
</span>
);
};
export default Tooltip;

View file

@ -42,6 +42,7 @@ type MultiSelectProps = MultiSelectOptionProps & {
hint?: string;
onChange: (selected: MultiValue<MultiSelectOption>) => void;
canCreate?: boolean;
testId?: string;
}
const multiValueColor = (color?: MultiSelectColor) => {
@ -92,6 +93,7 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
values,
onChange,
canCreate = false,
testId,
...props
}) => {
const id = useId();
@ -155,11 +157,12 @@ const MultiSelect: React.FC<MultiSelectProps> = ({
return (
<div className='flex flex-col'>
{title && <Heading htmlFor={id} grey useLabelTag>{title}</Heading>}
{
async ?
<div data-testid={testId}>
{async ?
(canCreate ? <AsyncCreatableSelect {...commonOptions} defaultOptions={defaultOptions} loadOptions={loadOptions} /> : <AsyncSelect {...commonOptions} defaultOptions={defaultOptions} loadOptions={loadOptions} />) :
(canCreate ? <CreatableSelect {...commonOptions} options={options} /> : <ReactSelect {...commonOptions} options={options} />)
}
</div>
{hint && <Hint color={error ? 'red' : ''}>{hint}</Hint>}
</div>
);

View file

@ -92,6 +92,13 @@ export const WithCallback: Story = {
}
};
export const Searchable: Story = {
args: {
options: selectOptions,
isSearchable: true
}
};
export const Error: Story = {
args: {
title: 'Title',

View file

@ -5,6 +5,7 @@ import Icon from '../Icon';
import React, {useId, useMemo} from 'react';
import ReactSelect, {ClearIndicatorProps, DropdownIndicatorProps, GroupBase, OptionProps, OptionsOrGroups, Props, components} from 'react-select';
import clsx from 'clsx';
import {useFocusContext} from '../../providers/DesignSystemProvider';
export interface SelectOption {
value: string;
@ -58,10 +59,12 @@ export type SelectProps = Props<SelectOption, false> & SelectOptionProps & {
clearBg?: boolean;
border?: boolean;
fullWidth?: boolean;
isSearchable?: boolean;
containerClassName?: string;
controlClasses?: SelectControlClasses;
unstyled?: boolean;
disabled?: boolean;
testId?: string;
}
const DropdownIndicator: React.FC<DropdownIndicatorProps<SelectOption, false> & {clearBg: boolean}> = ({clearBg, ...props}) => (
@ -98,13 +101,23 @@ const Select: React.FC<SelectProps> = ({
clearBg = true,
border = true,
fullWidth = true,
isSearchable = false,
containerClassName,
controlClasses,
unstyled,
disabled = false,
testId,
...props
}) => {
const id = useId();
const {setFocusState} = useFocusContext();
const handleFocus = () => {
setFocusState(true);
};
const handleBlur = () => {
setFocusState(false);
};
let containerClasses = '';
if (!unstyled) {
@ -132,12 +145,12 @@ const Select: React.FC<SelectProps> = ({
valueContainer: clsx('gap-1', controlClasses?.valueContainer),
placeHolder: clsx('text-grey-500 dark:text-grey-800', controlClasses?.placeHolder),
menu: clsx(
'z-[300] rounded-b bg-white py-2 shadow dark:border dark:border-grey-900 dark:bg-black',
'z-[300] rounded-b bg-white 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),
noOptionsMessage: clsx('nowrap 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),
clearIndicator: clsx('', controlClasses?.clearIndicator)
};
@ -163,17 +176,20 @@ const Select: React.FC<SelectProps> = ({
components: {DropdownIndicator: dropdownIndicatorComponent, Option, ClearIndicator},
inputId: id,
isClearable: false,
isSearchable: isSearchable,
options,
placeholder: prompt ? prompt : '',
value: selectedOption,
unstyled: true,
onChange: onSelect
onChange: onSelect,
onFocus: handleFocus,
onBlur: handleBlur
};
const select = (
<>
{title && <Heading className={hideTitle ? 'sr-only' : ''} grey={selectedOption || !prompt ? true : false} htmlFor={id} useLabelTag={true}>{title}</Heading>}
<div className={containerClasses}>
<div className={containerClasses} data-testid={testId}>
{async ?
<AsyncSelect<SelectOption, false> {...customProps} {...props} /> :
<ReactSelect<SelectOption, false> {...customProps} {...props} />

View file

@ -8,7 +8,7 @@ import SettingSection from '../../../admin-x-ds/settings/SettingSection';
export const searchKeywords = {
integrations: ['integration', 'zapier', 'slack', 'amp', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github'],
codeInjection: ['newsletter', 'enable', 'disable', 'turn on'],
labs: ['labs', 'alpha', 'beta', 'flag', 'import', 'export', 'migrate', 'routes', 'redirects', 'translation', 'delete'],
labs: ['labs', 'alpha', 'beta', 'flag', 'import', 'export', 'migrate', 'routes', 'redirect', 'translation', 'delete', 'content', 'editor', 'substack', 'migration', 'portal'],
history: ['history', 'log', 'events', 'user events', 'staff']
};

View file

@ -96,6 +96,7 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
options={webhookEventOptions}
prompt='Select an event'
selectedOption={webhookEventOptions.flatMap(group => group.options).find(option => option.value === formState.event)}
testId='event-select'
title='Event'
hideTitle
onSelect={(option) => {

View file

@ -131,6 +131,7 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
hint='Who should be able to subscribe to your site?'
options={RECIPIENT_FILTER_OPTIONS}
selectedOption={RECIPIENT_FILTER_OPTIONS.find(option => option.value === selectedOption)}
testId='default-recipients-select'
title="Default Newsletter recipients"
onSelect={(option) => {
if (option) {

View file

@ -366,6 +366,7 @@ const Sidebar: React.FC<{
<Select
options={fontOptions}
selectedOption={fontOptions.find(option => option.value === newsletter.body_font_category)}
testId='body-font-select'
title='Body style'
onSelect={option => updateNewsletter({body_font_category: option?.value})}
/>

View file

@ -77,7 +77,9 @@ const TimeZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
hint={<Hint timezone={publicationTimezone} />}
options={timezoneOptions}
selectedOption={timezoneOptions.find(option => option.value === publicationTimezone)}
testId='timezone-select'
title="Site timezone"
isSearchable
onSelect={option => handleTimezoneChange(option?.value)}
/>
</SettingGroupContent>

View file

@ -137,6 +137,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
hint='Who should be able to subscribe to your site?'
options={MEMBERS_SIGNUP_ACCESS_OPTIONS}
selectedOption={MEMBERS_SIGNUP_ACCESS_OPTIONS.find(option => option.value === membersSignupAccess)}
testId='subscription-access-select'
title="Subscription access"
onSelect={(option) => {
updateSetting('members_signup_access', option?.value || null);
@ -146,6 +147,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
hint='When a new post is created, who should have access?'
options={DEFAULT_CONTENT_VISIBILITY_OPTIONS}
selectedOption={DEFAULT_CONTENT_VISIBILITY_OPTIONS.find(option => option.value === defaultContentVisibility)}
testId='default-post-access-select'
title="Default post access"
onSelect={(option) => {
updateSetting('default_content_visibility', option?.value || null);
@ -155,6 +157,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
<MultiSelect
color='black'
options={tierOptionGroups.filter(group => group.options.length > 0)}
testId='tiers-select'
title='Select tiers'
values={selectedTierOptions}
clearBg
@ -165,6 +168,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
hint='Who can comment on posts?'
options={COMMENTS_ENABLED_OPTIONS}
selectedOption={COMMENTS_ENABLED_OPTIONS.find(option => option.value === commentsEnabled)}
testId='commenting-select'
title="Commenting"
onSelect={(option) => {
updateSetting('comments_enabled', option?.value || null);

View file

@ -103,6 +103,7 @@ const TipsOrDonations: React.FC<{ keywords: string[] }> = ({keywords}) => {
fullWidth={false}
options={currencySelectGroups()}
selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === donationsCurrency)}
isSearchable
onSelect={option => updateSetting('donations_currency', option?.value || 'USD')}
/>
)}

View file

@ -226,6 +226,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
options={currencySelectGroups()}
selectedOption={currencySelectGroups().flatMap(group => group.options).find(option => option.value === formState.currency)}
size='xs'
isSearchable
onSelect={option => updateForm(state => ({...state, currency: option?.value}))}
/>
</div>

View file

@ -54,6 +54,7 @@ const ThemeSetting: React.FC<{
hint={setting.description}
options={setting.options.map(option => ({label: option, value: option}))}
selectedOption={{label: setting.value, value: setting.value}}
testId={`setting-select-${setting.key}`}
title={humanizeSettingKey(setting.key)}
onSelect={option => setSetting(option?.value || null)}
/>

View file

@ -146,7 +146,7 @@ test.describe('Custom integrations', async () => {
await webhookModal.getByLabel('Name').fill('My webhook');
await webhookModal.getByLabel('Target URL').fill('https://example.com');
await chooseOptionInSelect(webhookModal.getByLabel('Event'), 'Post created');
await chooseOptionInSelect(webhookModal.getByTestId('event-select'), 'Post created');
await webhookModal.getByRole('button', {name: 'Add'}).click();

View file

@ -18,7 +18,7 @@ test.describe('Default recipient settings', async () => {
await expect(section.getByText('Whoever has access to the post')).toHaveCount(1);
await section.getByRole('button', {name: 'Edit'}).click();
await chooseOptionInSelect(section.getByLabel('Default newsletter recipients'), 'All members');
await chooseOptionInSelect(section.getByTestId('default-recipients-select'), 'All members');
await section.getByRole('button', {name: 'Save'}).click();
expect(lastApiRequests.editSettings?.body).toEqual({
@ -29,7 +29,7 @@ test.describe('Default recipient settings', async () => {
});
await section.getByRole('button', {name: 'Edit'}).click();
await chooseOptionInSelect(section.getByLabel('Default newsletter recipients'), 'Usually nobody');
await chooseOptionInSelect(section.getByTestId('default-recipients-select'), 'Usually nobody');
await section.getByRole('button', {name: 'Save'}).click();
expect(lastApiRequests.editSettings?.body).toEqual({
@ -40,10 +40,10 @@ test.describe('Default recipient settings', async () => {
});
await section.getByRole('button', {name: 'Edit'}).click();
await chooseOptionInSelect(section.getByLabel('Default newsletter recipients'), 'Paid-members only');
await chooseOptionInSelect(section.getByTestId('default-recipients-select'), 'Paid-members only');
await section.getByRole('button', {name: 'Save'}).click();
await expect(section.getByLabel('Default newsletter recipients')).toHaveCount(0);
await expect(section.getByTestId('default-recipients-select')).toHaveCount(0);
await expect(section.getByText('Paid-members only')).toHaveCount(1);
@ -79,7 +79,7 @@ test.describe('Default recipient settings', async () => {
await section.getByRole('button', {name: 'Edit'}).click();
await chooseOptionInSelect(section.getByLabel('Default newsletter recipients'), 'Specific people');
await chooseOptionInSelect(section.getByTestId('default-recipients-select'), 'Specific people');
await section.getByLabel('Filter').click();
await section.locator('[data-testid="select-option"]', {hasText: 'Basic Supporter'}).click();

View file

@ -75,7 +75,7 @@ test.describe('Newsletter settings', async () => {
await modal.getByPlaceholder('Weekly Roundup').fill('Updated newsletter');
await modal.getByRole('tab', {name: 'Design'}).click();
await chooseOptionInSelect(modal.getByLabel('Body style'), 'Clean sans-serif');
await chooseOptionInSelect(modal.getByTestId('body-font-select'), 'Clean sans-serif');
await modal.getByRole('button', {name: 'Save'}).click();

View file

@ -18,11 +18,11 @@ test.describe('Time zone settings', async () => {
await section.getByRole('button', {name: 'Edit'}).click();
await chooseOptionInSelect(section.getByLabel('Site timezone'), '(GMT -9:00) Alaska');
await chooseOptionInSelect(section.getByTestId('timezone-select'), '(GMT -9:00) Alaska');
await section.getByRole('button', {name: 'Save'}).click();
await expect(section.getByLabel('Site timezone')).toHaveCount(0);
await expect(section.getByTestId('timezone-select')).toHaveCount(0);
await expect(section.getByText('America/Anchorage')).toHaveCount(1);

View file

@ -22,13 +22,13 @@ test.describe('Access settings', async () => {
await section.getByRole('button', {name: 'Edit'}).click();
await chooseOptionInSelect(section.getByLabel('Subscription access'), 'Only people I invite');
await chooseOptionInSelect(section.getByLabel('Default post access'), /^Members only$/);
await chooseOptionInSelect(section.getByLabel('Commenting'), 'All members');
await chooseOptionInSelect(section.getByTestId('subscription-access-select'), 'Only people I invite');
await chooseOptionInSelect(section.getByTestId('default-post-access-select'), /^Members only$/);
await chooseOptionInSelect(section.getByTestId('commenting-select'), 'All members');
await section.getByRole('button', {name: 'Save'}).click();
await expect(section.getByLabel('Subscription access')).toHaveCount(0);
await expect(section.getByTestId('subscription-access-select')).toHaveCount(0);
await expect(section.getByText('Only people I invite')).toHaveCount(1);
await expect(section.getByText('Members only')).toHaveCount(1);
@ -59,8 +59,8 @@ test.describe('Access settings', async () => {
await section.getByRole('button', {name: 'Edit'}).click();
await chooseOptionInSelect(section.getByLabel('Default post access'), 'Specific tiers');
await section.getByLabel('Select tiers').click();
await chooseOptionInSelect(section.getByTestId('default-post-access-select'), 'Specific tiers');
await section.getByTestId('tiers-select').click();
await section.locator('[data-testid="select-option"]', {hasText: 'Basic Supporter'}).click();
await section.locator('[data-testid="select-option"]', {hasText: 'Ultimate Starlight Diamond Tier'}).click();

View file

@ -129,7 +129,7 @@ test.describe('Design settings', async () => {
await modal.getByRole('tab', {name: 'Site wide'}).click();
await chooseOptionInSelect(modal.getByLabel('Navigation layout'), 'Logo in the middle');
await chooseOptionInSelect(modal.getByTestId('setting-select-navigation_layout'), 'Logo in the middle');
await modal.getByRole('button', {name: 'Save'}).click();
const expectedSettings = {navigation_layout: 'Logo in the middle'};
@ -190,7 +190,7 @@ test.describe('Design settings', async () => {
await expect(showFeaturedPostsCustomThemeSetting).toBeVisible();
await chooseOptionInSelect(modal.getByLabel('Navigation layout'), 'Logo in the middle');
await chooseOptionInSelect(modal.getByTestId('setting-select-navigation_layout'), 'Logo in the middle');
await expect(showFeaturedPostsCustomThemeSetting).not.toBeVisible();