Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve messaging and error handling #20078

Merged
merged 19 commits into from May 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
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
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
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
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
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
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
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
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
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
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