Improve technical errors (#20046)

ref DES-229

Some of the error messages in Ghost and more specifically in Settings
were very technical, e.g.
`ValidationError: Validation (isEmpty) failed for locale`

This PR deals with some of the occurances for a more human error
communication.
This commit is contained in:
Peter Zimon 2024-04-24 08:42:22 +02:00 committed by GitHub
parent 571171bad5
commit 9f19e334c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 93 additions and 35 deletions

View File

@ -80,15 +80,6 @@ export const ResizeDisabled: Story = {
}
};
export const MaxLength: Story = {
args: {
title: 'Description',
placeholder: 'Try to enter more than 80 characters, I dare you...',
value: 'This is a nice text area that only accepts up to 80 characters. Try to add more:',
maxLength: 80
}
};
export const Error: Story = {
args: {
title: 'Description',

View File

@ -97,7 +97,6 @@ const TextArea: React.FC<TextAreaProps> = ({
</textarea>
{title && <Heading className={'order-1'} htmlFor={id} useLabelTag={true}>{title}</Heading>}
{hint && <Hint className='order-3' color={error ? 'red' : ''}>{hint}</Hint>}
{maxLength && <Hint>Max length is {maxLength}</Hint>}
</div>
);
};

View File

@ -63,6 +63,7 @@ const AddIntegrationModal: React.FC<RoutingModalProps> = () => {
autoFocus={true}
error={!!errors.name}
hint={errors.name}
maxLength={191}
placeholder='Custom integration'
title='Name'
value={name}

View File

@ -2,6 +2,7 @@ import APIKeys from './APIKeys';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
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 {Integration, useBrowseIntegrations, useEditIntegration} from '@tryghost/admin-x-framework/api/integrations';
@ -112,7 +113,11 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
const imageUrl = getImageUrl(await uploadImage({file}));
updateForm(state => ({...state, icon_image: imageUrl}));
} catch (e) {
handleError(e);
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
@ -124,12 +129,13 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
<TextField
error={Boolean(errors.name)}
hint={errors.name}
maxLength={191}
title='Title'
value={formState.name}
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
onKeyDown={() => clearError('name')}
/>
<TextField title='Description' value={formState.description || ''} onChange={e => updateForm(state => ({...state, description: e.target.value}))} />
<TextField maxLength={2000} title='Description' value={formState.description || ''} onChange={e => updateForm(state => ({...state, description: e.target.value}))} />
<APIKeys keys={[
{
label: 'Content API key',

View File

@ -78,6 +78,7 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
<TextField
error={Boolean(errors.name)}
hint={errors.name}
maxLength={191}
placeholder='Custom webhook'
title='Name'
value={formState.name}
@ -101,6 +102,7 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
<TextField
error={Boolean(errors.target_url)}
hint={errors.target_url}
maxLength={2000}
placeholder='https://example.com'
title='Target URL'
type='url'
@ -109,6 +111,7 @@ const WebhookModal: React.FC<WebhookModalProps> = ({webhook, integrationId}) =>
onKeyDown={() => clearError('target_url')}
/>
<TextField
maxLength={191}
placeholder='https://example.com'
title='Secret'
value={formState.secret || undefined}

View File

@ -71,10 +71,10 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
onOk: confirmModal => confirmModal?.remove()
});
} catch (e) {
let prompt = 'There was an error verifying your email address. Please try again.';
let prompt = 'There was an error verifying your email address. Try again later.';
if (e instanceof APIError && e.message === 'Token expired') {
prompt = 'The verification link has expired. Please try again.';
prompt = 'Verification link has expired.';
}
NiceModal.show(ConfirmationModal, {
title: 'Error verifying email address',

View File

@ -97,6 +97,7 @@ const AddNewsletterModal: React.FC<RoutingModalProps> = () => {
autoFocus={true}
error={Boolean(errors.name)}
hint={errors.name}
maxLength={191}
placeholder='Weekly roundup'
title='Name'
value={formState.name}
@ -104,6 +105,7 @@ const AddNewsletterModal: React.FC<RoutingModalProps> = () => {
onKeyDown={() => clearError('name')}
/>
<TextArea
maxLength={2000}
title='Description'
value={formState.description}
onChange={e => updateForm(state => ({...state, description: e.target.value}))}

View File

@ -76,6 +76,7 @@ const ReplyToEmailField: React.FC<{
<TextField
error={Boolean(errors.sender_reply_to)}
hint={errors.sender_reply_to}
maxLength={191}
placeholder={newsletterAddress || ''}
title="Reply-to email"
value={senderReplyTo}
@ -201,6 +202,7 @@ const Sidebar: React.FC<{
<TextField
error={Boolean(errors.sender_email)}
hint={errors.sender_email}
maxLength={191}
placeholder={defaultEmailAddress}
title="Sender email address"
value={newsletter.sender_email || ''}
@ -226,16 +228,17 @@ const Sidebar: React.FC<{
<TextField
error={Boolean(errors.name)}
hint={errors.name}
maxLength={191}
placeholder="Weekly Roundup"
title="Name"
value={newsletter.name || ''}
onChange={e => updateNewsletter({name: e.target.value})}
onKeyDown={() => clearError('name')}
/>
<TextArea rows={2} title="Description" value={newsletter.description || ''} onChange={e => updateNewsletter({description: e.target.value})} />
<TextArea maxLength={2000} rows={2} title="Description" value={newsletter.description || ''} onChange={e => updateNewsletter({description: e.target.value})} />
</Form>
<Form className='mt-6' gap='sm' margins='lg' title='Email info'>
<TextField placeholder={siteTitle} title="Sender name" value={newsletter.sender_name || ''} onChange={e => updateNewsletter({sender_name: e.target.value})} />
<TextField maxLength={191} placeholder={siteTitle} title="Sender name" value={newsletter.sender_name || ''} onChange={e => updateNewsletter({sender_name: e.target.value})} />
{renderSenderEmailField()}
<ReplyToEmailField clearError={clearError} errors={errors} newsletter={newsletter} updateNewsletter={updateNewsletter} validate={validate} />
</Form>

View File

@ -2,6 +2,7 @@ import React from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {FacebookLogo, ImageUpload, SettingGroupContent, TextField, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
@ -43,7 +44,11 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('og_image', imageUrl);
} catch (e) {
handleError(e);
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
};
@ -95,12 +100,14 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
<div className="flex flex-col gap-x-6 gap-y-7 px-4 pb-7">
<TextField
inputRef={focusRef}
maxLength={300}
placeholder={siteTitle}
title="Facebook title"
value={facebookTitle}
onChange={handleTitleChange}
/>
<TextField
maxLength={300}
placeholder={siteDescription}
title="Facebook description"
value={facebookDescription}

View File

@ -21,7 +21,7 @@ const LockSite: React.FC<{ keywords: string[] }> = ({keywords}) => {
onValidate: () => {
if (passwordEnabled && !password) {
return {
password: 'Password must be supplied'
password: 'Enter a password'
};
}

View File

@ -83,6 +83,7 @@ const Metadata: React.FC<{ keywords: string[] }> = ({keywords}) => {
<TextField
hint="Recommended: 70 characters"
inputRef={focusRef}
maxLength={300}
placeholder={siteTitle}
title="Meta title"
value={metaTitle}
@ -90,6 +91,7 @@ const Metadata: React.FC<{ keywords: string[] }> = ({keywords}) => {
/>
<TextField
hint="Recommended: 156 characters"
maxLength={500}
placeholder={siteDescription}
title="Meta description"
value={metaDescription}

View File

@ -49,6 +49,7 @@ const TitleAndDescription: React.FC<{ keywords: string[] }> = ({keywords}) => {
<TextField
hint="The name of your site"
inputRef={focusRef}
maxLength={150}
placeholder="Site title"
title="Site title"
value={title}
@ -56,6 +57,7 @@ const TitleAndDescription: React.FC<{ keywords: string[] }> = ({keywords}) => {
/>
<TextField
hint="A short description, used in your theme, meta data and search results"
maxLength={200}
placeholder="Site description"
title="Site description"
value={description}

View File

@ -2,6 +2,7 @@ import React from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {ImageUpload, SettingGroupContent, TextField, XLogo, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
@ -41,7 +42,11 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
const imageUrl = getImageUrl(await uploadImage({file}));
updateSetting('twitter_image', imageUrl);
} catch (e) {
handleError(e);
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
};
@ -91,12 +96,14 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
<div className="flex flex-col gap-x-6 gap-y-7 px-4 pb-7">
<TextField
inputRef={focusRef}
maxLength={300}
placeholder={siteTitle}
title="X title"
value={twitterTitle}
onChange={handleTitleChange}
/>
<TextField
maxLength={300}
placeholder={siteDescription}
title="X description"
value={twitterDescription}

View File

@ -9,6 +9,7 @@ import clsx from 'clsx';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useStaffUsers from '../../../hooks/useStaffUsers';
import validator from 'validator';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {ConfirmationModal, Heading, Icon, ImageUpload, LimitModal, Menu, MenuItem, Modal, showToast} from '@tryghost/admin-x-design-system';
import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
@ -267,7 +268,11 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
break;
}
} catch (e) {
handleError(e);
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
};
@ -374,7 +379,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
<div className='flex w-full max-w-[620px] flex-col gap-5 p-8 md:max-w-[auto] md:flex-row md:items-center'>
<div>
<ImageUpload
deleteButtonClassName='md:invisible absolute pr-3 -right-2 -top-2 flex h-8 w-16 cursor-pointer items-center justify-end rounded-full bg-[rgba(0,0,0,0.75)] text-white group-hover:!visible'
deleteButtonClassName='md:invisible absolute pr-3 -right-2 -top-2 flex h-8 w-10 cursor-pointer items-center justify-end rounded-full bg-[rgba(0,0,0,0.75)] text-white group-hover:!visible'
deleteButtonContent={<Icon colorClass='text-white' name='trash' size='sm' />}
editButtonClassName='md:invisible absolute right-[22px] -top-2 flex h-8 w-8 cursor-pointer items-center justify-center text-white group-hover:!visible z-20'
fileUploadClassName='rounded-full bg-black flex items-center justify-center opacity-80 transition hover:opacity-100 -ml-2 cursor-pointer h-[80px] w-[80px]'

View File

@ -13,6 +13,7 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validateField, clearErr
<TextField
error={!!errors?.name}
hint={errors?.name || 'Use real name so people can recognize you'}
maxLength={191}
title="Full name"
value={user.name}
onBlur={(e) => {
@ -26,6 +27,7 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validateField, clearErr
<TextField
error={!!errors?.email}
hint={errors?.email || 'Used for notifications'}
maxLength={191}
title="Email"
value={user.email}
onBlur={(e) => {
@ -38,6 +40,7 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validateField, clearErr
/>
<TextField
hint="https://example.com/author"
maxLength={191}
title="Slug"
value={user.slug}
onChange={(e) => {

View File

@ -13,6 +13,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
<TextField
error={!!errors?.location}
hint={errors?.location || 'Where in the world do you live?'}
maxLength={65535}
title="Location"
value={user.location || ''}
onBlur={(e) => {
@ -25,6 +26,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
<TextField
error={!!errors?.url}
hint={errors?.url || 'Have a website or blog other than this one? Link it!'}
maxLength={2000}
title="Website"
value={user.website || ''}
onBlur={(e) => {
@ -37,6 +39,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
<TextField
error={!!errors?.facebook}
hint={errors?.facebook || 'URL of your personal Facebook Profile'}
maxLength={2000}
title="Facebook profile"
value={facebookUrl}
onBlur={(e) => {
@ -53,6 +56,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
<TextField
error={!!errors?.twitter}
hint={errors?.twitter || 'URL of your X profile'}
maxLength={2000}
title="X (formerly Twitter) profile"
value={twitterUrl}
onBlur={(e) => {
@ -69,6 +73,7 @@ export const DetailsInputs: React.FC<UserDetailProps> = ({errors, clearError, va
<TextArea
error={!!errors?.bio}
hint={errors?.bio || <>Recommended: 200 characters. You&lsquo;ve used <span className='font-bold'>{user.bio?.length || 0}</span></>}
maxLength={65535}
title="Bio"
value={user.bio || ''}
onBlur={(e) => {

View File

@ -200,6 +200,7 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
autoFocus={true}
error={Boolean(errors.url)}
hint={errors.url || <>Need inspiration? <a className='text-green' href="https://www.ghost.org/explore" rel="noopener noreferrer" target='_blank'>Explore thousands of sites</a> to recommend</>}
maxLength={2000}
placeholder='https://www.example.com'
title='URL'
value={formState.url}

View File

@ -100,6 +100,7 @@ const RecommendationDescriptionForm: React.FC<Props<EditOrAddRecommendation | Re
autoFocus={true}
error={Boolean(errors.title)}
hint={errors.title}
maxLength={2000}
title="Title"
value={formState.title ?? ''}
onChange={(e) => {

View File

@ -1,5 +1,6 @@
import React, {useState} from 'react';
import clsx from 'clsx';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {Form, Heading, Icon, ImageUpload, Select, TextField, Toggle} from '@tryghost/admin-x-design-system';
import {ReactComponent as PortalIcon1} from '../../../../assets/icons/portal-icon-1.svg';
import {ReactComponent as PortalIcon2} from '../../../../assets/icons/portal-icon-2.svg';
@ -53,7 +54,11 @@ const LookAndFeel: React.FC<{
updateSetting('portal_button_icon', imageUrl);
setUploadedIcon(imageUrl);
} catch (e) {
handleError(e);
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
};

View File

@ -95,7 +95,7 @@ const PortalModal: React.FC = () => {
let prompt = 'There was an error verifying your email address. Please try again.';
if (e?.message === 'Token expired') {
prompt = 'The verification link has expired. Please try again.';
prompt = 'Verification link has expired.';
}
NiceModal.show(ConfirmationModal, {
title: 'Error verifying support address',

View File

@ -32,7 +32,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
const validators: {[key in keyof Tier]?: () => string | undefined} = {
name: () => (formState.name ? undefined : 'You must specify a name'),
name: () => (formState.name ? undefined : 'Enter a name for the tier'),
monthly_price: () => (formState.type !== 'free' ? validateCurrencyAmount(formState.monthly_price || 0, formState.currency, {allowZero: false}) : undefined),
yearly_price: () => (formState.type !== 'free' ? validateCurrencyAmount(formState.yearly_price || 0, formState.currency, {allowZero: false}) : undefined)
};
@ -213,6 +213,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
autoComplete='off'
error={Boolean(errors.name)}
hint={errors.name}
maxLength={191}
placeholder={isFreeTier ? 'Free' : 'Bronze'}
title='Name'
value={formState.name || ''}
@ -223,6 +224,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
<TextField
autoComplete='off'
autoFocus={isFreeTier}
maxLength={191}
placeholder={isFreeTier ? `Free preview` : 'Full access to premium content'}
title='Description'
value={formState.description || ''}
@ -297,6 +299,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
<URLTextField
baseUrl={siteData?.url}
hint={`Redirect to this URL after signup ${isFreeTier ? '' : ' for premium membership'}`}
maxLength={2000}
placeholder={siteData?.url}
title='Welcome page'
value={formState.welcome_page_url || null}
@ -315,10 +318,11 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
<div className='absolute left-[-32px] top-[7px] flex h-6 w-6 items-center justify-center bg-white group-hover:hidden dark:bg-black'><Icon name='check' size='sm' /></div>
<TextField
// className='grow border-b border-grey-500 py-2 focus:border-grey-800 group-hover:border-grey-600'
maxLength={191}
value={item}
onChange={e => benefits.updateItem(id, e.target.value)}
/>
<Button className='absolute right-1 top-1 z-10' icon='trash' iconColorClass='opacity-0 group-hover:opacity-100' size='sm' onClick={() => benefits.removeItem(id)} />
<Button className='absolute right-1 top-1 z-10 opacity-0 group-hover:opacity-100' color='grey' icon='trash' size='sm' onClick={() => benefits.removeItem(id)} />
</div>}
onMove={benefits.moveItem}
/>
@ -328,6 +332,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
<TextField
className='grow'
containerClassName='w-100'
maxLength={191}
placeholder='Expert analysis'
title='New benefit'
value={benefits.newItem}
@ -340,7 +345,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
}}
/>
<Button
className='absolute right-1 top-1 z-10'
className='absolute right-[5px] top-[5px] z-10'
color='green'
icon='add'
iconColorClass='text-white'

View File

@ -1,6 +1,7 @@
import React, {useRef, useState} from 'react';
import UnsplashSelector from '../../../selectors/UnsplashSelector';
import usePinturaEditor from '../../../../hooks/usePinturaEditor';
import {APIError} from '@tryghost/admin-x-framework/errors';
import {ColorPickerField, Heading, Hint, ImageUpload, SettingGroupContent, TextField, debounce} from '@tryghost/admin-x-design-system';
import {SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images';
@ -39,6 +40,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
<TextField
key='site-description'
hint='Used in your theme, meta data and search results'
maxLength={200}
title='Site description'
value={siteDescription}
onChange={(event) => {
@ -76,7 +78,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
try {
updateSetting('icon', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
@ -98,7 +104,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
try {
updateSetting('logo', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>
@ -136,7 +146,11 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
try {
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
} catch (e) {
handleError(e);
const error = e as APIError;
if (error.response!.status === 415) {
error.message = 'Unsupported file type';
}
handleError(error);
}
}}
>

View File

@ -38,10 +38,6 @@ test.describe('User profile', async () => {
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(modal).toContainText('Name is required');
await modal.getByLabel('Full name').fill(new Array(195).join('a'));
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(modal).toContainText('Name is too long');
await modal.getByLabel('Email').fill('test');
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(modal).toContainText('Please enter a valid email address');

View File

@ -21,7 +21,7 @@ test.describe('Tier settings', async () => {
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(page.getByTestId('toast-error')).toHaveText(/Can't save tier/);
await expect(modal).toHaveText(/You must specify a name/);
await expect(modal).toHaveText(/Enter a name for the tier/);
await expect(modal).toHaveText(/Amount must be at least \$1/);
await modal.getByLabel('Name').fill('Plus tier');
@ -107,7 +107,7 @@ test.describe('Tier settings', async () => {
await modal.getByRole('button', {name: 'Save & close'}).click();
await expect(page.getByTestId('toast-error')).toHaveText(/Can't save tier/);
await expect(modal).toHaveText(/You must specify a name/);
await expect(modal).toHaveText(/Enter a name for the tier/);
// Valid values