Просмотр исходного кода

feat(dashboard): Implement auto list page generation from extension

Michael Bromley 10 месяцев назад
Родитель
Сommit
9cd96943b3
25 измененных файлов с 476 добавлено и 255 удалено
  1. 62 72
      packages/dashboard/src/components/app-sidebar.tsx
  2. 9 8
      packages/dashboard/src/components/nav-main.tsx
  3. 1 1
      packages/dashboard/src/framework/defaults.ts
  4. 2 1
      packages/dashboard/src/framework/internal/data-table/data-table-column-header.tsx
  5. 34 0
      packages/dashboard/src/framework/internal/extension-api/define-dashboard-extension.ts
  6. 24 0
      packages/dashboard/src/framework/internal/extension-api/extension-api-types.ts
  7. 26 0
      packages/dashboard/src/framework/internal/extension-api/use-dashboard-extensions.ts
  8. 29 7
      packages/dashboard/src/framework/internal/nav-menu/nav-menu.ts
  9. 34 11
      packages/dashboard/src/framework/internal/page/list-page.tsx
  10. 9 0
      packages/dashboard/src/framework/internal/page/page-api.ts
  11. 6 0
      packages/dashboard/src/framework/internal/page/page-types.ts
  12. 0 11
      packages/dashboard/src/framework/internal/page/page.ts
  13. 79 0
      packages/dashboard/src/framework/internal/page/use-extended-router.tsx
  14. 0 20
      packages/dashboard/src/framework/internal/plugin-api/plugin-api.ts
  15. 2 2
      packages/dashboard/src/index.ts
  16. 24 3
      packages/dashboard/src/lib/utils.ts
  17. 11 25
      packages/dashboard/src/main.tsx
  18. 19 0
      packages/dashboard/src/router.ts
  19. 3 1
      packages/dashboard/src/routes/_authenticated.tsx
  20. 0 3
      packages/dashboard/src/routes/_authenticated/products.tsx
  21. 1 1
      packages/dashboard/src/styles.css
  22. 47 48
      packages/dashboard/vite/vite-plugin-admin-api-schema.ts
  23. 2 41
      packages/dev-server/test-plugins/reviews/dashboard/index.tsx
  24. 47 0
      packages/dev-server/test-plugins/reviews/dashboard/review-list.tsx
  25. 5 0
      packages/dev-server/test-plugins/reviews/dashboard/tsconfig.json

+ 62 - 72
packages/dashboard/src/components/app-sidebar.tsx

@@ -1,82 +1,72 @@
-import { getNavMenuConfig } from '@/framework/internal/nav-menu/nav-menu.js';
-import * as React from 'react';
-import {
-    AudioWaveform,
-    BookOpen,
-    Bot,
-    Command,
-    Frame,
-    GalleryVerticalEnd,
-    Map,
-    PieChart,
-    Settings2,
-    SquareTerminal,
-} from 'lucide-react';
-
 import { NavMain } from '@/components/nav-main';
 import { NavProjects } from '@/components/nav-projects';
 import { NavUser } from '@/components/nav-user';
 import { TeamSwitcher } from '@/components/team-switcher';
 import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail } from '@/components/ui/sidebar';
-
-// This is sample data.
-const data = {
-    user: {
-        name: 'shadcn',
-        email: 'm@example.com',
-        avatar: '/avatars/shadcn.jpg',
-    },
-    teams: [
-        {
-            name: 'Acme Inc',
-            logo: GalleryVerticalEnd,
-            plan: 'Enterprise',
-        },
-        {
-            name: 'Acme Corp.',
-            logo: AudioWaveform,
-            plan: 'Startup',
-        },
-        {
-            name: 'Evil Corp.',
-            logo: Command,
-            plan: 'Free',
-        },
-    ],
-    navMain: getNavMenuConfig().items,
-    projects: [
-        {
-            name: 'Design Engineering',
-            url: '#',
-            icon: Frame,
-        },
-        {
-            name: 'Sales & Marketing',
-            url: '#',
-            icon: PieChart,
-        },
-        {
-            name: 'Travel',
-            url: '#',
-            icon: Map,
-        },
-    ],
-};
+import { getNavMenuConfig } from '@/framework/internal/nav-menu/nav-menu.js';
+import { useDashboardExtensions } from '@/framework/internal/extension-api/use-dashboard-extensions.js';
+import { AudioWaveform, Command, Frame, GalleryVerticalEnd, Map, PieChart } from 'lucide-react';
+import * as React from 'react';
 
 export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
+    const { extensionsLoaded } = useDashboardExtensions();
+    const data = {
+        user: {
+            name: 'shadcn',
+            email: 'm@example.com',
+            avatar: '/avatars/shadcn.jpg',
+        },
+        teams: [
+            {
+                name: 'Acme Inc',
+                logo: GalleryVerticalEnd,
+                plan: 'Enterprise',
+            },
+            {
+                name: 'Acme Corp.',
+                logo: AudioWaveform,
+                plan: 'Startup',
+            },
+            {
+                name: 'Evil Corp.',
+                logo: Command,
+                plan: 'Free',
+            },
+        ],
+        navMain: getNavMenuConfig().sections,
+        projects: [
+            {
+                name: 'Design Engineering',
+                url: '#',
+                icon: Frame,
+            },
+            {
+                name: 'Sales & Marketing',
+                url: '#',
+                icon: PieChart,
+            },
+            {
+                name: 'Travel',
+                url: '#',
+                icon: Map,
+            },
+        ],
+    };
     return (
-        <Sidebar collapsible="icon" {...props}>
-            <SidebarHeader>
-                <TeamSwitcher teams={data.teams} />
-            </SidebarHeader>
-            <SidebarContent>
-                <NavMain items={data.navMain} />
-                <NavProjects projects={data.projects} />
-            </SidebarContent>
-            <SidebarFooter>
-                <NavUser user={data.user} />
-            </SidebarFooter>
-            <SidebarRail />
-        </Sidebar>
+        extensionsLoaded && (
+            <Sidebar collapsible="icon" {...props}>
+                <SidebarHeader>
+                    <TeamSwitcher teams={data.teams} />
+                </SidebarHeader>
+                <SidebarContent>
+                    <NavMain items={data.navMain} />
+                    <NavProjects projects={data.projects} />
+                </SidebarContent>
+                <SidebarFooter>
+                    <NavUser user={data.user} />
+                </SidebarFooter>
+                <SidebarRail />
+            </Sidebar>
+        )
     );
 }

+ 9 - 8
packages/dashboard/src/components/nav-main.tsx

@@ -1,9 +1,3 @@
-'use client';
-
-import { NavMenuItem } from '@/framework/internal/nav-menu/nav-menu.js';
-import { Link } from '@tanstack/react-router';
-import { ChevronRight, type LucideIcon } from 'lucide-react';
-
 import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
 import {
     SidebarGroup,
@@ -15,8 +9,12 @@ import {
     SidebarMenuSubButton,
     SidebarMenuSubItem,
 } from '@/components/ui/sidebar';
+import { NavMenuSection } from '@/framework/internal/nav-menu/nav-menu.js';
+import { Link, rootRouteId, useLocation, useMatch } from '@tanstack/react-router';
+import { ChevronRight } from 'lucide-react';
 
-export function NavMain({ items }: { items: NavMenuItem[] }) {
+export function NavMain({ items }: { items: NavMenuSection[] }) {
+    const location = useLocation();
     return (
         <SidebarGroup>
             <SidebarGroupLabel>Platform</SidebarGroupLabel>
@@ -40,7 +38,10 @@ export function NavMain({ items }: { items: NavMenuItem[] }) {
                                 <SidebarMenuSub>
                                     {item.items?.map(subItem => (
                                         <SidebarMenuSubItem key={subItem.title}>
-                                            <SidebarMenuSubButton asChild>
+                                            <SidebarMenuSubButton
+                                                asChild
+                                                isActive={location.pathname === subItem.url}
+                                            >
                                                 <Link to={subItem.url}>
                                                     <span>{subItem.title}</span>
                                                 </Link>

+ 1 - 1
packages/dashboard/src/framework/defaults.ts

@@ -2,7 +2,7 @@ import { navMenu } from '@/framework/internal/nav-menu/nav-menu.js';
 import { BookOpen, Bot, Settings2, SquareTerminal } from 'lucide-react';
 
 navMenu({
-    items: [
+    sections: [
         {
             id: 'catalog',
             title: 'Catalog',

+ 2 - 1
packages/dashboard/src/framework/internal/data-table/data-table-column-header.tsx

@@ -15,6 +15,7 @@ import {
 } from '@/components/ui/dialog.js';
 import { DataTableFilterDialog } from '@/framework/internal/data-table/data-table-filter-dialog.js';
 import { FieldInfo } from '@/framework/internal/document-introspection/get-document-structure.js';
+import { camelCaseToTitleCase } from '@/lib/utils.js';
 import { Trans } from '@lingui/react/macro';
 import { ColumnDef, HeaderContext } from '@tanstack/table-core';
 import { ArrowDown, ArrowUp, ArrowUpDown, EllipsisVertical, Filter } from 'lucide-react';
@@ -31,7 +32,7 @@ export function DataTableColumnHeader({ headerContext, customConfig }: DataTable
     const isFilterable = column.getCanFilter();
 
     const customHeader = customConfig.header;
-    let display = column.id;
+    let display = camelCaseToTitleCase(column.id);
     if (typeof customHeader === 'function') {
         display = customHeader(headerContext);
     } else if (typeof customHeader === 'string') {

+ 34 - 0
packages/dashboard/src/framework/internal/extension-api/define-dashboard-extension.ts

@@ -0,0 +1,34 @@
+import { DashboardExtension } from '@/framework/internal/extension-api/extension-api-types.js';
+import { addNavMenuItem, NavMenuItem } from '@/framework/internal/nav-menu/nav-menu.js';
+import { registerListView } from '@/framework/internal/page/page-api.js';
+
+const extensionSourceChangeCallbacks = new Set<() => void>();
+
+export function onExtensionSourceChange(callback: () => void) {
+    extensionSourceChangeCallbacks.add(callback);
+}
+
+export function defineDashboardExtension(extension: DashboardExtension) {
+    if (extension.routes) {
+        for (const route of extension.routes) {
+            if (route.navMenuItem) {
+                // Add the nav menu item
+                const item: NavMenuItem = {
+                    url: route.navMenuItem.url ?? route.path,
+                    id: route.navMenuItem.id ?? route.id,
+                    title: route.navMenuItem.title ?? route.title,
+                };
+                addNavMenuItem(item, route.navMenuItem.sectionId);
+            }
+            if (route.listQuery) {
+                // Configure a list page
+                registerListView(route);
+            }
+        }
+    }
+    if (extensionSourceChangeCallbacks.size) {
+        for (const callback of extensionSourceChangeCallbacks) {
+            callback();
+        }
+    }
+}

+ 24 - 0
packages/dashboard/src/framework/internal/extension-api/extension-api-types.ts

@@ -0,0 +1,24 @@
+import { NavMenuItem } from '@/framework/internal/nav-menu/nav-menu.js';
+import { ListPageProps, ListQueryOptionsShape, ListQueryShape } from '@/framework/internal/page/list-page.js';
+import { TypedDocumentNode } from '@graphql-typed-document-node/core';
+
+export interface DashboardBaseRouteDefinition {
+    id: string;
+    navMenuItem?: Partial<NavMenuItem> & { sectionId: string };
+    title: string;
+}
+
+export interface DashboardListRouteDefinition<
+    T extends TypedDocumentNode<U, V> = TypedDocumentNode<any, any>,
+    U extends ListQueryShape = any,
+    V extends ListQueryOptionsShape = any,
+> extends DashboardBaseRouteDefinition,
+        Omit<ListPageProps<T, U, V>, 'route'> {
+    path: string;
+}
+
+export type DashboardRouteDefinition = DashboardListRouteDefinition;
+
+export interface DashboardExtension {
+    routes: DashboardRouteDefinition[];
+}

+ 26 - 0
packages/dashboard/src/framework/internal/extension-api/use-dashboard-extensions.ts

@@ -0,0 +1,26 @@
+import { onExtensionSourceChange } from '@/framework/internal/extension-api/define-dashboard-extension.js';
+import { useEffect, useState } from 'react';
+import { runDashboardExtensions } from 'virtual:dashboard-extensions';
+
+/**
+ * @description
+ * This hook is used to load dashboard extensions via the `virtual:dashboard-extensions` module,
+ * which is provided by the `vite-plugin-dashboard-metadata` plugin.
+ *
+ * It should be used in any component whose rendering depends on the content of the dashboard extensions.
+ */
+export function useDashboardExtensions() {
+    const [extensionsLoaded, setExtensionsLoaded] = useState(false);
+    const [reloadCount, setReloadCount] = useState(0);
+
+    useEffect(() => {
+        void runDashboardExtensions().then(() => setExtensionsLoaded(true));
+        onExtensionSourceChange(() => {
+            // Setting this state var is only really done
+            // in order to force a re-render of components using this hook.
+            // This allows components to react to HMR events during development.
+            setReloadCount(old => old + 1);
+        });
+    }, []);
+    return { extensionsLoaded, reloadCount };
+}

+ 29 - 7
packages/dashboard/src/framework/internal/nav-menu/nav-menu.ts

@@ -1,27 +1,49 @@
 import type { LucideIcon } from 'lucide-react';
 
 export interface NavMenuItem {
+    id: string;
+    title: string;
+    url: string;
+}
+
+export interface NavMenuSection {
     title: string;
     id: string;
     icon?: LucideIcon;
     defaultOpen?: boolean;
-    items?: Array<{
-        id: string;
-        title: string;
-        url: string;
-    }>;
+    items?: NavMenuItem[];
 }
 
 export interface NavMenuConfig {
-    items: NavMenuItem[];
+    sections: NavMenuSection[];
 }
 
-let navMenuConfig: NavMenuConfig = { items: [] };
+let navMenuConfig: NavMenuConfig = { sections: [] };
 
 export function navMenu(config: NavMenuConfig) {
     navMenuConfig = config;
 }
 
+export function addNavMenuItem(item: NavMenuItem, sectionId: string) {
+    navMenuConfig.sections = [...navMenuConfig.sections];
+    const sectionIndex = navMenuConfig.sections.findIndex(s => s.id === sectionId);
+    if (sectionIndex !== -1) {
+        const section = {
+            ...navMenuConfig.sections[sectionIndex],
+            items: [...(navMenuConfig.sections[sectionIndex]?.items ?? [])],
+        };
+        const itemIndex = section.items.findIndex(i => i.id === item.id);
+        if (itemIndex === -1) {
+            section.items.push(item);
+            navMenuConfig.sections.splice(sectionIndex, 1, section);
+        } else {
+            section.items.splice(itemIndex, 1, item);
+        }
+
+        navMenuConfig.sections.splice(sectionIndex, 1, section);
+    }
+}
+
 export function getNavMenuConfig() {
     return navMenuConfig;
 }

+ 34 - 11
packages/dashboard/src/framework/internal/page/list-page.tsx

@@ -2,10 +2,12 @@ import { useComponentRegistry } from '@/framework/internal/component-registry/co
 import { DataTableColumnHeader } from '@/framework/internal/data-table/data-table-column-header.js';
 import { DataTable } from '@/framework/internal/data-table/data-table.js';
 import {
+    FieldInfo,
     getListQueryFields,
     getQueryName,
 } from '@/framework/internal/document-introspection/get-document-structure.js';
 import { useListQueryFields } from '@/framework/internal/document-introspection/hooks.js';
+import { PageProps } from '@/framework/internal/page/page-types.js';
 import { api } from '@/graphql/api.js';
 import { useDebounce } from 'use-debounce';
 
@@ -57,13 +59,10 @@ export interface ListPageProps<
     T extends TypedDocumentNode<U, V>,
     U extends ListQueryShape,
     V extends ListQueryOptionsShape,
-> {
-    title: string;
+> extends PageProps {
     listQuery: T;
     onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
-    route: AnyRoute;
     customizeColumns?: CustomizeColumnConfig<T>;
-    // TODO: not yet implemented
     defaultColumnOrder?: (keyof ListQueryFields<T>)[];
     defaultVisibility?: Partial<Record<keyof ListQueryFields<T>, boolean>>;
 }
@@ -76,11 +75,12 @@ export function ListPage<
     title,
     listQuery,
     customizeColumns,
-    route,
+    route: routeOrFn,
     defaultVisibility,
     onSearchTermChange,
 }: ListPageProps<T, U, V>) {
     const { getComponent } = useComponentRegistry();
+    const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
     const routeSearch = route.useSearch();
     const navigate = useNavigate<AnyRouter>({ from: route.fullPath });
     const [searchTerm, setSearchTerm] = React.useState<string>('');
@@ -156,6 +156,9 @@ export function ListPage<
                     if (Cmp) {
                         return <Cmp value={value} />;
                     }
+                    if (value !== null && typeof value === 'object') {
+                        return JSON.stringify(value);
+                    }
                     return value;
                 },
                 header: headerContext => {
@@ -168,12 +171,7 @@ export function ListPage<
         });
     }, [fields, customizeColumns]);
 
-    const columnVisibility = {
-        id: false,
-        createdAt: false,
-        updatedAt: false,
-        ...(defaultVisibility ?? {}),
-    };
+    const columnVisibility = getColumnVisibility(fields, defaultVisibility);
 
     function sortToString(sortingStates?: SortingState) {
         return sortingStates?.map(s => `${s.desc ? '-' : ''}${s.id}`).join(',');
@@ -224,3 +222,28 @@ export function ListPage<
         </div>
     );
 }
+
+/**
+ * Returns the default column visibility configuration.
+ *
+ * If the user specifies a `defaultVisibility` object with only true values, we will
+ * assume all the other columns should be false.
+ *
+ * If the user specifies a `defaultVisibility` object with only false values, we will
+ * assume all the other columns should be true.
+ */
+function getColumnVisibility(
+    fields: FieldInfo[],
+    defaultVisibility?: Record<string, boolean>,
+): Record<string, boolean> {
+    const allDefaultsTrue = defaultVisibility && Object.values(defaultVisibility).every(v => v === true);
+    const allDefaultsFalse = defaultVisibility && Object.values(defaultVisibility).every(v => v === false);
+    return {
+        id: false,
+        createdAt: false,
+        updatedAt: false,
+        ...(allDefaultsTrue ? { ...Object.fromEntries(fields.map(f => [f.name, false])) } : {}),
+        ...(allDefaultsFalse ? { ...Object.fromEntries(fields.map(f => [f.name, true])) } : {}),
+        ...defaultVisibility,
+    };
+}

+ 9 - 0
packages/dashboard/src/framework/internal/page/page-api.ts

@@ -0,0 +1,9 @@
+import { DashboardListRouteDefinition } from '@/framework/internal/extension-api/extension-api-types.js';
+
+export const listViewExtensionRoutes = new Map<string, DashboardListRouteDefinition>();
+
+export function registerListView(config: DashboardListRouteDefinition) {
+    if (config.path) {
+        listViewExtensionRoutes.set(config.path, config);
+    }
+}

+ 6 - 0
packages/dashboard/src/framework/internal/page/page-types.ts

@@ -0,0 +1,6 @@
+import { AnyRoute } from '@tanstack/react-router';
+
+export interface PageProps {
+    title: string;
+    route: AnyRoute | (() => AnyRoute);
+}

+ 0 - 11
packages/dashboard/src/framework/internal/page/page.ts

@@ -1,11 +0,0 @@
-import { DocumentNode } from 'graphql';
-
-export interface PageConfig {
-    title: string;
-}
-
-export interface ListViewConfig extends PageConfig {
-    listQuery: DocumentNode;
-}
-
-// export function defineListView(config: ListViewConfig) {}

+ 79 - 0
packages/dashboard/src/framework/internal/page/use-extended-router.tsx

@@ -0,0 +1,79 @@
+import { useDashboardExtensions } from '@/framework/internal/extension-api/use-dashboard-extensions.js';
+import { ListPage } from '@/framework/internal/page/list-page.js';
+import { listViewExtensionRoutes } from '@/framework/internal/page/page-api.js';
+import { AUTHENTICATED_ROUTE_PREFIX } from '@/routes/_authenticated.js';
+import { AnyRoute, createRoute, Router } from '@tanstack/react-router';
+import { useMemo } from 'react';
+
+/**
+ * Extends the TanStack Router with additional routes for each dashboard
+ * extension.
+ */
+const UseExtendedRouter = (router: Router<AnyRoute, any, any>) => {
+    const { extensionsLoaded } = useDashboardExtensions();
+
+    return useMemo(() => {
+        if (!extensionsLoaded) {
+            return router;
+        }
+
+        const authenticatedRouteIndex = router.routeTree.children.findIndex(
+            (r: AnyRoute) => r.id === AUTHENTICATED_ROUTE_PREFIX,
+        );
+
+        if (authenticatedRouteIndex === -1) {
+            return router;
+        }
+
+        let authenticatedRoute: AnyRoute = router.routeTree.children[authenticatedRouteIndex];
+
+        const newRoutes: AnyRoute[] = [];
+        // Create new routes for each extension
+        for (const [path, config] of listViewExtensionRoutes.entries()) {
+            const pathWithoutLeadingSlash = path.startsWith('/') ? path.slice(1) : path;
+            if (
+                authenticatedRoute.children.findIndex((r: AnyRoute) => r.path === pathWithoutLeadingSlash) >
+                -1
+            ) {
+                // Skip if the route already exists
+                continue;
+            }
+
+            const newRoute = createRoute({
+                path: `/${pathWithoutLeadingSlash}`,
+                getParentRoute: () => authenticatedRoute,
+                component: () => (
+                    <ListPage
+                        title={config.title}
+                        listQuery={config.listQuery}
+                        defaultVisibility={config.defaultVisibility}
+                        customizeColumns={config.customizeColumns}
+                        onSearchTermChange={config.onSearchTermChange}
+                        defaultColumnOrder={config.defaultColumnOrder}
+                        route={() => newRoute}
+                    />
+                ),
+            });
+            newRoutes.push(newRoute);
+        }
+
+        const childrenWithoutAuthenticated = router.routeTree.children.filter(
+            (r: AnyRoute) => r.id !== AUTHENTICATED_ROUTE_PREFIX,
+        );
+
+        // Create a new router with the modified route tree
+        const newRouter = new Router({
+            routeTree: router.routeTree.addChildren([
+                ...childrenWithoutAuthenticated,
+                authenticatedRoute.addChildren([...authenticatedRoute.children, ...newRoutes]),
+            ]),
+            basepath: router.basepath,
+            defaultPreload: router.options.defaultPreload,
+            defaultPreloadDelay: router.options.defaultPreloadDelay,
+        });
+        return newRouter;
+    }, [router, extensionsLoaded]);
+};
+
+// const UseExtendedRouter = UseExtendedRouter;
+export default UseExtendedRouter;

+ 0 - 20
packages/dashboard/src/framework/internal/plugin-api/plugin-api.ts

@@ -1,20 +0,0 @@
-import { DocumentNode } from 'graphql';
-
-export interface DashboardBaseRouteDefinition {
-    id: string;
-    title?: string;
-}
-
-export interface DashboardListRouteDefinition extends DashboardBaseRouteDefinition {
-    listQuery: DocumentNode;
-}
-
-export type DashboardRouteDefinition = DashboardListRouteDefinition;
-
-export interface DashboardExtension {
-    routes: DashboardRouteDefinition[];
-}
-
-export function defineDashboardExtension(extension: DashboardExtension) {
-    return extension;
-}

+ 2 - 2
packages/dashboard/src/index.ts

@@ -1,2 +1,2 @@
-export * from './framework/internal/plugin-api/plugin-api.js';
-export const test = 'test';
+export * from './framework/internal/extension-api/define-dashboard-extension.js';
+export * from '@/framework/internal/extension-api/extension-api-types.js';

+ 24 - 3
packages/dashboard/src/lib/utils.ts

@@ -1,6 +1,27 @@
-import { clsx, type ClassValue } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
 
 export function cn(...inputs: ClassValue[]) {
-  return twMerge(clsx(inputs))
+    return twMerge(clsx(inputs));
+}
+
+/**
+ * Converts a camelCase string to Title Case.
+ * Examples:
+ *   "firstName" -> "First Name"
+ *   "dateOfBirth" -> "Date Of Birth"
+ *   "totalItems" -> "Total Items"
+ */
+export function camelCaseToTitleCase(text: string): string {
+    if (!text) return '';
+
+    return (
+        text
+            // Insert space before capital letters
+            .replace(/([A-Z])/g, ' $1')
+            // Capitalize first character
+            .replace(/^./, str => str.toUpperCase())
+            // Handle the case where the string starts with a capital
+            .trim()
+    );
 }

+ 11 - 25
packages/dashboard/src/main.tsx

@@ -1,50 +1,36 @@
 import { AuthProvider, 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 React, { useEffect } from 'react';
-import ReactDOM from 'react-dom/client';
-import { RouterProvider, createRouter } from '@tanstack/react-router';
+import { RouterProvider } from '@tanstack/react-router';
+import { router } from '@/router.js';
 
 import '@/framework/defaults.js';
-import { routeTree } from './routeTree.gen';
+import React, { useEffect } from 'react';
+import ReactDOM from 'react-dom/client';
 import './styles.css';
-import { runDashboardExtensions } from 'virtual:dashboard-extensions';
-
-// Set up a Router instance
-const router = createRouter({
-    routeTree,
-    defaultPreload: 'intent',
-    scrollRestoration: true,
-    context: {
-        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;
-    }
-}
 
 const queryClient = new QueryClient();
 
 function InnerApp() {
     const auth = useAuth();
-    return <RouterProvider router={router} context={{ auth }} />;
+    const extendedRouter = UseExtendedRouter(router);
+    return <RouterProvider router={extendedRouter} context={{ auth }} />;
 }
 
 function App() {
     const [i18nLoaded, setI18nLoaded] = React.useState(false);
+    const { extensionsLoaded } = useDashboardExtensions();
     useEffect(() => {
         // With this method we dynamically load the catalogs
         dynamicActivate(defaultLocale, () => {
             setI18nLoaded(true);
         });
-        runDashboardExtensions();
     }, []);
     return (
-        i18nLoaded && (
+        i18nLoaded &&
+        extensionsLoaded && (
             <I18nProvider>
                 <QueryClientProvider client={queryClient}>
                     <AuthProvider>

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

@@ -0,0 +1,19 @@
+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 - 1
packages/dashboard/src/routes/_authenticated.tsx

@@ -12,7 +12,9 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/s
 import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
 import * as React from 'react';
 
-export const Route = createFileRoute('/_authenticated')({
+export const AUTHENTICATED_ROUTE_PREFIX = '/_authenticated';
+
+export const Route = createFileRoute(AUTHENTICATED_ROUTE_PREFIX)({
     beforeLoad: ({ context, location }) => {
         if (!context.auth.isAuthenticated) {
             throw redirect({

+ 0 - 3
packages/dashboard/src/routes/_authenticated/products.tsx

@@ -34,9 +34,6 @@ export function ProductListPage() {
             customizeColumns={{
                 name: { header: 'Product Name' },
             }}
-            defaultVisibility={{
-                id: true,
-            }}
             onSearchTermChange={searchTerm => {
                 return {
                     name: { contains: searchTerm },

+ 1 - 1
packages/dashboard/src/styles.css

@@ -33,7 +33,7 @@
   --sidebar-foreground: hsl(240 5.3% 26.1%);
   --sidebar-primary: hsl(240 5.9% 10%);
   --sidebar-primary-foreground: hsl(0 0% 98%);
-  --sidebar-accent: hsl(240 4.8% 95.9%);
+  --sidebar-accent: hsl(0, 0%, 92%);
   --sidebar-accent-foreground: hsl(240 5.9% 10%);
   --sidebar-border: hsl(220 13% 91%);
   --sidebar-ring: hsl(217.2 91.2% 59.8%);

+ 47 - 48
packages/dashboard/vite/vite-plugin-admin-api-schema.ts

@@ -6,7 +6,6 @@ import {
     runPluginConfigurations,
     setConfig,
     VENDURE_ADMIN_API_TYPE_PATHS,
-    VendureConfig,
 } from '@vendure/core';
 import {
     buildSchema,
@@ -47,6 +46,53 @@ export interface SchemaInfo {
     scalars: string[];
 }
 
+const virtualModuleId = 'virtual:admin-api-schema';
+const resolvedVirtualModuleId = `\0${virtualModuleId}`;
+
+export function adminApiSchemaPlugin(): Plugin {
+    let configLoaderApi: ConfigLoaderApi;
+    let schemaInfo: SchemaInfo;
+
+    return {
+        name: 'vendure:admin-api-schema',
+        configResolved({ plugins }) {
+            configLoaderApi = getConfigLoaderApi(plugins);
+        },
+        async buildStart() {
+            const vendureConfig = await configLoaderApi.getVendureConfig();
+            if (!schemaInfo) {
+                this.info(`Constructing Admin API schema...`);
+                resetConfig();
+                await setConfig(vendureConfig ?? {});
+
+                const runtimeConfig = await runPluginConfigurations(getConfig() as any);
+                const typesLoader = new GraphQLTypesLoader();
+                const finalSchema = await getFinalVendureSchema({
+                    config: runtimeConfig,
+                    typePaths: VENDURE_ADMIN_API_TYPE_PATHS,
+                    typesLoader,
+                    apiType: 'admin',
+                    output: 'sdl',
+                });
+                const safeSchema = buildSchema(finalSchema);
+                schemaInfo = generateSchemaInfo(safeSchema);
+            }
+        },
+        resolveId(id) {
+            if (id === virtualModuleId) {
+                return resolvedVirtualModuleId;
+            }
+        },
+        load(id) {
+            if (id === resolvedVirtualModuleId) {
+                return `
+                    export const schemaInfo = ${JSON.stringify(schemaInfo)};
+                `;
+            }
+        },
+    };
+}
+
 function getTypeInfo(type: GraphQLType) {
     let nullable = true;
     let list = false;
@@ -101,50 +147,3 @@ function generateSchemaInfo(schema: GraphQLSchema): SchemaInfo {
 
     return result;
 }
-
-const virtualModuleId = 'virtual:admin-api-schema';
-const resolvedVirtualModuleId = `\0${virtualModuleId}`;
-
-export function adminApiSchemaPlugin(): Plugin {
-    let configLoaderApi: ConfigLoaderApi;
-    let schemaInfo: SchemaInfo;
-
-    return {
-        name: 'vendure:admin-api-schema',
-        async configResolved({ plugins }) {
-            configLoaderApi = getConfigLoaderApi(plugins);
-        },
-        async buildStart() {
-            const vendureConfig = await configLoaderApi.getVendureConfig();
-            if (!schemaInfo) {
-                this.info(`Constructing Admin API schema...`);
-                resetConfig();
-                await setConfig(vendureConfig ?? {});
-
-                const runtimeConfig = await runPluginConfigurations(getConfig() as any);
-                const typesLoader = new GraphQLTypesLoader();
-                const finalSchema = await getFinalVendureSchema({
-                    config: runtimeConfig,
-                    typePaths: VENDURE_ADMIN_API_TYPE_PATHS,
-                    typesLoader,
-                    apiType: 'admin',
-                    output: 'sdl',
-                });
-                const safeSchema = buildSchema(finalSchema);
-                schemaInfo = generateSchemaInfo(safeSchema);
-            }
-        },
-        resolveId(id) {
-            if (id === virtualModuleId) {
-                return resolvedVirtualModuleId;
-            }
-        },
-        load(id) {
-            if (id === resolvedVirtualModuleId) {
-                return `
-                    export const schemaInfo = ${JSON.stringify(schemaInfo)};
-                `;
-            }
-        },
-    };
-}

+ 2 - 41
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -1,46 +1,7 @@
 import { defineDashboardExtension } from '@vendure/dashboard';
-import { test } from '@vendure/dashboard';
-import gql from 'graphql-tag';
 
-export function Test() {
-    return <div>{test}</div>;
-}
+import { reviewList } from './review-list';
 
 export default defineDashboardExtension({
-    routes: [
-        {
-            id: 'review-list',
-            title: 'Product Reviews',
-            listQuery: gql`
-                query GetProductReviews {
-                    productReviews {
-                        items {
-                            id
-                            createdAt
-                            updatedAt
-                            product {
-                                id
-                                name
-                            }
-                            productVariant {
-                                id
-                                name
-                                sku
-                            }
-                            summary
-                            body
-                            rating
-                            authorName
-                            authorLocation
-                            upvotes
-                            downvotes
-                            state
-                            response
-                            responseCreatedAt
-                        }
-                    }
-                }
-            `,
-        },
-    ],
+    routes: [reviewList],
 });

+ 47 - 0
packages/dev-server/test-plugins/reviews/dashboard/review-list.tsx

@@ -0,0 +1,47 @@
+import { DashboardListRouteDefinition } from '@vendure/dashboard';
+import gql from 'graphql-tag';
+
+const getReviewList = gql`
+    query GetProductReviews {
+        productReviews {
+            items {
+                id
+                createdAt
+                updatedAt
+                product {
+                    id
+                    name
+                }
+                productVariant {
+                    id
+                    name
+                    sku
+                }
+                summary
+                body
+                rating
+                authorName
+                authorLocation
+                upvotes
+                downvotes
+                state
+                response
+                responseCreatedAt
+            }
+        }
+    }
+`;
+
+export const reviewList: DashboardListRouteDefinition = {
+    id: 'review-list',
+    title: 'Product Reviews!',
+    path: '/reviews',
+    navMenuItem: { sectionId: 'catalog' },
+    defaultVisibility: {
+        product: true,
+        summary: true,
+        rating: true,
+        authorName: true,
+    },
+    listQuery: getReviewList,
+};

+ 5 - 0
packages/dev-server/test-plugins/reviews/dashboard/tsconfig.json

@@ -0,0 +1,5 @@
+{
+  "compilerOptions": {
+    "module": "nodenext",
+  }
+}