Browse Source

feat(dashboard): Improve data table support for sort & nested types

Michael Bromley 11 months ago
parent
commit
1244534074

+ 8 - 0
packages/dashboard/src/framework/internal/component-registry/component-registry.tsx

@@ -1,3 +1,4 @@
+import { AssetThumbnail } from '@/framework/internal/component-registry/data-types/asset.js';
 import { BooleanDisplayCheckbox } from '@/framework/internal/component-registry/data-types/boolean.js';
 import { DateTime } from './data-types/date-time.js';
 
@@ -31,6 +32,13 @@ export const COMPONENT_REGISTRY = {
                 },
             },
         },
+        asset: {
+            display: {
+                default: {
+                    component: AssetThumbnail,
+                },
+            },
+        },
     },
 } satisfies ComponentRegistry;
 

+ 13 - 0
packages/dashboard/src/framework/internal/component-registry/data-types/asset.tsx

@@ -0,0 +1,13 @@
+export interface AssetLike {
+    preview: string;
+    name?: string;
+    focalPoint?: { x: number; y: number };
+}
+
+export function AssetThumbnail({ value }: { value: AssetLike }) {
+    let url = value.preview + '?preset=tiny';
+    if (value.focalPoint) {
+        url += `&fpx=${value.focalPoint.x}&fpy=${value.focalPoint.y}`;
+    }
+    return <img src={url} alt={value.name} className="rounded-sm" />;
+}

+ 2 - 2
packages/dashboard/src/framework/internal/data-table/data-table-pagination.tsx

@@ -1,8 +1,8 @@
 import { Table } from '@tanstack/react-table';
 import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
 
-import { Button } from '@/components/ui/button';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Button } from '@/components/ui/button.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select.js';
 
 interface DataTablePaginationProps<TData> {
     table: Table<TData>;

+ 2 - 2
packages/dashboard/src/framework/internal/data-table/data-table-view-options.tsx

@@ -4,14 +4,14 @@ import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
 import { Table } from '@tanstack/react-table';
 import { Settings2 } from 'lucide-react';
 
-import { Button } from '@/components/ui/button';
+import { Button } from '@/components/ui/button.js';
 import {
     DropdownMenu,
     DropdownMenuCheckboxItem,
     DropdownMenuContent,
     DropdownMenuLabel,
     DropdownMenuSeparator,
-} from '@/components/ui/dropdown-menu';
+} from '@/components/ui/dropdown-menu.js';
 
 interface DataTableViewOptionsProps<TData> {
     table: Table<TData>;

+ 6 - 5
packages/dashboard/src/framework/internal/data-table/data-table.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.js';
 import { DataTablePagination } from '@/framework/internal/data-table/data-table-pagination.js';
 import { DataTableViewOptions } from '@/framework/internal/data-table/data-table-view-options.js';
 
@@ -12,6 +12,7 @@ import {
     PaginationState,
     SortingState,
     useReactTable,
+    Table as TableType,
 } from '@tanstack/react-table';
 import React, { useEffect } from 'react';
 
@@ -21,8 +22,8 @@ interface DataTableProps<TData, TValue> {
     totalItems: number;
     page?: number;
     itemsPerPage?: number;
-    onPageChange?: (page: number, itemsPerPage: number) => void;
-    onSortChange?: (sorting: SortingState) => void;
+    onPageChange?: (table: TableType<TData>, page: number, itemsPerPage: number) => void;
+    onSortChange?: (table: TableType<TData>, sorting: SortingState) => void;
 }
 
 export function DataTable<TData, TValue>({
@@ -56,11 +57,11 @@ export function DataTable<TData, TValue>({
     });
 
     useEffect(() => {
-        onPageChange?.(pagination.pageIndex + 1, pagination.pageSize);
+        onPageChange?.(table, pagination.pageIndex + 1, pagination.pageSize);
     }, [pagination]);
 
     useEffect(() => {
-        onSortChange?.(sorting);
+        onSortChange?.(table, sorting);
     }, [sorting]);
 
     return (

+ 3 - 2
packages/dashboard/src/framework/internal/document-introspection/get-document-structure.ts

@@ -109,11 +109,12 @@ function collectFields(
     fragments: Record<string, FragmentDefinitionNode>,
 ) {
     if (fieldNode.kind === 'Field') {
-        fields.push(getObjectFieldInfo(typeName, fieldNode.name.value));
+        const fieldInfo = getObjectFieldInfo(typeName, fieldNode.name.value);
+        fields.push(fieldInfo);
         if (fieldNode.selectionSet) {
             fieldNode.selectionSet.selections.forEach(subSelection => {
                 if (subSelection.kind === 'Field') {
-                    collectFields(typeName, subSelection, fields, fragments);
+                    collectFields(fieldInfo.type, subSelection, [], fragments);
                 } else if (subSelection.kind === 'FragmentSpread') {
                     const fragmentName = subSelection.name.value;
                     const fragment = fragments[fragmentName];

+ 70 - 31
packages/dashboard/src/framework/internal/page/list-page.tsx

@@ -6,13 +6,12 @@ import {
     getQueryName,
 } from '@/framework/internal/document-introspection/get-document-structure.js';
 import { api } from '@/graphql/api.js';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
 
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { useQuery } from '@tanstack/react-query';
-import { AnyRouter, Route, useNavigate } from '@tanstack/react-router';
-import { AnyRoute } from '@tanstack/react-router';
-import { createColumnHelper } from '@tanstack/react-table';
+import { AnyRoute, AnyRouter, useNavigate } from '@tanstack/react-router';
+import { createColumnHelper, SortingState, Table } from '@tanstack/react-table';
+import { ColumnDef } from '@tanstack/table-core';
 import { ResultOf } from 'gql.tada';
 import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
 import React from 'react';
@@ -26,9 +25,7 @@ type ListQueryFields<T extends TypedDocumentNode> = {
 }[keyof ResultOf<T>];
 
 export type CustomizeColumnConfig<T extends TypedDocumentNode> = {
-    [Key in keyof ListQueryFields<T>]?: {
-        header: string;
-    };
+    [Key in keyof ListQueryFields<T>]?: Partial<ColumnDef<any>>;
 };
 
 export type ListQueryShape = {
@@ -60,11 +57,13 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
     };
     const sorting = routeSearch.sort;
     const sort = (sorting?.split(',') || [])?.reduce((acc: any, sort: string) => {
-        const [field, direction] = sort.split(':');
+        const direction = sort.startsWith('-') ? 'DESC' : 'ASC';
+        const field = sort.replace(/^-/, '');
+
         if (!field || !direction) {
             return acc;
         }
-        return { ...acc, [field]: direction === '1' ? 'ASC' : 'DESC' };
+        return { ...acc, [field]: direction };
     }, {});
     const { data } = useQuery({
         queryFn: () =>
@@ -81,8 +80,10 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
     const queryName = getQueryName(listQuery);
     const columnHelper = createColumnHelper();
 
-    const columns = fields.map(field =>
-        columnHelper.accessor(field.name as any, {
+    const columns = fields.map(field => {
+        const customConfig = customizeColumns?.[field.name as keyof ListQueryFields<T>] ?? {};
+        const { header, ...customConfigRest } = customConfig;
+        return columnHelper.accessor(field.name as any, {
             meta: { type: field.type },
             cell: ({ cell }) => {
                 const value = cell.getValue();
@@ -96,30 +97,72 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
                 if (field.type === 'Boolean') {
                     Cmp = getComponent('boolean.display');
                 }
+                if (field.type === 'Asset') {
+                    Cmp = getComponent('asset.display');
+                }
 
                 if (Cmp) {
                     return <Cmp value={value} />;
                 }
                 return value;
             },
-            header: ({ column }) => {
+            header: headerContext => {
+                const column = headerContext.column;
+                const isSortable = column.getCanSort();
+                console.log(`${field.name} isSortable: `, isSortable);
+
+                const customHeader = customConfig.header;
+                let display = field.name;
+                if (typeof customHeader === 'function') {
+                    display = customHeader(headerContext);
+                } else if (typeof customHeader === 'string') {
+                    display = customHeader;
+                }
+
                 const columSort = column.getIsSorted();
                 const nextSort = columSort === 'asc' ? true : columSort === 'desc' ? undefined : false;
                 return (
-                    <Button variant="ghost" onClick={() => column.toggleSorting(nextSort)}>
-                        {customizeColumns?.[field.name as keyof ListQueryFields<T>]?.header ?? field.name}
-                        {columSort === 'desc' ? (
-                            <ArrowUp />
-                        ) : columSort === 'asc' ? (
-                            <ArrowDown />
-                        ) : (
-                            <ArrowUpDown className="opacity-30" />
+                    <>
+                        {display}
+                        {isSortable && (
+                            <Button variant="ghost" onClick={() => column.toggleSorting(nextSort)}>
+                                {columSort === 'desc' ? (
+                                    <ArrowUp />
+                                ) : columSort === 'asc' ? (
+                                    <ArrowDown />
+                                ) : (
+                                    <ArrowUpDown className="opacity-30" />
+                                )}
+                            </Button>
                         )}
-                    </Button>
+                    </>
                 );
             },
-        }),
-    );
+            ...customConfigRest,
+        });
+    });
+
+    function persistListStateToUrl(
+        table: Table<any>,
+        listState: {
+            page?: number;
+            perPage?: number;
+            sort?: SortingState;
+        },
+    ) {
+        const tableState = table.getState();
+        const page = listState.page || tableState.pagination.pageIndex + 1;
+        const perPage = listState.perPage || tableState.pagination.pageSize;
+
+        function sortToString(sortingStates?: SortingState) {
+            return sortingStates?.map(s => `${s.desc ? '-' : ''}${s.id}`).join(',');
+        }
+
+        const sort = sortToString(listState.sort ?? tableState.sorting);
+        navigate({
+            search: () => ({ sort, page, perPage }) as never,
+        });
+    }
 
     return (
         <div className="m-4">
@@ -130,15 +173,11 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
                 page={pagination.page}
                 itemsPerPage={pagination.itemsPerPage}
                 totalItems={(data as any)?.[queryName]?.totalItems ?? 0}
-                onPageChange={(page, perPage) => {
-                    navigate({ search: () => ({ page, perPage }) as never });
+                onPageChange={(table, page, perPage) => {
+                    persistListStateToUrl(table, { page, perPage });
                 }}
-                onSortChange={sorting => {
-                    navigate({
-                        replace: false,
-                        search: () =>
-                            ({ sort: sorting.map(s => `${s.id}:${s.desc ? 1 : 0}`).join(',') }) as never,
-                    });
+                onSortChange={(table, sorting) => {
+                    persistListStateToUrl(table, { sort: sorting });
                 }}
             ></DataTable>
         </div>

+ 1 - 1
packages/dashboard/src/routes/__root.tsx

@@ -10,7 +10,7 @@ interface MyRouterContext {
 export const Route = createRootRouteWithContext<MyRouterContext>()({
     component: RootComponent,
     search: {
-        middlewares: [retainSearchParams(['page', 'perPage', 'sort'] as any)],
+        // middlewares: [retainSearchParams(['page', 'perPage', 'sort'] as any)],
     },
 });
 

+ 31 - 10
packages/dashboard/src/routes/_authenticated/products.tsx

@@ -7,21 +7,38 @@ export const Route = createFileRoute('/_authenticated/products')({
     component: ProductListPage,
 });
 
-const productListDocument = graphql(`
-    query ProductList($options: ProductListOptions) {
-        products(options: $options) {
-            items {
-                id
-                createdAt
-                name
-                updatedAt
-                enabled
+const productFragment = graphql(`
+    fragment ProductFragment on Product {
+        id
+        createdAt
+        updatedAt
+        name
+        featuredAsset {
+            id
+            preview
+            focalPoint {
+                x
+                y
             }
-            totalItems
         }
+        enabled
     }
 `);
 
+const productListDocument = graphql(
+    `
+        query ProductList($options: ProductListOptions) {
+            products(options: $options) {
+                items {
+                    ...ProductFragment
+                }
+                totalItems
+            }
+        }
+    `,
+    [productFragment],
+);
+
 export function ProductListPage() {
     return (
         <ListPage
@@ -29,6 +46,10 @@ export function ProductListPage() {
             listQuery={productListDocument}
             customizeColumns={{
                 name: { header: 'Product Name' },
+                featuredAsset: {
+                    header: 'Image',
+                    enableSorting: false,
+                },
             }}
             route={Route}
         />