2
1
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2023-12-13 21:00:40 +01: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:
Ronald Langeveld 2023-09-28 18:09:29 +07:00 committed by GitHub
parent 05215734af
commit 1f73028729
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 162 additions and 36 deletions

View file

@ -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}

View file

@ -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',

View file

@ -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);

View file

@ -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

View file

@ -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