0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Post analytics router update (#22050)

ref https://linear.app/ghost/issue/DES-1082/router-prototype

This task is about testing, figuring out pros and cons of React Router
compared to our current (custom) router, and what effort and risks are
involved in migrating to it.
This commit is contained in:
Peter Zimon 2025-01-23 16:48:29 +01:00 committed by GitHub
parent c0ccdbe280
commit 19d9c3e3e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 229 additions and 55 deletions

View file

@ -91,6 +91,7 @@
"@vitejs/plugin-react": "4.2.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router": "^7.1.3",
"vite": "4.5.3",
"vite-plugin-css-injected-by-js": "^3.3.0",
"vite-plugin-svgr": "3.3.0",
@ -116,4 +117,4 @@
}
}
}
}
}

View file

@ -1,6 +1,13 @@
export {FrameworkProvider, useFramework} from './providers/FrameworkProvider';
// Framework
export type {FrameworkContextType, FrameworkProviderProps, TopLevelFrameworkProps} from './providers/FrameworkProvider';
export {FrameworkProvider, useFramework} from './providers/FrameworkProvider';
export {useQueryClient} from '@tanstack/react-query';
// Routing
export type {RouteObject} from 'react-router';
export type {RouterProviderProps} from './providers/RouterProvider';
export {RouterProvider, useNavigate} from './providers/RouterProvider';
export {useLocation, useParams, useSearchParams, Outlet} from 'react-router';
// Data fetching
export type {InfiniteData} from '@tanstack/react-query';
export {useQueryClient} from '@tanstack/react-query';

View file

@ -0,0 +1,81 @@
import {ErrorPage} from '@tryghost/shade';
import React, {useCallback, useMemo} from 'react';
import {createHashRouter, RouteObject, RouterProvider as ReactRouterProvider, NavigateOptions as ReactRouterNavigateOptions, useNavigate as useReactRouterNavigate} from 'react-router';
import {useFramework} from './FrameworkProvider';
/**
* READ THIS BEFORE USING THIS PROVIDER
*
* This is an experimental provider that tests using React Router to provide
* a router context to React apps in Ghost.
*
* It is not ready for production yet. For apps in production, use the custom
* RoutingProvider.
*/
/**
* Wrap React Router in a custom provider to provide a standard, simplified
* interface for all Ghost apps for routing. It also sanitizes the routes and
* adds a default error element.
*/
export interface RouterProviderProps {
routes: RouteObject[];
prefix?: string;
// Custom routing props
errorElement?: React.ReactNode;
}
export function RouterProvider({
routes,
prefix,
errorElement
}: RouterProviderProps) {
// Memoize the router to avoid re-creating it on every render
const router = useMemo(() => {
// Ensure prefix has a leading slash and no double+ or trailing slashes
const normalizedPrefix = `/${prefix?.replace(/\/+/g, '/').replace(/^\/|\/$/g, '')}`;
// Add default error element if not provided
const finalRoutes = routes.map(route => ({
...route,
errorElement: route.errorElement || errorElement || <ErrorPage />
}));
return createHashRouter(finalRoutes, {
basename: normalizedPrefix
});
}, [routes, prefix, errorElement]);
return (
<ReactRouterProvider
router={router}
/>
);
}
/**
* Override the default navigate function to add the crossApp option. This is
* used to determine if the navigate should be handled by the custom router, ie.
* if we need to navigate outside of the current app in Ghost.
*/
interface NavigateOptions extends ReactRouterNavigateOptions {
crossApp?: boolean;
}
export function useNavigate() {
const navigate = useReactRouterNavigate();
const {externalNavigate} = useFramework();
return useCallback((
to: string,
options?: NavigateOptions
) => {
if (options?.crossApp) {
externalNavigate({route: to, isExternal: true});
return;
}
navigate(to, options);
}, [navigate, externalNavigate]);
}

View file

@ -52,7 +52,5 @@
}
}
},
"dependencies": {
"react-router": "^7.1.3"
}
"dependencies": {}
}

View file

@ -1,7 +1,6 @@
import {FrameworkProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
import {RouterProvider} from 'react-router';
import {APP_ROUTE_PREFIX, routes} from './routes';
import {FrameworkProvider, RouterProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
import {ShadeApp, ShadeAppProps, SidebarProvider} from '@tryghost/shade';
import {router} from './routes';
interface AppProps {
framework: TopLevelFrameworkProps;
@ -13,7 +12,7 @@ const App: React.FC<AppProps> = ({framework, designSystem}) => {
<FrameworkProvider {...framework}>
<ShadeApp className='posts' {...designSystem}>
<SidebarProvider>
<RouterProvider router={router} />
<RouterProvider prefix={APP_ROUTE_PREFIX} routes={routes} />
</SidebarProvider>
</ShadeApp>
</FrameworkProvider>

View file

@ -1,15 +1,30 @@
import {Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, H1, LucideIcon} from '@tryghost/shade';
import ShareModal from '../views/post-analytics/modals/ShareModal';
import {Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger, H1, LucideIcon} from '@tryghost/shade';
import {useLocation, useNavigate, useParams} from '@tryghost/admin-x-framework';
interface headerProps {};
const Header: React.FC<headerProps> = () => {
const navigate = useNavigate();
const location = useLocation();
const {postId} = useParams();
// Handling the share dialog via navigation
const isShareDialogOpen = location.pathname === `/analytics/${postId}/share`;
const openShareDialog = () => {
navigate(`/analytics/${postId}/share`);
};
const closeShareDialog = () => {
navigate(`/analytics/${postId}`);
};
return (
<div className="flex flex-col items-start justify-between gap-1 pt-9">
<div className='flex h-8 w-full items-center justify-between gap-3'>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/ghost/posts">
<BreadcrumbLink className='cursor-pointer' onClick={() => navigate('/')}>
Posts
</BreadcrumbLink>
</BreadcrumbItem>
@ -22,36 +37,38 @@ const Header: React.FC<headerProps> = () => {
</BreadcrumbList>
</Breadcrumb>
<div className='flex items-center gap-2'>
<Button variant='outline' onClick={openShareDialog}><LucideIcon.Share />Share</Button>
<Dialog open={isShareDialogOpen} onOpenChange={closeShareDialog}>
<ShareModal />
</Dialog>
<Dialog>
<DialogTrigger asChild>
<Button variant='outline'><LucideIcon.Share />Share</Button>
</DialogTrigger>
<DialogContent className='max-w-2xl'>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant='outline'><LucideIcon.Ellipsis /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem>
<span>Edit post</span>
<DropdownMenuShortcut>E</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<span>View in browser</span>
<DropdownMenuShortcut>O</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DialogTrigger className="w-full">
<DropdownMenuItem className="text-red">
Delete
</DropdownMenuItem>
</DialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader>
<DialogTitle>Share</DialogTitle>
<DialogDescription>
This is a dialog, lets see how it works.
</DialogDescription>
<DialogTitle>Are you sure you want to delete this post?</DialogTitle>
</DialogHeader>
</DialogContent>
</Dialog>
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant='outline'><LucideIcon.Ellipsis /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem>
<span>Edit post</span>
<DropdownMenuShortcut>E</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<span>View in browser</span>
<DropdownMenuShortcut>O</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<H1 className='mt-2 max-w-[960px]'>The Evolution of Basketball: From Pastime to Professional and One of the Most Popular Sports</H1>

View file

@ -1,14 +1,18 @@
import Newsletter from './views/post-analytics/components/Newsletter';
import Overview from './views/post-analytics/components/Overview';
import PostAnalytics from './views/post-analytics/PostAnalytics';
import {createHashRouter} from 'react-router';
import Posts from './views/posts/Posts';
import {RouteObject} from '@tryghost/admin-x-framework';
export const BASE_PATH = '/posts-x';
export const ANALYTICS = `${BASE_PATH}/analytics`;
export const APP_ROUTE_PREFIX = '/posts-x';
const postAnalyticsRoutes = [
export const routes: RouteObject[] = [
{
path: `${BASE_PATH}/analytics/:postId`,
path: '',
Component: Posts
},
{
path: 'analytics/:postId',
Component: PostAnalytics,
children: [
{
@ -22,9 +26,11 @@ const postAnalyticsRoutes = [
{
path: 'newsletter',
Component: Newsletter
},
{
path: 'share',
Component: Overview
}
]
}
];
export const router = createHashRouter(postAnalyticsRoutes);

View file

@ -1,6 +1,5 @@
import Header from '../../components/Header';
import {ANALYTICS} from '../../routes';
import {Outlet, useLocation, useNavigate, useParams} from 'react-router';
import {Outlet, useLocation, useNavigate, useParams} from '@tryghost/admin-x-framework';
import {Page, Tabs, TabsList, TabsTrigger} from '@tryghost/shade';
interface postAnalyticsProps {};
@ -17,9 +16,9 @@ const PostAnalytics: React.FC<postAnalyticsProps> = () => {
const handleTabChange = (value: string) => {
if (value === 'overview') {
navigate(`${ANALYTICS}/${postId}`);
navigate(`analytics/${postId}`);
} else {
navigate(`${ANALYTICS}/${postId}/${value}`);
navigate(`analytics/${postId}/${value}`);
}
};

View file

@ -0,0 +1,16 @@
import {DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@tryghost/shade';
const ShareModal = () => {
return (
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>Share</DialogTitle>
<DialogDescription>
This is a dialog opened with router and with a custom width.
</DialogDescription>
</DialogHeader>
</DialogContent>
);
};
export default ShareModal;

View file

@ -0,0 +1,21 @@
import {Button, H1, Page} from '@tryghost/shade';
import {useNavigate} from '@tryghost/admin-x-framework';
interface postAnalyticsProps {};
const Posts: React.FC<postAnalyticsProps> = () => {
const navigate = useNavigate();
return (
<Page>
<H1 className='my-8 min-h-[38px]'>Posts</H1>
<div className='flex items-center gap-2'>
<Button onClick={() => navigate('analytics/123')}>Analytics</Button>
<Button variant='secondary' onClick={() => navigate('analytics/123/share')}>Open dialog via navigation</Button>
<Button variant='secondary' onClick={() => navigate('dashboard', {crossApp: true})}>Go to dashboard (external route)</Button>
</div>
</Page>
);
};
export default Posts;

View file

@ -0,0 +1,23 @@
import {cn} from '@/lib/utils';
import * as React from 'react';
export interface ErrorPageProps
extends React.HTMLAttributes<HTMLDivElement> {}
const ErrorPage = React.forwardRef<HTMLDivElement, ErrorPageProps>(
({className, ...props}, ref) => {
return (
<div
ref={ref}
className={cn('max-w-page mx-auto w-full min-h-full px-8 flex flex-col', className)}
{...props}
>
<h1>Error</h1>
</div>
);
}
);
ErrorPage.displayName = 'ErrorPage';
export {ErrorPage};

View file

@ -24,7 +24,7 @@ export const Default: Story = {
Card contents
</CardContent>,
<CardFooter key="footer" className="flex justify-between">
<CardFooter key="footer" className="flex grow justify-between">
<Button variant="outline">Cancel</Button>
<Button>Deploy</Button>
</CardFooter>

View file

@ -1,5 +1,5 @@
import type {Meta, StoryObj} from '@storybook/react';
import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle} from './dialog';
import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription} from './dialog';
import {Button} from './button';
const meta = {
@ -26,7 +26,15 @@ export const Default: Story = {
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. Are you sure you want to permanently
delete this file from our servers?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="submit" variant="outline">Cancel</Button>
<Button type="submit">Confirm</Button>
</DialogFooter>
</DialogContent>
</>
)

View file

@ -19,6 +19,7 @@ export * from './components/ui/tooltip';
// Layout components
export * from './components/layout/page';
export * from './components/layout/error-page';
export * from './components/layout/heading';
// Third party components

View file

@ -164,7 +164,7 @@
{{/if}}
{{#if (feature "postsX")}}
<li>
<LinkTo @route="posts-x.analytics.123" @current-when="posts-x">{{svg-jar "chart"}}Post analytics</LinkTo>
<LinkTo @route="posts-x" @current-when="posts-x">{{svg-jar "chart"}}Post analytics</LinkTo>
</li>
{{/if}}
</ul>

View file

@ -54,9 +54,6 @@ Router.map(function () {
this.route('posts-x', function () {
this.route('posts-x', {path: '/*sub'});
this.route('analytics', function () {
this.route('123');
});
});
this.route('settings-x', {path: '/settings'}, function () {