mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-13 22:41:32 -05:00
Added invalid theme modal to AdminX (#18387)
refs https://ghost.slack.com/archives/C0568LN2CGJ/p1695801433166809?thread_ts=1695741379.821479&cid=C0568LN2CGJ --- <!-- Leave the line below if you'd like GitHub Copilot to generate a summary from your commit --> <!-- copilot:summary --> ### <samp>🤖 Generated by Copilot at f43070f</samp> This pull request adds a new feature to show a modal with the errors of an invalid theme when uploading a theme fails in the admin settings app. It creates a new `InvalidThemeModal` component that uses the `admin-x-ds` library and the `ThemeProblemView` component to display the error details. It also updates the `handleError` function to handle JSON errors for theme uploads.
This commit is contained in:
parent
05215734af
commit
1f73028729
5 changed files with 162 additions and 36 deletions
|
@ -13,9 +13,10 @@ export interface FileUploadProps {
|
|||
onUpload: (file: File) => void;
|
||||
style?: CSSProperties;
|
||||
unstyled?: boolean;
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const FileUpload: React.FC<FileUploadProps> = ({id, onUpload, children, style, unstyled = false, ...props}) => {
|
||||
const FileUpload: React.FC<FileUploadProps> = ({id, onUpload, children, style, unstyled = false, inputRef, ...props}) => {
|
||||
const [fileKey, setFileKey] = useState<number>(Date.now());
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
|
@ -28,7 +29,7 @@ const FileUpload: React.FC<FileUploadProps> = ({id, onUpload, children, style, u
|
|||
|
||||
return (
|
||||
<label htmlFor={id} style={style} {...props}>
|
||||
<input key={fileKey} id={id} type="file" hidden onChange={handleFileChange} />
|
||||
<input key={fileKey} ref={inputRef || null} id={id} type="file" hidden onChange={handleFileChange} />
|
||||
{(typeof children === 'string') ?
|
||||
<div className={!unstyled ? `inline-flex h-[34px] cursor-pointer items-center justify-center rounded px-4 text-sm font-semibold hover:bg-grey-100 dark:text-white dark:hover:bg-grey-900` : ''}>
|
||||
{children}
|
||||
|
|
|
@ -3,12 +3,13 @@ import Breadcrumbs from '../../../admin-x-ds/global/Breadcrumbs';
|
|||
import Button from '../../../admin-x-ds/global/Button';
|
||||
import ConfirmationModal from '../../../admin-x-ds/global/modal/ConfirmationModal';
|
||||
import FileUpload from '../../../admin-x-ds/global/form/FileUpload';
|
||||
import InvalidThemeModal from './theme/InvalidThemeModal';
|
||||
import LimitModal from '../../../admin-x-ds/global/modal/LimitModal';
|
||||
import Modal from '../../../admin-x-ds/global/modal/Modal';
|
||||
import NiceModal, {NiceModalHandler, useModal} from '@ebay/nice-modal-react';
|
||||
import OfficialThemes from './theme/OfficialThemes';
|
||||
import PageHeader from '../../../admin-x-ds/global/layout/PageHeader';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import TabView from '../../../admin-x-ds/global/TabView';
|
||||
import ThemeInstalledModal from './theme/ThemeInstalledModal';
|
||||
import ThemePreview from './theme/ThemePreview';
|
||||
|
@ -49,6 +50,14 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
|||
|
||||
const [isUploading, setUploading] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleRetry = () => {
|
||||
if (fileInputRef?.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (limiter) {
|
||||
// Sending a bad string to make sure it fails (empty string isn't valid)
|
||||
|
@ -68,6 +77,36 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
|||
updateRoute('design/edit');
|
||||
};
|
||||
|
||||
const onThemeUpload = async (file: File) => {
|
||||
const themeFileName = file?.name.replace(/\.zip$/, '');
|
||||
const existingThemeNames = themes.map(t => t.name);
|
||||
if (existingThemeNames.includes(themeFileName)) {
|
||||
NiceModal.show(ConfirmationModal, {
|
||||
title: 'Overwrite theme',
|
||||
prompt: (
|
||||
<>
|
||||
The theme <strong>{themeFileName}</strong> already exists.
|
||||
Do you want to overwrite it?
|
||||
</>
|
||||
),
|
||||
okLabel: 'Overwrite',
|
||||
cancelLabel: 'Cancel',
|
||||
okRunningLabel: 'Overwriting...',
|
||||
okColor: 'red',
|
||||
onOk: async (confirmModal) => {
|
||||
setUploading(true);
|
||||
await handleThemeUpload({file, onActivate: onClose});
|
||||
setUploading(false);
|
||||
setCurrentTab('installed');
|
||||
confirmModal?.remove();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCurrentTab('installed');
|
||||
handleThemeUpload({file, onActivate: onClose});
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeUpload = async ({
|
||||
file,
|
||||
onActivate
|
||||
|
@ -76,6 +115,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
|||
onActivate?: () => void
|
||||
}) => {
|
||||
let data: ThemesInstallResponseType | undefined;
|
||||
let fatalErrors = null;
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
|
@ -83,7 +123,24 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
|||
setUploading(false);
|
||||
} catch (e) {
|
||||
setUploading(false);
|
||||
handleError(e);
|
||||
const errorsJson = await handleError(e) as {errors?: []};
|
||||
if (errorsJson?.errors) {
|
||||
fatalErrors = errorsJson.errors;
|
||||
}
|
||||
}
|
||||
|
||||
if (fatalErrors && !data) {
|
||||
let title = 'Invalid Theme';
|
||||
let prompt = <>This theme is invalid and cannot be activated. Fix the following errors and re-upload the theme</>;
|
||||
NiceModal.show(InvalidThemeModal, {
|
||||
title,
|
||||
prompt,
|
||||
fatalErrors,
|
||||
onRetry: async (modal) => {
|
||||
modal?.remove();
|
||||
handleRetry();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
|
@ -158,37 +215,10 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
|||
<div className='flex items-center gap-3'>
|
||||
{uploadConfig && (
|
||||
uploadConfig.enabled ?
|
||||
<FileUpload id='theme-upload' onUpload={async (file: File) => {
|
||||
const themeFileName = file?.name.replace(/\.zip$/, '');
|
||||
const existingThemeNames = themes.map(t => t.name);
|
||||
if (existingThemeNames.includes(themeFileName)) {
|
||||
NiceModal.show(ConfirmationModal, {
|
||||
title: 'Overwrite theme',
|
||||
prompt: (
|
||||
<>
|
||||
The theme <strong>{themeFileName}</strong> already exists.
|
||||
Do you want to overwrite it?
|
||||
</>
|
||||
),
|
||||
okLabel: 'Overwrite',
|
||||
cancelLabel: 'Cancel',
|
||||
okRunningLabel: 'Overwriting...',
|
||||
okColor: 'red',
|
||||
onOk: async (confirmModal) => {
|
||||
setUploading(true);
|
||||
await handleThemeUpload({file, onActivate: onClose});
|
||||
setUploading(false);
|
||||
setCurrentTab('installed');
|
||||
confirmModal?.remove();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setCurrentTab('installed');
|
||||
handleThemeUpload({file, onActivate: onClose});
|
||||
}
|
||||
}}>
|
||||
<FileUpload id='theme-upload' inputRef={fileInputRef} onUpload={onThemeUpload}>
|
||||
<Button color='black' label='Upload theme' loading={isUploading} tag='div' />
|
||||
</FileUpload> :
|
||||
// for when user's plan does not support custom themes
|
||||
<Button color='black' label='Upload theme' onClick={() => {
|
||||
NiceModal.show(LimitModal, {
|
||||
title: 'Upgrade to enable custom themes',
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
import Button from '../../../../admin-x-ds/global/Button';
|
||||
import Heading from '../../../../admin-x-ds/global/Heading';
|
||||
import List from '../../../../admin-x-ds/global/List';
|
||||
import ListItem from '../../../../admin-x-ds/global/ListItem';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React, {ReactNode, useState} from 'react';
|
||||
import {ConfirmationModalContent} from '../../../../admin-x-ds/global/modal/ConfirmationModal';
|
||||
import {ThemeProblem} from '../../../../api/themes';
|
||||
|
||||
type FatalError = {
|
||||
details: {
|
||||
errors: ThemeProblem[];
|
||||
};
|
||||
};
|
||||
|
||||
type FatalErrors = FatalError[];
|
||||
|
||||
export const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => {
|
||||
const [isExpanded, setExpanded] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
setExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
return <ListItem
|
||||
title={
|
||||
<>
|
||||
<div className={`${problem.level === 'error' ? 'before:bg-red' : 'before:bg-yellow'} relative px-4 text-sm before:absolute before:left-0 before:top-1.5 before:block before:h-2 before:w-2 before:rounded-full before:content-['']`}>
|
||||
{
|
||||
problem?.fatal ?
|
||||
<strong>Fatal: </strong>
|
||||
:
|
||||
<strong>{problem.level === 'error' ? 'Error: ' : 'Warning: '}</strong>
|
||||
}
|
||||
<span dangerouslySetInnerHTML={{__html: problem.rule}} />
|
||||
<div className='absolute -right-4 top-1'>
|
||||
<Button color="green" icon={isExpanded ? 'chevron-down' : 'chevron-right'} iconColorClass='text-grey-700' size='sm' link onClick={() => handleClick()} />
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
isExpanded ?
|
||||
<div className='mt-2 px-4 text-[13px] leading-8'>
|
||||
<div dangerouslySetInnerHTML={{__html: problem.details}} className='mb-4' />
|
||||
<Heading level={6}>Affected files:</Heading>
|
||||
<ul className='mt-1'>
|
||||
{problem.failures.map(failure => <li><code>{failure.ref}</code>{failure.message ? `: ${failure.message}` : ''}</li>)}
|
||||
</ul>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</>
|
||||
}
|
||||
hideActions
|
||||
separator
|
||||
/>;
|
||||
};
|
||||
|
||||
const InvalidThemeModal: React.FC<{
|
||||
title: string
|
||||
prompt: ReactNode
|
||||
fatalErrors?: FatalErrors;
|
||||
onRetry?: (modal?: {
|
||||
remove: () => void;
|
||||
}) => void | Promise<void>;
|
||||
}> = ({title, prompt, fatalErrors, onRetry}) => {
|
||||
let warningPrompt = null;
|
||||
if (fatalErrors) {
|
||||
warningPrompt = <div className="mt-10">
|
||||
<List title="Errors">
|
||||
{fatalErrors?.map((error: any) => error?.details?.errors?.map((err: any) => <ThemeProblemView problem={err} />
|
||||
))}
|
||||
</List>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <ConfirmationModalContent
|
||||
cancelLabel='Close'
|
||||
okColor='black'
|
||||
okLabel={'Retry'}
|
||||
prompt={<>
|
||||
{prompt}
|
||||
{warningPrompt}
|
||||
</>}
|
||||
title={title}
|
||||
onOk={onRetry}
|
||||
/>;
|
||||
};
|
||||
|
||||
export default NiceModal.create(InvalidThemeModal);
|
|
@ -9,7 +9,7 @@ import {ConfirmationModalContent} from '../../../../admin-x-ds/global/modal/Conf
|
|||
import {InstalledTheme, ThemeProblem, useActivateTheme} from '../../../../api/themes';
|
||||
import {showToast} from '../../../../admin-x-ds/global/Toast';
|
||||
|
||||
const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => {
|
||||
export const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => {
|
||||
const [isExpanded, setExpanded] = useState(false);
|
||||
|
||||
return <ListItem
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as Sentry from '@sentry/react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {APIError, ValidationError} from '../errors';
|
||||
import {APIError, JSONError, ValidationError} from '../errors';
|
||||
import {showToast} from '../../admin-x-ds/global/Toast';
|
||||
import {useCallback} from 'react';
|
||||
import {useSentryDSN} from '../../components/providers/ServiceProvider';
|
||||
|
@ -18,8 +18,10 @@ const useHandleError = () => {
|
|||
* @param options.withToast Show a toast with the error message (default: true).
|
||||
* In general we should validate on the client side before sending the request to avoid errors,
|
||||
* so this toast is intended as a worst-case fallback message when we don't know what else to do.
|
||||
*
|
||||
*/
|
||||
const handleError = useCallback((error: unknown, {withToast = true}: {withToast?: boolean} = {}) => {
|
||||
type HandleErrorReturnType = void | any;
|
||||
const handleError = useCallback((error: unknown, {withToast = true}: {withToast?: boolean} = {}) : HandleErrorReturnType => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
|
||||
|
@ -39,6 +41,10 @@ const useHandleError = () => {
|
|||
|
||||
toast.remove();
|
||||
|
||||
if (error instanceof JSONError && error.response?.status === 422) {
|
||||
return error.data;
|
||||
}
|
||||
|
||||
if (error instanceof APIError && error.response?.status === 418) {
|
||||
// We use this status in tests to indicate the API request was not mocked -
|
||||
// don't show a toast because it may block clicking things in the test
|
||||
|
|
Loading…
Add table
Reference in a new issue