Skip to content

Commit

Permalink
Improve messaging and error handling (#20078)
Browse files Browse the repository at this point in the history
ref DES-228

This PR updates messaging and error handling in order to make Ghost calmer and friendlier. High level summary of the changes:

- Removed all onBlur validation in Settings -> now it’s possible to just click around without being warned to fill mandatory fields
- Removed  lot of technical errors like `ValidationError: Validation (isEmpty) failed for locale`
- Completely removed the red background toast notifications, it was aggressive and raw esp. on the top
- Removed some unnecessary notifications (e.g. when removing a webhook, the removal already communicates the result)
- Now we show field errors on submitting forms, and in case of an error we show a “Retry” button in Settings too. This allowed to remove a lot of unnecessary error messages, like the big error message on the top, plus it’s consistent with the patterns outside Settings.
- Notification style is white now with filled color icons which makes everything much calmer and more refined.
- Removes redundant copy (e.g. "successful(ly)") from notifications

---------

Co-authored-by: Sodbileg Gansukh <sodbileg.gansukh@gmail.com>
  • Loading branch information
peterzimon and minimaluminium committed May 14, 2024
1 parent 842290c commit 770f657
Show file tree
Hide file tree
Showing 63 changed files with 391 additions and 317 deletions.
1 change: 1 addition & 0 deletions apps/admin-x-design-system/src/assets/icons/error-fill.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/admin-x-design-system/src/assets/icons/info-fill.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion apps/admin-x-design-system/src/global/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from 'react';

const icons: Record<string, {ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>}> = import.meta.glob('../assets/icons/*.svg', {eager: true});

export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'custom' | number;
export type IconSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'custom' | number;

export interface IconProps {
name: string;
Expand Down Expand Up @@ -36,6 +36,9 @@ const Icon: React.FC<IconProps> = ({name, size = 'md', colorClass = '', classNam
switch (size) {
case 'custom':
break;
case '2xs':
styles = 'w-2 h-2';
break;
case 'xs':
styles = 'w-3 h-3';
break;
Expand Down
51 changes: 51 additions & 0 deletions apps/admin-x-design-system/src/global/Toast.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,77 @@ type Story = StoryObj<typeof ToastContainer>;

export const Default: Story = {
args: {
title: 'Toast title',
message: 'Hello notification in a toast'
}
};

export const TitleOnly: Story = {
args: {
title: 'Hello notification in a toast'
}
};

export const MinWidth: Story = {
args: {
title: 'Min toast'
}
};

export const TitleWithIcon: Story = {
args: {
title: 'Hello notification in a toast',
type: 'info',
options: {
duration: Infinity
}
}
};

export const MessageOnly: Story = {
args: {
message: 'Hey, this is a message in a toast. Almost like a message in a bottle.'
}
};

export const Info: Story = {
args: {
title: 'Toast title',
message: 'Hello success message in a toast',
type: 'info'
}
};

export const Success: Story = {
args: {
title: 'Toast title',
message: 'Hello success message in a toast',
type: 'success'
}
};

export const Error: Story = {
args: {
title: 'Toast title',
message: 'Hello error message in a toast',
type: 'error'
}
};

export const Infinite: Story = {
args: {
title: 'Toast title',
message: 'Hello error message in a toast',
type: 'error',
options: {
duration: Infinity
}
}
};

export const PageError: Story = {
args: {
title: 'Toast title',
message: 'This is a page error which should not be automatically dismissed.',
type: 'pageError'
}
Expand Down
39 changes: 26 additions & 13 deletions apps/admin-x-design-system/src/global/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import React from 'react';
import {Toast as HotToast, ToastOptions, toast} from 'react-hot-toast';
import Icon from './Icon';

export type ToastType = 'neutral' | 'success' | 'error' | 'pageError';
export type ToastType = 'neutral' | 'info' | 'success' | 'error' | 'pageError';

export interface ShowToastProps {
title?: React.ReactNode;
message?: React.ReactNode;
type?: ToastType;
icon?: React.ReactNode | string;
Expand All @@ -31,35 +32,41 @@ const Toast: React.FC<ToastProps> = ({
children,
props
}) => {
let iconColorClass = 'text-grey-500';

switch (props?.type) {
case 'info':
props.icon = props.icon || 'info-fill';
iconColorClass = 'text-grey-500';
break;
case 'success':
props.icon = props.icon || 'check-circle';
props.icon = props.icon || 'success-fill';
iconColorClass = 'text-green';
break;
case 'error':
props.icon = props.icon || 'warning';
props.icon = props.icon || 'error-fill';
iconColorClass = 'text-red';
break;
}

const classNames = clsx(
'z-[90] flex items-start justify-between gap-6 rounded px-4 py-3 text-sm font-medium text-white',
(props?.type === 'success' || props?.type === 'neutral') && 'w-[300px] bg-black dark:bg-grey-950',
props?.type === 'error' && 'w-[300px] bg-red',
props?.options?.position === 'top-center' && 'w-full max-w-[520px] bg-red',
'relative z-[90] mb-[14px] ml-[6px] flex min-w-[272px] items-start justify-between gap-3 rounded-lg bg-white p-4 text-sm text-black shadow-md-heavy dark:bg-grey-925 dark:text-white',
props?.options?.position === 'top-center' ? 'max-w-[520px]' : 'max-w-[320px]',
t.visible ? (props?.options?.position === 'top-center' ? 'animate-toaster-top-in' : 'animate-toaster-in') : 'animate-toaster-out'
);

return (
<div className={classNames} data-testid={`toast-${props?.type}`}>
<div className='flex items-start gap-3'>
<div className='mr-7 flex items-start gap-[10px]'>
{props?.icon && (typeof props.icon === 'string' ?
<div className='mt-0.5'><Icon className='grow' colorClass={props.type === 'success' ? 'text-green' : 'text-white'} name={props.icon} size='sm' /></div> : props.icon)}
<div className='mt-px'><Icon className='grow' colorClass={iconColorClass} name={props.icon} size='sm' /></div> : props.icon)}
{children}
</div>
<button className='cursor-pointer' type='button' onClick={() => {
<button className='absolute right-5 top-5 -mr-1.5 -mt-1.5 cursor-pointer rounded-full p-2 text-grey-700 hover:text-black dark:hover:text-white' type='button' onClick={() => {
toast.dismiss(t.id);
}}>
<div className='mt-1'>
<Icon colorClass='text-white' name='close' size='xs' />
<div>
<Icon colorClass='stroke-2' name='close' size='2xs' />
</div>
</button>
</div>
Expand All @@ -69,6 +76,7 @@ const Toast: React.FC<ToastProps> = ({
export default Toast;

export const showToast = ({
title,
message,
type = 'neutral',
icon = '',
Expand All @@ -93,7 +101,12 @@ export const showToast = ({
icon: icon,
options: options
}} t={t}>
{message}
<div>
{title && <span className='mt-px block text-md font-semibold leading-tighter tracking-[0.1px]'>{title}</span>}
{message &&
<div className={`text-grey-900 dark:text-grey-300 ${title ? 'mt-1' : ''}`}>{message}</div>
}
</div>
</Toast>
),
{
Expand Down
19 changes: 7 additions & 12 deletions apps/admin-x-design-system/tailwind.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ module.exports = {
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)',
'md-heavy': '0 0 1px rgba(0,0,0,0.22), 0 1px 6px rgba(0,0,0,0.15), 0 8px 10px -8px rgba(0,0,0,0.16), 0px 24px 37px -21px rgba(0, 0, 0, 0.46)',
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)',
Expand All @@ -106,32 +107,26 @@ module.exports = {
keyframes: {
toasterIn: {
'0.00%': {
opacity: '0',
transform: 'translateX(-232.05px)'
transform: 'translateY(100%)'
},
'26.52%': {
opacity: '0.5',
transform: 'translateX(5.90px)'
transform: 'translateY(-3.90px)'
},
'63.26%': {
opacity: '1',
transform: 'translateX(-1.77px)'
transform: 'translateY(1.2px)'
},
'100.00%': {
transform: 'translateX(0px)'
transform: 'translateY(0px)'
}
},
toasterTopIn: {
'0.00%': {
opacity: '0',
transform: 'translateY(-82px)'
},
'26.52%': {
opacity: '0.5',
transform: 'translateY(5.90px)'
},
'63.26%': {
opacity: '1',
transform: 'translateY(-1.77px)'
},
'100.00%': {
Expand Down Expand Up @@ -264,7 +259,7 @@ module.exports = {
sm: '0.3rem',
DEFAULT: '0.4rem',
md: '0.6rem',
lg: '0.7rem',
lg: '0.8rem',
xl: '1.2rem',
'2xl': '1.6rem',
'3xl': '2.4rem',
Expand All @@ -274,7 +269,7 @@ module.exports = {
'2xs': '1.0rem',
base: '1.4rem',
xs: '1.2rem',
sm: '1.32rem',
sm: '1.3rem',
md: '1.40rem',
lg: '1.65rem',
xl: '2rem',
Expand Down
21 changes: 19 additions & 2 deletions apps/admin-x-framework/src/hooks/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ const useForm = <State>({initialState, savingDelay, savedDelay = 2000, onSave, o
// function to save the changed settings via API
const handleSave = useCallback<SaveHandler>(async (options = {}) => {
if (!validate()) {
setSaveState('error');
return false;
}

Expand Down Expand Up @@ -122,10 +123,26 @@ const useForm = <State>({initialState, savingDelay, savedDelay = 2000, onSave, o
setSaveState('unsaved');
}, []);

let okColor: ButtonColor = 'black';
if (saveState === 'saved') {
okColor = 'green';
} else if (saveState === 'error') {
okColor = 'red';
}

let okLabel = '';
if (saveState === 'saved') {
okLabel = 'Saved';
} else if (saveState === 'saving') {
okLabel = 'Saving...';
} else if (saveState === 'error') {
okLabel = 'Retry';
}

const okProps: OkProps = {
disabled: saveState === 'saving',
color: saveState === 'saved' ? 'green' : 'black',
label: saveState === 'saved' ? 'Saved' : (saveState === 'saving' ? 'Saving...' : undefined)
color: okColor,
label: okLabel || undefined
};

return {
Expand Down
6 changes: 3 additions & 3 deletions apps/admin-x-framework/src/hooks/useHandleError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,17 @@ const useHandleError = () => {
} else if (error instanceof ValidationError && error.data?.errors[0]) {
showToast({
message: error.data.errors[0].context || error.data.errors[0].message,
type: 'pageError'
type: 'error'
});
} else if (error instanceof APIError) {
showToast({
message: error.message,
type: 'pageError'
type: 'error'
});
} else {
showToast({
message: 'Something went wrong, please try again.',
type: 'pageError'
type: 'error'
});
}
}, [sentryDSN]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
try {
await deleteAllContent(null);
showToast({
type: 'success',
message: 'All content deleted from database.'
title: 'All content deleted from database.',
type: 'success'
});
modal?.remove();
await client.refetchQueries();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,11 @@ const CustomIntegrations: React.FC<{integrations: Integration[]}> = ({integratio
await deleteIntegration(integration.id);
confirmModal?.remove();
showToast({
message: 'Integration deleted',
type: 'success'
title: 'Integration deleted',
type: 'info',
options: {
position: 'bottom-left'
}
});
} catch (e) {
handleError(e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import React, {useEffect, useState} from 'react';
import WebhooksTable from './WebhooksTable';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {APIKey, useRefreshAPIKey} from '@tryghost/admin-x-framework/api/apiKeys';
import {ConfirmationModal, Form, ImageUpload, Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {ConfirmationModal, Form, ImageUpload, Modal, TextField} from '@tryghost/admin-x-design-system';
import {Integration, useBrowseIntegrations, useEditIntegration} from '@tryghost/admin-x-framework/api/integrations';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
import {getGhostPaths} from '@tryghost/admin-x-framework/helpers';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {toast} from 'react-hot-toast';
import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';

const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({integration}) => {
Expand Down Expand Up @@ -37,7 +36,7 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
const newErrors: Record<string, string> = {};

if (!formState.name) {
newErrors.name = 'Name is required.';
newErrors.name = 'Enter integration title';
}

return newErrors;
Expand Down Expand Up @@ -88,16 +87,10 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
okLabel={okProps.label || 'Save & close'}
size='md'
testId='custom-integration-modal'
title={formState.name}
title={formState.name || 'Custom integration'}
stickyFooter
onOk={async () => {
toast.remove();
if (!(await handleSave({fakeWhenUnchanged: true}))) {
showToast({
type: 'pageError',
message: 'Can\'t save integration, please double check that you\'ve filled all mandatory fields.'
});
}
await handleSave({fakeWhenUnchanged: true});
}}
>
<div className='mt-7 flex w-full flex-col gap-7 md:flex-row'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const PinturaModal = NiceModal.create(() => {

showToast({
type: 'success',
message: `Pintura ${form} uploaded successfully`
title: `Pintura ${form} uploaded`
});
} catch (e) {
setUploadingState({js: false, css: false});
Expand Down

0 comments on commit 770f657

Please sign in to comment.