mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 20:03:12 +03:00
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:
parent
8dc4923041
commit
9bfbd5b3b9
@ -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',
|
||||
|
@ -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 |
@ -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>;
|
||||
}
|
||||
};
|
@ -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;
|
@ -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'
|
||||
}
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
};
|
92
apps/admin-x-settings/src/admin-x-ds/global/Popover.tsx
Normal file
92
apps/admin-x-settings/src/admin-x-ds/global/Popover.tsx
Normal 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;
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
|
217
apps/admin-x-settings/src/api/actions.ts
Normal file
217
apps/admin-x-settings/src/api/actions.ts
Normal 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;
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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> — by {action.actor?.name || action.actor?.slug}</span>
|
||||
</div>
|
||||
}
|
||||
separator
|
||||
/>)}
|
||||
</List>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default HistoryModal;
|
@ -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> = {};
|
||||
|
@ -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 (
|
||||
|
@ -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'>
|
||||
|
@ -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) {
|
||||
|
@ -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'>
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
45
apps/admin-x-settings/test/e2e/advanced/history.test.ts
Normal file
45
apps/admin-x-settings/test/e2e/advanced/history.test.ts
Normal 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');
|
||||
});
|
||||
});
|
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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/`}]}
|
||||
};
|
||||
|
||||
|
96
apps/admin-x-settings/test/utils/responses/actions.json
Normal file
96
apps/admin-x-settings/test/utils/responses/actions.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user