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:
parent
c0ccdbe280
commit
19d9c3e3e2
16 changed files with 229 additions and 55 deletions
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
81
apps/admin-x-framework/src/providers/RouterProvider.tsx
Normal file
81
apps/admin-x-framework/src/providers/RouterProvider.tsx
Normal 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]);
|
||||
}
|
|
@ -52,7 +52,5 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"react-router": "^7.1.3"
|
||||
}
|
||||
"dependencies": {}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
16
apps/posts/src/views/post-analytics/modals/ShareModal.tsx
Normal file
16
apps/posts/src/views/post-analytics/modals/ShareModal.tsx
Normal 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;
|
21
apps/posts/src/views/posts/Posts.tsx
Normal file
21
apps/posts/src/views/posts/Posts.tsx
Normal 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;
|
23
apps/shade/src/components/layout/error-page.tsx
Normal file
23
apps/shade/src/components/layout/error-page.tsx
Normal 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};
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 () {
|
||||
|
|
Loading…
Add table
Reference in a new issue