AdminX various UI fixes (#18089)

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

- some of the UI components' scrollbar was visible where it wasn't necessary
- metadata preview was shown in view mode too
- social accounts value was shown even if it was empty
- user detail modal was missing field descriptions
- it was not possible to reopen the Zapier modal after closing it the "Close" button
- copy had to be updated for analytics export to make it clear what is going to be exported
- invite modal had to be closed after successful invitation
- toggle component was only active on the text itself, instead of the whole row
This commit is contained in:
Peter Zimon 2023-09-12 17:11:12 +03:00 committed by GitHub
parent e8b175c574
commit dbe1c0fa2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 94 additions and 46 deletions

View File

@ -66,7 +66,7 @@ function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, d
</div> </div>
</div> </div>
<div className="relative flex-auto pt-[3vmin] tablet:ml-[300px] tablet:pt-[85px]"> <div className="relative flex-auto pt-[3vmin] tablet:ml-[300px] tablet:pt-[85px]">
<div className='pointer-events-none fixed inset-x-0 top-0 z-[5] hidden h-[80px] bg-gradient-to-t from-transparent to-white to-60% dark:to-black tablet:!visible tablet:!block'></div> {/* <div className='pointer-events-none fixed inset-x-0 top-0 z-[5] hidden h-[80px] bg-gradient-to-t from-transparent to-white to-60% dark:to-black tablet:!visible tablet:!block'></div> */}
<Settings /> <Settings />
</div> </div>
</div> </div>

View File

@ -40,7 +40,7 @@ function TabView<ID extends string = string>({
}; };
const containerClasses = clsx( const containerClasses = clsx(
'flex w-full overflow-x-scroll', 'no-scrollbar flex w-full overflow-x-auto',
width === 'narrow' && 'gap-3', width === 'narrow' && 'gap-3',
width === 'normal' && 'gap-5', width === 'normal' && 'gap-5',
width === 'wide' && 'gap-7', width === 'wide' && 'gap-7',

View File

@ -67,7 +67,7 @@ const Table: React.FC<TableProps> = ({children, borderTop, hint, hintSeparator,
return ( return (
<> <>
<div className='w-full overflow-x-scroll'> <div className='w-full overflow-x-auto'>
{pageTitle && <Heading>{pageTitle}</Heading>} {pageTitle && <Heading>{pageTitle}</Heading>}
{!isLoading && <table ref={table} className={tableClasses}> {!isLoading && <table ref={table} className={tableClasses}>
<tbody> <tbody>

View File

@ -63,7 +63,7 @@ const Form: React.FC<FormProps> = ({
} }
let titleClasses = clsx( let titleClasses = clsx(
grouped ? 'mb-2' : 'mb-3' grouped ? 'mb-3' : 'mb-4'
); );
if (grouped || title) { if (grouped || title) {

View File

@ -89,7 +89,7 @@ const Toggle: React.FC<ToggleProps> = ({
type="checkbox" type="checkbox"
onChange={onChange} /> onChange={onChange} />
{label && {label &&
<label className={`flex flex-col hover:cursor-pointer ${direction === 'rtl' && 'order-1'} ${labelStyles}`} htmlFor={id}> <label className={`flex grow flex-col hover:cursor-pointer ${direction === 'rtl' && 'order-1'} ${labelStyles}`} htmlFor={id}>
{ {
labelStyle === 'heading' ? labelStyle === 'heading' ?
<span className={`${Heading6StylesGrey} mt-1`}>{label}</span> <span className={`${Heading6StylesGrey} mt-1`}>{label}</span>

View File

@ -1,15 +1,40 @@
import React from 'react'; import React from 'react';
import clsx from 'clsx';
interface ToggleGroupProps { interface ToggleGroupProps {
children?: React.ReactNode; children?: React.ReactNode;
gap?: 'sm' | 'md' | 'lg';
className?: string;
} }
/** /**
* A simple container to group sequencing toggle switches * A simple container to group sequencing toggle switches
*/ */
const ToggleGroup: React.FC<ToggleGroupProps> = ({children}) => { const ToggleGroup: React.FC<ToggleGroupProps> = ({children, gap = 'md', className}) => {
let gapClass = 'gap-3';
switch (gap) {
case 'sm':
gapClass = 'gap-2';
break;
case 'md':
gapClass = 'gap-3';
break;
case 'lg':
gapClass = 'gap-4';
break;
default:
break;
}
className = clsx(
'flex flex-col gap-3',
gapClass,
className
);
return ( return (
<div className='flex flex-col gap-3'> <div className={className}>
{children} {children}
</div> </div>
); );

View File

@ -161,7 +161,7 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
} }
const containerClasses = clsx( const containerClasses = clsx(
'min-w-100 absolute inset-y-0 left-0 right-[400px] flex grow flex-col overflow-y-scroll', 'min-w-100 absolute inset-y-0 left-0 right-[400px] flex grow flex-col overflow-y-auto',
previewBgClass previewBgClass
); );

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import SettingValue from './SettingValue'; import SettingValue from './SettingValue';
import {ISettingValue} from './SettingValue'; import {SettingValueProps} from './SettingValue';
interface ISettingGroupContent { interface ISettingGroupContent {
columns?: 1 | 2; columns?: 1 | 2;
@ -9,7 +9,7 @@ interface ISettingGroupContent {
/** /**
* Use this array to display setting values with standard formatting in the content area of a setting group * Use this array to display setting values with standard formatting in the content area of a setting group
*/ */
values?: Array<ISettingValue>; values?: Array<SettingValueProps>;
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
} }

View File

@ -2,14 +2,19 @@ import React, {ReactNode} from 'react';
import Heading from '../global/Heading'; import Heading from '../global/Heading';
export interface ISettingValue { export interface SettingValueProps {
key: string, key: string;
heading?: string, heading?: string;
value: ReactNode, value: ReactNode;
hint?: ReactNode hint?: ReactNode;
hideEmptyValue?: boolean;
} }
const SettingValue: React.FC<ISettingValue> = ({heading, value, hint, ...props}) => { const SettingValue: React.FC<SettingValueProps> = ({heading, value, hint, hideEmptyValue, ...props}) => {
if (!value && hideEmptyValue) {
return <></>;
}
return ( return (
<div className='flex flex-col' {...props}> <div className='flex flex-col' {...props}>
{heading && <Heading grey={true} level={6}>{heading}</Heading>} {heading && <Heading grey={true} level={6}>{heading}</Heading>}

View File

@ -33,7 +33,7 @@ const Sidebar: React.FC = () => {
}; };
return ( return (
<div className='tablet:h-[calc(100vh-5vmin-84px)] tablet:w-[240px] tablet:overflow-y-scroll'> <div className='no-scrollbar tablet:h-[calc(100vh-5vmin-84px)] tablet:w-[240px] tablet:overflow-y-scroll'>
<div className='relative mb-10 md:pt-4 tablet:pt-[32px]'> <div className='relative mb-10 md:pt-4 tablet:pt-[32px]'>
<Icon className='absolute top-2 md:top-6 tablet:top-10' colorClass='text-grey-500' name='magnifying-glass' size='sm' /> <Icon className='absolute top-2 md:top-6 tablet:top-10' colorClass='text-grey-500' name='magnifying-glass' size='sm' />
<TextField autoComplete="off" className='border-b border-grey-500 bg-transparent px-3 py-1.5 pl-[24px] text-sm dark:text-white' placeholder="Search" title="Search" value={filter} hideTitle unstyled onChange={updateSearch} /> <TextField autoComplete="off" className='border-b border-grey-500 bg-transparent px-3 py-1.5 pl-[24px] text-sm dark:text-white' placeholder="Search" title="Search" value={filter} hideTitle unstyled onChange={updateSearch} />

View File

@ -75,6 +75,7 @@ const ZapierModal = NiceModal.create(() => {
testId='zapier-modal' testId='zapier-modal'
title='' title=''
onOk={() => { onOk={() => {
updateRoute('integrations');
modal.remove(); modal.remove();
}} }}
> >

View File

@ -271,39 +271,39 @@ const Sidebar: React.FC<{
checked={newsletter.show_feature_image} checked={newsletter.show_feature_image}
direction="rtl" direction="rtl"
label='Feature image' label='Feature image'
labelStyle='value' labelStyle='heading'
onChange={e => updateNewsletter({show_feature_image: e.target.checked})} onChange={e => updateNewsletter({show_feature_image: e.target.checked})}
/> />
</Form> </Form>
<Form className='mt-6' gap='sm' margins='lg' title='Footer'> <Form className='mt-6' gap='sm' margins='lg' title='Footer'>
<ToggleGroup> <ToggleGroup gap='lg'>
<Toggle <Toggle
checked={newsletter.feedback_enabled} checked={newsletter.feedback_enabled}
direction="rtl" direction="rtl"
label='Ask your readers for feedback' label='Ask your readers for feedback'
labelStyle='value' labelStyle='heading'
onChange={e => updateNewsletter({feedback_enabled: e.target.checked})} onChange={e => updateNewsletter({feedback_enabled: e.target.checked})}
/> />
<Toggle <Toggle
checked={newsletter.show_comment_cta} checked={newsletter.show_comment_cta}
direction="rtl" direction="rtl"
label='Add a link to your comments' label='Add a link to your comments'
labelStyle='value' labelStyle='heading'
onChange={e => updateNewsletter({show_comment_cta: e.target.checked})} onChange={e => updateNewsletter({show_comment_cta: e.target.checked})}
/> />
<Toggle <Toggle
checked={newsletter.show_latest_posts} checked={newsletter.show_latest_posts}
direction="rtl" direction="rtl"
label='Share your latest posts' label='Share your latest posts'
labelStyle='value' labelStyle='heading'
onChange={e => updateNewsletter({show_latest_posts: e.target.checked})} onChange={e => updateNewsletter({show_latest_posts: e.target.checked})}
/> />
<Toggle <Toggle
checked={newsletter.show_subscription_details} checked={newsletter.show_subscription_details}
direction="rtl" direction="rtl"
label='Show subscription details' label='Show subscription details'
labelStyle='value' labelStyle='heading'
onChange={e => updateNewsletter({show_subscription_details: e.target.checked})} onChange={e => updateNewsletter({show_subscription_details: e.target.checked})}
/> />
</ToggleGroup> </ToggleGroup>

View File

@ -13,6 +13,7 @@ import {useEffect, useRef, useState} from 'react';
type RoleType = 'administrator' | 'editor' | 'author' | 'contributor'; type RoleType = 'administrator' | 'editor' | 'author' | 'contributor';
const InviteUserModal = NiceModal.create(() => { const InviteUserModal = NiceModal.create(() => {
const modal = NiceModal.useModal();
const rolesQuery = useBrowseRoles(); const rolesQuery = useBrowseRoles();
const assignableRolesQuery = useBrowseRoles({ const assignableRolesQuery = useBrowseRoles({
searchParams: {limit: 'all', permissions: 'assign'} searchParams: {limit: 'all', permissions: 'assign'}
@ -108,6 +109,9 @@ const InviteUserModal = NiceModal.create(() => {
message: `Invitation successfully sent to ${email}`, message: `Invitation successfully sent to ${email}`,
type: 'success' type: 'success'
}); });
modal.remove();
updateRoute('users');
} catch (e) { } catch (e) {
setSaveState('error'); setSaveState('error');

View File

@ -111,13 +111,17 @@ const Metadata: React.FC<{ keywords: string[] }> = ({keywords}) => {
onEditingChange={handleEditingChange} onEditingChange={handleEditingChange}
onSave={handleSave} onSave={handleSave}
> >
<SearchEnginePreview {isEditing &&
description={metaDescription ? metaDescription : siteDescription} <>
icon={siteData?.icon} <SearchEnginePreview
title={metaTitle ? metaTitle : siteTitle} description={metaDescription ? metaDescription : siteDescription}
url={siteData?.url} icon={siteData?.icon}
/> title={metaTitle ? metaTitle : siteTitle}
{isEditing ? inputFields : null} url={siteData?.url}
/>
{inputFields}
</>
}
</SettingGroup> </SettingGroup>
); );
}; };

View File

@ -98,12 +98,14 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => {
{ {
heading: `URL of your publication's Facebook Page`, heading: `URL of your publication's Facebook Page`,
key: 'facebook', key: 'facebook',
value: facebookUrl value: facebookUrl,
hideEmptyValue: true
}, },
{ {
heading: 'URL of your TWITTER PROFILE', heading: 'URL of your TWITTER PROFILE',
key: 'twitter', key: 'twitter',
value: twitterUrl value: twitterUrl,
hideEmptyValue: true
} }
]} ]}
/> />

View File

@ -120,7 +120,7 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validators, user, setUs
/> />
<TextField <TextField
error={!!errors?.email} error={!!errors?.email}
hint={errors?.email || ''} hint={errors?.email || 'Used for notifications'}
title="Email" title="Email"
value={user.email} value={user.email}
onBlur={(e) => { onBlur={(e) => {
@ -159,6 +159,7 @@ const DetailsInputs: React.FC<UserDetailProps> = ({errors, validators, user, set
}} }}
/> />
<TextField <TextField
hint="Where in the world do you live?"
title="Location" title="Location"
value={user.location} value={user.location}
onChange={(e) => { onChange={(e) => {
@ -167,7 +168,8 @@ const DetailsInputs: React.FC<UserDetailProps> = ({errors, validators, user, set
/> />
<TextField <TextField
error={!!errors?.url} error={!!errors?.url}
hint={errors?.url || ''} hint={errors?.url || 'Have a website or blog other than this one? Link it!'}
placeholder='https://example.com'
title="Website" title="Website"
value={user.website} value={user.website}
onBlur={(e) => { onBlur={(e) => {
@ -178,6 +180,8 @@ const DetailsInputs: React.FC<UserDetailProps> = ({errors, validators, user, set
}} }}
/> />
<TextField <TextField
hint='URL of your personal Facebook Profile'
placeholder='https://www.facebook.com/ghost'
title="Facebook profile" title="Facebook profile"
value={user.facebook} value={user.facebook}
onChange={(e) => { onChange={(e) => {
@ -185,6 +189,8 @@ const DetailsInputs: React.FC<UserDetailProps> = ({errors, validators, user, set
}} }}
/> />
<TextField <TextField
hint='URL of your personal Twitter profile'
placeholder='https://twitter.com/ghost'
title="Twitter profile" title="Twitter profile"
value={user.twitter} value={user.twitter}
onChange={(e) => { onChange={(e) => {

View File

@ -106,7 +106,8 @@ const Analytics: React.FC<{ keywords: string[] }> = ({keywords}) => {
> >
{inputs} {inputs}
<div className='mt-1'> <div className='mt-1'>
<Button color='green' label='Export analytics' link={true} onClick={exportPosts} /> <Button color='green' label='Export post analytics' link={true} onClick={exportPosts} />
<div className='text-xs text-grey-700 dark:text-grey-600'>Download the data from your last 1,000 posts</div>
</div> </div>
</SettingGroup> </SettingGroup>
); );

View File

@ -73,4 +73,13 @@ html, body, #root {
.dark .gh-loading-orb { .dark .gh-loading-orb {
filter: invert(100%); filter: invert(100%);
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Chrome */
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
} }

View File

@ -15,9 +15,6 @@ test.describe('Metadata settings', async () => {
const section = page.getByTestId('metadata'); const section = page.getByTestId('metadata');
await expect(section.getByText('Test Site')).toHaveCount(1);
await expect(section.getByText('Thoughts, stories and ideas.')).toHaveCount(1);
await section.getByRole('button', {name: 'Edit'}).click(); await section.getByRole('button', {name: 'Edit'}).click();
await section.getByLabel('Meta title').fill('Alternative title'); await section.getByLabel('Meta title').fill('Alternative title');
@ -27,9 +24,6 @@ test.describe('Metadata settings', async () => {
await expect(section.getByLabel('Meta title')).toHaveCount(0); await expect(section.getByLabel('Meta title')).toHaveCount(0);
await expect(section.getByText('Alternative title')).toHaveCount(1);
await expect(section.getByText('Alternative description')).toHaveCount(1);
expect(lastApiRequests.editSettings?.body).toEqual({ expect(lastApiRequests.editSettings?.body).toEqual({
settings: [ settings: [
{key: 'meta_title', value: 'Alternative title'}, {key: 'meta_title', value: 'Alternative title'},

View File

@ -40,9 +40,6 @@ test.describe('User invitations', async () => {
await expect(page.getByTestId('toast')).toHaveText(/Invitation successfully sent to newuser@test\.com/); await expect(page.getByTestId('toast')).toHaveText(/Invitation successfully sent to newuser@test\.com/);
// Currently clicking the backdrop is the only way to close this modal
await page.locator('#modal-backdrop').click({position: {x: 0, y: 0}});
await section.getByRole('tab', {name: 'Invited'}).click(); await section.getByRole('tab', {name: 'Invited'}).click();
const listItem = section.getByTestId('user-invite').last(); const listItem = section.getByTestId('user-invite').last();

View File

@ -49,7 +49,7 @@ test.describe('Analytics settings', async () => {
const section = page.getByTestId('analytics'); const section = page.getByTestId('analytics');
await section.getByRole('button', {name: 'Export analytics'}).click(); await section.getByRole('button', {name: 'Export post analytics'}).click();
const hasDownloadUrl = lastApiRequests.postsExport?.url?.includes('/posts/export/?limit=1000'); const hasDownloadUrl = lastApiRequests.postsExport?.url?.includes('/posts/export/?limit=1000');
expect(hasDownloadUrl).toBe(true); expect(hasDownloadUrl).toBe(true);

View File

@ -81,7 +81,7 @@ test.describe('Design settings', async () => {
await modal.getByLabel('Site description').fill('new description'); await modal.getByLabel('Site description').fill('new description');
// set timeout of 500ms to wait for the debounce // set timeout of 500ms to wait for the debounce
await page.waitForTimeout(500); await page.waitForTimeout(1000);
await modal.getByRole('button', {name: 'Save'}).click(); await modal.getByRole('button', {name: 'Save'}).click();
expect(lastPreviewRequest.previewHeader).toMatch(/&d=new\+description&/); expect(lastPreviewRequest.previewHeader).toMatch(/&d=new\+description&/);