Added "No search result" screen to Settings (#19672)

refs.
https://linear.app/tryghost/issue/DES-21/empty-screen-is-missing-for-search-in-settings

- Search is one of the most useful functions in Settings and currently
the screen when there's no result for a searchterm is just a plain white
screen. Very non user-friendly.
- This update gives us an opportunity to improve the overall visual
hierarchy and focus of Settings in general.

---------

Co-authored-by: Ronald Langeveld <hi@ronaldlangeveld.com>
This commit is contained in:
Peter Zimon 2024-02-08 08:32:40 +01:00 committed by GitHub
parent 46866788dd
commit 3ef8b53fad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 115 additions and 80 deletions

View File

@ -76,13 +76,7 @@ const SettingGroup = forwardRef<HTMLDivElement, SettingGroupProps>(function Sett
onSave?.();
};
if (saveState === 'unsaved') {
styles += ' border-green';
} else if (isEditing){
styles += ' border-grey-700 dark:border-grey-600';
} else {
styles += ' border-grey-300 dark:border-grey-800 hover:border-grey-500';
}
styles += ' border-grey-250 dark:border-grey-925';
const viewButtons: ButtonProps[] = [];
@ -149,11 +143,11 @@ const SettingGroup = forwardRef<HTMLDivElement, SettingGroupProps>(function Sett
});
const containerClasses = clsx(
'relative flex-col gap-6 rounded-lg transition-all',
border && 'border p-5 md:p-7',
'relative flex-col gap-6 rounded-xl transition-all hover:border-grey-200',
border && 'border p-5 hover:shadow-sm md:p-7',
isVisible ? 'flex' : 'hidden',
(highlight && highlightOnModalClose) && 'before:pointer-events-none before:absolute before:inset-[1px] before:animate-setting-highlight-fade-out before:rounded before:shadow-[0_0_0_3px_rgba(48,207,67,0.45)]',
!isEditing && 'is-not-editing group/setting-group',
(highlight && highlightOnModalClose) && 'border-grey-200 shadow-sm',
!isEditing ? 'is-not-editing group/setting-group' : 'border-grey-200 shadow-sm',
styles
);

View File

@ -21,14 +21,14 @@ const SettingNavItem = forwardRef<HTMLLIElement, SettingNavItemProps>(function S
...props
}, ref) {
const classNames = clsx(
'w-100 flex h-8 cursor-pointer items-center rounded-md px-2 py-1 text-left text-sm transition-all hover:bg-grey-100 focus:bg-grey-100 dark:text-grey-300 dark:hover:bg-grey-925 dark:focus:bg-grey-900',
isCurrent && 'bg-grey-200 dark:bg-grey-900',
'w-100 mt-1 flex h-[38px] cursor-pointer items-center rounded-lg px-3 py-2 text-left text-[14px] font-medium transition-all hover:bg-grey-200 focus:bg-grey-100 dark:text-grey-600 dark:hover:bg-grey-950 dark:focus:bg-grey-925',
isCurrent ? 'bg-grey-250 text-black dark:bg-grey-925 dark:text-white' : 'text-grey-800',
!isVisible && 'hidden'
);
return (
<li ref={ref} {...props}><a className={classNames} id={navid} onClick={onClick}>
{icon && <Icon className='mr-[7px]' name={icon} size='sm' />}
{icon && <Icon className='mr-[7px] h-[18px] w-[18px]' name={icon} size='custom' />}
{title}
</a></li>
);

View File

@ -1,4 +1,5 @@
import React from 'react';
import {Separator} from '..';
export interface SettingNavSectionProps {
title?: string;
@ -13,11 +14,14 @@ const SettingNavSection: React.FC<SettingNavSectionProps> = ({title, isVisible,
return (
<>
{title && <h2 className='mb-4 ml-2 text-[16px] tracking-tight'>{title}</h2>}
{title && <h2 className='mb-4 ml-2 text-base font-semibold tracking-normal text-black dark:text-grey-400'>{title}</h2>}
{children &&
<ul className="mb-14 mt-[-8px]">
<>
<ul className="-mt-1 mb-7">
{children}
</ul>
<Separator className='mx-2 mb-7 border-grey-300 dark:border-grey-950' />
</>
}
</>
);

View File

@ -10,7 +10,7 @@ export interface SettingSectionProps {
const SettingSection: React.FC<SettingSectionProps> = ({title, isVisible = true, children}) => {
const containerClassNames = clsx(
'mb-[16vh]',
'mb-[10vh]',
isVisible ? '' : 'hidden'
);
@ -18,7 +18,7 @@ const SettingSection: React.FC<SettingSectionProps> = ({title, isVisible = true,
<div className={containerClassNames}>
{title && <SettingSectionHeader title={title} />}
{children &&
<div className="mb-[100px] flex flex-col gap-12">
<div className="mb-10 flex flex-col gap-12">
{children}
</div>
}

View File

@ -8,7 +8,7 @@ export interface SettingSectionHeaderProps {
const SettingSectionHeader: React.FC<SettingSectionHeaderProps> = ({title, sticky = false}) => {
const classNames = clsx(
'z-20 mb-px pb-10 text-4xl font-bold tracking-tighter',
'z-20 mb-px pb-10 text-3xl font-bold tracking-tighter',
(sticky ? 'sticky top-0 mt-[calc(-8vmin-4px)] bg-gradient-to-t from-transparent via-white via-20% to-white pt-[calc(8vmin-4px)] dark:bg-black' : 'mt-[-5px]')
);

View File

@ -66,7 +66,7 @@
}
@media (max-width: 860px) {
@media (max-width: 800px) {
.admin-x-base {
height: calc(100vh - 55px);
}
@ -96,4 +96,4 @@
/* Prose classes are for formatting arbitrary HTML that comes from the API */
.gh-prose-links a {
color: #30CF43;
}
}

View File

@ -27,6 +27,7 @@ module.exports = {
100: '#F4F5F6',
150: '#F1F3F4',
200: '#EBEEF0',
250: '#E5E9ED',
300: '#DDE1E5',
400: '#CED4D9',
500: '#AEB7C1',
@ -35,7 +36,8 @@ module.exports = {
800: '#626D79',
900: '#394047',
925: '#2E3338',
950: '#222427'
950: '#222427',
975: '#191B1E'
},
green: {
DEFAULT: '#30CF43',
@ -92,8 +94,9 @@ module.exports = {
},
boxShadow: {
DEFAULT: '0 0 1px rgba(0,0,0,.05), 0 5px 18px rgba(0,0,0,.08)',
sm: '0 0 1px rgba(0,0,0,.12), 0 1px 6px rgba(0,0,0,0.03), 0 6px 10px -8px rgba(0,0,0,.1)',
md: '0 0 1px rgba(0,0,0,.05), 0 8px 28px rgba(0,0,0,.12)',
xs: '0 0 1px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.03), 0 8px 10px -12px rgba(0,0,0,.1)',
sm: '0 0 1px rgba(0,0,0,.12), 0 1px 6px rgba(0,0,0,0.03), 0 8px 10px -8px rgba(0,0,0,.1)',
md: '0 0 1px rgba(0,0,0,0.12), 0 1px 6px rgba(0,0,0,0.03), 0 8px 10px -8px rgba(0,0,0,0.05), 0px 24px 37px -21px rgba(0, 0, 0, 0.05)',
lg: '0 0 7px rgba(0, 0, 0, 0.08), 0 2.1px 2.2px -5px rgba(0, 0, 0, 0.011), 0 5.1px 5.3px -5px rgba(0, 0, 0, 0.016), 0 9.5px 10px -5px rgba(0, 0, 0, 0.02), 0 17px 17.9px -5px rgba(0, 0, 0, 0.024), 0 31.8px 33.4px -5px rgba(0, 0, 0, 0.029), 0 76px 80px -5px rgba(0, 0, 0, 0.04)',
xl: '0 2.8px 2.2px rgba(0, 0, 0, 0.02), 0 6.7px 5.3px rgba(0, 0, 0, 0.028), 0 12.5px 10px rgba(0, 0, 0, 0.035), 0 22.3px 17.9px rgba(0, 0, 0, 0.042), 0 41.8px 33.4px rgba(0, 0, 0, 0.05), 0 100px 80px rgba(0, 0, 0, 0.07)',
inner: 'inset 0 0 4px 0 rgb(0 0 0 / 0.08)',
@ -269,14 +272,14 @@ module.exports = {
},
fontSize: {
'2xs': '1.0rem',
base: '1.45rem',
base: '1.4rem',
xs: '1.2rem',
sm: '1.35rem',
md: '1.45rem',
lg: '1.75rem',
sm: '1.32rem',
md: '1.40rem',
lg: '1.65rem',
xl: '2rem',
'2xl': '2.4rem',
'3xl': '3rem',
'3xl': '3.2rem',
'4xl': '3.6rem',
'5xl': ['4.2rem', '1.15'],
'6xl': ['6rem', '1'],

View File

@ -11,11 +11,10 @@ import {useRouting} from '@tryghost/admin-x-framework/routing';
const Page: React.FC<{children: ReactNode}> = ({children}) => {
return <>
<div className='sticky top-0 z-30 px-[5vmin] py-4 tablet:fixed tablet:px-6'>
<div className='sticky top-0 z-30 bg-white px-[5vmin] py-4 dark:bg-grey-975 tablet:fixed tablet:bg-transparent tablet:px-6 dark:tablet:bg-transparent xl:p-12'>
<ExitSettingsButton />
</div>
<div className="mx-auto flex max-w-[1080px] flex-col px-[5vmin] pb-[12vmin] tablet:flex-row tablet:items-start tablet:gap-x-10 tablet:py-[8vmin]" id="admin-x-settings-content">
<div className="w-full dark:bg-grey-975 tablet:fixed tablet:left-0 tablet:top-0 tablet:flex tablet:h-full" id="admin-x-settings-content">
{children}
</div>
</>;
@ -43,8 +42,8 @@ const MainContent: React.FC = () => {
if (isEditorUser(currentUser)) {
return (
<Page>
<div className='w-full'>
<Heading className='mb-10'>Settings</Heading>
<div className='mx-auto w-full max-w-5xl px-[5vmin] tablet:mt-16 xl:mt-10' id="admin-x-settings-scroller">
<Heading className='mb-[5vmin]'>Settings</Heading>
<Users highlight={false} keywords={[]} />
</div>
</Page>
@ -54,14 +53,12 @@ const MainContent: React.FC = () => {
return (
<Page>
{loadingModal && <div className={`fixed inset-0 z-40 h-[calc(100vh-55px)] w-[100vw] tablet:h-[100vh] ${topLevelBackdropClasses}`} />}
{/* Sidebar */}
<div className="sticky -top-px z-20 mt-[-55px] min-w-[260px] grow-0 bg-white pt-[52px] dark:bg-black tablet:fixed tablet:top-[8vmin] tablet:mt-0 tablet:basis-[260px] tablet:pt-0">
<div className="relative w-full bg-white dark:bg-black">
<div className="no-scrollbar fixed inset-x-0 top-[52px] z-[999] flex-1 basis-[320px] bg-white px-8 pb-8 dark:bg-grey-975 tablet:relative tablet:inset-x-auto tablet:top-auto tablet:h-full tablet:overflow-y-scroll tablet:bg-grey-50 tablet:pb-0 dark:tablet:bg-black" id="admin-x-settings-sidebar-scroller">
<div className="relative w-full">
<Sidebar />
</div>
</div>
<div className="relative flex-auto pt-[10vmin] tablet:ml-[330px] tablet:pt-0">
<div className="relative h-full flex-1 overflow-y-scroll pt-11 tablet:basis-[800px]" id="admin-x-settings-scroller">
<Settings />
</div>
</Page>

View File

@ -2,10 +2,10 @@ import {SettingSection, SettingSectionProps} from '@tryghost/admin-x-design-syst
import {useSearch} from './providers/SettingsAppProvider';
const SearchableSection: React.FC<Omit<SettingSectionProps, 'isVisible'> & {keywords: string[]}> = ({keywords, ...props}) => {
const {checkVisible} = useSearch();
const {checkVisible, noResult} = useSearch();
return (
<SettingSection isVisible={checkVisible(keywords)} {...props} />
<SettingSection isVisible={checkVisible(keywords) || noResult} {...props} />
);
};

View File

@ -10,7 +10,7 @@ import SiteSettings from './settings/site/SiteSettings';
const Settings: React.FC = () => {
return (
<>
<div className='mb-[40vh]'>
<div className='mb-[40vh] px-8 pt-8 tablet:max-w-[760px] tablet:px-14 xl:pt-0'>
<GeneralSettings />
<SiteSettings />
<MembershipSettings />

View File

@ -31,7 +31,7 @@ const NavItem: React.FC<Omit<SettingNavItemProps, 'isVisible' | 'isCurrent'> & {
};
const Sidebar: React.FC = () => {
const {filter, setFilter, checkVisible} = useSearch();
const {filter, setFilter, checkVisible, noResult, setNoResult} = useSearch();
const {updateRoute} = useRouting();
const searchInputRef = useRef<HTMLInputElement | null>(null);
const {isAnyTextFieldFocused} = useFocusContext();
@ -65,6 +65,19 @@ const Sidebar: React.FC = () => {
}
}, []);
useEffect(() => {
if (!checkVisible(Object.values(generalSearchKeywords).flat()) &&
!checkVisible(Object.values(siteSearchKeywords).flat()) &&
!checkVisible(Object.values(membershipSearchKeywords).flat()) &&
!checkVisible(Object.values(growthSearchKeywords).flat()) &&
!checkVisible(Object.values(emailSearchKeywords).flat()) &&
!checkVisible(Object.values(advancedSearchKeywords).flat())) {
setNoResult(true);
} else {
setNoResult(false);
}
}, [checkVisible, setNoResult, filter]);
const {settings, config} = useGlobalData();
const [newslettersEnabled] = getSettingValues(settings, ['editor_default_email_recipients']) as [string];
const hasStripeEnabled = checkStripeEnabled(settings || [], config || {});
@ -72,6 +85,7 @@ const Sidebar: React.FC = () => {
const handleSectionClick = (e?: React.MouseEvent<HTMLAnchorElement>) => {
if (e) {
setFilter('');
setNoResult(false);
updateRoute(e.currentTarget.id);
}
};
@ -88,32 +102,42 @@ const Sidebar: React.FC = () => {
};
const navClasses = clsx(
'no-scrollbar hidden pt-10 tablet:!visible tablet:!block tablet:h-[calc(100vh-8vmin-36px)] tablet:overflow-y-auto'
'hidden pt-10 tablet:!visible tablet:!block'
);
return (
<div data-testid="sidebar">
<div className='relative flex content-stretch items-end tablet:h-[36px]'>
<Icon className='absolute left-2 top-[10px] z-10' colorClass='text-grey-500' name='magnifying-glass' size='sm' />
<TextField
autoComplete="off"
className='-mx-1 flex h-9 w-[calc(100%+8px)] items-center rounded-full border border-transparent bg-grey-150 px-[33px] py-1.5 text-sm transition-all hover:bg-grey-100 focus:border-green focus:bg-white focus:shadow-[0_0_0_1px_rgba(48,207,67,1)] focus:outline-2 dark:bg-grey-900 dark:text-white dark:focus:bg-black'
containerClassName='w-100'
inputRef={searchInputRef}
placeholder="Search settings"
title="Search"
value={filter}
clearBg
hideTitle
unstyled
onChange={updateSearch}
/>
{filter ? <Button className='absolute right-3 top-[10px] p-1' icon='close' iconColorClass='text-grey-700 !w-[10px] !h-[10px]' size='sm' unstyled onClick={() => {
setFilter('');
searchInputRef.current?.focus();
}} /> : <div className='absolute right-0 top-[20px] hidden rounded border border-grey-400 bg-white px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wider text-grey-600 shadow-[0px_1px_#CED4D9] dark:bg-grey-800 dark:text-grey-500 tablet:!visible tablet:right-3 tablet:top-[7px] tablet:!block'>/</div>}
<div className='ml-auto flex w-full flex-col pt-0 tablet:max-w-[240px]' data-testid="sidebar">
<div className='sticky top-0 flex content-stretch items-end bg-grey-50 dark:bg-grey-975 tablet:h-28 dark:tablet:bg-black xl:h-20'>
<div className='relative w-full'>
<Icon className='absolute left-3 top-3 z-10' colorClass='text-grey-500' name='magnifying-glass' size='sm' />
<TextField
autoComplete="off"
className='flex h-10 w-full items-center rounded-lg border border-grey-200 bg-white px-[33px] py-1.5 text-[14px] shadow-xs transition-all hover:shadow-sm focus:border-green focus:bg-white focus:shadow-[0_0_0_1px_rgba(48,207,67,1)] focus:outline-2 dark:border-grey-950 dark:bg-grey-950 dark:text-white dark:placeholder:text-grey-800 dark:focus:bg-black'
containerClassName='w-100'
inputRef={searchInputRef}
placeholder="Search settings"
title="Search"
value={filter}
clearBg
hideTitle
unstyled
onChange={updateSearch}
/>
{filter ? <Button className='absolute right-3 top-3 p-1' icon='close' iconColorClass='text-grey-700 !w-[10px] !h-[10px]' size='sm' unstyled onClick={() => {
setFilter('');
searchInputRef.current?.focus();
}} /> : <div className='absolute -right-1 top-[9px] hidden rounded border border-grey-400 bg-white px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wider text-grey-600 shadow-[0px_1px_#CED4D9] dark:border-grey-800 dark:bg-grey-900 dark:text-grey-500 dark:shadow-[0px_1px_#626D79] tablet:!visible tablet:right-3 tablet:!block'>/</div>}
</div>
</div>
<div className={navClasses} id='admin-x-settings-sidebar'>
<nav className={navClasses} id='admin-x-settings-sidebar'>
{noResult &&
<div className='ml-2 text-base text-grey-700'>
<h2 className='mb-2 text-base font-semibold tracking-normal text-black dark:text-white'>No result</h2>
<div>
{`We couldn't find any setting matching '${filter}'`}.
</div>
</div>
}
<SettingNavSection isVisible={checkVisible(Object.values(generalSearchKeywords).flat())} title="General settings">
<NavItem icon='textfield' keywords={generalSearchKeywords.titleAndDescription} navid='general' title="Title & description" onClick={handleSectionClick} />
<NavItem icon='world-clock' keywords={generalSearchKeywords.timeZone} navid='timezone' title="Timezone" onClick={handleSectionClick} />
@ -166,14 +190,14 @@ const Sidebar: React.FC = () => {
</SettingNavSection>
{!filter &&
<a className='mb-10 ml-1 flex cursor-pointer items-center gap-1.5 pl-1 text-sm !font-normal' onClick={() => {
<a className='w-100 mb-10 mt-1 flex h-[38px] cursor-pointer items-center rounded-lg px-3 py-2 text-left text-[14px] font-medium text-grey-800 transition-all hover:bg-grey-200 focus:bg-grey-100 dark:text-grey-600 dark:hover:bg-grey-950 dark:focus:bg-grey-925' onClick={() => {
updateRoute('about');
}}>
<img alt='Ghost Logo' className='h-[18px] w-[18px]' src={GhostLogo} />
<img alt='Ghost Logo' className='mr-[7px] h-[18px] w-[18px]' src={GhostLogo} />
About Ghost
</a>
}
</div>
</nav>
</div>
);
};

View File

@ -5,7 +5,7 @@ import {useScrollSection} from '../hooks/useScrollSection';
import {useSearch} from './providers/SettingsAppProvider';
const TopLevelGroup: React.FC<Omit<SettingGroupProps, 'isVisible' | 'highlight'> & {keywords: string[]}> = ({keywords, navid, ...props}) => {
const {checkVisible} = useSearch();
const {checkVisible, noResult} = useSearch();
const {route} = useRouting();
const [highlight, setHighlight] = useState(false);
const {ref} = useScrollSection(navid);
@ -18,11 +18,11 @@ const TopLevelGroup: React.FC<Omit<SettingGroupProps, 'isVisible' | 'highlight'>
if (highlight) {
setTimeout(() => {
setHighlight(false);
}, 3000);
}, 2000);
}
}, [highlight]);
return <Base ref={ref} highlight={highlight} isVisible={checkVisible(keywords)} navid={navid} {...props} />;
return <Base ref={ref} highlight={highlight} isVisible={checkVisible(keywords) || noResult} navid={navid} {...props} />;
};
export default TopLevelGroup;

View File

@ -43,7 +43,14 @@ interface SettingsAppContextType {
const SettingsAppContext = createContext<SettingsAppContextType>({
officialThemes: [],
zapierTemplates: [],
search: {filter: '', setFilter: () => {}, checkVisible: () => true, highlightKeywords: () => ''},
search: {
filter: '',
setFilter: () => {},
checkVisible: () => true,
highlightKeywords: () => '',
noResult: false,
setNoResult: () => {}
},
sortingState: []
});

View File

@ -16,8 +16,8 @@ export const searchKeywords = {
timeZone: ['general', 'time', 'date', 'site timezone', 'time zone'],
publicationLanguage: ['general', 'publication language', 'locale'],
metadata: ['general', 'metadata', 'title', 'description', 'search', 'engine', 'google', 'meta data'],
twitter: ['general', 'twitter card', 'structured data', 'rich cards', 'x card'],
facebook: ['general', 'facebook card', 'structured data', 'rich cards'],
twitter: ['general', 'twitter card', 'structured data', 'rich cards', 'x card', 'social'],
facebook: ['general', 'facebook card', 'structured data', 'rich cards', 'social'],
socialAccounts: ['general', 'social accounts', 'facebook', 'twitter', 'structured data', 'rich cards'],
lockSite: ['general', 'password protection', 'lock site', 'make this site private'],
users: ['general', 'users and permissions', 'roles', 'staff', 'invite people', 'contributors', 'editors', 'authors', 'administrators']

View File

@ -21,7 +21,7 @@ export const useScrollSectionContext = () => useContext(ScrollSectionContext);
const scrollMargin = 193;
const scrollToSection = (element: HTMLDivElement, doneInitialScroll: boolean) => {
const root = document.querySelector('.admin-x-settings')!;
const root = document.getElementById('admin-x-settings-scroller')!;
const top = element.getBoundingClientRect().top + root.scrollTop;
root.scrollTo({
@ -31,7 +31,8 @@ const scrollToSection = (element: HTMLDivElement, doneInitialScroll: boolean) =>
};
const scrollSidebarNav = (navElement: HTMLLIElement, doneInitialScroll: boolean) => {
const sidebar = document.getElementById('admin-x-settings-sidebar')!;
// const sidebar = document.getElementById('admin-x-settings-sidebar')!;
const sidebar = document.getElementById('admin-x-settings-sidebar-scroller')!;
const bounds = navElement.getBoundingClientRect();

View File

@ -5,10 +5,13 @@ export interface SearchService {
setFilter: (value: string) => void;
checkVisible: (keywords: string[]) => boolean;
highlightKeywords: (text: ReactNode) => ReactNode;
noResult: boolean;
setNoResult: (value: boolean) => void;
}
const useSearchService = () => {
const [filter, setFilter] = useState('');
const [noResult, setNoResult] = useState(false);
const checkVisible = (keywords: string[]) => {
if (!keywords.length) {
@ -47,7 +50,9 @@ const useSearchService = () => {
filter,
setFilter,
checkVisible,
highlightKeywords
highlightKeywords,
noResult,
setNoResult
};
};

View File

@ -171,7 +171,7 @@ test.describe('Recommendations', async () => {
expect(confirmation).toContainText('Your recommendation Recommendation 1 title will no longer be visible to your audience.');
await confirmation.getByRole('button', {name: 'Delete'}).click();
await expect(page.getByTestId('toast-success')).toContainText('deleted the recommendation');
await expect(page.getByTestId('toast-success')).toContainText('Successfully deleted the recommendation');
expect(lastApiRequests.deleteRecommendation).toBeTruthy();
});

View File

@ -1448,10 +1448,10 @@
padding: 0;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 3.6rem;
font-size: 3.2rem;
line-height: 1.3em;
font-weight: 700;
letter-spacing: -0.021em;
letter-spacing: -0.05em;
/* match button height to avoid jump on navigation between screens*/
min-height: 35px;
color: var(--black);