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

feat(dashboard): Introspect properties of a PaginatedList query

Michael Bromley пре 11 месеци
родитељ
комит
47a5231555

+ 61 - 152
packages/dashboard/src/components/app-sidebar.tsx

@@ -1,173 +1,82 @@
-import * as React from "react"
+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"
+    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"
+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: [
-    {
-      title: "Playground",
-      url: "#",
-      icon: SquareTerminal,
-      isActive: true,
-      items: [
-        {
-          title: "History",
-          url: "#",
-        },
-        {
-          title: "Starred",
-          url: "#",
-        },
-        {
-          title: "Settings",
-          url: "#",
-        },
-      ],
+    user: {
+        name: 'shadcn',
+        email: 'm@example.com',
+        avatar: '/avatars/shadcn.jpg',
     },
-    {
-      title: "Models",
-      url: "#",
-      icon: Bot,
-      items: [
+    teams: [
         {
-          title: "Genesis",
-          url: "#",
+            name: 'Acme Inc',
+            logo: GalleryVerticalEnd,
+            plan: 'Enterprise',
         },
         {
-          title: "Explorer",
-          url: "#",
+            name: 'Acme Corp.',
+            logo: AudioWaveform,
+            plan: 'Startup',
         },
         {
-          title: "Quantum",
-          url: "#",
+            name: 'Evil Corp.',
+            logo: Command,
+            plan: 'Free',
         },
-      ],
-    },
-    {
-      title: "Documentation",
-      url: "#",
-      icon: BookOpen,
-      items: [
+    ],
+    navMain: getNavMenuConfig().items,
+    projects: [
         {
-          title: "Introduction",
-          url: "#",
+            name: 'Design Engineering',
+            url: '#',
+            icon: Frame,
         },
         {
-          title: "Get Started",
-          url: "#",
+            name: 'Sales & Marketing',
+            url: '#',
+            icon: PieChart,
         },
         {
-          title: "Tutorials",
-          url: "#",
+            name: 'Travel',
+            url: '#',
+            icon: Map,
         },
-        {
-          title: "Changelog",
-          url: "#",
-        },
-      ],
-    },
-    {
-      title: "Settings",
-      url: "#",
-      icon: Settings2,
-      items: [
-        {
-          title: "General",
-          url: "#",
-        },
-        {
-          title: "Team",
-          url: "#",
-        },
-        {
-          title: "Billing",
-          url: "#",
-        },
-        {
-          title: "Limits",
-          url: "#",
-        },
-      ],
-    },
-  ],
-  projects: [
-    {
-      name: "Design Engineering",
-      url: "#",
-      icon: Frame,
-    },
-    {
-      name: "Sales & Marketing",
-      url: "#",
-      icon: PieChart,
-    },
-    {
-      name: "Travel",
-      url: "#",
-      icon: Map,
-    },
-  ],
-}
+    ],
+};
 
 export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
-  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>
-  )
+    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>
+    );
 }

+ 53 - 68
packages/dashboard/src/components/nav-main.tsx

@@ -1,73 +1,58 @@
-"use client"
+'use client';
 
-import { ChevronRight, type LucideIcon } from "lucide-react"
+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 {
-  Collapsible,
-  CollapsibleContent,
-  CollapsibleTrigger,
-} from "@/components/ui/collapsible"
-import {
-  SidebarGroup,
-  SidebarGroupLabel,
-  SidebarMenu,
-  SidebarMenuButton,
-  SidebarMenuItem,
-  SidebarMenuSub,
-  SidebarMenuSubButton,
-  SidebarMenuSubItem,
-} from "@/components/ui/sidebar"
+    SidebarGroup,
+    SidebarGroupLabel,
+    SidebarMenu,
+    SidebarMenuButton,
+    SidebarMenuItem,
+    SidebarMenuSub,
+    SidebarMenuSubButton,
+    SidebarMenuSubItem,
+} from '@/components/ui/sidebar';
 
-export function NavMain({
-  items,
-}: {
-  items: {
-    title: string
-    url: string
-    icon?: LucideIcon
-    isActive?: boolean
-    items?: {
-      title: string
-      url: string
-    }[]
-  }[]
-}) {
-  return (
-    <SidebarGroup>
-      <SidebarGroupLabel>Platform</SidebarGroupLabel>
-      <SidebarMenu>
-        {items.map((item) => (
-          <Collapsible
-            key={item.title}
-            asChild
-            defaultOpen={item.isActive}
-            className="group/collapsible"
-          >
-            <SidebarMenuItem>
-              <CollapsibleTrigger asChild>
-                <SidebarMenuButton tooltip={item.title}>
-                  {item.icon && <item.icon />}
-                  <span>{item.title}</span>
-                  <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
-                </SidebarMenuButton>
-              </CollapsibleTrigger>
-              <CollapsibleContent>
-                <SidebarMenuSub>
-                  {item.items?.map((subItem) => (
-                    <SidebarMenuSubItem key={subItem.title}>
-                      <SidebarMenuSubButton asChild>
-                        <a href={subItem.url}>
-                          <span>{subItem.title}</span>
-                        </a>
-                      </SidebarMenuSubButton>
-                    </SidebarMenuSubItem>
-                  ))}
-                </SidebarMenuSub>
-              </CollapsibleContent>
-            </SidebarMenuItem>
-          </Collapsible>
-        ))}
-      </SidebarMenu>
-    </SidebarGroup>
-  )
+export function NavMain({ items }: { items: NavMenuItem[] }) {
+    return (
+        <SidebarGroup>
+            <SidebarGroupLabel>Platform</SidebarGroupLabel>
+            <SidebarMenu>
+                {items.map(item => (
+                    <Collapsible
+                        key={item.title}
+                        asChild
+                        defaultOpen={item.defaultOpen}
+                        className="group/collapsible"
+                    >
+                        <SidebarMenuItem>
+                            <CollapsibleTrigger asChild>
+                                <SidebarMenuButton tooltip={item.title}>
+                                    {item.icon && <item.icon />}
+                                    <span>{item.title}</span>
+                                    <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
+                                </SidebarMenuButton>
+                            </CollapsibleTrigger>
+                            <CollapsibleContent>
+                                <SidebarMenuSub>
+                                    {item.items?.map(subItem => (
+                                        <SidebarMenuSubItem key={subItem.title}>
+                                            <SidebarMenuSubButton asChild>
+                                                <Link to={subItem.url}>
+                                                    <span>{subItem.title}</span>
+                                                </Link>
+                                            </SidebarMenuSubButton>
+                                        </SidebarMenuSubItem>
+                                    ))}
+                                </SidebarMenuSub>
+                            </CollapsibleContent>
+                        </SidebarMenuItem>
+                    </Collapsible>
+                ))}
+            </SidebarMenu>
+        </SidebarGroup>
+    );
 }

+ 111 - 0
packages/dashboard/src/components/ui/table.tsx

@@ -0,0 +1,111 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+  return (
+    <div className="relative w-full overflow-auto">
+      <table
+        data-slot="table"
+        className={cn("w-full caption-bottom text-sm", className)}
+        {...props}
+      />
+    </div>
+  )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+  return (
+    <thead
+      data-slot="table-header"
+      className={cn("[&_tr]:border-b", className)}
+      {...props}
+    />
+  )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+  return (
+    <tbody
+      data-slot="table-body"
+      className={cn("[&_tr:last-child]:border-0", className)}
+      {...props}
+    />
+  )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+  return (
+    <tfoot
+      data-slot="table-footer"
+      className={cn(
+        "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+  return (
+    <tr
+      data-slot="table-row"
+      className={cn(
+        "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+  return (
+    <th
+      data-slot="table-head"
+      className={cn(
+        "text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+  return (
+    <td
+      data-slot="table-cell"
+      className={cn(
+        "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableCaption({
+  className,
+  ...props
+}: React.ComponentProps<"caption">) {
+  return (
+    <caption
+      data-slot="table-caption"
+      className={cn("text-muted-foreground mt-4 text-sm", className)}
+      {...props}
+    />
+  )
+}
+
+export {
+  Table,
+  TableHeader,
+  TableBody,
+  TableFooter,
+  TableHead,
+  TableRow,
+  TableCell,
+  TableCaption,
+}

+ 85 - 0
packages/dashboard/src/framework/defaults.ts

@@ -0,0 +1,85 @@
+import { navMenu } from '@/framework/internal/nav-menu/nav-menu.js';
+import { BookOpen, Bot, Settings2, SquareTerminal } from 'lucide-react';
+
+navMenu({
+    items: [
+        {
+            id: 'catalog',
+            title: 'Catalog',
+            icon: SquareTerminal,
+            defaultOpen: true,
+            items: [
+                {
+                    id: 'products',
+                    title: 'Products',
+                    url: '/products',
+                },
+            ],
+        },
+        // {
+        //     title: 'Models',
+        //     url: '#',
+        //     icon: Bot,
+        //     items: [
+        //         {
+        //             title: 'Genesis',
+        //             url: '#',
+        //         },
+        //         {
+        //             title: 'Explorer',
+        //             url: '#',
+        //         },
+        //         {
+        //             title: 'Quantum',
+        //             url: '#',
+        //         },
+        //     ],
+        // },
+        // {
+        //     title: 'Documentation',
+        //     url: '#',
+        //     icon: BookOpen,
+        //     items: [
+        //         {
+        //             title: 'Introduction',
+        //             url: '#',
+        //         },
+        //         {
+        //             title: 'Get Started',
+        //             url: '#',
+        //         },
+        //         {
+        //             title: 'Tutorials',
+        //             url: '#',
+        //         },
+        //         {
+        //             title: 'Changelog',
+        //             url: '#',
+        //         },
+        //     ],
+        // },
+        // {
+        //     title: 'Settings',
+        //     url: '#',
+        //     icon: Settings2,
+        //     items: [
+        //         {
+        //             title: 'General',
+        //             url: '#',
+        //         },
+        //         {
+        //             title: 'Team',
+        //             url: '#',
+        //         },
+        //         {
+        //             title: 'Billing',
+        //             url: '#',
+        //         },
+        //         {
+        //             title: 'Limits',
+        //             url: '#',
+        //         },
+        //     ],
+        // },
+    ],
+});

+ 59 - 0
packages/dashboard/src/framework/internal/data-table/data-table.tsx

@@ -0,0 +1,59 @@
+'use client';
+
+import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
+
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+
+interface DataTableProps<TData, TValue> {
+    columns: ColumnDef<TData, TValue>[];
+    data: TData[];
+}
+
+export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
+    const table = useReactTable({
+        data,
+        columns,
+        getCoreRowModel: getCoreRowModel(),
+    });
+
+    return (
+        <div className="rounded-md border">
+            <Table>
+                <TableHeader>
+                    {table.getHeaderGroups().map(headerGroup => (
+                        <TableRow key={headerGroup.id}>
+                            {headerGroup.headers.map(header => {
+                                return (
+                                    <TableHead key={header.id}>
+                                        {header.isPlaceholder
+                                            ? null
+                                            : flexRender(header.column.columnDef.header, header.getContext())}
+                                    </TableHead>
+                                );
+                            })}
+                        </TableRow>
+                    ))}
+                </TableHeader>
+                <TableBody>
+                    {table.getRowModel().rows?.length ? (
+                        table.getRowModel().rows.map(row => (
+                            <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
+                                {row.getVisibleCells().map(cell => (
+                                    <TableCell key={cell.id}>
+                                        {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                                    </TableCell>
+                                ))}
+                            </TableRow>
+                        ))
+                    ) : (
+                        <TableRow>
+                            <TableCell colSpan={columns.length} className="h-24 text-center">
+                                No results.
+                            </TableCell>
+                        </TableRow>
+                    )}
+                </TableBody>
+            </Table>
+        </div>
+    );
+}

+ 119 - 0
packages/dashboard/src/framework/internal/document-introspection/get-document-structure.ts

@@ -0,0 +1,119 @@
+import { DocumentNode, OperationDefinitionNode, FieldNode, FragmentDefinitionNode } from 'graphql';
+import { schemaInfo } from 'virtual:admin-api-schema';
+
+interface FieldInfo {
+    name: string;
+    type: string;
+    nullable: boolean;
+    list: boolean;
+    isPaginatedList: boolean;
+}
+
+/**
+ * Given a DocumentNode of a PaginatedList query, returns information about each
+ * of the selected fields.
+ */
+export function getListQueryFields(documentNode: DocumentNode): FieldInfo[] {
+    const fields: FieldInfo[] = [];
+    const fragments: Record<string, FragmentDefinitionNode> = {};
+
+    // Collect all fragment definitions
+    documentNode.definitions.forEach(def => {
+        if (def.kind === 'FragmentDefinition') {
+            fragments[def.name.value] = def;
+        }
+    });
+
+    const operationDefinition = documentNode.definitions.find(
+        (def): def is OperationDefinitionNode =>
+            def.kind === 'OperationDefinition' && def.operation === 'query',
+    );
+
+    for (const query of operationDefinition?.selectionSet.selections ?? []) {
+        if (query.kind === 'Field') {
+            const queryField = query;
+            const fieldInfo = getQueryInfo(queryField.name.value);
+            if (fieldInfo.isPaginatedList) {
+                const itemsField = queryField.selectionSet?.selections.find(
+                    selection => selection.kind === 'Field' && selection.name.value === 'items',
+                ) as FieldNode;
+                const typeName = getPaginatedListType(fieldInfo.name);
+                for (const item of itemsField.selectionSet?.selections ?? []) {
+                    collectFields(typeName, item, fields, fragments);
+                }
+            }
+        }
+    }
+
+    return fields;
+}
+
+function getQueryInfo(name: string): FieldInfo {
+    const fieldInfo = schemaInfo.types.Query[name];
+    return {
+        name,
+        type: fieldInfo[0],
+        nullable: fieldInfo[1],
+        list: fieldInfo[2],
+        isPaginatedList: fieldInfo[3],
+    };
+}
+
+function getPaginatedListType(name: string): string | undefined {
+    const queryInfo = getQueryInfo(name);
+    if (queryInfo.isPaginatedList) {
+        const paginagedListType = getObjectFieldInfo(queryInfo.type, 'items').type;
+        return paginagedListType;
+    }
+}
+
+function getObjectFieldInfo(typeName: string, fieldName: string): FieldInfo {
+    const fieldInfo = schemaInfo.types[typeName][fieldName];
+    return {
+        name: fieldName,
+        type: fieldInfo[0],
+        nullable: fieldInfo[1],
+        list: fieldInfo[2],
+        isPaginatedList: fieldInfo[3],
+    };
+}
+
+function collectFields(
+    typeName: string,
+    fieldNode: FieldNode,
+    fields: FieldInfo[],
+    fragments: Record<string, FragmentDefinitionNode>,
+) {
+    if (fieldNode.kind === 'Field') {
+        fields.push(getObjectFieldInfo(typeName, fieldNode.name.value));
+    }
+    if (fieldNode.kind === 'FragmentSpread') {
+        const fragmentName = fieldNode.name.value;
+        const fragment = fragments[fragmentName];
+        if (fragment) {
+            fragment.selectionSet.selections.forEach(fragmentSelection => {
+                if (fragmentSelection.kind === 'Field') {
+                    collectFields(typeName, fragmentSelection, fields, fragments);
+                }
+            });
+        }
+    }
+
+    if (fieldNode.selectionSet) {
+        fieldNode.selectionSet.selections.forEach(subSelection => {
+            if (subSelection.kind === 'Field') {
+                collectFields(typeName, subSelection, fields, fragments);
+            } else if (subSelection.kind === 'FragmentSpread') {
+                const fragmentName = subSelection.name.value;
+                const fragment = fragments[fragmentName];
+                if (fragment) {
+                    fragment.selectionSet.selections.forEach(fragmentSelection => {
+                        if (fragmentSelection.kind === 'Field') {
+                            collectFields(typeName, fragmentSelection, fields, fragments);
+                        }
+                    });
+                }
+            }
+        });
+    }
+}

+ 27 - 0
packages/dashboard/src/framework/internal/nav-menu/nav-menu.ts

@@ -0,0 +1,27 @@
+import type { LucideIcon } from 'lucide-react';
+
+export interface NavMenuItem {
+    title: string;
+    id: string;
+    icon?: LucideIcon;
+    defaultOpen?: boolean;
+    items?: Array<{
+        id: string;
+        title: string;
+        url: string;
+    }>;
+}
+
+export interface NavMenuConfig {
+    items: NavMenuItem[];
+}
+
+let navMenuConfig: NavMenuConfig = { items: [] };
+
+export function navMenu(config: NavMenuConfig) {
+    navMenuConfig = config;
+}
+
+export function getNavMenuConfig() {
+    return navMenuConfig;
+}

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

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

+ 0 - 3
packages/dashboard/src/main.tsx

@@ -5,9 +5,6 @@ import React, { useEffect } from 'react';
 import ReactDOM from 'react-dom/client';
 import { RouterProvider, createRouter } from '@tanstack/react-router';
 
-import { schemaInfo } from 'virtual:admin-api-schema';
-
-console.log(`schemaInfo:`, schemaInfo);
 import '@/framework/defaults.js';
 import { routeTree } from './routeTree.gen';
 import './styles.css';

+ 164 - 106
packages/dashboard/src/routeTree.gen.ts

@@ -10,156 +10,202 @@
 
 // Import Routes
 
-import { Route as rootRoute } from './routes/__root'
-import { Route as LoginImport } from './routes/login'
-import { Route as AboutImport } from './routes/about'
-import { Route as AuthenticatedImport } from './routes/_authenticated'
-import { Route as AuthenticatedIndexImport } from './routes/_authenticated/index'
-import { Route as AuthenticatedDashboardImport } from './routes/_authenticated/dashboard'
+import { Route as rootRoute } from './routes/__root';
+import { Route as LoginImport } from './routes/login';
+import { Route as AboutImport } from './routes/about';
+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';
 
 // Create/Update Routes
 
 const LoginRoute = LoginImport.update({
-  id: '/login',
-  path: '/login',
-  getParentRoute: () => rootRoute,
-} as any)
+    id: '/login',
+    path: '/login',
+    getParentRoute: () => rootRoute,
+} as any);
 
 const AboutRoute = AboutImport.update({
-  id: '/about',
-  path: '/about',
-  getParentRoute: () => rootRoute,
-} as any)
+    id: '/about',
+    path: '/about',
+    getParentRoute: () => rootRoute,
+} as any);
 
 const AuthenticatedRoute = AuthenticatedImport.update({
-  id: '/_authenticated',
-  getParentRoute: () => rootRoute,
-} as any)
+    id: '/_authenticated',
+    getParentRoute: () => rootRoute,
+} as any);
 
 const AuthenticatedIndexRoute = AuthenticatedIndexImport.update({
-  id: '/',
-  path: '/',
-  getParentRoute: () => AuthenticatedRoute,
-} as any)
+    id: '/',
+    path: '/',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
+const AuthenticatedProductsRoute = AuthenticatedProductsImport.update({
+    id: '/products',
+    path: '/products',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
 
 const AuthenticatedDashboardRoute = AuthenticatedDashboardImport.update({
-  id: '/dashboard',
-  path: '/dashboard',
-  getParentRoute: () => AuthenticatedRoute,
-} as any)
+    id: '/dashboard',
+    path: '/dashboard',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
+const AuthenticatedProductsIdRoute = AuthenticatedProductsIdImport.update({
+    id: '/$id',
+    path: '/$id',
+    getParentRoute: () => AuthenticatedProductsRoute,
+} as any);
 
 // Populate the FileRoutesByPath interface
 
 declare module '@tanstack/react-router' {
-  interface FileRoutesByPath {
-    '/_authenticated': {
-      id: '/_authenticated'
-      path: ''
-      fullPath: ''
-      preLoaderRoute: typeof AuthenticatedImport
-      parentRoute: typeof rootRoute
-    }
-    '/about': {
-      id: '/about'
-      path: '/about'
-      fullPath: '/about'
-      preLoaderRoute: typeof AboutImport
-      parentRoute: typeof rootRoute
-    }
-    '/login': {
-      id: '/login'
-      path: '/login'
-      fullPath: '/login'
-      preLoaderRoute: typeof LoginImport
-      parentRoute: typeof rootRoute
-    }
-    '/_authenticated/dashboard': {
-      id: '/_authenticated/dashboard'
-      path: '/dashboard'
-      fullPath: '/dashboard'
-      preLoaderRoute: typeof AuthenticatedDashboardImport
-      parentRoute: typeof AuthenticatedImport
+    interface FileRoutesByPath {
+        '/_authenticated': {
+            id: '/_authenticated';
+            path: '';
+            fullPath: '';
+            preLoaderRoute: typeof AuthenticatedImport;
+            parentRoute: typeof rootRoute;
+        };
+        '/about': {
+            id: '/about';
+            path: '/about';
+            fullPath: '/about';
+            preLoaderRoute: typeof AboutImport;
+            parentRoute: typeof rootRoute;
+        };
+        '/login': {
+            id: '/login';
+            path: '/login';
+            fullPath: '/login';
+            preLoaderRoute: typeof LoginImport;
+            parentRoute: typeof rootRoute;
+        };
+        '/_authenticated/dashboard': {
+            id: '/_authenticated/dashboard';
+            path: '/dashboard';
+            fullPath: '/dashboard';
+            preLoaderRoute: typeof AuthenticatedDashboardImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
+        '/_authenticated/products': {
+            id: '/_authenticated/products';
+            path: '/products';
+            fullPath: '/products';
+            preLoaderRoute: typeof AuthenticatedProductsImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
+        '/_authenticated/': {
+            id: '/_authenticated/';
+            path: '/';
+            fullPath: '/';
+            preLoaderRoute: typeof AuthenticatedIndexImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
+        '/_authenticated/products/$id': {
+            id: '/_authenticated/products/$id';
+            path: '/$id';
+            fullPath: '/products/$id';
+            preLoaderRoute: typeof AuthenticatedProductsIdImport;
+            parentRoute: typeof AuthenticatedProductsImport;
+        };
     }
-    '/_authenticated/': {
-      id: '/_authenticated/'
-      path: '/'
-      fullPath: '/'
-      preLoaderRoute: typeof AuthenticatedIndexImport
-      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
-  AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
+    AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute;
+    AuthenticatedProductsRoute: typeof AuthenticatedProductsRouteWithChildren;
+    AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
 }
 
 const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
-  AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
-  AuthenticatedIndexRoute: AuthenticatedIndexRoute,
-}
+    AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
+    AuthenticatedProductsRoute: AuthenticatedProductsRouteWithChildren,
+    AuthenticatedIndexRoute: AuthenticatedIndexRoute,
+};
 
-const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
-  AuthenticatedRouteChildren,
-)
+const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(AuthenticatedRouteChildren);
 
 export interface FileRoutesByFullPath {
-  '': typeof AuthenticatedRouteWithChildren
-  '/about': typeof AboutRoute
-  '/login': typeof LoginRoute
-  '/dashboard': typeof AuthenticatedDashboardRoute
-  '/': typeof AuthenticatedIndexRoute
+    '': typeof AuthenticatedRouteWithChildren;
+    '/about': typeof AboutRoute;
+    '/login': typeof LoginRoute;
+    '/dashboard': typeof AuthenticatedDashboardRoute;
+    '/products': typeof AuthenticatedProductsRouteWithChildren;
+    '/': typeof AuthenticatedIndexRoute;
+    '/products/$id': typeof AuthenticatedProductsIdRoute;
 }
 
 export interface FileRoutesByTo {
-  '/about': typeof AboutRoute
-  '/login': typeof LoginRoute
-  '/dashboard': typeof AuthenticatedDashboardRoute
-  '/': typeof AuthenticatedIndexRoute
+    '/about': typeof AboutRoute;
+    '/login': typeof LoginRoute;
+    '/dashboard': typeof AuthenticatedDashboardRoute;
+    '/products': typeof AuthenticatedProductsRouteWithChildren;
+    '/': typeof AuthenticatedIndexRoute;
+    '/products/$id': typeof AuthenticatedProductsIdRoute;
 }
 
 export interface FileRoutesById {
-  __root__: typeof rootRoute
-  '/_authenticated': typeof AuthenticatedRouteWithChildren
-  '/about': typeof AboutRoute
-  '/login': typeof LoginRoute
-  '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
-  '/_authenticated/': typeof AuthenticatedIndexRoute
+    __root__: typeof rootRoute;
+    '/_authenticated': typeof AuthenticatedRouteWithChildren;
+    '/about': typeof AboutRoute;
+    '/login': typeof LoginRoute;
+    '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute;
+    '/_authenticated/products': typeof AuthenticatedProductsRouteWithChildren;
+    '/_authenticated/': typeof AuthenticatedIndexRoute;
+    '/_authenticated/products/$id': typeof AuthenticatedProductsIdRoute;
 }
 
 export interface FileRouteTypes {
-  fileRoutesByFullPath: FileRoutesByFullPath
-  fullPaths: '' | '/about' | '/login' | '/dashboard' | '/'
-  fileRoutesByTo: FileRoutesByTo
-  to: '/about' | '/login' | '/dashboard' | '/'
-  id:
-    | '__root__'
-    | '/_authenticated'
-    | '/about'
-    | '/login'
-    | '/_authenticated/dashboard'
-    | '/_authenticated/'
-  fileRoutesById: FileRoutesById
+    fileRoutesByFullPath: FileRoutesByFullPath;
+    fullPaths: '' | '/about' | '/login' | '/dashboard' | '/products' | '/' | '/products/$id';
+    fileRoutesByTo: FileRoutesByTo;
+    to: '/about' | '/login' | '/dashboard' | '/products' | '/' | '/products/$id';
+    id:
+        | '__root__'
+        | '/_authenticated'
+        | '/about'
+        | '/login'
+        | '/_authenticated/dashboard'
+        | '/_authenticated/products'
+        | '/_authenticated/'
+        | '/_authenticated/products/$id';
+    fileRoutesById: FileRoutesById;
 }
 
 export interface RootRouteChildren {
-  AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
-  AboutRoute: typeof AboutRoute
-  LoginRoute: typeof LoginRoute
+    AuthenticatedRoute: typeof AuthenticatedRouteWithChildren;
+    AboutRoute: typeof AboutRoute;
+    LoginRoute: typeof LoginRoute;
 }
 
 const rootRouteChildren: RootRouteChildren = {
-  AuthenticatedRoute: AuthenticatedRouteWithChildren,
-  AboutRoute: AboutRoute,
-  LoginRoute: LoginRoute,
-}
+    AuthenticatedRoute: AuthenticatedRouteWithChildren,
+    AboutRoute: AboutRoute,
+    LoginRoute: LoginRoute,
+};
 
-export const routeTree = rootRoute
-  ._addFileChildren(rootRouteChildren)
-  ._addFileTypes<FileRouteTypes>()
+export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileTypes<FileRouteTypes>();
 
 /* ROUTE_MANIFEST_START
 {
@@ -176,6 +222,7 @@ export const routeTree = rootRoute
       "filePath": "_authenticated.tsx",
       "children": [
         "/_authenticated/dashboard",
+        "/_authenticated/products",
         "/_authenticated/"
       ]
     },
@@ -189,9 +236,20 @@ export const routeTree = rootRoute
       "filePath": "_authenticated/dashboard.tsx",
       "parent": "/_authenticated"
     },
+    "/_authenticated/products": {
+      "filePath": "_authenticated/products.tsx",
+      "parent": "/_authenticated",
+      "children": [
+        "/_authenticated/products/$id"
+      ]
+    },
     "/_authenticated/": {
       "filePath": "_authenticated/index.tsx",
       "parent": "/_authenticated"
+    },
+    "/_authenticated/products/$id": {
+      "filePath": "_authenticated/products.$id.tsx",
+      "parent": "/_authenticated/products"
     }
   }
 }

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

@@ -0,0 +1,10 @@
+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>;
+}

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

@@ -0,0 +1,49 @@
+import { getListQueryFields } from '@/framework/internal/document-introspection/get-document-structure.js';
+import { createFileRoute } from '@tanstack/react-router';
+import { graphql } from '@/graphql/graphql.js';
+import React from 'react';
+
+export const Route = createFileRoute('/_authenticated/products')({
+    component: ProductListPage,
+});
+
+const productListDocument = graphql(`
+    query ProductList($options: ProductListOptions) {
+        products(options: $options) {
+            items {
+                id
+                name
+                slug
+                enabled
+            }
+        }
+    }
+`);
+
+const productFragment = graphql(`
+    fragment ProductFragment on Product {
+        id
+        name
+        slug
+        enabled
+    }
+`);
+
+const productListDocument2 = graphql(
+    `
+        query ProductList($options: ProductListOptions) {
+            products(options: $options) {
+                items {
+                    ...ProductFragment
+                }
+            }
+        }
+    `,
+    [productFragment],
+);
+
+export function ProductListPage() {
+    console.log('regular', getListQueryFields(productListDocument));
+    console.log('with fragment', getListQueryFields(productListDocument2));
+    return <div>Product List Page</div>;
+}

+ 4 - 0
packages/dashboard/src/types.ts

@@ -0,0 +1,4 @@
+declare module 'virtual:admin-api-schema' {
+    import { SchemaInfo } from '../vite/api-schema/vite-plugin-admin-api-schema.js';
+    export const schemaInfo: SchemaInfo;
+}

+ 16 - 3
packages/dashboard/vite/api-schema/vite-plugin-admin-api-schema.ts

@@ -12,6 +12,7 @@ import {
     buildSchema,
     GraphQLList,
     GraphQLNonNull,
+    GraphQLObjectType,
     GraphQLSchema,
     GraphQLType,
     isInputObjectType,
@@ -19,10 +20,15 @@ import {
 } from 'graphql';
 import { Plugin } from 'vite';
 
-interface SchemaInfo {
+export interface SchemaInfo {
     types: {
         [typename: string]: {
-            [fieldname: string]: readonly [type: string, nullable: boolean, list: boolean];
+            [fieldname: string]: readonly [
+                type: string,
+                nullable: boolean,
+                list: boolean,
+                isPaginatedList: boolean,
+            ];
         };
     };
 }
@@ -30,6 +36,7 @@ interface SchemaInfo {
 function getTypeInfo(type: GraphQLType) {
     let nullable = true;
     let list = false;
+    let isPaginatedList = false;
 
     // Unwrap NonNull
     if (type instanceof GraphQLNonNull) {
@@ -43,7 +50,13 @@ function getTypeInfo(type: GraphQLType) {
         type = type.ofType;
     }
 
-    return [type.toString().replace(/!$/, ''), nullable, list] as const;
+    if (type instanceof GraphQLObjectType) {
+        if (type.getInterfaces().some(i => i.name === 'PaginatedList')) {
+            isPaginatedList = true;
+        }
+    }
+
+    return [type.toString().replace(/!$/, ''), nullable, list, isPaginatedList] as const;
 }
 
 function generateSchemaInfo(schema: GraphQLSchema): SchemaInfo {