فهرست منبع

fix(dashboard): Improve rendering & layout of data table

Michael Bromley 10 ماه پیش
والد
کامیت
aa93ea712c

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 110 - 113
package-lock.json


+ 2 - 1
packages/dashboard/package.json

@@ -36,7 +36,8 @@
     "react-dom": "^19.0.0",
     "tailwind-merge": "^3.0.1",
     "tailwindcss": "^4.0.6",
-    "tailwindcss-animate": "^1.0.7"
+    "tailwindcss-animate": "^1.0.7",
+    "use-debounce": "^10.0.4"
   },
   "devDependencies": {
     "@eslint/js": "^9.19.0",

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

@@ -19,28 +19,8 @@ interface DataTableViewOptionsProps<TData> {
 }
 
 export function DataTableViewOptions<TData>({ table }: DataTableViewOptionsProps<TData>) {
-    const columnFilters = table.getState().columnFilters;
     return (
         <div className="flex items-center gap-2">
-            <div className="flex gap-1">
-                {columnFilters.map(f => {
-                    const [operator, value] = Object.entries(f.value as Record<string, string>)[0];
-                    return (
-                        <Badge key={f.id} className="flex gap-1 items-center" variant="secondary">
-                            <Filter size="12" className="opacity-50" />
-                            <div>{f.id}</div>
-                            <div>{operator}</div>
-                            <div>{value}</div>
-                            <button
-                                className="cursor-pointer"
-                                onClick={() => table.setColumnFilters(old => old.filter(x => x.id !== f.id))}
-                            >
-                                <CircleX size="14" />
-                            </button>
-                        </Badge>
-                    );
-                })}
-            </div>
             <DropdownMenu>
                 <DropdownMenuTrigger asChild>
                     <Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">

+ 42 - 1
packages/dashboard/src/framework/internal/data-table/data-table.tsx

@@ -1,5 +1,7 @@
 'use client';
 
+import { Badge } from '@/components/ui/badge.js';
+import { Input } from '@/components/ui/input.js';
 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';
@@ -17,6 +19,7 @@ import {
     ColumnFilter,
     ColumnFiltersState,
 } from '@tanstack/react-table';
+import { CircleX, Filter } from 'lucide-react';
 import React, { useEffect } from 'react';
 
 interface DataTableProps<TData, TValue> {
@@ -30,6 +33,7 @@ interface DataTableProps<TData, TValue> {
     onPageChange?: (table: TableType<TData>, page: number, itemsPerPage: number) => void;
     onSortChange?: (table: TableType<TData>, sorting: SortingState) => void;
     onFilterChange?: (table: TableType<TData>, columnFilters: ColumnFilter[]) => void;
+    onSearchTermChange?: (searchTerm: string) => void;
     defaultColumnVisibility?: VisibilityState;
 }
 
@@ -44,6 +48,7 @@ export function DataTable<TData, TValue>({
     onPageChange,
     onSortChange,
     onFilterChange,
+    onSearchTermChange,
     defaultColumnVisibility,
 }: DataTableProps<TData, TValue>) {
     const [sorting, setSorting] = React.useState<SortingState>(sortingInitialState || []);
@@ -91,7 +96,43 @@ export function DataTable<TData, TValue>({
 
     return (
         <>
-            <DataTableViewOptions table={table} />
+            <div className="flex justify-between items-start mt-2">
+                <div className="flex flex-col">
+                    <div>
+                        {onSearchTermChange && (
+                            <div className="flex items-center">
+                                <Input
+                                    placeholder="Filter..."
+                                    onChange={event => onSearchTermChange(event.target.value)}
+                                    className="max-w-sm w-md"
+                                />
+                            </div>
+                        )}
+                    </div>
+                    <div className="flex gap-1 mt-2">
+                        {columnFilters.map(f => {
+                            const [operator, value] = Object.entries(f.value as Record<string, string>)[0];
+                            return (
+                                <Badge key={f.id} className="flex gap-1 items-center" variant="secondary">
+                                    <Filter size="12" className="opacity-50" />
+                                    <div>{f.id}</div>
+                                    <div>{operator}</div>
+                                    <div>{value}</div>
+                                    <button
+                                        className="cursor-pointer"
+                                        onClick={() =>
+                                            setColumnFilters(old => old.filter(x => x.id !== f.id))
+                                        }
+                                    >
+                                        <CircleX size="14" />
+                                    </button>
+                                </Badge>
+                            );
+                        })}
+                    </div>
+                </div>
+                <DataTableViewOptions table={table} />
+            </div>
             <div className="rounded-md border my-2">
                 <Table>
                     <TableHeader>

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

@@ -19,6 +19,8 @@ export interface FieldInfo {
 /**
  * Given a DocumentNode of a PaginatedList query, returns information about each
  * of the selected fields.
+ *
+ * Inside React components, use the `useListQueryFields` hook to get this information.
  */
 export function getListQueryFields(documentNode: DocumentNode): FieldInfo[] {
     const fields: FieldInfo[] = [];

+ 13 - 0
packages/dashboard/src/framework/internal/document-introspection/hooks.ts

@@ -0,0 +1,13 @@
+import {
+    FieldInfo,
+    getListQueryFields,
+} from '@/framework/internal/document-introspection/get-document-structure.js';
+import { DocumentNode } from 'graphql';
+import { useMemo } from 'react';
+
+/**
+ * Returns a stable array of FieldInfo objects representing the fields of the list query.
+ */
+export function useListQueryFields(documentNode: DocumentNode): FieldInfo[] {
+    return useMemo(() => getListQueryFields(documentNode), [documentNode]);
+}

+ 82 - 47
packages/dashboard/src/framework/internal/page/list-page.tsx

@@ -5,7 +5,9 @@ import {
     getListQueryFields,
     getQueryName,
 } from '@/framework/internal/document-introspection/get-document-structure.js';
+import { useListQueryFields } from '@/framework/internal/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';
@@ -18,10 +20,11 @@ import {
     Table,
 } from '@tanstack/react-table';
 import { ColumnDef } from '@tanstack/table-core';
+import { ListQueryOptions } from '@vendure/core/src/index.js';
 import { ResultOf } from 'gql.tada';
-import React from 'react';
+import React, { useMemo } from 'react';
 
-type ListQueryFields<T extends TypedDocumentNode> = {
+type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
         ? U extends any[]
             ? U[number]
@@ -29,7 +32,7 @@ type ListQueryFields<T extends TypedDocumentNode> = {
         : never;
 }[keyof ResultOf<T>];
 
-export type CustomizeColumnConfig<T extends TypedDocumentNode> = {
+export type CustomizeColumnConfig<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof ListQueryFields<T>]?: Partial<ColumnDef<any>>;
 };
 
@@ -40,26 +43,49 @@ export type ListQueryShape = {
     };
 };
 
-export interface ListPageProps<T extends TypedDocumentNode<U>, U extends ListQueryShape> {
+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,
+    V extends ListQueryOptionsShape,
+> {
     title: string;
     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>>;
-    route: AnyRoute;
 }
 
-export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string, any> = any>({
+export function ListPage<
+    T extends TypedDocumentNode<U, V>,
+    U extends Record<string, any> = any,
+    V extends ListQueryOptionsShape = {},
+>({
     title,
     listQuery,
     customizeColumns,
     route,
     defaultVisibility,
-}: ListPageProps<T, U>) {
+    onSearchTermChange,
+}: ListPageProps<T, U, V>) {
     const { getComponent } = useComponentRegistry();
     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,
@@ -79,60 +105,69 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
         }
         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: () =>
-            api.query(listQuery, {
+        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,
+                    filter: mergedFilter,
                 },
-            }),
-        queryKey: ['ListPage', route.id, pagination, sorting, filter],
+            } as unknown as V);
+        },
+        queryKey: ['ListPage', route.id, pagination, sorting, filter, debouncedSearchTerm],
     });
-    const fields = getListQueryFields(listQuery);
+    const fields = useListQueryFields(listQuery);
     const queryName = getQueryName(listQuery);
     const columnHelper = createColumnHelper();
 
-    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: { 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('boolean.display');
-                }
-                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: headerContext => {
-                return <DataTableColumnHeader headerContext={headerContext} customConfig={customConfig} />;
-            },
-            ...customConfigRest,
+    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('boolean.display');
+                    }
+                    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: headerContext => {
+                    return (
+                        <DataTableColumnHeader headerContext={headerContext} customConfig={customConfig} />
+                    );
+                },
+                ...customConfigRest,
+            });
         });
-    });
+    }, [fields, customizeColumns]);
 
     const columnVisibility = {
         id: false,
@@ -182,9 +217,9 @@ export function ListPage<T extends TypedDocumentNode<U>, U extends Record<string
                     persistListStateToUrl(table, { sort: sorting });
                 }}
                 onFilterChange={(table, filters) => {
-                    console.log('filters', filters);
                     persistListStateToUrl(table, { filters });
                 }}
+                onSearchTermChange={onSearchTermChange ? term => setSearchTerm(term) : undefined}
                 defaultColumnVisibility={columnVisibility}
             ></DataTable>
         </div>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است