Преглед изворни кода

fix(dashboard): Fix nested breadcrumb generation

Michael Bromley пре 10 месеци
родитељ
комит
4a4653eab7

+ 36 - 0
packages/dashboard/src/app-providers.tsx

@@ -0,0 +1,36 @@
+import { AuthProvider } from '@/auth.js';
+import { I18nProvider } from '@/i18n/i18n-provider.js';
+import { routeTree } from '@/routeTree.gen.js';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { createRouter } from '@tanstack/react-router';
+import React from 'react';
+
+export const queryClient = new QueryClient();
+
+export const router = createRouter({
+    routeTree,
+    defaultPreload: 'intent',
+    scrollRestoration: true,
+    context: {
+        /* eslint-disable @typescript-eslint/no-non-null-assertion */
+        auth: undefined!, // This will be set after we wrap the app in an AuthProvider
+        queryClient,
+    },
+});
+
+// Register things for typesafety
+declare module '@tanstack/react-router' {
+    interface Register {
+        router: typeof router;
+    }
+}
+
+export function AppProviders({ children }: { children: React.ReactNode }) {
+    return (
+        <I18nProvider>
+            <QueryClientProvider client={queryClient}>
+                <AuthProvider>{children}</AuthProvider>
+            </QueryClientProvider>
+        </I18nProvider>
+    );
+}

+ 65 - 0
packages/dashboard/src/components/generated-breadcrumbs.tsx

@@ -0,0 +1,65 @@
+import {
+    Breadcrumb,
+    BreadcrumbItem,
+    BreadcrumbLink,
+    BreadcrumbList,
+    BreadcrumbSeparator,
+} from '@/components/ui/breadcrumb.js';
+import { Link, useRouterState } from '@tanstack/react-router';
+import * as React from 'react';
+import { Fragment } from 'react';
+
+export interface BreadcrumbItem {
+    label: string;
+    path: string;
+}
+
+export type BreadcrumbShorthand = string;
+
+export type PageBreadcrumb = BreadcrumbItem | BreadcrumbShorthand;
+
+export function GeneratedBreadcrumbs() {
+    const matches = useRouterState({ select: s => s.matches });
+    const breadcrumbs = matches
+        .filter(match => match.loaderData?.breadcrumb)
+        .map(({ pathname, loaderData }) => {
+            if (typeof loaderData.breadcrumb === 'string') {
+                return {
+                    label: loaderData.breadcrumb,
+                    path: pathname,
+                };
+            }
+            if (Array.isArray(loaderData.breadcrumb)) {
+                return loaderData.breadcrumb.map((breadcrumb: PageBreadcrumb) => {
+                    if (typeof breadcrumb === 'string') {
+                        return {
+                            label: breadcrumb,
+                            path: pathname,
+                        };
+                    } else {
+                        return {
+                            label: breadcrumb.label,
+                            path: breadcrumb.path,
+                        };
+                    }
+                });
+            }
+        })
+        .flat();
+    return (
+        <Breadcrumb>
+            <BreadcrumbList>
+                {breadcrumbs.map(({ label, path }, index, arr) => (
+                    <Fragment key={index}>
+                        <BreadcrumbItem className="hidden md:block">
+                            <BreadcrumbLink asChild>
+                                <Link to={path}>{label}</Link>
+                            </BreadcrumbLink>
+                        </BreadcrumbItem>
+                        {index < arr.length - 1 && <BreadcrumbSeparator className="hidden md:block" />}
+                    </Fragment>
+                ))}
+            </BreadcrumbList>
+        </Breadcrumb>
+    );
+}

+ 32 - 0
packages/dashboard/src/framework/internal/page/detail-page.tsx

@@ -0,0 +1,32 @@
+import { getQueryName } from '@/framework/internal/document-introspection/get-document-structure.js';
+import { PageProps } from '@/framework/internal/page/page-types.js';
+import { api } from '@/graphql/api.js';
+import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { queryOptions } from '@tanstack/react-query';
+import { Variables } from 'graphql-request';
+import { DocumentNode } from 'graphql/index.js';
+import React from 'react';
+
+export function getDetailQueryOptions<T, V extends Variables = Variables>(
+    document: TypedDocumentNode<T, V> | DocumentNode,
+    variables: V,
+) {
+    const queryName = getQueryName(document);
+    return queryOptions({
+        queryKey: ['DetailPage', queryName, variables],
+        queryFn: () => api.query(document, variables),
+    });
+}
+
+export interface DetailPageProps extends PageProps {
+    entity: any;
+}
+
+export function DetailPage({ title, entity }: DetailPageProps) {
+    return (
+        <div className="m-4">
+            <h1 className="text-2xl font-bold">{title}</h1>
+            <pre>{JSON.stringify(entity, null, 2)}</pre>
+        </div>
+    );
+}

+ 1 - 4
packages/dashboard/src/framework/internal/page/use-extended-router.tsx

@@ -9,7 +9,7 @@ import { useMemo } from 'react';
  * Extends the TanStack Router with additional routes for each dashboard
  * extension.
  */
-const UseExtendedRouter = (router: Router<AnyRoute, any, any>) => {
+export const useExtendedRouter = (router: Router<AnyRoute, any, any>) => {
     const { extensionsLoaded } = useDashboardExtensions();
 
     return useMemo(() => {
@@ -77,6 +77,3 @@ const UseExtendedRouter = (router: Router<AnyRoute, any, any>) => {
         return newRouter;
     }, [router, extensionsLoaded]);
 };
-
-// const UseExtendedRouter = UseExtendedRouter;
-export default UseExtendedRouter;

+ 10 - 17
packages/dashboard/src/main.tsx

@@ -1,22 +1,19 @@
-import { AuthProvider, useAuth } from '@/auth.js';
+import { AppProviders, queryClient, router } from '@/app-providers.js';
+import { useAuth } from '@/auth.js';
 import { useDashboardExtensions } from '@/framework/internal/extension-api/use-dashboard-extensions.js';
-import UseExtendedRouter from '@/framework/internal/page/use-extended-router.js';
-import { defaultLocale, dynamicActivate, I18nProvider } from '@/i18n/i18n-provider.js';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { RouterProvider } from '@tanstack/react-router';
-import { router } from '@/router.js';
+import { useExtendedRouter } from '@/framework/internal/page/use-extended-router.js';
+import { defaultLocale, dynamicActivate } from '@/i18n/i18n-provider.js';
 
 import '@/framework/defaults.js';
+import { RouterProvider } from '@tanstack/react-router';
 import React, { useEffect } from 'react';
 import ReactDOM from 'react-dom/client';
 import './styles.css';
 
-const queryClient = new QueryClient();
-
 function InnerApp() {
     const auth = useAuth();
-    const extendedRouter = UseExtendedRouter(router);
-    return <RouterProvider router={extendedRouter} context={{ auth }} />;
+    const extendedRouter = useExtendedRouter(router);
+    return <RouterProvider router={extendedRouter} context={{ auth, queryClient }} />;
 }
 
 function App() {
@@ -31,13 +28,9 @@ function App() {
     return (
         i18nLoaded &&
         extensionsLoaded && (
-            <I18nProvider>
-                <QueryClientProvider client={queryClient}>
-                    <AuthProvider>
-                        <InnerApp />
-                    </AuthProvider>
-                </QueryClientProvider>
-            </I18nProvider>
+            <AppProviders>
+                <InnerApp />
+            </AppProviders>
         )
     );
 }

+ 23 - 35
packages/dashboard/src/routeTree.gen.ts

@@ -17,7 +17,7 @@ import { Route as AuthenticatedImport } from './routes/_authenticated';
 import { Route as AuthenticatedIndexImport } from './routes/_authenticated/index';
 import { Route as AuthenticatedProductsImport } from './routes/_authenticated/products';
 import { Route as AuthenticatedDashboardImport } from './routes/_authenticated/dashboard';
-import { Route as AuthenticatedProductsIdImport } from './routes/_authenticated/products.$id';
+import { Route as AuthenticatedProductsIdImport } from './routes/_authenticated/products_.$id';
 
 // Create/Update Routes
 
@@ -57,9 +57,9 @@ const AuthenticatedDashboardRoute = AuthenticatedDashboardImport.update({
 } as any);
 
 const AuthenticatedProductsIdRoute = AuthenticatedProductsIdImport.update({
-    id: '/$id',
-    path: '/$id',
-    getParentRoute: () => AuthenticatedProductsRoute,
+    id: '/products_/$id',
+    path: '/products/$id',
+    getParentRoute: () => AuthenticatedRoute,
 } as any);
 
 // Populate the FileRoutesByPath interface
@@ -108,40 +108,30 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedIndexImport;
             parentRoute: typeof AuthenticatedImport;
         };
-        '/_authenticated/products/$id': {
-            id: '/_authenticated/products/$id';
-            path: '/$id';
+        '/_authenticated/products_/$id': {
+            id: '/_authenticated/products_/$id';
+            path: '/products/$id';
             fullPath: '/products/$id';
             preLoaderRoute: typeof AuthenticatedProductsIdImport;
-            parentRoute: typeof AuthenticatedProductsImport;
+            parentRoute: typeof AuthenticatedImport;
         };
     }
 }
 
 // Create and export the route tree
 
-interface AuthenticatedProductsRouteChildren {
-    AuthenticatedProductsIdRoute: typeof AuthenticatedProductsIdRoute;
-}
-
-const AuthenticatedProductsRouteChildren: AuthenticatedProductsRouteChildren = {
-    AuthenticatedProductsIdRoute: AuthenticatedProductsIdRoute,
-};
-
-const AuthenticatedProductsRouteWithChildren = AuthenticatedProductsRoute._addFileChildren(
-    AuthenticatedProductsRouteChildren,
-);
-
 interface AuthenticatedRouteChildren {
     AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute;
-    AuthenticatedProductsRoute: typeof AuthenticatedProductsRouteWithChildren;
+    AuthenticatedProductsRoute: typeof AuthenticatedProductsRoute;
     AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
+    AuthenticatedProductsIdRoute: typeof AuthenticatedProductsIdRoute;
 }
 
 const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
-    AuthenticatedProductsRoute: AuthenticatedProductsRouteWithChildren,
+    AuthenticatedProductsRoute: AuthenticatedProductsRoute,
     AuthenticatedIndexRoute: AuthenticatedIndexRoute,
+    AuthenticatedProductsIdRoute: AuthenticatedProductsIdRoute,
 };
 
 const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(AuthenticatedRouteChildren);
@@ -151,7 +141,7 @@ export interface FileRoutesByFullPath {
     '/about': typeof AboutRoute;
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
-    '/products': typeof AuthenticatedProductsRouteWithChildren;
+    '/products': typeof AuthenticatedProductsRoute;
     '/': typeof AuthenticatedIndexRoute;
     '/products/$id': typeof AuthenticatedProductsIdRoute;
 }
@@ -160,7 +150,7 @@ export interface FileRoutesByTo {
     '/about': typeof AboutRoute;
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
-    '/products': typeof AuthenticatedProductsRouteWithChildren;
+    '/products': typeof AuthenticatedProductsRoute;
     '/': typeof AuthenticatedIndexRoute;
     '/products/$id': typeof AuthenticatedProductsIdRoute;
 }
@@ -171,9 +161,9 @@ export interface FileRoutesById {
     '/about': typeof AboutRoute;
     '/login': typeof LoginRoute;
     '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute;
-    '/_authenticated/products': typeof AuthenticatedProductsRouteWithChildren;
+    '/_authenticated/products': typeof AuthenticatedProductsRoute;
     '/_authenticated/': typeof AuthenticatedIndexRoute;
-    '/_authenticated/products/$id': typeof AuthenticatedProductsIdRoute;
+    '/_authenticated/products_/$id': typeof AuthenticatedProductsIdRoute;
 }
 
 export interface FileRouteTypes {
@@ -189,7 +179,7 @@ export interface FileRouteTypes {
         | '/_authenticated/dashboard'
         | '/_authenticated/products'
         | '/_authenticated/'
-        | '/_authenticated/products/$id';
+        | '/_authenticated/products_/$id';
     fileRoutesById: FileRoutesById;
 }
 
@@ -223,7 +213,8 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "children": [
         "/_authenticated/dashboard",
         "/_authenticated/products",
-        "/_authenticated/"
+        "/_authenticated/",
+        "/_authenticated/products_/$id"
       ]
     },
     "/about": {
@@ -238,18 +229,15 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
     },
     "/_authenticated/products": {
       "filePath": "_authenticated/products.tsx",
-      "parent": "/_authenticated",
-      "children": [
-        "/_authenticated/products/$id"
-      ]
+      "parent": "/_authenticated"
     },
     "/_authenticated/": {
       "filePath": "_authenticated/index.tsx",
       "parent": "/_authenticated"
     },
-    "/_authenticated/products/$id": {
-      "filePath": "_authenticated/products.$id.tsx",
-      "parent": "/_authenticated/products"
+    "/_authenticated/products_/$id": {
+      "filePath": "_authenticated/products_.$id.tsx",
+      "parent": "/_authenticated"
     }
   }
 }

+ 0 - 19
packages/dashboard/src/router.ts

@@ -1,19 +0,0 @@
-import { routeTree } from '@/routeTree.gen.js';
-import { createRouter } from '@tanstack/react-router';
-
-export const router = createRouter({
-    routeTree,
-    defaultPreload: 'intent',
-    scrollRestoration: true,
-    context: {
-        /* eslint-disable @typescript-eslint/no-non-null-assertion */
-        auth: undefined!, // This will be set after we wrap the app in an AuthProvider
-    },
-});
-
-// Register things for typesafety
-declare module '@tanstack/react-router' {
-    interface Register {
-        router: typeof router;
-    }
-}

+ 3 - 34
packages/dashboard/src/routes/_authenticated.tsx

@@ -1,15 +1,8 @@
 import { AppSidebar } from '@/components/app-sidebar.js';
-import {
-    Breadcrumb,
-    BreadcrumbItem,
-    BreadcrumbLink,
-    BreadcrumbList,
-    BreadcrumbPage,
-    BreadcrumbSeparator,
-} from '@/components/ui/breadcrumb.js';
+import { GeneratedBreadcrumbs } from '@/components/generated-breadcrumbs.js';
 import { Separator } from '@/components/ui/separator.js';
 import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar.js';
-import { createFileRoute, Link, Outlet, redirect, useRouterState } from '@tanstack/react-router';
+import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
 import * as React from 'react';
 
 export const AUTHENTICATED_ROUTE_PREFIX = '/_authenticated';
@@ -32,15 +25,6 @@ export const Route = createFileRoute(AUTHENTICATED_ROUTE_PREFIX)({
 });
 
 function AuthLayout() {
-    const matches = useRouterState({ select: s => s.matches });
-    const breadcrumbs = matches
-        .filter(match => match.loaderData?.breadcrumb)
-        .map(({ pathname, loaderData }) => {
-            return {
-                title: loaderData.breadcrumb,
-                path: pathname,
-            };
-        });
     return (
         <SidebarProvider>
             <AppSidebar />
@@ -49,22 +33,7 @@ function AuthLayout() {
                     <div className="flex items-center gap-2 px-4">
                         <SidebarTrigger className="-ml-1" />
                         <Separator orientation="vertical" className="mr-2 h-4" />
-                        <Breadcrumb>
-                            <BreadcrumbList>
-                                {breadcrumbs.map(({ title, path }, index, arr) => (
-                                    <>
-                                        <BreadcrumbItem key={index} className="hidden md:block">
-                                            <BreadcrumbLink asChild>
-                                                <Link to={path}>{title}</Link>
-                                            </BreadcrumbLink>
-                                        </BreadcrumbItem>
-                                        {index < arr.length - 1 && (
-                                            <BreadcrumbSeparator className="hidden md:block" />
-                                        )}
-                                    </>
-                                ))}
-                            </BreadcrumbList>
-                        </Breadcrumb>
+                        <GeneratedBreadcrumbs />
                     </div>
                 </header>
                 <Outlet />

+ 0 - 10
packages/dashboard/src/routes/_authenticated/products.$id.tsx

@@ -1,10 +0,0 @@
-import { createFileRoute } from '@tanstack/react-router';
-import React from 'react';
-
-export const Route = createFileRoute('/_authenticated/products/$id')({
-    component: ProductDetailPage,
-});
-
-export function ProductDetailPage() {
-    return <div>Product Detail Page</div>;
-}

+ 13 - 5
packages/dashboard/src/routes/_authenticated/products.tsx

@@ -1,13 +1,12 @@
+import { Button } from '@/components/ui/button.js';
 import { ListPage } from '@/framework/internal/page/list-page.js';
 import { graphql } from '@/graphql/graphql.js';
-import { createFileRoute } from '@tanstack/react-router';
+import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
 import React from 'react';
 
 export const Route = createFileRoute('/_authenticated/products')({
     component: ProductListPage,
-    loader: () => ({
-        breadcrumb: 'Products',
-    }),
+    loader: () => ({ breadcrumb: 'Products' }),
 });
 
 const productListDocument = graphql(`
@@ -35,7 +34,16 @@ export function ProductListPage() {
         <ListPage
             title="Products"
             customizeColumns={{
-                name: { header: 'Product Name' },
+                name: {
+                    header: 'Product Name',
+                    cell: ({ row }) => {
+                        return (
+                            <Link to={`./${row.original.id}`}>
+                                <Button variant="ghost">{row.original.name}</Button>
+                            </Link>
+                        );
+                    },
+                },
             }}
             onSearchTermChange={searchTerm => {
                 return {

+ 40 - 0
packages/dashboard/src/routes/_authenticated/products_.$id.tsx

@@ -0,0 +1,40 @@
+import { DetailPage, getDetailQueryOptions } from '@/framework/internal/page/detail-page.js';
+import { graphql } from '@/graphql/graphql.js';
+import { useSuspenseQuery } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
+import React from 'react';
+
+export const Route = createFileRoute('/_authenticated/products_/$id')({
+    component: ProductDetailPage,
+    loader: async ({ context, params }) => {
+        const result = await context.queryClient.ensureQueryData(
+            getDetailQueryOptions(productDetailDocument, { id: params.id }),
+        );
+        return { breadcrumb: [{ path: '/products', label: 'Products' }, result.product.name] };
+    },
+});
+
+const productDetailDocument = graphql(`
+    query ProductDetail($id: ID!) {
+        product(id: $id) {
+            id
+            createdAt
+            updatedAt
+            enabled
+            name
+            slug
+            description
+            featuredAsset {
+                id
+                preview
+            }
+        }
+    }
+`);
+
+export function ProductDetailPage() {
+    const params = Route.useParams();
+    const detailQuery = useSuspenseQuery(getDetailQueryOptions(productDetailDocument, { id: params.id }));
+    const entity = detailQuery.data;
+    return <DetailPage title={entity.product?.name ?? ''} route={Route} entity={entity}></DetailPage>;
+}