0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Updated AdminX detail modals to use routes ()

refs https://github.com/TryGhost/Product/issues/3349
This commit is contained in:
Jono M 2023-08-10 13:04:23 +01:00 committed by GitHub
parent 8d0eed2dcd
commit 7ddaad8209
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 167 additions and 48 deletions

View file

@ -85,6 +85,7 @@ export const useEditUser = createMutation<UsersResponseType, User>({
method: 'PUT',
path: user => `/users/${user.id}/`,
body: user => ({users: [user]}),
searchParams: () => ({include: 'roles'}),
updateQueries: {
dataType,
update: updateUsers

View file

@ -5,26 +5,39 @@ import InviteUserModal from '../settings/general/InviteUserModal';
import NavigationModal from '../settings/site/NavigationModal';
import NiceModal from '@ebay/nice-modal-react';
import PortalModal from '../settings/membership/portal/PortalModal';
import React, {createContext, useCallback, useEffect, useState} from 'react';
import React, {createContext, useCallback, useEffect, useRef, useState} from 'react';
import StripeConnectModal from '../settings/membership/stripe/StripeConnectModal';
import TierDetailModal from '../settings/membership/tiers/TierDetailModal';
type RoutingContextProps = {
export type RouteParams = {[key: string]: string}
export type RoutingContextData = {
route: string;
scrolledRoute: string;
yScroll: number;
updateRoute: (newPath: string) => void;
updateRoute: (newPath: string, params?: RouteParams) => void;
updateScrolled: (newPath: string) => void;
addRouteChangeListener: (listener: RouteChangeListener) => (() => void);
};
export const RouteContext = createContext<RoutingContextProps>({
export const RouteContext = createContext<RoutingContextData>({
route: '',
scrolledRoute: '',
yScroll: 0,
updateRoute: () => {},
updateScrolled: () => {}
updateScrolled: () => {},
addRouteChangeListener: () => (() => {})
});
// These routes need to be handled by a SettingGroup (or other component) with the
// useHandleRoute hook. The idea is that those components will open a modal after
// loading any data required for the route
export const modalRoutes = {
showUser: 'users/show/:slug',
showNewsletter: 'newsletters/show/:id',
showTier: 'tiers/show/:id'
};
function getHashPath(urlPath: string | undefined) {
if (!urlPath) {
return null;
@ -84,29 +97,55 @@ const handleNavigation = (scroll: boolean = true) => {
return '';
};
const matchRoute = (pathname: string, routeDefinition: string) => {
const regex = new RegExp(routeDefinition.replace(/:(\w+)/, '(?<$1>[^/]+)'));
return pathname.match(regex)?.groups;
};
const callRouteChangeListeners = (newPath: string, listeners: RouteChangeListener[]) => {
listeners.forEach((listener) => {
const params = matchRoute(newPath, listener.route);
if (params) {
listener.callback(params);
}
});
};
type RouteProviderProps = {
children: React.ReactNode;
};
type RouteChangeListener = {
route: string;
callback: (params: RouteParams) => void;
}
const RoutingProvider: React.FC<RouteProviderProps> = ({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) => {
if (newPath) {
if (newPath === route) {
scrollToSectionGroup(newPath);
} else {
window.location.hash = `/settings-x/${newPath}`;
}
const updateRoute = useCallback((newPath: string, params?: RouteParams) => {
if (params) {
newPath = Object.entries(params).reduce(
(path, [name, value]) => path.replace(`:${name}`, value),
newPath
);
}
if (newPath) {
if (newPath === route) {
scrollToSectionGroup(newPath);
} else {
window.location.hash = `/settings-x`;
window.location.hash = `/settings-x/${newPath}`;
}
},
[route]
);
} else {
window.location.hash = `/settings-x`;
}
}, [route]);
const updateScrolled = useCallback((newPath: string) => {
setScrolledRoute(newPath);
@ -116,6 +155,7 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
const handleHashChange = () => {
const matchedRoute = handleNavigation();
setRoute(matchedRoute);
callRouteChangeListeners(matchedRoute, routeChangeListeners.current);
};
const handleScroll = () => {
@ -127,6 +167,7 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
const element = document.getElementById('admin-x-root');
const matchedRoute = handleNavigation();
setRoute(matchedRoute);
callRouteChangeListeners(matchedRoute, routeChangeListeners.current);
element!.addEventListener('scroll', handleScroll);
window.addEventListener('hashchange', handleHashChange);
@ -135,7 +176,17 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
element!.removeEventListener('scroll', handleScroll);
window.removeEventListener('hashchange', handleHashChange);
};
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const addRouteChangeListener = useCallback((listener: RouteChangeListener) => {
if (route && !routeChangeListeners.current.some(current => current.route === listener.route)) {
callRouteChangeListeners(route, [listener]);
}
routeChangeListeners.current = [...routeChangeListeners.current, listener];
return () => routeChangeListeners.current = routeChangeListeners.current.filter(current => current !== listener);
}, [route]);
return (
<RouteContext.Provider
@ -144,7 +195,8 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
scrolledRoute,
yScroll,
updateRoute,
updateScrolled
updateScrolled,
addRouteChangeListener
}}
>
{children}

View file

@ -1,9 +1,13 @@
import Button from '../../../admin-x-ds/global/Button';
import NewsletterDetailModal from './newsletters/NewsletterDetailModal';
import NewslettersList from './newsletters/NewslettersList';
import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import useHandleRoute from '../../../hooks/useHandleRoute';
import useRouting from '../../../hooks/useRouting';
import {modalRoutes} from '../../providers/RoutingProvider';
import {useBrowseNewsletters} from '../../../api/newsletters';
const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
@ -14,6 +18,16 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
const [selectedTab, setSelectedTab] = useState('active-newsletters');
const {data: {newsletters} = {}} = useBrowseNewsletters();
useHandleRoute(modalRoutes.showNewsletter, ({id}) => {
const newsletter = newsletters?.find(u => u.id === id);
if (!newsletter) {
return;
}
NiceModal.show(NewsletterDetailModal, {newsletter});
}, [newsletters]);
const buttons = (
<Button color='green' label='Add newsletter' link={true} onClick={() => {
openNewsletterModal();

View file

@ -1,6 +1,5 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NewsletterDetailModal from './NewsletterDetailModal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
@ -8,6 +7,7 @@ import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {modalRoutes} from '../../../providers/RoutingProvider';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
import {useAddNewsletter} from '../../../../api/newsletters';
@ -37,9 +37,7 @@ const AddNewsletterModal: React.FC<AddNewsletterModalProps> = () => {
opt_in_existing: formState.optInExistingSubscribers
});
NiceModal.show(NewsletterDetailModal, {
newsletter: response.newsletters[0]
});
updateRoute(modalRoutes.showNewsletter, {id: response.newsletters[0].id});
},
onValidate: () => {
const newErrors: Record<string, string> = {};
@ -65,7 +63,6 @@ const AddNewsletterModal: React.FC<AddNewsletterModalProps> = () => {
toast.remove();
if (await handleSave()) {
modal.remove();
updateRoute('newsletters');
} else {
showToast({
type: 'pageError',

View file

@ -17,6 +17,7 @@ import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import ToggleGroup from '../../../../admin-x-ds/global/form/ToggleGroup';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import validator from 'validator';
import {Newsletter, useEditNewsletter} from '../../../../api/newsletters';
import {PreviewModalContent} from '../../../../admin-x-ds/global/modal/PreviewModal';
@ -281,6 +282,7 @@ const NewsletterDetailModal: React.FC<NewsletterDetailModalProps> = ({newsletter
const modal = useModal();
const {siteData} = useGlobalData();
const {mutateAsync: editNewsletter} = useEditNewsletter();
const {updateRoute} = useRouting();
const {formState, updateForm, handleSave, validate, errors, clearError} = useForm({
initialState: newsletter,
@ -329,6 +331,7 @@ const NewsletterDetailModal: React.FC<NewsletterDetailModalProps> = ({newsletter
const sidebar = <Sidebar clearError={clearError} errors={errors} newsletter={formState} updateNewsletter={updateNewsletter} validate={validate} />;
return <PreviewModalContent
afterClose={() => updateRoute('newsletters')}
deviceSelector={false}
okLabel='Save & close'
preview={preview}
@ -342,6 +345,7 @@ const NewsletterDetailModal: React.FC<NewsletterDetailModalProps> = ({newsletter
toast.remove();
if (await handleSave()) {
modal.remove();
updateRoute('newsletters');
} else {
showToast({
type: 'pageError',

View file

@ -1,13 +1,14 @@
import Button from '../../../../admin-x-ds/global/Button';
import ConfirmationModal from '../../../../admin-x-ds/global/modal/ConfirmationModal';
import NewsletterDetailModal from './NewsletterDetailModal';
import NiceModal from '@ebay/nice-modal-react';
import NoValueLabel from '../../../../admin-x-ds/global/NoValueLabel';
import React from 'react';
import Table from '../../../../admin-x-ds/global/Table';
import TableCell from '../../../../admin-x-ds/global/TableCell';
import TableRow from '../../../../admin-x-ds/global/TableRow';
import useRouting from '../../../../hooks/useRouting';
import {Newsletter, useEditNewsletter} from '../../../../api/newsletters';
import {modalRoutes} from '../../../providers/RoutingProvider';
interface NewslettersListProps {
newsletters: Newsletter[]
@ -15,6 +16,7 @@ interface NewslettersListProps {
const NewsletterItem: React.FC<{newsletter: Newsletter, onlyOne: boolean}> = ({newsletter, onlyOne}) => {
const {mutateAsync: editNewsletter} = useEditNewsletter();
const {updateRoute} = useRouting();
const action = newsletter.status === 'active' ? (
<Button color='green' disabled={onlyOne} label='Archive' link onClick={() => {
@ -48,7 +50,7 @@ const NewsletterItem: React.FC<{newsletter: Newsletter, onlyOne: boolean}> = ({n
);
const showDetails = () => {
NiceModal.show(NewsletterDetailModal, {newsletter});
updateRoute(modalRoutes.showNewsletter, {id: newsletter.id});
};
return (

View file

@ -12,6 +12,7 @@ import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextField from '../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import validator from 'validator';
import {User, isAdminUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner, useUpdatePassword} from '../../../api/users';
@ -419,6 +420,7 @@ const UserMenuTrigger = () => (
);
const UserDetailModal:React.FC<UserDetailModalProps> = ({user}) => {
const {updateRoute} = useRouting();
const {ownerUser} = useStaffUsers();
const [userData, setUserData] = useState(user);
const [saveState, setSaveState] = useState('');
@ -434,6 +436,15 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user}) => {
const {mutateAsync: deleteUser} = useDeleteUser();
const {mutateAsync: makeOwner} = useMakeOwner();
useEffect(() => {
if (saveState === 'saved') {
setTimeout(() => {
mainModal.remove();
updateRoute('users');
}, 300);
}
}, [mainModal, saveState, updateRoute]);
const confirmSuspend = (_user: User) => {
let warningText = 'This user will no longer be able to log in but their posts will be kept.';
if (_user.status === 'inactive') {
@ -583,9 +594,6 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user}) => {
okLabel = 'Saving...';
} else if (saveState === 'saved') {
okLabel = 'Saved';
setTimeout(() => {
mainModal.remove();
}, 300);
}
const fileUploadButtonClasses = 'absolute right-[104px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
@ -617,6 +625,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user}) => {
return (
<Modal
afterClose={() => updateRoute('users')}
okLabel={okLabel}
size='lg'
stickyFooter={true}

View file

@ -8,11 +8,13 @@ import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import UserDetailModal from './UserDetailModal';
import useHandleRoute from '../../../hooks/useHandleRoute';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import {User} from '../../../api/users';
import {UserInvite, useAddInvite, useDeleteInvite} from '../../../api/invites';
import {generateAvatarColor, getInitials} from '../../../utils/helpers';
import {modalRoutes} from '../../providers/RoutingProvider';
import {showToast} from '../../../admin-x-ds/global/Toast';
interface OwnerProps {
@ -31,8 +33,10 @@ interface InviteListProps {
}
const Owner: React.FC<OwnerProps> = ({user}) => {
const {updateRoute} = useRouting();
const showDetailModal = () => {
NiceModal.show(UserDetailModal, {user});
updateRoute(modalRoutes.showUser, {slug: user.slug});
};
if (!user) {
@ -51,8 +55,10 @@ const Owner: React.FC<OwnerProps> = ({user}) => {
};
const UsersList: React.FC<UsersListProps> = ({users}) => {
const {updateRoute} = useRouting();
const showDetailModal = (user: User) => {
NiceModal.show(UserDetailModal, {user});
updateRoute(modalRoutes.showUser, {slug: user.slug});
};
if (!users || !users.length) {
@ -179,6 +185,7 @@ const InvitesUserList: React.FC<InviteListProps> = ({users}) => {
const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
users,
ownerUser,
adminUsers,
editorUsers,
@ -186,7 +193,19 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
contributorUsers,
invites
} = useStaffUsers();
const {updateRoute} = useRouting();
useHandleRoute(modalRoutes.showUser, ({slug}) => {
const user = users.find(u => u.slug === slug);
if (!user) {
return;
}
NiceModal.show(UserDetailModal, {user});
}, [users]);
const showInviteModal = () => {
updateRoute('users/invite');
};

View file

@ -1,11 +1,15 @@
import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import StripeButton from '../../../admin-x-ds/settings/StripeButton';
import TabView from '../../../admin-x-ds/global/TabView';
import TierDetailModal from './tiers/TierDetailModal';
import TiersList from './tiers/TiersList';
import useHandleRoute from '../../../hooks/useHandleRoute';
import useRouting from '../../../hooks/useRouting';
import {Tier, getActiveTiers, getArchivedTiers, useBrowseTiers} from '../../../api/tiers';
import {checkStripeEnabled} from '../../../api/settings';
import {modalRoutes} from '../../providers/RoutingProvider';
import {useGlobalData} from '../../providers/GlobalDataProvider';
const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
@ -16,6 +20,14 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
const archivedTiers = getArchivedTiers(tiers || []);
const {updateRoute} = useRouting();
useHandleRoute(modalRoutes.showTier, ({id}) => {
const tier = tiers?.find(t => t.id === id);
if (tier) {
NiceModal.show(TierDetailModal, {tier});
}
}, [tiers]);
const openConnectModal = () => {
updateRoute('stripe-connect');
};

View file

@ -132,6 +132,7 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
if (saveState !== 'unsaved') {
toast.dismiss();
modal.remove();
updateRoute('tiers');
}
}}
>

View file

@ -1,12 +1,11 @@
import Button from '../../../../admin-x-ds/global/Button';
import Icon from '../../../../admin-x-ds/global/Icon';
import NiceModal from '@ebay/nice-modal-react';
import NoValueLabel from '../../../../admin-x-ds/global/NoValueLabel';
import React from 'react';
import TierDetailModal from './TierDetailModal';
import useRouting from '../../../../hooks/useRouting';
import {Tier, useEditTier} from '../../../../api/tiers';
import {currencyToDecimal, getSymbol} from '../../../../utils/currency';
import {modalRoutes} from '../../../providers/RoutingProvider';
import {numberWithCommas} from '../../../../utils/helpers';
interface TiersListProps {
@ -21,6 +20,7 @@ interface TierCardProps {
const cardContainerClasses = 'group flex min-h-[200px] flex-col items-start justify-between gap-4 self-stretch rounded-sm border border-grey-300 p-4 transition-all hover:border-grey-400';
const TierCard: React.FC<TierCardProps> = ({tier}) => {
const {updateRoute} = useRouting();
const {mutateAsync: updateTier} = useEditTier();
const currency = tier?.currency || 'USD';
const currencySymbol = currency ? getSymbol(currency) : '$';
@ -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={() => {
NiceModal.show(TierDetailModal, {tier});
updateRoute(modalRoutes.showTier, {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

@ -0,0 +1,14 @@
import {RouteContext, RouteParams} from '../components/providers/RoutingProvider';
import {useContext, useEffect} from 'react';
const useHandleRoute = (route: string, callback: (params: RouteParams) => void, dependencies: unknown[]) => {
const {addRouteChangeListener} = useContext(RouteContext);
useEffect(() => {
const unsubscribe = addRouteChangeListener({route, callback});
return unsubscribe;
}, [route, ...dependencies]); // eslint-disable-line react-hooks/exhaustive-deps
};
export default useHandleRoute;

View file

@ -1,13 +1,7 @@
import {RouteContext} from '../components/providers/RoutingProvider';
import {RouteContext, RoutingContextData} from '../components/providers/RoutingProvider';
import {useContext} from 'react';
export type RoutingHook = {
route: string;
scrolledRoute: string;
yScroll?: number;
updateScrolled: (newPath: string) => void,
updateRoute: (newPath: string) => void
};
export type RoutingHook = Pick<RoutingContextData, 'route' | 'scrolledRoute' | 'yScroll' | 'updateRoute' | 'updateScrolled'>;
const useRouting = (): RoutingHook => {
const {route, scrolledRoute, yScroll, updateScrolled, updateRoute} = useContext(RouteContext);

View file

@ -8,7 +8,7 @@ test.describe('User actions', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/`, response: {
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
users: [{
...userToEdit,
status: 'inactive'
@ -59,7 +59,7 @@ test.describe('User actions', async () => {
}
]
}},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/`, response: {
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
users: [{
...userToEdit,
status: 'active'
@ -152,7 +152,7 @@ test.describe('User actions', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users},
editUser: {method: 'PUT', path: /^\/users\/\w{24}\/$/, response: responseFixtures.users},
editUser: {method: 'PUT', path: /^\/users\/\w{24}\/\?include=roles$/, response: responseFixtures.users},
makeOwner: {method: 'PUT', path: '/users/owner/', response: makeOwnerResponse}
}});

View file

@ -8,7 +8,7 @@ test.describe('User profile', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/`, response: {
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
users: [{
...userToEdit,
email: 'newadmin@test.com',
@ -111,7 +111,7 @@ test.describe('User profile', async () => {
...globalDataRequests,
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users},
uploadImage: {method: 'POST', path: '/images/upload/', response: {images: [{url: 'http://example.com/image.png', ref: null}]}},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/`, response: {
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
users: [{
...userToEdit,
profile_image: 'http://example.com/image.png',

View file

@ -45,7 +45,7 @@ test.describe('User roles', async () => {
browseUsers: {method: 'GET', path: '/users/?limit=all&include=roles', response: responseFixtures.users},
browseRoles: {method: 'GET', path: '/roles/?limit=all', response: responseFixtures.roles},
browseAssignableRoles: {method: 'GET', path: '/roles/?limit=all&permissions=assign', response: responseFixtures.roles},
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/`, response: {
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
users: [{
...userToEdit,
roles: [responseFixtures.roles.roles.find(role => role.name === 'Editor')!]