Browse Source

feat(dashboard): Customer list view

Michael Bromley 10 months ago
parent
commit
1edf48737d

+ 1 - 1
packages/dashboard/src/components/data-table/data-table.tsx

@@ -43,7 +43,7 @@ interface DataTableProps<TData, TValue> {
     onFilterChange?: (table: TableType<TData>, columnFilters: ColumnFilter[]) => void;
     onSearchTermChange?: (searchTerm: string) => void;
     defaultColumnVisibility?: VisibilityState;
-    facetedFilters?: Record<string, FacetedFilter>;
+    facetedFilters?: { [key: string]: FacetedFilter | undefined };
 }
 
 export function DataTable<TData, TValue>({

+ 32 - 10
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -23,6 +23,7 @@ import {
 } from '@tanstack/react-table';
 import { AccessorKeyColumnDef, ColumnDef } from '@tanstack/table-core';
 import React, { Key, useMemo } from 'react';
+import { customerListDocument } from '@/routes/_authenticated/_customers/customers.graphql.js';
 
 // Type that identifies a paginated list structure (has items array and totalItems)
 type IsPaginatedList<T> = T extends { items: any[]; totalItems: number } ? true : false;
@@ -91,7 +92,7 @@ export type PaginatedListKeys<
 }[keyof PaginatedListItemFields<T, Path>];
 
 export type CustomizeColumnConfig<T extends TypedDocumentNode<any, any>> = {
-    [Key in keyof PaginatedListItemFields<T>]?: Partial<ColumnDef<any>>;
+    [Key in keyof PaginatedListItemFields<T>]?: Partial<ColumnDef<PaginatedListItemFields<T>>>;
 };
 
 export type FacetedFilterConfig<T extends TypedDocumentNode<any, any>> = {
@@ -126,6 +127,10 @@ export type ListQueryOptionsShape = {
     [key: string]: any;
 };
 
+export type AdditionalColumns<T extends TypedDocumentNode<any, any>> = {
+    [key: string]: ColumnDef<PaginatedListItemFields<T>>
+}
+
 export interface PaginatedListContext {
     refetchPaginatedList: () => void;
 }
@@ -160,12 +165,14 @@ export interface PaginatedListDataTableProps<
     T extends TypedDocumentNode<U, V>,
     U extends any,
     V extends ListQueryOptionsShape,
+    AC extends AdditionalColumns<T>,
 > {
     listQuery: T;
     transformQueryKey?: (queryKey: any[]) => any[];
     transformVariables?: (variables: V) => V;
     customizeColumns?: CustomizeColumnConfig<T>;
-    additionalColumns?: ColumnDef<any>[];
+    additionalColumns?: AC;
+    defaultColumnOrder?: (keyof PaginatedListItemFields<T> | AC[number]['id'])[];
     defaultVisibility?: Partial<Record<keyof PaginatedListItemFields<T>, boolean>>;
     onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
     page: number;
@@ -182,6 +189,7 @@ export function PaginatedListDataTable<
     T extends TypedDocumentNode<U, V>,
     U extends Record<string, any> = any,
     V extends ListQueryOptionsShape = {},
+    AC extends AdditionalColumns<T> = AdditionalColumns<T>,
 >({
     listQuery,
     transformQueryKey,
@@ -189,6 +197,7 @@ export function PaginatedListDataTable<
     customizeColumns,
     additionalColumns,
     defaultVisibility,
+    defaultColumnOrder,
     onSearchTermChange,
     page,
     itemsPerPage,
@@ -198,7 +207,7 @@ export function PaginatedListDataTable<
     onSortChange,
     onFilterChange,
     facetedFilters,
-}: PaginatedListDataTableProps<T, U, V>) {
+}: PaginatedListDataTableProps<T, U, V, AC>) {
     const [searchTerm, setSearchTerm] = React.useState<string>('');
     const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
     const queryClient = useQueryClient();
@@ -224,7 +233,7 @@ export function PaginatedListDataTable<
           }
         : undefined;
 
-    const defaultQueryKey = ['PaginatedListDataTable', listQuery, page, itemsPerPage, sorting, filter];
+    const defaultQueryKey = ['PaginatedListDataTable', listQuery, page, itemsPerPage, sorting, filter, debouncedSearchTerm];
     const queryKey = transformQueryKey ? transformQueryKey(defaultQueryKey) : defaultQueryKey;
 
     function refetchPaginatedList() {
@@ -258,7 +267,7 @@ export function PaginatedListDataTable<
         listData = listData?.[path];
     }
 
-    const columnHelper = createColumnHelper();
+    const columnHelper = createColumnHelper<PaginatedListItemFields<T>>();
 
     const columns = useMemo(() => {
         const columnConfigs: Array<{ fieldInfo: FieldInfo; isCustomField: boolean }> = [];
@@ -282,7 +291,7 @@ export function PaginatedListDataTable<
             const { header, ...customConfigRest } = customConfig;
             const enableColumnFilter = fieldInfo.isScalar && !facetedFilters?.[fieldInfo.name];
 
-            return columnHelper.accessor(fieldInfo.name, {
+            return columnHelper.accessor(fieldInfo.name as any, {
                 meta: { fieldInfo, isCustomField },
                 enableColumnFilter,
                 enableSorting: fieldInfo.isScalar,
@@ -319,13 +328,26 @@ export function PaginatedListDataTable<
             });
         });
 
-        const finalColumns: AccessorKeyColumnDef<unknown, never>[] = [...queryBasedColumns];
+        let finalColumns = [...queryBasedColumns];
 
-        for (const column of additionalColumns ?? []) {
-            if (!column.id) {
+        for (const [id, column] of Object.entries(additionalColumns ?? {})) {
+            if (!id) {
                 throw new Error('Column id is required');
             }
-            finalColumns.push(columnHelper.accessor(column.id, column));
+            finalColumns.push(columnHelper.accessor(id as any, { ...column, id }));
+        }
+
+        if (defaultColumnOrder) {
+            // ensure the columns with ids matching the items in defaultColumnOrder
+            // appear as the first columns in sequence, and leave the remainder in the
+            // existing order
+            const orderedColumns = finalColumns.filter(
+                column => column.id && defaultColumnOrder.includes(column.id),
+            );
+            const remainingColumns = finalColumns.filter(
+                column => !column.id || !defaultColumnOrder.includes(column.id),
+            );
+            finalColumns = [...orderedColumns, ...remainingColumns];
         }
 
         return finalColumns;

+ 12 - 4
packages/dashboard/src/framework/page/list-page.tsx

@@ -1,11 +1,13 @@
 import { PageProps } from '@/framework/page/page-types.js';
 
 import {
+    AdditionalColumns,
     CustomizeColumnConfig,
     FacetedFilterConfig,
     ListQueryOptionsShape,
     ListQueryShape,
     PaginatedListDataTable,
+    PaginatedListItemFields,
 } from '@/components/shared/paginated-list-data-table.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { AnyRouter, useNavigate } from '@tanstack/react-router';
@@ -13,6 +15,7 @@ import { ColumnDef, ColumnFiltersState, SortingState, Table } from '@tanstack/re
 import { ResultOf } from 'gql.tada';
 import { Page, PageActionBar, PageTitle } from '../layout-engine/page-layout.js';
 import { FacetedFilter } from '@/components/data-table/data-table.js';
+import { customerListDocument } from '@/routes/_authenticated/_customers/customers.graphql.js';
 
 type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
     [Key in keyof ResultOf<T>]: ResultOf<T>[Key] extends { items: infer U }
@@ -22,18 +25,20 @@ type ListQueryFields<T extends TypedDocumentNode<any, any>> = {
         : never;
 }[keyof ResultOf<T>];
 
+
 export interface ListPageProps<
     T extends TypedDocumentNode<U, V>,
     U extends ListQueryShape,
     V extends ListQueryOptionsShape,
+    AC extends AdditionalColumns<T>,
 > extends PageProps {
     listQuery: T;
     transformVariables?: (variables: V) => V;
     onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
     customizeColumns?: CustomizeColumnConfig<T>;
-    additionalColumns?: ColumnDef<any>[];
-    defaultColumnOrder?: (keyof ListQueryFields<T>)[];
-    defaultVisibility?: Partial<Record<keyof ListQueryFields<T>, boolean>>;
+    additionalColumns?: AC;
+    defaultColumnOrder?: (keyof ListQueryFields<T> | keyof AC)[];
+    defaultVisibility?: Partial<Record<keyof ListQueryFields<T> | keyof AC, boolean>>;
     children?: React.ReactNode;
     facetedFilters?: FacetedFilterConfig<T>;
 }
@@ -42,18 +47,20 @@ export function ListPage<
     T extends TypedDocumentNode<U, V>,
     U extends Record<string, any> = any,
     V extends ListQueryOptionsShape = {},
+    AC extends AdditionalColumns<T> = AdditionalColumns<T>,
 >({
     title,
     listQuery,
     transformVariables,
     customizeColumns,
     additionalColumns,
+    defaultColumnOrder,
     route: routeOrFn,
     defaultVisibility,
     onSearchTermChange,
     facetedFilters,
     children,
-}: ListPageProps<T, U, V>) {
+}: ListPageProps<T, U, V, AC>) {
     const route = typeof routeOrFn === 'function' ? routeOrFn() : routeOrFn;
     const routeSearch = route.useSearch();
     const navigate = useNavigate<AnyRouter>({ from: route.fullPath });
@@ -102,6 +109,7 @@ export function ListPage<
                 transformVariables={transformVariables}
                 customizeColumns={customizeColumns}
                 additionalColumns={additionalColumns}
+                defaultColumnOrder={defaultColumnOrder}
                 defaultVisibility={defaultVisibility}
                 onSearchTermChange={onSearchTermChange}
                 page={pagination.page}

+ 27 - 0
packages/dashboard/src/routeTree.gen.ts

@@ -22,6 +22,7 @@ import { Route as AuthenticatedProductsProductsImport } from './routes/_authenti
 import { Route as AuthenticatedProductVariantsProductVariantsImport } from './routes/_authenticated/_product-variants/product-variants';
 import { Route as AuthenticatedOrdersOrdersImport } from './routes/_authenticated/_orders/orders';
 import { Route as AuthenticatedFacetsFacetsImport } from './routes/_authenticated/_facets/facets';
+import { Route as AuthenticatedCustomersCustomersImport } from './routes/_authenticated/_customers/customers';
 import { Route as AuthenticatedCollectionsCollectionsImport } from './routes/_authenticated/_collections/collections';
 import { Route as AuthenticatedAssetsAssetsImport } from './routes/_authenticated/_assets/assets';
 import { Route as AuthenticatedProductsProductsIdImport } from './routes/_authenticated/_products/products_.$id';
@@ -98,6 +99,12 @@ const AuthenticatedFacetsFacetsRoute = AuthenticatedFacetsFacetsImport.update({
     getParentRoute: () => AuthenticatedRoute,
 } as any);
 
+const AuthenticatedCustomersCustomersRoute = AuthenticatedCustomersCustomersImport.update({
+    id: '/_customers/customers',
+    path: '/customers',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
 const AuthenticatedCollectionsCollectionsRoute = AuthenticatedCollectionsCollectionsImport.update({
     id: '/_collections/collections',
     path: '/collections',
@@ -194,6 +201,13 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedCollectionsCollectionsImport;
             parentRoute: typeof AuthenticatedImport;
         };
+        '/_authenticated/_customers/customers': {
+            id: '/_authenticated/_customers/customers';
+            path: '/customers';
+            fullPath: '/customers';
+            preLoaderRoute: typeof AuthenticatedCustomersCustomersImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
         '/_authenticated/_facets/facets': {
             id: '/_authenticated/_facets/facets';
             path: '/facets';
@@ -281,6 +295,7 @@ interface AuthenticatedRouteChildren {
     AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
     AuthenticatedAssetsAssetsRoute: typeof AuthenticatedAssetsAssetsRoute;
     AuthenticatedCollectionsCollectionsRoute: typeof AuthenticatedCollectionsCollectionsRoute;
+    AuthenticatedCustomersCustomersRoute: typeof AuthenticatedCustomersCustomersRoute;
     AuthenticatedFacetsFacetsRoute: typeof AuthenticatedFacetsFacetsRoute;
     AuthenticatedOrdersOrdersRoute: typeof AuthenticatedOrdersOrdersRoute;
     AuthenticatedProductVariantsProductVariantsRoute: typeof AuthenticatedProductVariantsProductVariantsRoute;
@@ -299,6 +314,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedIndexRoute: AuthenticatedIndexRoute,
     AuthenticatedAssetsAssetsRoute: AuthenticatedAssetsAssetsRoute,
     AuthenticatedCollectionsCollectionsRoute: AuthenticatedCollectionsCollectionsRoute,
+    AuthenticatedCustomersCustomersRoute: AuthenticatedCustomersCustomersRoute,
     AuthenticatedFacetsFacetsRoute: AuthenticatedFacetsFacetsRoute,
     AuthenticatedOrdersOrdersRoute: AuthenticatedOrdersOrdersRoute,
     AuthenticatedProductVariantsProductVariantsRoute: AuthenticatedProductVariantsProductVariantsRoute,
@@ -322,6 +338,7 @@ export interface FileRoutesByFullPath {
     '/': typeof AuthenticatedIndexRoute;
     '/assets': typeof AuthenticatedAssetsAssetsRoute;
     '/collections': typeof AuthenticatedCollectionsCollectionsRoute;
+    '/customers': typeof AuthenticatedCustomersCustomersRoute;
     '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/orders': typeof AuthenticatedOrdersOrdersRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
@@ -342,6 +359,7 @@ export interface FileRoutesByTo {
     '/': typeof AuthenticatedIndexRoute;
     '/assets': typeof AuthenticatedAssetsAssetsRoute;
     '/collections': typeof AuthenticatedCollectionsCollectionsRoute;
+    '/customers': typeof AuthenticatedCustomersCustomersRoute;
     '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/orders': typeof AuthenticatedOrdersOrdersRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
@@ -364,6 +382,7 @@ export interface FileRoutesById {
     '/_authenticated/': typeof AuthenticatedIndexRoute;
     '/_authenticated/_assets/assets': typeof AuthenticatedAssetsAssetsRoute;
     '/_authenticated/_collections/collections': typeof AuthenticatedCollectionsCollectionsRoute;
+    '/_authenticated/_customers/customers': typeof AuthenticatedCustomersCustomersRoute;
     '/_authenticated/_facets/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/_authenticated/_orders/orders': typeof AuthenticatedOrdersOrdersRoute;
     '/_authenticated/_product-variants/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
@@ -387,6 +406,7 @@ export interface FileRouteTypes {
         | '/'
         | '/assets'
         | '/collections'
+        | '/customers'
         | '/facets'
         | '/orders'
         | '/product-variants'
@@ -406,6 +426,7 @@ export interface FileRouteTypes {
         | '/'
         | '/assets'
         | '/collections'
+        | '/customers'
         | '/facets'
         | '/orders'
         | '/product-variants'
@@ -426,6 +447,7 @@ export interface FileRouteTypes {
         | '/_authenticated/'
         | '/_authenticated/_assets/assets'
         | '/_authenticated/_collections/collections'
+        | '/_authenticated/_customers/customers'
         | '/_authenticated/_facets/facets'
         | '/_authenticated/_orders/orders'
         | '/_authenticated/_product-variants/product-variants'
@@ -472,6 +494,7 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
         "/_authenticated/",
         "/_authenticated/_assets/assets",
         "/_authenticated/_collections/collections",
+        "/_authenticated/_customers/customers",
         "/_authenticated/_facets/facets",
         "/_authenticated/_orders/orders",
         "/_authenticated/_product-variants/product-variants",
@@ -507,6 +530,10 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/_collections/collections.tsx",
       "parent": "/_authenticated"
     },
+    "/_authenticated/_customers/customers": {
+      "filePath": "_authenticated/_customers/customers.tsx",
+      "parent": "/_authenticated"
+    },
     "/_authenticated/_facets/facets": {
       "filePath": "_authenticated/_facets/facets.tsx",
       "parent": "/_authenticated"

+ 26 - 0
packages/dashboard/src/routes/_authenticated/_customers/components/customer-status-badge.tsx

@@ -0,0 +1,26 @@
+import { Badge } from '@/components/ui/badge.js';
+import { BadgeX, BadgeCheck } from 'lucide-react';
+import { Trans } from '@lingui/react/macro';
+
+export function CustomerStatusBadge({ status }: { status: 'guest' | 'registered' | 'verified' }) {
+    return (
+        <Badge variant="outline">
+            {status === 'verified' ? (
+                <div className="flex items-center gap-2">
+                    <BadgeCheck className="w-4 h-4 text-success" />
+                    <Trans>Verified</Trans>
+                </div>
+            ) : status === 'registered' ? (
+                <div className="flex items-center gap-2">
+                    <BadgeCheck className="w-4 h-4" />
+                    <Trans>Registered</Trans>
+                </div>
+            ) : (
+                <div className="flex items-center gap-2">
+                    <BadgeX className="w-4 h-4" />
+                    <Trans>Unverified</Trans>
+                </div>
+            )}
+        </Badge>
+    );
+}

+ 21 - 0
packages/dashboard/src/routes/_authenticated/_customers/customers.graphql.ts

@@ -0,0 +1,21 @@
+import { graphql } from '@/graphql/graphql.js';
+
+export const customerListDocument = graphql(`
+    query GetCustomerList($options: CustomerListOptions) {
+        customers(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                firstName
+                lastName
+                emailAddress
+                user {
+                    id
+                    verified
+                }
+            }
+            totalItems
+        }
+    }
+`);

+ 78 - 0
packages/dashboard/src/routes/_authenticated/_customers/customers.tsx

@@ -0,0 +1,78 @@
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { ListPage } from '@/framework/page/list-page.js';
+import { createFileRoute } from '@tanstack/react-router';
+import { ResultOf } from 'gql.tada';
+import { CustomerStatusBadge } from './components/customer-status-badge.js';
+import { customerListDocument } from './customers.graphql.js';
+import { Button } from '@/components/ui/button.js';
+import { Link } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/_authenticated/_customers/customers')({
+    component: CustomerListPage,
+});
+
+export function CustomerListPage() {
+    return (
+        <ListPage
+            title="Customers"
+            onSearchTermChange={searchTerm => {
+                return {
+                    lastName: {
+                        contains: searchTerm,
+                    },
+                    emailAddress: {
+                        contains: searchTerm,
+                    },
+                };
+            }}
+            transformVariables={variables => {
+                return {
+                    options: {
+                        ...variables.options,
+                        filterOperator: 'OR',
+                    },
+                };
+            }}
+            listQuery={addCustomFields(customerListDocument)}
+            route={Route}
+            customizeColumns={{
+                user: {
+                    header: 'Status',
+                    cell: ({ cell }) => {
+                        const value = cell.getValue() as ResultOf<
+                            typeof customerListDocument
+                        >['customers']['items'][number]['user'];
+                        const status = value ? (value.verified ? 'verified' : 'registered') : 'guest';
+                        return <CustomerStatusBadge status={status} />;
+                    },
+                },
+            }}
+            additionalColumns={{
+                name: {
+                    header: 'Name',
+                    cell: ({ row }) => {
+                        const value = `${row.original.firstName} ${row.original.lastName}`;
+                        return (
+                            <Button asChild variant="ghost">
+                                <Link
+                                    to="/_authenticated/_customers/customers/$id"
+                                    params={{ id: row.original.id }}
+                                >
+                                    {value}
+                                </Link>
+                            </Button>
+                        );
+                    },
+                },
+            }}
+            defaultColumnOrder={['name', 'emailAddress', 'user', 'createdAt']}
+            defaultVisibility={{
+                id: false,
+                createdAt: false,
+                updatedAt: false,
+                firstName: false,
+                lastName: false,
+            }}
+        />
+    );
+}