AdminX Portal UI (#17185)

refs. https://github.com/TryGhost/Team/issues/3545

As the first step of Portal settings being rebuilt in AdminX, we needed a couple of static skeleton components to get started. For this we also had to extend the props of Preview modal component in the design system.
This commit is contained in:
Peter Zimon 2023-07-03 16:53:40 +02:00 committed by GitHub
parent dda42f521b
commit de4186ab97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 180 additions and 31 deletions

View File

@ -35,40 +35,43 @@ import SBGlobalError from './assets/global-error-example.png';
The three most common errors in Ghost Admin should fall into one of the following categories:
1. **Local errors** — errors related typically to input fields in forms
1. **Inline errors** — errors related typically to input fields in forms
2. **Contextual errors** — errors which are related to a screen, page or a modal and the tasks the user is doing at that specific context.
3. **System errors** — global errors which are relevant no matter what the actual screen is or the user's actual task
## Local errors
## Inline errors
Local errors are typically related to form validation and appear most commonly close to a form element. An example of a local error is when the user doesn't fill a mandatory input field and an error message appears near the field.
Inline errors are typically related to form validation and appear most commonly close to a form element. An example of an inline error is when the user doesn't fill a mandatory input field and an error message appears near the field.
Example of a local error:
Example of a inline error:
<img src={SBLocalError} />
<br /><br />
Frontend form validation usually happens without an API roundtrip, ideally after the user explicitly sends the form by pressing the submit button:
Frontend form validation usually happens without an API roundtrip, ideally right after the user finishes editing a field (`onFocusOut`):
1. User fills the form
2. Clicks Submit
3. The system does (typically frontend) form validation and shows the field errors.
2. Clicks on the next form field (focus is out of the current input field)
3. The system does form field validation and  in case of an error — shows the related inline field error
All the form fields in AdminX have a built in capability to show local errors via the `error` prop set to `true` and using the `hint` prop to set the error message. Example: [Textfield](/story/global-form-textfield--error)
All the form fields in AdminX have a built in capability to show inline errors via the `error` prop set to `true` and using the `hint` prop to set the error message. Example: [Textfield](/story/global-form-textfield--error)
Ideally, all validation should be inline: that is, as soon as the user has finished filling in a field, an indicator should appear nearby if the field contains an error. This type of error message is easily noticeable; moreover, fixing the error immediately after the field has been completed requires the least interaction cost for users: they dont need to locate it or navigate to the field, nor do they have to switch context from a new field to return to an old field they thought they had completed successfully.
Of course, there will be situations where inline validation wont be possible and data entered by the user will need to be sent to a server for verification. ([ref](https://www.nngroup.com/articles/errors-forms-design-guidelines/))
## Page errors
Page errors are related to the actual page, modal or the task but unlike local errors, they always appear on the top of the page. An important property of page errors is that they _disappear_ if the user navigates away from the page.
Page errors are related to the actual page, modal or the task but unlike inline errors, they always appear on the top of the page. An important property of page errors is that they _disappear_ if the user navigates away from the page.
A common example of page errors is a summary of form validation errors or an API error or a summary of errors on submitting a form.
A typical example when page errors would be used:
1. User fills a form
2. Clicks Submit  frontend validation is complete, no errors found &rarr; API call
1. User fills a form. All inline form field validation is passed without an error
2. Clicks Submit &rarr; API call
3. There's an API error
4. We show a page error with the appropriate error message
4. A page error is shown with the appropriate error message
</div>
@ -78,16 +81,9 @@ A typical example when page errors would be used:
</div>
<div className="sb-doc">
Another example is a summary of validation errors:
[TBD: screenshot]
Use the [Toast / Page error](/story/global-toast--page-error) component to display page errors.
## System errors
System errors are always visible until explicitly dismissed no matter where the user navigates. Unlike local or page errors, they are not connected to a specific context but to the overall behavior or status of the system.
System errors are always visible until explicitly dismissed no matter where the user navigates. Unlike inline or page errors, they are not connected to a specific context but to the overall behavior or status of the system.
For example:
</div>
@ -99,9 +95,11 @@ For example:
<div className="sb-doc">
## Do's and dont's [WIP]
## Best practices
- Show errors on submitting a form. It can either happen when the user clicks the [Submit] button, or sometimes hitting `Enter` on an input field. **Don't validate** on focus out and especially **don't validate prematurely**, ie. while the user is still typing (e.g. `onKeyUp`)
- Use a summary **page error** in most of the cases if there's one or more validation errors in the form.
- Optimise for inline error messages and instead page level errors. With inline validation, the error message is naturally shown next to the field causing the error.
- Use modals sparingly. They are disruptive and the error message is presented in a window that needs to be dismissed in order to fix the error, so any complex instructions will have to be stored in users working memory, thus increasing their cognitive load.
- Don't validate fields prematurely, ie. show the error messages only when the user finished interacting with the given field.
- Dont use summary errors as the only indication of an error. If you show a validation summary page error make sure that all fields with errors are also indicated with an appropriate inline error message.
</div>

View File

@ -20,6 +20,9 @@ export interface PreviewModalProps {
okColor?: string;
buttonsDisabled?: boolean
previewToolbar?: boolean;
leftToolbar?: boolean;
rightToolbar?: boolean;
deviceSelector?: boolean;
previewToolbarURLs?: SelectOption[];
selectedURL?: string;
previewToolbarTabs?: Tab[];
@ -30,7 +33,7 @@ export interface PreviewModalProps {
onCancel?: () => void;
onOk?: () => void;
onSelectURL: (url: string) => void;
onSelectURL?: (url: string) => void;
onSelectDesktopView?: () => void;
onSelectMobileView?: () => void;
}
@ -45,6 +48,9 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
okLabel = 'OK',
okColor = 'black',
previewToolbar = true,
leftToolbar = true,
rightToolbar = true,
deviceSelector = true,
previewToolbarURLs,
selectedURL,
previewToolbarTabs,
@ -64,7 +70,7 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
const [view, setView] = useState('desktop');
if (view === 'mobile') {
if (view === 'mobile' && deviceSelector) {
preview = (
<MobileChrome data-testid="preview-mobile">
{preview}
@ -76,7 +82,7 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
let toolbarLeft = (<></>);
if (previewToolbarURLs) {
toolbarLeft = (
<Select options={previewToolbarURLs!} selectedOption={selectedURL} onSelect={onSelectURL} />
<Select options={previewToolbarURLs!} selectedOption={selectedURL} onSelect={onSelectURL!} />
);
} else if (previewToolbarTabs) {
toolbarLeft = <TabView
@ -84,12 +90,12 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
selectedTab={selectedURL}
tabs={previewToolbarTabs}
width='wide'
onTabChange={onSelectURL}
onTabChange={onSelectURL!}
/>;
}
const unSelectedIconColorClass = 'text-grey-500';
const toolbarRight = (
const toolbarRight = deviceSelector && (
<ButtonGroup
buttons={[
{
@ -124,8 +130,8 @@ export const PreviewModalContent: React.FC<PreviewModalProps> = ({
data-testid="design-toolbar"
size='lg'
toolbarCenter={<></>}
toolbarLeft={toolbarLeft}
toolbarRight={toolbarRight}
toolbarLeft={leftToolbar && toolbarLeft}
toolbarRight={rightToolbar && toolbarRight}
/>
<div className='flex h-full grow items-center justify-center bg-grey-50 text-sm text-grey-400'>
{preview}

View File

@ -1,9 +1,11 @@
import Access from './Access';
import Analytics from './Analytics';
import Portal from './Portal';
import React from 'react';
import SettingSection from '../../../admin-x-ds/settings/SettingSection';
const searchKeywords = {
portal: ['portal', 'signup', 'sign up', 'sign in', 'login', 'account'],
access: ['access', 'subscription', 'post'],
analytics: ['analytics', 'tracking', 'privacy']
};
@ -11,6 +13,7 @@ const searchKeywords = {
const MembershipSettings: React.FC = () => {
return (
<SettingSection keywords={Object.values(searchKeywords).flat()} title='Membership'>
<Portal keywords={searchKeywords.portal} />
<Access keywords={searchKeywords.access} />
<Analytics keywords={searchKeywords.analytics} />
</SettingSection>

View File

@ -0,0 +1,24 @@
import Button from '../../../admin-x-ds/global/Button';
import NiceModal from '@ebay/nice-modal-react';
import PortalModal from './PortalModal';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
const Portal: React.FC<{ keywords: string[] }> = ({keywords}) => {
const openPreviewModal = () => {
NiceModal.show(PortalModal);
};
return (
<SettingGroup
customButtons={<Button color='green' label='Customize' link onClick={openPreviewModal}/>}
description="Customize members modal signup flow"
keywords={keywords}
navid='portal'
testId='portal'
title="Portal settings"
/>
);
};
export default Portal;

View File

@ -0,0 +1,70 @@
import AccountPage from './portal/AccountPage';
import LookAndFeel from './portal/LookAndFeel';
import NiceModal from '@ebay/nice-modal-react';
import PortalPreview from './portal/PortalPreview';
import React, {useState} from 'react';
import SignupOptions from './portal/SignupOptions';
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import {PreviewModalContent} from '../../../admin-x-ds/global/modal/PreviewModal';
const Sidebar: React.FC = () => {
const [selectedTab, setSelectedTab] = useState('signupOptions');
const tabs: Tab[] = [
{
id: 'signupOptions',
title: 'Signup options',
contents: <SignupOptions />
},
{
id: 'lookAndFeel',
title: 'Look & feel',
contents: <LookAndFeel />
},
{
id: 'accountPage',
title: 'Account page',
contents: <AccountPage />
}
];
const handleTabChange = (id: string) => {
setSelectedTab(id);
};
return (
<div className='pt-4'>
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={handleTabChange} />
</div>
);
};
const PortalModal: React.FC = () => {
const [selectedPreviewTab, setSelectedPreviewTab] = useState('signup');
const onSelectURL = (id: string) => {
setSelectedPreviewTab(id);
};
const sidebar = <Sidebar />;
const preview = <PortalPreview selectedTab={selectedPreviewTab} />;
let previewTabs: Tab[] = [
{id: 'signup', title: 'Signup'},
{id: 'account', title: 'Account page'},
{id: 'links', title: 'Links'}
];
return <PreviewModalContent
deviceSelector={selectedPreviewTab !== 'links'}
preview={preview}
previewToolbarTabs={previewTabs}
selectedURL={selectedPreviewTab}
sidebar={sidebar}
testId='portal-modal'
title='Portal'
onSelectURL={onSelectURL}
/>;
};
export default NiceModal.create(PortalModal);

View File

@ -0,0 +1,7 @@
import React from 'react';
const AccountPage: React.FC = () => {
return <>Account page</>;
};
export default AccountPage;

View File

@ -0,0 +1,7 @@
import React from 'react';
const LookAndFeel: React.FC = () => {
return <>Look & feel</>;
};
export default LookAndFeel;

View File

@ -0,0 +1,27 @@
import React from 'react';
interface PortalPreviewProps {
selectedTab: string;
}
const PortalPreview: React.FC<PortalPreviewProps> = ({
selectedTab = 'signup'
}) => {
let tabContents = <></>;
switch (selectedTab) {
case 'account':
tabContents = <>Account preview</>;
break;
case 'links':
tabContents = <>Links</>;
break;
default:
tabContents = <>Signup preview</>;
break;
}
return tabContents;
};
export default PortalPreview;

View File

@ -0,0 +1,7 @@
import React from 'react';
const SignupOptions: React.FC = () => {
return <>Signup options</>;
};
export default SignupOptions;