History log in AdminX (#17666)

refs. https://github.com/TryGhost/Product/issues/3710

---------

Co-authored-by: Jono Mingard <reason.koan@gmail.com>
This commit is contained in:
Peter Zimon 2023-08-14 14:11:53 +02:00 committed by GitHub
parent 8dc4923041
commit 9bfbd5b3b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 954 additions and 104 deletions

View File

@ -2,7 +2,7 @@ import ExitSettingsButton from './components/ExitSettingsButton';
import GlobalDataProvider from './components/providers/GlobalDataProvider';
import Heading from './admin-x-ds/global/Heading';
import NiceModal from '@ebay/nice-modal-react';
import RoutingProvider from './components/providers/RoutingProvider';
import RoutingProvider, {ExternalLink} from './components/providers/RoutingProvider';
import Settings from './components/Settings';
import Sidebar from './components/Sidebar';
import {GlobalDirtyStateProvider} from './hooks/useGlobalDirtyState';
@ -13,6 +13,7 @@ import {Toaster} from 'react-hot-toast';
interface AppProps {
ghostVersion: string;
officialThemes: OfficialTheme[];
externalNavigate: (link: ExternalLink) => void;
}
const queryClient = new QueryClient({
@ -25,12 +26,12 @@ const queryClient = new QueryClient({
}
});
function App({ghostVersion, officialThemes}: AppProps) {
function App({ghostVersion, officialThemes, externalNavigate}: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes}>
<GlobalDataProvider>
<RoutingProvider>
<RoutingProvider externalNavigate={externalNavigate}>
<GlobalDirtyStateProvider>
<div className="admin-x-settings h-[100vh] w-full overflow-y-auto" id="admin-x-root" style={{
height: '100vh',

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5"><defs></defs><title>pencil</title><path d="M22.19 1.81a3.639 3.639 0 0 0-5.17.035l-14.5 14.5L.75 23.25l6.905-1.771 14.5-14.5a3.637 3.637 0 0 0 .035-5.169Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="m16.606 2.26 5.134 5.134" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path><path d="m2.521 16.344 5.139 5.13" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 575 B

View File

@ -0,0 +1,31 @@
import {useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import InfiniteScrollListener from './InfiniteScrollListener';
const meta = {
title: 'Global / Infinite scroll listener',
component: InfiniteScrollListener,
tags: ['autodocs']
} satisfies Meta<typeof InfiniteScrollListener>;
export default meta;
type Story = StoryObj<typeof InfiniteScrollListener>;
export const Default: Story = {
args: {
offset: 250
},
render: function Component(args) {
const [wasTriggered, setTriggered] = useState(false);
return <div>
<div>Try scrolling here ... {wasTriggered && <strong>Near the end, time to load the next page!</strong>}</div>
<div style={{overflow: 'auto', height: '300px'}}>
<div style={{position: 'relative', height: '2000px', background: 'linear-gradient(to bottom, #000, #fff)'}}>
<InfiniteScrollListener {...args} onTrigger={() => setTriggered(true)} />
</div>
</div>
</div>;
}
};

View File

@ -0,0 +1,33 @@
import React, {useEffect, useRef} from 'react';
/**
* Triggers a callback when the user scrolls close to the end of an element
* (exactly how close is configurable with `offset`). The parent element must have
* position: relative/absolute/etc.
*/
const InfiniteScrollListener: React.FC<{
/** How many pixels before the end of the container the callback should trigger */
offset: number
onTrigger: () => void
}> = ({offset, onTrigger}) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const intersectionObserver = new IntersectionObserver((entries) => {
if (entries[0].intersectionRatio <= 0) {
return;
}
onTrigger();
});
if (ref.current) {
intersectionObserver.observe(ref.current);
}
return () => intersectionObserver.disconnect();
}, [onTrigger]);
return <div ref={ref} className="absolute w-full" style={{bottom: offset}} />;
};
export default InfiniteScrollListener;

View File

@ -1,4 +1,3 @@
import {ReactNode} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import Button from './Button';
@ -7,8 +6,7 @@ import Menu from './Menu';
const meta = {
title: 'Global / Menu',
component: Menu,
tags: ['autodocs'],
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '100px', margin: '0 auto', padding: '100px 0 200px'}}>{_story()}</div>)]
tags: ['autodocs']
} satisfies Meta<typeof Menu>;
export default meta;
@ -22,15 +20,9 @@ const items = [
}}
];
const longItems = [
{id: 'item-1', label: 'This is a really, really long item that nobody should be using but oh well'},
{id: 'item-2', label: 'Item 2'},
{id: 'item-3', label: 'Item 3'}
];
export const Default: Story = {
args: {
trigger: <Button color='green' label="Click"></Button>,
trigger: <Button color='black' label="Click"></Button>,
items: items,
position: 'left'
},
@ -43,7 +35,7 @@ export const Default: Story = {
export const Right: Story = {
args: {
trigger: <Button color='green' label="Click"></Button>,
trigger: <Button color='black' label="Click"></Button>,
items: items,
position: 'right'
},
@ -53,11 +45,3 @@ export const Right: Story = {
)
]
};
export const LongLabels: Story = {
args: {
trigger: <Button color='green' label="Click"></Button>,
items: longItems,
position: 'right'
}
};

View File

@ -1,6 +1,6 @@
import Button, {ButtonProps, ButtonSize} from './Button';
import React, {useState} from 'react';
import clsx from 'clsx';
import Popover, {PopoverPosition} from './Popover';
import React from 'react';
export type MenuItem = {
id: string,
@ -8,57 +8,32 @@ export type MenuItem = {
onClick?: () => void
}
type MenuPosition = 'left' | 'right';
interface MenuProps {
trigger?: React.ReactNode;
triggerButtonProps?: ButtonProps;
triggerSize?: ButtonSize;
items: MenuItem[];
position?: MenuPosition;
className?: string;
position?: PopoverPosition;
}
const Menu: React.FC<MenuProps> = ({trigger, triggerButtonProps, items, position, className}) => {
const [menuOpen, setMenuOpen] = useState(false);
const toggleMenu = () => {
setMenuOpen(!menuOpen);
};
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
setMenuOpen(false);
}
};
const Menu: React.FC<MenuProps> = ({
trigger,
triggerButtonProps,
items,
position = 'left'
}) => {
if (!trigger) {
trigger = <Button icon='ellipsis' label='Menu' hideLabel {...triggerButtonProps} />;
}
const menuClasses = clsx(
'absolute z-40 mt-2 w-max min-w-[160px] origin-top-right rounded bg-white shadow-md ring-1 ring-[rgba(0,0,0,0.01)] focus:outline-none',
position === 'left' && 'right-0',
(position === 'right' || !position) && 'left-0',
menuOpen ? 'block' : 'hidden'
);
return (
<div className={`relative inline-block ${className}`}>
<div className={`fixed inset-0 z-40 ${menuOpen ? 'block' : 'hidden'}`} data-testid="menu-overlay" onClick={handleBackdropClick}></div>
{/* Menu Trigger */}
<div className='relative z-30' onClick={toggleMenu}>
{trigger}
</div>
{/* Menu List */}
<div aria-labelledby="menu-button" aria-orientation="vertical" className={menuClasses} role="menu">
<div className="flex flex-col justify-stretch py-1" role="none">
<Popover position={position} trigger={trigger} closeOnItemClick>
<div className="flex min-w-[160px] flex-col justify-stretch py-1" role="none">
{items.map(item => (
<button key={item.id} className="mx-1 block cursor-pointer rounded-[2.5px] px-4 py-1.5 text-left text-sm hover:bg-grey-100" type="button" onClick={item.onClick}>{item.label}</button>
))}
</div>
</div>
</div>
</Popover>
);
};

View File

@ -0,0 +1,34 @@
import type {Meta, StoryObj} from '@storybook/react';
// import BoilerPlate from './Boilerplate';
import Button from './Button';
import Popover from './Popover';
const meta = {
title: 'Global / Popover',
component: Popover,
tags: ['autodocs'],
argTypes: {
trigger: {
control: {
type: 'text'
}
}
}
} satisfies Meta<typeof Popover>;
export default meta;
type Story = StoryObj<typeof Popover>;
export const Default: Story = {
args: {
trigger: (
<Button color='grey' label='Open popover' />
),
children: (
<div className='p-5 text-sm' style={{maxWidth: '320px'}}>
This is a popover. You can put anything in it. The styling of the content defines how it will look at the end.
</div>
)
}
};

View File

@ -0,0 +1,92 @@
import React, {useRef, useState} from 'react';
import clsx from 'clsx';
import {createPortal} from 'react-dom';
export type PopoverPosition = 'left' | 'right';
interface PopoverProps {
trigger: React.ReactNode;
children: React.ReactNode;
position?: PopoverPosition;
closeOnItemClick?: boolean;
}
const getOffsetPosition = (element: HTMLDivElement | null) => {
// innerZoomElementWrapper fixes weird behaviour in Storybook - the preview container
// uses transform which changes how position:fixed works and means getBoundingClientRect
// won't return the right position
return element?.closest('.innerZoomElementWrapper')?.getBoundingClientRect() || {x: 0, y: 0};
};
const Popover: React.FC<PopoverProps> = ({
trigger,
children,
position = 'left',
closeOnItemClick
}) => {
const [open, setOpen] = useState(false);
const [positionX, setPositionX] = useState(0);
const [positionY, setPositionY] = useState(0);
const triggerRef = useRef<HTMLDivElement | null>(null);
const handleTriggerClick = () => {
if (!open && triggerRef.current) {
const parentRect = getOffsetPosition(triggerRef.current);
let {x, y, width, height} = triggerRef.current.getBoundingClientRect();
x -= parentRect.x;
y -= parentRect.y;
const finalX = (position === 'left') ? x : x - width;
setOpen(true);
setPositionX(finalX);
setPositionY(y + height);
} else {
setOpen(false);
}
};
const style: React.CSSProperties = {
top: `${positionY}px`,
left: `${positionX}px`
};
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
setOpen(false);
}
};
const handleContentClick = () => {
if (closeOnItemClick) {
setOpen(false);
}
};
let className = '';
className = clsx(
'fixed z-50 mt-2 origin-top-right rounded bg-white shadow-md ring-1 ring-[rgba(0,0,0,0.01)] focus:outline-none',
className
);
const backdropClasses = clsx(
'fixed inset-0 z-40',
open ? 'block' : 'hidden'
);
return (
<>
<div ref={triggerRef} onClick={handleTriggerClick}>
{trigger}
</div>
{open && createPortal(<div className='fixed z-[9999] inline-block' onClick={handleContentClick}>
<div className={backdropClasses} data-testid="popover-overlay" onClick={handleBackdropClick}></div>
<div className={className} data-testid='popover-content' style={style}>
{children}
</div>
</div>, triggerRef.current?.closest('.admin-x-settings') || document.body)}
</>
);
};
export default Popover;

View File

@ -10,6 +10,13 @@ const meta = {
title: 'Global / Modal',
component: Modal,
tags: ['autodocs'],
argTypes: {
topRightContent: {
control: {
type: 'text'
}
}
},
decorators: [(_story: () => ReactNode, context: StoryContext) => (
<NiceModal.Provider>
<ModalContainer {...context.args} />
@ -28,6 +35,8 @@ export const Default: Story = {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
topRightContent: 'close',
title: 'Modal dialog',
children: modalContent
}
@ -39,6 +48,7 @@ export const Small: Story = {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
title: 'Small modal',
children: modalContent
}
@ -50,6 +60,7 @@ export const Medium: Story = {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
title: 'Medium modal (default size)',
children: modalContent
}
@ -61,6 +72,7 @@ export const Large: Story = {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
title: 'Large modal',
children: modalContent
}
@ -72,6 +84,7 @@ export const ExtraLarge: Story = {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
title: 'Extra large modal',
children: modalContent
}
@ -83,6 +96,7 @@ export const Full: Story = {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
title: 'Full modal',
children: modalContent
}
@ -94,6 +108,7 @@ export const Bleed: Story = {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
title: 'Full bleed modal',
children: modalContent
}
@ -125,6 +140,7 @@ export const CustomButtons: Story = {
onOk: () => {
alert('Clicked Yep!');
},
onCancel: undefined,
title: 'Custom buttons',
children: modalContent
}
@ -152,6 +168,7 @@ export const StickyFooter: Story = {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
title: 'Sticky footer',
children: longContent
}
@ -164,6 +181,7 @@ export const Dirty: Story = {
onOk: () => {
alert('Clicked OK!');
},
onCancel: undefined,
title: 'Dirty modal',
children: <p>Simulates if there were unsaved changes of a form. Click on Cancel</p>
}

View File

@ -28,6 +28,7 @@ export interface ModalProps {
noPadding?: boolean;
onOk?: () => void;
onCancel?: () => void;
topRightContent?: 'close' | React.ReactNode;
afterClose?: () => void;
children?: React.ReactNode;
backDrop?: boolean;
@ -51,6 +52,7 @@ const Modal: React.FC<ModalProps> = ({
onOk,
okColor = 'black',
onCancel,
topRightContent,
afterClose,
children,
backDrop = true,
@ -240,7 +242,18 @@ const Modal: React.FC<ModalProps> = ({
<section className={modalClasses} data-testid={testId} style={modalStyles}>
<div className={contentClasses}>
<div className='h-full'>
{topRightContent === 'close' ?
(<>
{title && <Heading level={3}>{title}</Heading>}
<div className='absolute right-6 top-6'>
<Button className='-m-2 cursor-pointer p-2 opacity-50 hover:opacity-100' icon='close' size='sm' unstyled onClick={removeModal} />
</div>
</>)
:
(<div className='flex items-center justify-between gap-5'>
{title && <Heading level={3}>{title}</Heading>}
{topRightContent}
</div>)}
{children}
</div>
</div>

View File

@ -0,0 +1,217 @@
import {ExternalLink, InternalLink, modalRoutes} from '../components/providers/RoutingProvider';
import {InfiniteData} from '@tanstack/react-query';
import {JSONObject} from './config';
import {Meta, createInfiniteQuery} from '../utils/apiRequests';
// Types
export type Action = {
id: string;
resource_id: string;
resource_type: string;
actor_id: string;
actor_type: string;
event: string;
context: JSONObject;
created_at: string;
actor?: {
id: string;
name: string;
slug: string;
image: string;
},
resource?: {
id: string;
slug: string;
name: string;
title?: string;
}
skip?: boolean
count?: number
};
export interface ActionsResponseType {
actions: Action[];
meta: Meta;
isEnd: boolean;
}
// Requests
const dataType = 'ActionsResponseType';
export const useBrowseActions = createInfiniteQuery<ActionsResponseType>({
dataType,
path: '/actions/',
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<{
actions: Array<Omit<Action, 'context'> & {context: string}>,
meta: Meta
}>;
let actions = pages.flatMap(page => page.actions.map(
({context, ...action}) => ({...action, context: JSON.parse(context)})
));
actions = actions.reverse();
let count = 1;
actions.forEach((action, index) => {
const nextAction = actions[index + 1];
// depending on the similarity, add additional properties to be used on the frontend for grouping
// skip - used for hiding the event on the frontend
// count - the number of similar events which is added to the last item
if (nextAction && action.resource_id === nextAction.resource_id && action.event === nextAction.event) {
action.skip = true;
count += 1;
} else if (count > 1) {
action.count = count;
count = 1;
}
});
return {
actions: actions.reverse(),
meta: pages.at(-1)!.meta,
isEnd: pages.at(-1)!.actions.length < pages.at(-1)!.meta.pagination.limit
};
}
});
// Helpers
export const getActorLinkTarget = (action: Action): InternalLink | ExternalLink | undefined => {
if (!action.actor) {
return;
}
switch (action.actor_type) {
case 'integration':
if (!action.actor.id) {
return;
}
return {route: modalRoutes.showIntegration, params: {id: action.actor.id}};
case 'user':
if (!action.actor.slug) {
return;
}
return {route: modalRoutes.showUser, params: {slug: action.actor.slug}};
}
return;
};
export const getLinkTarget = (action: Action): InternalLink | ExternalLink | undefined => {
let resourceType = action.resource_type;
if (action.event !== 'deleted') {
switch (action.resource_type) {
case 'page':
case 'post':
if (!action.resource || !action.resource.id) {
return;
}
if (resourceType === 'post') {
if (action.context?.type) {
resourceType = action.context?.type as string;
}
}
return {
isExternal: true,
route: 'editor.edit',
models: [resourceType, action.resource.id]
};
case 'integration':
if (!action.resource || !action.resource.id) {
return;
}
return {route: modalRoutes.showIntegration, params: {id: action.resource.id}};
case 'offer':
if (!action.resource || !action.resource.id) {
return;
}
return {
isExternal: true,
route: 'offer',
models: [action.resource.id]
};
case 'tag':
if (!action.resource || !action.resource.slug) {
return;
}
return {
isExternal: true,
route: 'tag',
models: [action.resource.slug]
};
case 'product':
return {route: 'tiers'};
case 'user':
if (!action.resource || !action.resource.slug) {
return;
}
return {route: modalRoutes.showUser, params: {slug: action.resource.slug}};
}
}
return;
};
export const getActionTitle = (action: Action) => {
let resourceType = action.resource_type;
if (resourceType === 'api_key') {
resourceType = 'API key';
} else if (resourceType === 'setting') {
resourceType = 'settings';
} else if (resourceType === 'product') {
resourceType = 'tier';
}
// Because a `page` and `post` both use the same model, we store the
// actual type in the context, so let's check if that exists
if (resourceType === 'post') {
if (action.context?.type) {
resourceType = action.context?.type as string;
}
}
let actionName = action.event;
if (action.event === 'edited') {
if (action.context.action_name) {
actionName = action.context.action_name as string;
}
}
if (action.context.count && (action.context.count as number) > 1) {
return `${action.context.count} ${resourceType}s ${actionName}`;
}
return `${resourceType.slice(0, 1).toUpperCase()}${resourceType.slice(1)} ${actionName}`;
};
export const getContextResource = (action: Action) => {
if (action.resource_type === 'setting') {
if (action.context?.group && action.context?.key) {
return {
group: action.context.group as string,
key: action.context.key as string
};
}
}
};
export const isBulkAction = (action: Action) => typeof action.context.count === 'number' && action.context.count > 1;

View File

@ -1,6 +1,7 @@
import AddNewsletterModal from '../settings/email/newsletters/AddNewsletterModal';
import ChangeThemeModal from '../settings/site/ThemeModal';
import DesignModal from '../settings/site/DesignModal';
import HistoryModal from '../settings/advanced/HistoryModal';
import InviteUserModal from '../settings/general/InviteUserModal';
import NavigationModal from '../settings/site/NavigationModal';
import NiceModal from '@ebay/nice-modal-react';
@ -11,11 +12,23 @@ import TierDetailModal from '../settings/membership/tiers/TierDetailModal';
export type RouteParams = {[key: string]: string}
export type ExternalLink = {
isExternal: true;
route: string;
models?: string[] | null
};
export type InternalLink = {
isExternal?: false;
route: string;
params?: RouteParams;
}
export type RoutingContextData = {
route: string;
scrolledRoute: string;
yScroll: number;
updateRoute: (newPath: string, params?: RouteParams) => void;
updateRoute: (to: string | InternalLink | ExternalLink) => void;
updateScrolled: (newPath: string) => void;
addRouteChangeListener: (listener: RouteChangeListener) => (() => void);
};
@ -35,7 +48,8 @@ export const RouteContext = createContext<RoutingContextData>({
export const modalRoutes = {
showUser: 'users/show/:slug',
showNewsletter: 'newsletters/show/:id',
showTier: 'tiers/show/:id'
showTier: 'tiers/show/:id',
showIntegration: 'integrations/show/:id'
};
function getHashPath(urlPath: string | undefined) {
@ -86,6 +100,8 @@ const handleNavigation = (scroll: boolean = true) => {
NiceModal.show(StripeConnectModal);
} else if (pathName === 'newsletters/add') {
NiceModal.show(AddNewsletterModal);
} else if (pathName === 'history/view') {
NiceModal.show(HistoryModal);
}
if (scroll) {
@ -114,6 +130,7 @@ const callRouteChangeListeners = (newPath: string, listeners: RouteChangeListene
};
type RouteProviderProps = {
externalNavigate: (link: ExternalLink) => void;
children: React.ReactNode;
};
@ -122,15 +139,24 @@ type RouteChangeListener = {
callback: (params: RouteParams) => void;
}
const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
const RoutingProvider: React.FC<RouteProviderProps> = ({externalNavigate, children}) => {
const [route, setRoute] = useState<string>('');
const [yScroll, setYScroll] = useState(0);
const [scrolledRoute, setScrolledRoute] = useState<string>('');
const routeChangeListeners = useRef<RouteChangeListener[]>([]);
const updateRoute = useCallback((newPath: string, params?: RouteParams) => {
if (params) {
newPath = Object.entries(params).reduce(
const updateRoute = useCallback((to: string | InternalLink | ExternalLink) => {
const options = typeof to === 'string' ? {route: to} : to;
if (options.isExternal) {
externalNavigate(options);
return;
}
let newPath = options.route;
if (options.params) {
newPath = Object.entries(options.params).reduce(
(path, [name, value]) => path.replace(`:${name}`, value),
newPath
);
@ -145,7 +171,7 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
} else {
window.location.hash = `/settings-x`;
}
}, [route]);
}, [externalNavigate, route]);
const updateScrolled = useCallback((newPath: string) => {
setScrolledRoute(newPath);

View File

@ -1,15 +1,18 @@
import CodeInjection from './CodeInjection';
import History from './History';
import React from 'react';
import SettingSection from '../../../admin-x-ds/settings/SettingSection';
const searchKeywords = {
codeInjection: ['newsletter', 'enable', 'disable', 'turn on']
codeInjection: ['newsletter', 'enable', 'disable', 'turn on'],
history: ['history', 'log', 'events', 'user events', 'staff']
};
const AdvancedSettings: React.FC = () => {
return (
<SettingSection keywords={Object.values(searchKeywords).flat()} title='Advanced'>
<CodeInjection keywords={searchKeywords.codeInjection} />
<History keywords={searchKeywords.history} />
</SettingSection>
);
};

View File

@ -0,0 +1,24 @@
import Button from '../../../admin-x-ds/global/Button';
import React from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import useRouting from '../../../hooks/useRouting';
const History: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {updateRoute} = useRouting();
const openHistoryModal = () => {
updateRoute('history/view');
};
return (
<SettingGroup
customButtons={<Button color='green' label='View history' link onClick={openHistoryModal}/>}
description="View system event log"
keywords={keywords}
navid='history'
testId='history'
title="History"
/>
);
};
export default History;

View File

@ -0,0 +1,217 @@
import Avatar from '../../../admin-x-ds/global/Avatar';
import Button from '../../../admin-x-ds/global/Button';
import Icon from '../../../admin-x-ds/global/Icon';
import InfiniteScrollListener from '../../../admin-x-ds/global/InfiniteScrollListener';
import List from '../../../admin-x-ds/global/List';
import ListItem from '../../../admin-x-ds/global/ListItem';
import Modal from '../../../admin-x-ds/global/modal/Modal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import Popover from '../../../admin-x-ds/global/Popover';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import ToggleGroup from '../../../admin-x-ds/global/form/ToggleGroup';
import useRouting from '../../../hooks/useRouting';
import {Action, getActionTitle, getContextResource, getLinkTarget, isBulkAction, useBrowseActions} from '../../../api/actions';
import {generateAvatarColor, getInitials} from '../../../utils/helpers';
import {useCallback, useState} from 'react';
const HistoryIcon: React.FC<{action: Action}> = ({action}) => {
let name = 'pen';
switch (action.event) {
case 'added':
name = 'add';
break;
case 'deleted':
name = 'trash';
break;
}
return <Icon name={name} size='xs' />;
};
const HistoryAvatar: React.FC<{action: Action}> = ({action}) => {
return (
<div className='relative'>
<Avatar
bgColor={generateAvatarColor(action.actor?.name || action.actor?.slug || '')}
image={action.actor?.image}
label={getInitials(action.actor?.name || action.actor?.slug)}
labelColor='white'
size='md'
/>
<div className='absolute -bottom-1 -right-1 flex items-center justify-center rounded-full border border-grey-100 bg-white p-1 shadow-sm'>
<HistoryIcon action={action} />
</div>
</div>
);
};
const HistoryFilterToggle: React.FC<{
label: string;
item: string;
excludedItems: string[];
toggleItem: (item: string, included: boolean) => void;
}> = ({label, item, excludedItems, toggleItem}) => {
return <Toggle
checked={!excludedItems.includes(item)}
direction='rtl'
label={label}
labelClasses='text-sm'
onChange={e => toggleItem(item, e.target.checked)}
/>;
};
const HistoryFilter: React.FC<{
excludedEvents: string[];
excludedResources: string[];
toggleEventType: (event: string, included: boolean) => void;
toggleResourceType: (resource: string, included: boolean) => void;
}> = ({excludedEvents, excludedResources, toggleEventType, toggleResourceType}) => {
return (
<Popover trigger={<Button label='Filter' link />}>
<div className='flex w-[220px] flex-col gap-8 p-5'>
<ToggleGroup>
<HistoryFilterToggle excludedItems={excludedEvents} item='added' label='Added' toggleItem={toggleEventType} />
<HistoryFilterToggle excludedItems={excludedEvents} item='edited' label='Edited' toggleItem={toggleEventType} />
<HistoryFilterToggle excludedItems={excludedEvents} item='deleted' label='Deleted' toggleItem={toggleEventType} />
</ToggleGroup>
<ToggleGroup>
<HistoryFilterToggle excludedItems={excludedResources} item='post' label='Posts' toggleItem={toggleResourceType} />
<HistoryFilterToggle excludedItems={excludedResources} item='page' label='Pages' toggleItem={toggleResourceType} />
<HistoryFilterToggle excludedItems={excludedResources} item='tag' label='Tags' toggleItem={toggleResourceType} />
<HistoryFilterToggle excludedItems={excludedResources} item='offer,product' label='Tiers & offers' toggleItem={toggleResourceType} />
<HistoryFilterToggle excludedItems={excludedResources} item='api_key,integration,setting,user,webhook' label='Settings & staff' toggleItem={toggleResourceType} />
</ToggleGroup>
</div>
</Popover>
);
};
const HistoryActionDescription: React.FC<{action: Action}> = ({action}) => {
const {updateRoute} = useRouting();
const contextResource = getContextResource(action);
if (contextResource) {
const {group, key} = contextResource;
return <>
{group.slice(0, 1).toUpperCase()}{group.slice(1)}
{group !== key && <span className='text-xs'> <code className='mb-1 bg-white text-grey-800'>({key})</code></span>}
</>;
} else if (action.resource?.title || action.resource?.name || action.context.primary_name) {
const linkTarget = getLinkTarget(action);
if (linkTarget) {
return <a className='font-bold' href='#' onClick={(e) => {
e.preventDefault();
updateRoute(linkTarget);
}}>{action.resource?.title || action.resource?.name}</a>;
} else {
return <>{action.resource?.title || action.resource?.name || action.context.primary_name}</>;
}
} else {
return <span className='text-grey-500'>(unknown)</span>;
}
};
const formatDateForFilter = (date: Date) => {
const partsList = new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).formatToParts(date);
const parts = partsList.reduce<Record<string, string>>((result, {type, value}) => ({...result, [type]: value}), {});
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
};
const PAGE_SIZE = 200;
const HistoryModal = NiceModal.create(() => {
const modal = useModal();
const {updateRoute} = useRouting();
const [excludedEvents, setExcludedEvents] = useState<string[]>([]);
const [excludedResources, setExcludedResources] = useState<string[]>(['label']);
const [user] = useState<string>();
const {data, fetchNextPage} = useBrowseActions({
searchParams: {
include: 'actor,resource',
limit: PAGE_SIZE.toString(),
filter: [
excludedEvents.length && `event:-[${excludedEvents.join(',')}]`,
excludedResources.length && `resource_type:-[${excludedResources.join(',')}]`,
user && `actor_id:${user}`
].filter(Boolean).join('+')
},
getNextPageParams: (lastPage, otherParams) => ({
...otherParams,
filter: [otherParams.filter, `created_at:<'${formatDateForFilter(new Date(lastPage.actions[lastPage.actions.length - 1].created_at))}'`].join('+')
}),
keepPreviousData: true
});
const fetchNext = useCallback(() => {
if (!data?.isEnd) {
fetchNextPage();
}
}, [data?.isEnd, fetchNextPage]);
const toggleValue = (setter: (fn: (values: string[]) => string[]) => void, value: string, included: boolean) => {
setter(values => (included ? values.concat(value) : values.filter(current => current !== value)));
};
return (
<Modal
afterClose={() => {
updateRoute('history');
}}
cancelLabel=''
okLabel='Close'
scrolling={true}
size='md'
stickyFooter={true}
testId='history-modal'
title='History'
topRightContent={<HistoryFilter
excludedEvents={excludedEvents}
excludedResources={excludedResources}
toggleEventType={(event, included) => toggleValue(setExcludedEvents, event, !included)}
toggleResourceType={(resource, included) => toggleValue(setExcludedResources, resource, !included)}
/>}
onOk={() => {
modal.remove();
updateRoute('history');
}}
>
<div className='relative -mb-8 mt-6'>
<List hint={data?.isEnd ? 'End of history log' : undefined}>
<InfiniteScrollListener offset={250} onTrigger={fetchNext} />
{data?.actions.map(action => !action.skip && <ListItem
avatar={<HistoryAvatar action={action} />}
detail={[
new Date(action.created_at).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}),
new Date(action.created_at).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit', second: '2-digit'})
].join(' | ')}
title={
<div className='text-sm'>
{getActionTitle(action)}{isBulkAction(action) ? '' : ': '}
{!isBulkAction(action) && <HistoryActionDescription action={action} />}
{action.count ? <> {action.count} times</> : null}
<span> &mdash; by {action.actor?.name || action.actor?.slug}</span>
</div>
}
separator
/>)}
</List>
</div>
</Modal>
);
});
export default HistoryModal;

View File

@ -37,7 +37,7 @@ const AddNewsletterModal: React.FC<AddNewsletterModalProps> = () => {
opt_in_existing: formState.optInExistingSubscribers
});
updateRoute(modalRoutes.showNewsletter, {id: response.newsletters[0].id});
updateRoute({route: modalRoutes.showNewsletter, params: {id: response.newsletters[0].id}});
},
onValidate: () => {
const newErrors: Record<string, string> = {};

View File

@ -50,7 +50,7 @@ const NewsletterItem: React.FC<{newsletter: Newsletter, onlyOne: boolean}> = ({n
);
const showDetails = () => {
updateRoute(modalRoutes.showNewsletter, {id: newsletter.id});
updateRoute({route: modalRoutes.showNewsletter, params: {id: newsletter.id}});
};
return (

View File

@ -671,7 +671,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user}) => {
handleImageUpload('cover_image', file);
}}
>Upload cover image</ImageUpload>
<div className="absolute bottom-12 right-12">
<div className="absolute bottom-12 right-12 z-10">
<Menu items={menuItems} position='left' trigger={<UserMenuTrigger />}></Menu>
</div>
<div className='relative flex items-center gap-4 px-12 pb-7 pt-60'>

View File

@ -36,7 +36,7 @@ const Owner: React.FC<OwnerProps> = ({user}) => {
const {updateRoute} = useRouting();
const showDetailModal = () => {
updateRoute(modalRoutes.showUser, {slug: user.slug});
updateRoute({route: modalRoutes.showUser, params: {slug: user.slug}});
};
if (!user) {
@ -58,7 +58,7 @@ const UsersList: React.FC<UsersListProps> = ({users}) => {
const {updateRoute} = useRouting();
const showDetailModal = (user: User) => {
updateRoute(modalRoutes.showUser, {slug: user.slug});
updateRoute({route: modalRoutes.showUser, params: {slug: user.slug}});
};
if (!users || !users.length) {

View File

@ -28,7 +28,7 @@ const TierCard: React.FC<TierCardProps> = ({tier}) => {
return (
<div className={cardContainerClasses} data-testid='tier-card'>
<div className='w-full grow cursor-pointer' onClick={() => {
updateRoute(modalRoutes.showTier, {id: tier.id});
updateRoute({route: modalRoutes.showTier, params: {id: tier.id}});
}}>
<div className='text-[1.65rem] font-bold leading-tight tracking-tight text-pink'>{tier.name}</div>
<div className='mt-2 flex items-baseline'>

View File

@ -6,6 +6,7 @@ import ReactDOM from 'react-dom/client';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App
externalNavigate={() => {}}
ghostVersion='5.x'
officialThemes={[{
name: 'Casper',

View File

@ -1,5 +1,6 @@
import {QueryClient, UseQueryOptions, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {QueryClient, UseInfiniteQueryOptions, UseQueryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {getGhostPaths} from './helpers';
import {useMemo} from 'react';
import {useServices} from '../components/providers/ServiceProvider';
export interface Meta {
@ -79,7 +80,7 @@ export const useFetchApi = () => {
const {apiRoot} = getGhostPaths();
const apiUrl = (path: string, searchParams: { [key: string]: string } = {}) => {
const apiUrl = (path: string, searchParams: Record<string, string> = {}) => {
const url = new URL(`${apiRoot}${path}`, window.location.origin);
url.search = new URLSearchParams(searchParams).toString();
return url.toString();
@ -93,11 +94,11 @@ const parameterizedPath = (path: string, params: string | string[]) => {
interface QueryOptions<ResponseData> {
dataType: string
path: string
defaultSearchParams?: { [key: string]: string };
defaultSearchParams?: Record<string, string>;
returnData?: (originalData: unknown) => ResponseData;
}
type QueryHookOptions<ResponseData> = UseQueryOptions<ResponseData> & { searchParams?: { [key: string]: string } };
type QueryHookOptions<ResponseData> = UseQueryOptions<ResponseData> & { searchParams?: Record<string, string> };
export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) => ({searchParams, ...query}: QueryHookOptions<ResponseData> = {}) => {
const url = apiUrl(options.path, searchParams || options.defaultSearchParams);
@ -109,9 +110,40 @@ export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) =
...query
});
const data = useMemo(() => (
(result.data && options.returnData) ? options.returnData(result.data) : result.data)
, [result]);
return {
...result,
data: (result.data && options.returnData) ? options.returnData(result.data) : result.data
data
};
};
type InfiniteQueryOptions<ResponseData> = Omit<QueryOptions<ResponseData>, 'returnData'> & {
returnData: NonNullable<QueryOptions<ResponseData>['returnData']>
}
type InfiniteQueryHookOptions<ResponseData> = UseInfiniteQueryOptions<ResponseData> & {
searchParams?: Record<string, string>;
getNextPageParams: (data: ResponseData, params: Record<string, string>) => Record<string, string>;
};
export const createInfiniteQuery = <ResponseData>(options: InfiniteQueryOptions<ResponseData>) => ({searchParams, getNextPageParams, ...query}: InfiniteQueryHookOptions<ResponseData>) => {
const fetchApi = useFetchApi();
const result = useInfiniteQuery<ResponseData>({
queryKey: [options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams)],
queryFn: ({pageParam}) => fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams)),
getNextPageParam: data => getNextPageParams(data, searchParams || options.defaultSearchParams || {}),
...query
});
const data = useMemo(() => result.data && options.returnData(result.data), [result]);
return {
...result,
data
};
};
@ -132,7 +164,7 @@ const mutate = <ResponseData, Payload>({fetchApi, path, payload, searchParams, o
fetchApi: ReturnType<typeof useFetchApi>;
path: string;
payload?: Payload;
searchParams?: { [key: string]: string };
searchParams?: Record<string, string>;
options: MutationOptions<ResponseData, Payload>
}) => {
const {defaultSearchParams, body, ...requestOptions} = options;

View File

@ -0,0 +1,45 @@
import {expect, test} from '@playwright/test';
import {globalDataRequests, mockApi, responseFixtures} from '../../utils/e2e';
test.describe('History', async () => {
test('Browsing history', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseActionsFiltered: {
method: 'GET',
path: /\/actions\/.+post/,
response: {
...responseFixtures.actions,
actions: responseFixtures.actions.actions.filter(action => action.resource_type !== 'post')
}
},
browseActionsAll: {method: 'GET', path: /\/actions\//, response: responseFixtures.actions}
}});
await page.goto('/');
const historySection = page.getByTestId('history');
await historySection.getByRole('button', {name: 'View history'}).click();
const historyModal = page.getByTestId('history-modal');
await expect(historyModal).toHaveText(/Settings edited: Site \(navigation\) 2 times/);
await expect(historyModal).toHaveText(/Page edited: The Clunkers Hall of Shame 2 times/);
expect(lastApiRequests.browseActionsAll?.url).toEqual('http://localhost:5173/ghost/api/admin/actions/?include=actor%2Cresource&limit=200&filter=resource_type%3A-%5Blabel%5D');
await historyModal.getByRole('button', {name: 'Filter'}).click();
await page.getByTestId('popover-content').getByLabel('Posts').uncheck();
await expect(historyModal).not.toHaveText(/Page edited/);
await expect(historyModal).toHaveText(/Settings edited/);
expect(lastApiRequests.browseActionsFiltered?.url).toEqual('http://localhost:5173/ghost/api/admin/actions/?include=actor%2Cresource&limit=200&filter=resource_type%3A-%5Blabel%2Cpost%5D');
await page.getByTestId('popover-content').getByLabel('Deleted').uncheck();
expect(lastApiRequests.browseActionsFiltered?.url).toEqual('http://localhost:5173/ghost/api/admin/actions/?include=actor%2Cresource&limit=200&filter=event%3A-%5Bdeleted%5D%2Bresource_type%3A-%5Blabel%2Cpost%5D');
});
});

View File

@ -30,7 +30,7 @@ test.describe('User actions', async () => {
const modal = page.getByTestId('user-detail-modal');
await modal.getByRole('button', {name: 'Actions'}).click();
await modal.getByRole('button', {name: 'Suspend user'}).click();
await page.getByTestId('popover-content').getByRole('button', {name: 'Suspend user'}).click();
const confirmation = page.getByTestId('confirmation-modal');
await confirmation.getByRole('button', {name: 'Suspend'}).click();
@ -83,7 +83,7 @@ test.describe('User actions', async () => {
await expect(modal).toHaveText(/Suspended/);
await modal.getByRole('button', {name: 'Actions'}).click();
await modal.getByRole('button', {name: 'Un-suspend user'}).click();
await page.getByTestId('popover-content').getByRole('button', {name: 'Un-suspend user'}).click();
const confirmation = page.getByTestId('confirmation-modal');
await confirmation.getByRole('button', {name: 'Un-suspend'}).click();
@ -121,7 +121,7 @@ test.describe('User actions', async () => {
const modal = page.getByTestId('user-detail-modal');
await modal.getByRole('button', {name: 'Actions'}).click();
await modal.getByRole('button', {name: 'Delete user'}).click();
await page.getByTestId('popover-content').getByRole('button', {name: 'Delete user'}).click();
const confirmation = page.getByTestId('confirmation-modal');
await confirmation.getByRole('button', {name: 'Delete user'}).click();
@ -171,7 +171,8 @@ test.describe('User actions', async () => {
await listItem.getByRole('button', {name: 'Edit'}).click();
await modal.getByRole('button', {name: 'Actions'}).click();
await expect(modal.getByRole('button', {name: 'Make owner'})).toHaveCount(0);
await expect(page.getByTestId('popover-content').getByRole('button', {name: 'Make owner'})).toHaveCount(0);
await page.getByTestId('popover-overlay').click();
await modal.getByRole('button', {name: 'Close'}).click();
@ -183,7 +184,7 @@ test.describe('User actions', async () => {
await listItem.getByRole('button', {name: 'Edit'}).click();
await modal.getByRole('button', {name: 'Actions'}).click();
await modal.getByRole('button', {name: 'Make owner'}).click();
await page.getByTestId('popover-content').getByRole('button', {name: 'Make owner'}).click();
const confirmation = page.getByTestId('confirmation-modal');
await confirmation.getByRole('button', {name: 'Yep — I\'m sure'}).click();

View File

@ -108,16 +108,14 @@ test.describe('Theme settings', async () => {
// Download the active theme
await casper.getByRole('button', {name: 'Menu'}).click();
await casper.getByRole('button', {name: 'Download'}).click();
await page.getByTestId('popover-content').getByRole('button', {name: 'Download'}).click();
await expect(page.locator('iframe#iframeDownload')).toHaveAttribute('src', /\/api\/admin\/themes\/casper\/download/);
await page.locator('[data-testid="menu-overlay"]:visible').click();
// Delete the inactive theme
await edition.getByRole('button', {name: 'Menu'}).click();
await edition.getByRole('button', {name: 'Delete'}).click();
await page.getByTestId('popover-content').getByRole('button', {name: 'Delete'}).click();
const confirmation = page.getByTestId('confirmation-modal');
await confirmation.getByRole('button', {name: 'Delete'}).click();

View File

@ -1,16 +1,17 @@
import {ConfigResponseType} from '../../src/utils/api/config';
import {CustomThemeSettingsResponseType} from '../../src/utils/api/customThemeSettings';
import {InvitesResponseType} from '../../src/utils/api/invites';
import {LabelsResponseType} from '../../src/utils/api/labels';
import {NewslettersResponseType} from '../../src/utils/api/newsletters';
import {OffersResponseType} from '../../src/utils/api/offers';
import {ActionsResponseType} from '../../src/api/actions';
import {ConfigResponseType} from '../../src/api/config';
import {CustomThemeSettingsResponseType} from '../../src/api/customThemeSettings';
import {InvitesResponseType} from '../../src/api/invites';
import {LabelsResponseType} from '../../src/api/labels';
import {NewslettersResponseType} from '../../src/api/newsletters';
import {OffersResponseType} from '../../src/api/offers';
import {Page} from '@playwright/test';
import {RolesResponseType} from '../../src/utils/api/roles';
import {SettingsResponseType} from '../../src/utils/api/settings';
import {SiteResponseType} from '../../src/utils/api/site';
import {ThemesResponseType} from '../../src/utils/api/themes';
import {TiersResponseType} from '../../src/utils/api/tiers';
import {UsersResponseType} from '../../src/utils/api/users';
import {RolesResponseType} from '../../src/api/roles';
import {SettingsResponseType} from '../../src/api/settings';
import {SiteResponseType} from '../../src/api/site';
import {ThemesResponseType} from '../../src/api/themes';
import {TiersResponseType} from '../../src/api/tiers';
import {UsersResponseType} from '../../src/api/users';
import {readFileSync} from 'fs';
interface MockRequestConfig {
@ -42,6 +43,7 @@ export const responseFixtures = {
offers: JSON.parse(readFileSync(`${__dirname}/responses/offers.json`).toString()) as OffersResponseType,
themes: JSON.parse(readFileSync(`${__dirname}/responses/themes.json`).toString()) as ThemesResponseType,
newsletters: JSON.parse(readFileSync(`${__dirname}/responses/newsletters.json`).toString()) as NewslettersResponseType,
actions: JSON.parse(readFileSync(`${__dirname}/responses/actions.json`).toString()) as ActionsResponseType,
latestPost: {posts: [{id: '1', url: `${siteFixture.site.url}/test-post/`}]}
};

View File

@ -0,0 +1,96 @@
{
"actions": [
{
"id": "64d62b327ca62600011d0818",
"resource_id": "6006c5c5604ccc0039ba9b9b",
"resource_type": "setting",
"actor_id": "1",
"actor_type": "user",
"event": "edited",
"context": "{\"key\":\"navigation\",\"group\":\"site\"}",
"created_at": "2023-08-11T12:36:02.000Z",
"actor": {
"id": "1",
"name": "Jamie Larson",
"slug": "main",
"image": null
},
"resource": {
"id": "6006c5c5604ccc0039ba9b9b"
}
},
{
"id": "64d62b1e7ca62600011d0817",
"resource_id": "6006c5c5604ccc0039ba9b9b",
"resource_type": "setting",
"actor_id": "1",
"actor_type": "user",
"event": "edited",
"context": "{\"key\":\"navigation\",\"group\":\"site\"}",
"created_at": "2023-08-11T12:35:42.000Z",
"actor": {
"id": "1",
"name": "Jamie Larson",
"slug": "main",
"image": null
},
"resource": {
"id": "6006c5c5604ccc0039ba9b9b"
}
},
{
"id": "64d627714676110001e89805",
"resource_id": "64d623b64676110001e897d9",
"resource_type": "post",
"actor_id": "1",
"actor_type": "user",
"event": "edited",
"context": "{\"type\":\"page\",\"primary_name\":\"The Clunkers Hall of Shame\"}",
"created_at": "2023-08-11T12:20:01.000Z",
"actor": {
"id": "1",
"name": "Jamie Larson",
"slug": "main",
"image": null
},
"resource": {
"id": "64d623b64676110001e897d9",
"title": "The Clunkers Hall of Shame",
"slug": "the-clunkers-hall-of-shame",
"image": null
}
},
{
"id": "64d627134676110001e89803",
"resource_id": "64d623b64676110001e897d9",
"resource_type": "post",
"actor_id": "1",
"actor_type": "user",
"event": "edited",
"context": "{\"type\":\"page\",\"primary_name\":\"The Clunkers Hall of Shame\"}",
"created_at": "2023-08-11T12:18:27.000Z",
"actor": {
"id": "1",
"name": "Jamie Larson",
"slug": "main",
"image": null
},
"resource": {
"id": "64d623b64676110001e897d9",
"title": "The Clunkers Hall of Shame",
"slug": "the-clunkers-hall-of-shame",
"image": null
}
}
],
"meta": {
"pagination": {
"page": 1,
"limit": 200,
"pages": 1,
"total": 4,
"next": null,
"prev": null
}
}
}

View File

@ -213,6 +213,7 @@ export default class AdminXSettings extends Component {
@service session;
@service store;
@service settings;
@service router;
@inject config;
@ -232,6 +233,10 @@ export default class AdminXSettings extends Component {
// don't rethrow, app should attempt to gracefully recover
}
externalNavigate = ({route, models = []}) => {
this.router.transitionTo(route, ...models);
};
ReactComponent = () => {
return (
<div className={['admin-x-settings-container-', this.args.className].filter(Boolean).join(' ')}>
@ -240,6 +245,7 @@ export default class AdminXSettings extends Component {
<AdminXApp
ghostVersion={config.APP.version}
officialThemes={officialThemes}
externalNavigate={this.externalNavigate}
/>
</Suspense>
</ErrorHandler>