ソースを参照

feat(dashboard): Implement variant list on product detail view

Michael Bromley 10 ヶ月 前
コミット
88cd995295

+ 212 - 0
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -0,0 +1,212 @@
+import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header.js';
+import { DataTable } from '@/components/data-table/data-table.js';
+import { useComponentRegistry } from '@/framework/component-registry/component-registry.js';
+import {
+    FieldInfo,
+    getQueryName
+} from '@/framework/document-introspection/get-document-structure.js';
+import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
+import { api } from '@/graphql/api.js';
+import { useDebounce } from 'use-debounce';
+
+import { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { useQuery } from '@tanstack/react-query';
+import {
+    ColumnFiltersState,
+    ColumnSort,
+    createColumnHelper,
+    SortingState,
+    Table,
+} from '@tanstack/react-table';
+import { ColumnDef } from '@tanstack/table-core';
+import { ResultOf } from 'gql.tada';
+import React, { useMemo } from 'react';
+
+type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
+    [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
+        ? U extends any[]
+            ? U[number]
+            : never
+        : never;
+}[keyof ResultOf<T>];
+
+export type CustomizeColumnConfig<T extends TypedDocumentNode<any, any>> = {
+    [Key in keyof ListQueryFields<T>]?: Partial<ColumnDef<any>>;
+};
+
+export type ListQueryShape = {
+    [key: string]: {
+        items: any[];
+        totalItems: number;
+    };
+};
+
+export type ListQueryOptionsShape = {
+    options?: {
+        skip?: number;
+        take?: number;
+        sort?: {
+            [key: string]: 'ASC' | 'DESC';
+        };
+        filter?: any;
+    };
+};
+
+export interface PaginatedListDataTableProps<
+    T extends TypedDocumentNode<U, V>,
+    U extends ListQueryShape,
+    V extends ListQueryOptionsShape,
+> {
+    listQuery: T;
+    transformVariables?: (variables: V) => V;
+    customizeColumns?: CustomizeColumnConfig<T>;
+    defaultVisibility?: Partial<Record<keyof ListQueryFields<T>, boolean>>;
+    onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
+    page: number;
+    itemsPerPage: number;
+    sorting: SortingState;
+    columnFilters?: ColumnFiltersState;
+    onPageChange: (table: Table<any>, page: number, perPage: number) => void;
+    onSortChange: (table: Table<any>, sorting: SortingState) => void;
+    onFilterChange: (table: Table<any>, filters: ColumnFiltersState) => void;
+}
+
+export function PaginatedListDataTable<
+    T extends TypedDocumentNode<U, V>,
+    U extends Record<string, any> = any,
+    V extends ListQueryOptionsShape = {},
+>({
+    listQuery,
+    transformVariables,
+    customizeColumns,
+    defaultVisibility,
+    onSearchTermChange,
+    page,
+    itemsPerPage,
+    sorting,
+    columnFilters,
+    onPageChange,
+    onSortChange,
+    onFilterChange,
+}: PaginatedListDataTableProps<T, U, V>) {
+    const { getComponent } = useComponentRegistry();
+    const [searchTerm, setSearchTerm] = React.useState<string>('');
+    const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
+
+    const sort = sorting?.reduce((acc: any, sort: ColumnSort) => {
+        const direction = sort.desc ? 'DESC' : 'ASC';
+        const field = sort.id;
+
+        if (!field || !direction) {
+            return acc;
+        }
+        return { ...acc, [field]: direction };
+    }, {});
+
+    const filter = columnFilters?.length
+        ? { _and: columnFilters.map(f => ({ [f.id]: f.value })) }
+        : undefined;
+
+    const { data } = useQuery({
+        queryFn: () => {
+            const searchFilter = onSearchTermChange ? onSearchTermChange(debouncedSearchTerm) : {};
+            const mergedFilter = { ...filter, ...searchFilter };
+            const variables = {
+                options: {
+                    take: itemsPerPage,
+                    skip: (page - 1) * itemsPerPage,
+                    sort,
+                    filter: mergedFilter,
+                },
+            } as V;
+
+            const transformedVariables = transformVariables ? transformVariables(variables) : variables;
+            return api.query(listQuery, transformedVariables);
+        },
+        queryKey: ['PaginatedListDataTable', listQuery, page, itemsPerPage, sorting, filter, debouncedSearchTerm],
+    });
+
+    const fields = useListQueryFields(listQuery);
+    const queryName = getQueryName(listQuery);
+    const columnHelper = createColumnHelper();
+
+    const columns = useMemo(() => {
+        return fields.map(field => {
+            const customConfig = customizeColumns?.[field.name as keyof ListQueryFields<T>] ?? {};
+            const { header, ...customConfigRest } = customConfig;
+            return columnHelper.accessor(field.name as any, {
+                meta: { field },
+                enableColumnFilter: field.isScalar,
+                enableSorting: field.isScalar,
+                cell: ({ cell }) => {
+                    const value = cell.getValue();
+                    if (field.list && Array.isArray(value)) {
+                        return value.join(', ');
+                    }
+                    let Cmp: React.ComponentType<{ value: any }> | undefined = undefined;
+                    if ((field.type === 'DateTime' && typeof value === 'string') || value instanceof Date) {
+                        Cmp = getComponent('dateTime.display');
+                    }
+                    if (field.type === 'Boolean') {
+                        Cmp = getComponent('boolean.display');
+                    }
+                    if (field.type === 'Asset') {
+                        Cmp = getComponent('asset.display');
+                    }
+
+                    if (Cmp) {
+                        return <Cmp value={value} />;
+                    }
+                    if (value !== null && typeof value === 'object') {
+                        return JSON.stringify(value);
+                    }
+                    return value;
+                },
+                header: headerContext => {
+                    return (
+                        <DataTableColumnHeader headerContext={headerContext} customConfig={customConfig} />
+                    );
+                },
+                ...customConfigRest,
+            });
+        });
+    }, [fields, customizeColumns]);
+
+    const columnVisibility = getColumnVisibility(fields, defaultVisibility);
+
+    return (
+        <DataTable
+            columns={columns}
+            data={(data as any)?.[queryName]?.items ?? []}
+            page={page}
+            itemsPerPage={itemsPerPage}
+            sorting={sorting}
+            columnFilters={columnFilters}
+            totalItems={(data as any)?.[queryName]?.totalItems ?? 0}
+            onPageChange={onPageChange}
+            onSortChange={onSortChange}
+            onFilterChange={onFilterChange}
+            onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
+            defaultColumnVisibility={columnVisibility}
+        />
+    );
+}
+
+/**
+ * Returns the default column visibility configuration.
+ */
+function getColumnVisibility(
+    fields: FieldInfo[],
+    defaultVisibility?: Record<string, boolean | undefined>,
+): 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,
+    };
+} 

+ 13 - 151
packages/dashboard/src/framework/page/list-page.tsx

@@ -1,29 +1,17 @@
-import { useComponentRegistry } from '@/framework/component-registry/component-registry.js';
-import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header.js';
-import { DataTable } from '@/components/data-table/data-table.js';
 import {
-    FieldInfo,
-    getListQueryFields,
-    getQueryName,
+    FieldInfo
 } from '@/framework/document-introspection/get-document-structure.js';
-import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
 import { PageProps } from '@/framework/page/page-types.js';
-import { api } from '@/graphql/api.js';
-import { useDebounce } from 'use-debounce';
 
+import { CustomizeColumnConfig, ListQueryOptionsShape, ListQueryShape, PaginatedListDataTable } from '@/components/shared/paginated-list-data-table.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
-import { useQuery } from '@tanstack/react-query';
-import { AnyRoute, AnyRouter, useNavigate } from '@tanstack/react-router';
+import { AnyRouter, useNavigate } from '@tanstack/react-router';
 import {
     ColumnFiltersState,
-    ColumnSort,
-    createColumnHelper,
     SortingState,
-    Table,
+    Table
 } from '@tanstack/react-table';
-import { ColumnDef } from '@tanstack/table-core';
 import { ResultOf } from 'gql.tada';
-import React, { useMemo } from 'react';
 
 type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
@@ -33,28 +21,6 @@ type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
         : never;
 }[keyof ResultOf<T>];
 
-export type CustomizeColumnConfig<T extends TypedDocumentNode<any, any>> = {
-    [Key in keyof ListQueryFields<T>]?: Partial<ColumnDef<any>>;
-};
-
-export type ListQueryShape = {
-    [key: string]: {
-        items: any[];
-        totalItems: number;
-    };
-};
-
-export type ListQueryOptionsShape = {
-    options?: {
-        skip?: number;
-        take?: number;
-        sort?: {
-            [key: string]: 'ASC' | 'DESC';
-        };
-        filter?: any;
-    };
-};
-
 export interface ListPageProps<
     T extends TypedDocumentNode<U, V>,
     U extends ListQueryShape,
@@ -79,99 +45,21 @@ export function ListPage<
     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>('');
-    const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
+
     const pagination = {
         page: routeSearch.page ? parseInt(routeSearch.page) : 1,
         itemsPerPage: routeSearch.perPage ? parseInt(routeSearch.perPage) : 10,
     };
+
     const sorting: SortingState = (routeSearch.sort ?? '').split(',').map((s: string) => {
         return {
             id: s.replace(/^-/, ''),
             desc: s.startsWith('-'),
         };
     });
-    const sort = sorting?.reduce((acc: any, sort: ColumnSort) => {
-        const direction = sort.desc ? 'DESC' : 'ASC';
-        const field = sort.id;
-
-        if (!field || !direction) {
-            return acc;
-        }
-        return { ...acc, [field]: direction };
-    }, {});
-
-    const columnFilters = routeSearch.filters;
-    const filter = columnFilters?.length
-        ? { _and: (routeSearch.filters as ColumnFiltersState).map(f => ({ [f.id]: f.value })) }
-        : undefined;
-
-    const { data } = useQuery({
-        queryFn: () => {
-            const searchFilter = onSearchTermChange ? onSearchTermChange(debouncedSearchTerm) : {};
-            const mergedFilter = { ...filter, ...searchFilter };
-            return api.query(listQuery, {
-                options: {
-                    take: pagination.itemsPerPage,
-                    skip: (pagination.page - 1) * pagination.itemsPerPage,
-                    sort,
-                    filter: mergedFilter,
-                },
-            } as unknown as V);
-        },
-        queryKey: ['ListPage', route.id, pagination, sorting, filter, debouncedSearchTerm],
-    });
-    const fields = useListQueryFields(listQuery);
-    const queryName = getQueryName(listQuery);
-    const columnHelper = createColumnHelper();
-
-    const columns = useMemo(() => {
-        return fields.map(field => {
-            const customConfig = customizeColumns?.[field.name as keyof ListQueryFields<T>] ?? {};
-            const { header, ...customConfigRest } = customConfig;
-            return columnHelper.accessor(field.name as any, {
-                meta: { field },
-                enableColumnFilter: field.isScalar,
-                enableSorting: field.isScalar,
-                cell: ({ cell }) => {
-                    const value = cell.getValue();
-                    if (field.list && Array.isArray(value)) {
-                        return value.join(', ');
-                    }
-                    let Cmp: React.ComponentType<{ value: any }> | undefined = undefined;
-                    if ((field.type === 'DateTime' && typeof value === 'string') || value instanceof Date) {
-                        Cmp = getComponent('dateTime.display');
-                    }
-                    if (field.type === 'Boolean') {
-                        Cmp = getComponent('boolean.display');
-                    }
-                    if (field.type === 'Asset') {
-                        Cmp = getComponent('asset.display');
-                    }
-
-                    if (Cmp) {
-                        return <Cmp value={value} />;
-                    }
-                    if (value !== null && typeof value === 'object') {
-                        return JSON.stringify(value);
-                    }
-                    return value;
-                },
-                header: headerContext => {
-                    return (
-                        <DataTableColumnHeader headerContext={headerContext} customConfig={customConfig} />
-                    );
-                },
-                ...customConfigRest,
-            });
-        });
-    }, [fields, customizeColumns]);
-
-    const columnVisibility = getColumnVisibility(fields, defaultVisibility);
 
     function sortToString(sortingStates?: SortingState) {
         return sortingStates?.map(s => `${s.desc ? '-' : ''}${s.id}`).join(',');
@@ -199,14 +87,15 @@ export function ListPage<
     return (
         <div className="m-4">
             <h1 className="text-2xl font-bold">{title}</h1>
-            <DataTable
-                columns={columns}
-                data={(data as any)?.[queryName]?.items ?? []}
+            <PaginatedListDataTable
+                listQuery={listQuery}
+                customizeColumns={customizeColumns}
+                defaultVisibility={defaultVisibility}
+                onSearchTermChange={onSearchTermChange}
                 page={pagination.page}
                 itemsPerPage={pagination.itemsPerPage}
                 sorting={sorting}
-                columnFilters={columnFilters}
-                totalItems={(data as any)?.[queryName]?.totalItems ?? 0}
+                columnFilters={routeSearch.filters}
                 onPageChange={(table, page, perPage) => {
                     persistListStateToUrl(table, { page, perPage });
                 }}
@@ -216,34 +105,7 @@ export function ListPage<
                 onFilterChange={(table, filters) => {
                     persistListStateToUrl(table, { filters });
                 }}
-                onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
-                defaultColumnVisibility={columnVisibility}
-            ></DataTable>
+            />
         </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 | undefined>,
-): 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,
-    };
-}

+ 42 - 42
packages/dashboard/src/routeTree.gen.ts

@@ -15,9 +15,9 @@ 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';
+import { Route as AuthenticatedProductsProductsImport } from './routes/_authenticated/_products/products';
+import { Route as AuthenticatedProductsProductsIdImport } from './routes/_authenticated/_products/products_.$id';
 
 // Create/Update Routes
 
@@ -44,20 +44,20 @@ const AuthenticatedIndexRoute = AuthenticatedIndexImport.update({
     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);
 
-const AuthenticatedProductsIdRoute = AuthenticatedProductsIdImport.update({
-    id: '/products_/$id',
+const AuthenticatedProductsProductsRoute = AuthenticatedProductsProductsImport.update({
+    id: '/_products/products',
+    path: '/products',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
+const AuthenticatedProductsProductsIdRoute = AuthenticatedProductsProductsIdImport.update({
+    id: '/_products/products_/$id',
     path: '/products/$id',
     getParentRoute: () => AuthenticatedRoute,
 } as any);
@@ -94,13 +94,6 @@ declare module '@tanstack/react-router' {
             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: '/';
@@ -108,11 +101,18 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedIndexImport;
             parentRoute: typeof AuthenticatedImport;
         };
-        '/_authenticated/products_/$id': {
-            id: '/_authenticated/products_/$id';
+        '/_authenticated/_products/products': {
+            id: '/_authenticated/_products/products';
+            path: '/products';
+            fullPath: '/products';
+            preLoaderRoute: typeof AuthenticatedProductsProductsImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
+        '/_authenticated/_products/products_/$id': {
+            id: '/_authenticated/_products/products_/$id';
             path: '/products/$id';
             fullPath: '/products/$id';
-            preLoaderRoute: typeof AuthenticatedProductsIdImport;
+            preLoaderRoute: typeof AuthenticatedProductsProductsIdImport;
             parentRoute: typeof AuthenticatedImport;
         };
     }
@@ -122,16 +122,16 @@ declare module '@tanstack/react-router' {
 
 interface AuthenticatedRouteChildren {
     AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute;
-    AuthenticatedProductsRoute: typeof AuthenticatedProductsRoute;
     AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
-    AuthenticatedProductsIdRoute: typeof AuthenticatedProductsIdRoute;
+    AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
+    AuthenticatedProductsProductsIdRoute: typeof AuthenticatedProductsProductsIdRoute;
 }
 
 const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
-    AuthenticatedProductsRoute: AuthenticatedProductsRoute,
     AuthenticatedIndexRoute: AuthenticatedIndexRoute,
-    AuthenticatedProductsIdRoute: AuthenticatedProductsIdRoute,
+    AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
+    AuthenticatedProductsProductsIdRoute: AuthenticatedProductsProductsIdRoute,
 };
 
 const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(AuthenticatedRouteChildren);
@@ -141,18 +141,18 @@ export interface FileRoutesByFullPath {
     '/about': typeof AboutRoute;
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
-    '/products': typeof AuthenticatedProductsRoute;
     '/': typeof AuthenticatedIndexRoute;
-    '/products/$id': typeof AuthenticatedProductsIdRoute;
+    '/products': typeof AuthenticatedProductsProductsRoute;
+    '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 
 export interface FileRoutesByTo {
     '/about': typeof AboutRoute;
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
-    '/products': typeof AuthenticatedProductsRoute;
     '/': typeof AuthenticatedIndexRoute;
-    '/products/$id': typeof AuthenticatedProductsIdRoute;
+    '/products': typeof AuthenticatedProductsProductsRoute;
+    '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 
 export interface FileRoutesById {
@@ -161,25 +161,25 @@ export interface FileRoutesById {
     '/about': typeof AboutRoute;
     '/login': typeof LoginRoute;
     '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute;
-    '/_authenticated/products': typeof AuthenticatedProductsRoute;
     '/_authenticated/': typeof AuthenticatedIndexRoute;
-    '/_authenticated/products_/$id': typeof AuthenticatedProductsIdRoute;
+    '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
+    '/_authenticated/_products/products_/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 
 export interface FileRouteTypes {
     fileRoutesByFullPath: FileRoutesByFullPath;
-    fullPaths: '' | '/about' | '/login' | '/dashboard' | '/products' | '/' | '/products/$id';
+    fullPaths: '' | '/about' | '/login' | '/dashboard' | '/' | '/products' | '/products/$id';
     fileRoutesByTo: FileRoutesByTo;
-    to: '/about' | '/login' | '/dashboard' | '/products' | '/' | '/products/$id';
+    to: '/about' | '/login' | '/dashboard' | '/' | '/products' | '/products/$id';
     id:
         | '__root__'
         | '/_authenticated'
         | '/about'
         | '/login'
         | '/_authenticated/dashboard'
-        | '/_authenticated/products'
         | '/_authenticated/'
-        | '/_authenticated/products_/$id';
+        | '/_authenticated/_products/products'
+        | '/_authenticated/_products/products_/$id';
     fileRoutesById: FileRoutesById;
 }
 
@@ -212,9 +212,9 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated.tsx",
       "children": [
         "/_authenticated/dashboard",
-        "/_authenticated/products",
         "/_authenticated/",
-        "/_authenticated/products_/$id"
+        "/_authenticated/_products/products",
+        "/_authenticated/_products/products_/$id"
       ]
     },
     "/about": {
@@ -227,16 +227,16 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/dashboard.tsx",
       "parent": "/_authenticated"
     },
-    "/_authenticated/products": {
-      "filePath": "_authenticated/products.tsx",
-      "parent": "/_authenticated"
-    },
     "/_authenticated/": {
       "filePath": "_authenticated/index.tsx",
       "parent": "/_authenticated"
     },
-    "/_authenticated/products_/$id": {
-      "filePath": "_authenticated/products_.$id.tsx",
+    "/_authenticated/_products/products": {
+      "filePath": "_authenticated/_products/products.tsx",
+      "parent": "/_authenticated"
+    },
+    "/_authenticated/_products/products_/$id": {
+      "filePath": "_authenticated/_products/products_.$id.tsx",
       "parent": "/_authenticated"
     }
   }

+ 37 - 0
packages/dashboard/src/routes/_authenticated/_products/components/product-variants-table.tsx

@@ -0,0 +1,37 @@
+import { PaginatedListDataTable } from "@/components/shared/paginated-list-data-table.js";
+import { productVariantListDocument } from "../products.graphql.js";
+import { useState } from "react";
+import { ColumnFiltersState, SortingState } from "@tanstack/react-table";
+
+interface ProductVariantsTableProps {
+    productId: string;
+}
+
+export function ProductVariantsTable({ productId }: ProductVariantsTableProps) {
+    const [page, setPage] = useState(1);
+    const [pageSize, setPageSize] = useState(10);
+    const [sorting, setSorting] = useState<SortingState>([]);
+    const [filters, setFilters] = useState<ColumnFiltersState>([]);
+
+    return <PaginatedListDataTable
+        listQuery={productVariantListDocument}
+        transformVariables={variables => ({
+            ...variables,
+            productId,
+        })}
+        page={page}
+        itemsPerPage={pageSize}
+        sorting={sorting}
+        columnFilters={filters}
+        onPageChange={(_, page, perPage) => {
+            setPage(page);
+            setPageSize(perPage);
+        }}
+        onSortChange={(_, sorting) => {
+            setSorting(sorting);
+        }}
+        onFilterChange={(_, filters) => {
+            setFilters(filters);
+        }}
+    />;
+}

+ 97 - 0
packages/dashboard/src/routes/_authenticated/_products/products.graphql.ts

@@ -0,0 +1,97 @@
+import { assetFragment } from '@/graphql/fragments.js';
+import { graphql } from '@/graphql/graphql.js';
+
+export const productListDocument = graphql(`
+    query ProductList($options: ProductListOptions) {
+        products(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                featuredAsset {
+                    id
+                    preview
+                }
+                name
+                slug
+                enabled
+            }
+            totalItems
+        }
+    }
+`);
+
+export const productDetailFragment = graphql(
+    `
+        fragment ProductDetail on Product {
+            id
+            createdAt
+            updatedAt
+            enabled
+            name
+            slug
+            description
+            featuredAsset {
+                ...Asset
+            }
+            assets {
+                ...Asset
+            }
+            translations {
+                id
+                languageCode
+                name
+                slug
+                description
+            }
+
+            facetValues {
+                id
+                name
+                code
+                facet {
+                    id
+                    name
+                    code
+                }
+            }
+        }
+    `,
+    [assetFragment],
+);
+
+export const productVariantListDocument = graphql(`
+    query ProductVariantList($options: ProductVariantListOptions, $productId: ID) {
+        productVariants(options: $options, productId: $productId) {
+            items {
+                id
+                name
+                sku
+                price
+                priceWithTax
+            }
+        }
+    }
+`);
+
+export const productDetailDocument = graphql(
+    `
+        query ProductDetail($id: ID!) {
+            product(id: $id) {
+                ...ProductDetail
+            }
+        }
+    `,
+    [productDetailFragment],
+);
+
+export const updateProductDocument = graphql(
+    `
+        mutation UpdateProduct($input: UpdateProductInput!) {
+            updateProduct(input: $input) {
+                ...ProductDetail
+            }
+        }
+    `,
+    [productDetailFragment],
+);

+ 3 - 24
packages/dashboard/src/routes/_authenticated/products.tsx → packages/dashboard/src/routes/_authenticated/_products/products.tsx

@@ -1,34 +1,13 @@
 import { Button } from '@/components/ui/button.js';
 import { ListPage } from '@/framework/page/list-page.js';
-import { graphql } from '@/graphql/graphql.js';
-import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
-import React from 'react';
+import { createFileRoute, Link } from '@tanstack/react-router';
+import { productListDocument } from './products.graphql.js';
 
-export const Route = createFileRoute('/_authenticated/products')({
+export const Route = createFileRoute('/_authenticated/_products/products')({
     component: ProductListPage,
     loader: () => ({ breadcrumb: 'Products' }),
 });
 
-const productListDocument = graphql(`
-    query ProductList($options: ProductListOptions) {
-        products(options: $options) {
-            items {
-                id
-                createdAt
-                updatedAt
-                featuredAsset {
-                    id
-                    preview
-                }
-                name
-                slug
-                enabled
-            }
-            totalItems
-        }
-    }
-`);
-
 export function ProductListPage() {
     return (
         <ListPage

+ 9 - 72
packages/dashboard/src/routes/_authenticated/products_.$id.tsx → packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx

@@ -19,15 +19,14 @@ import { Textarea } from '@/components/ui/textarea.js';
 import { useGeneratedForm } from '@/framework/form-engine/use-generated-form.js';
 import { DetailPage, getDetailQueryOptions } from '@/framework/page/detail-page.js';
 import { api } from '@/graphql/api.js';
-import { assetFragment } from '@/graphql/fragments.js';
-import { graphql } from '@/graphql/graphql.js';
 import { Trans } from '@lingui/react/macro';
 import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
 import { createFileRoute } from '@tanstack/react-router';
-import { useEffect } from 'react';
 import { toast } from 'sonner';
+import { ProductVariantsTable } from './components/product-variants-table.js';
+import { productDetailDocument, updateProductDocument } from './products.graphql.js';
 
-export const Route = createFileRoute('/_authenticated/products_/$id')({
+export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
     loader: async ({ context, params }) => {
         const result = await context.queryClient.ensureQueryData(
@@ -37,68 +36,6 @@ export const Route = createFileRoute('/_authenticated/products_/$id')({
     },
 });
 
-const productDetailFragment = graphql(
-    `
-        fragment ProductDetail on Product {
-            id
-            createdAt
-            updatedAt
-            enabled
-            name
-            slug
-            description
-            featuredAsset {
-                ...Asset
-            }
-            assets {
-                ...Asset
-            }
-            translations {
-                id
-                languageCode
-
-                name
-                slug
-                description
-            }
-            
-            facetValues {
-                id
-                name
-                code
-                facet {
-                    id
-                    name
-                    code
-                }
-            }
-        }
-    `,
-    [assetFragment],
-);
-
-const productDetailDocument = graphql(
-    `
-        query ProductDetail($id: ID!) {
-            product(id: $id) {
-                ...ProductDetail
-            }
-        }
-    `,
-    [productDetailFragment],
-);
-
-const updateProductDocument = graphql(
-    `
-        mutation UpdateProduct($input: UpdateProductInput!) {
-            updateProduct(input: $input) {
-                ...ProductDetail
-            }
-        }
-    `,
-    [productDetailFragment],
-);
-
 export function ProductDetailPage() {
     const params = Route.useParams();
     const queryClient = useQueryClient();
@@ -112,7 +49,7 @@ export function ProductDetailPage() {
                 position: 'top-right',
             });
             form.reset();
-            queryClient.invalidateQueries(detailQueryOptions.queryKey);
+            queryClient.invalidateQueries({ queryKey: detailQueryOptions.queryKey });
         },
         onError: err => {
             console.error(err);
@@ -141,11 +78,6 @@ export function ProductDetailPage() {
         },
     });
 
-    // log changes to the form
-    useEffect(() => {
-        console.log(form.getValues());
-    }, [form.getValues()]);
-
     return (
         <DetailPage title={entity?.name ?? ''} route={Route} entity={entity}>
             <Form {...form}>
@@ -224,6 +156,11 @@ export function ProductDetailPage() {
                                     </div>
                                 </CardContent>
                             </Card>
+                            <Card className="">
+                                <CardContent className="pt-6">
+                                    <ProductVariantsTable productId={params.id} />
+                                </CardContent>
+                            </Card>
                         </div>
                         <div className="lg:col-span-1 flex flex-col gap-4">
                             <Card className="">

+ 1 - 1
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -42,7 +42,7 @@ export function vendureDashboardPlugin(options: VitePluginVendureDashboardOption
 
     return [
         lingui(),
-        TanStackRouterVite({ autoCodeSplitting: true }),
+        TanStackRouterVite({ autoCodeSplitting: true, routeFileIgnorePattern: '.graphql.ts|components' }),
         react({
             babel: {
                 plugins: ['@lingui/babel-plugin-lingui-macro'],