Browse Source

feat(dashboard): Implement facet list

Michael Bromley 10 months ago
parent
commit
5c398695ba

+ 6 - 0
packages/dashboard/src/components/layout/generated-breadcrumbs.tsx

@@ -44,6 +44,12 @@ export function GeneratedBreadcrumbs() {
                     }
                 });
             }
+            if (typeof loaderData.breadcrumb === 'function') {
+                return {
+                    label: loaderData.breadcrumb(),
+                    path: pathname,
+                };
+            }
         })
         .flat();
     return (

+ 3 - 1
packages/dashboard/src/components/shared/assigned-facet-values.tsx

@@ -47,12 +47,14 @@ export function AssignedFacetValues({
                     const facetValue = knownFacetValues.find(fv => fv.id === id);
                     if (!facetValue) return null;
                     return (
+                        <div className="mb-2 mr-1">
                         <FacetValueChip
                             key={facetValue.id}
                             facetValue={facetValue}
                             removable={canUpdate}
                             onRemove={onRemoveHandler}
-                        />
+                            />
+                        </div>
                     );
                 })}
             </div>

+ 4 - 3
packages/dashboard/src/components/shared/facet-value-chip.tsx

@@ -16,18 +16,19 @@ interface FacetValue {
 interface FacetValueChipProps {
     facetValue: FacetValue;
     removable?: boolean;
+    displayFacetName?: boolean;
     onRemove?: (id: string) => void;
 }
 
-export function FacetValueChip({ facetValue, removable = true, onRemove }: FacetValueChipProps) {
+export function FacetValueChip({ facetValue, removable = true, onRemove, displayFacetName = true }: FacetValueChipProps) {
     return (
         <Badge 
             variant="secondary"
-            className="mr-2 mb-2 flex items-center gap-2 py-0.5 pl-2 pr-1 h-6 hover:bg-secondary/80"
+            className="flex items-center gap-2 py-0.5 pl-2 pr-1 h-6 hover:bg-secondary/80"
         >
             <div className="flex items-center gap-1.5">
                 <span className="font-medium">{facetValue.name}</span>
-                <span className="text-muted-foreground text-xs">in {facetValue.facet.name}</span>
+                {displayFacetName && <span className="text-muted-foreground text-xs">in {facetValue.facet.name}</span>}
             </div>
             {removable && (
                 <button

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

@@ -20,6 +20,11 @@ navMenu({
                     title: 'Product Variants',
                     url: '/product-variants',
                 },
+                {
+                    id: 'facets',
+                    title: 'Facets',
+                    url: '/facets',
+                },
             ],
         },
         {

+ 3 - 0
packages/dashboard/src/framework/page/list-page.tsx

@@ -26,6 +26,7 @@ export interface ListPageProps<
     V extends ListQueryOptionsShape,
 > extends PageProps {
     listQuery: T;
+    transformVariables?: (variables: V) => V;
     onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
     customizeColumns?: CustomizeColumnConfig<T>;
     defaultColumnOrder?: (keyof ListQueryFields<T>)[];
@@ -40,6 +41,7 @@ export function ListPage<
 >({
     title,
     listQuery,
+    transformVariables,
     customizeColumns,
     route: routeOrFn,
     defaultVisibility,
@@ -91,6 +93,7 @@ export function ListPage<
             <PageActionBar>{children}</PageActionBar>
             <PaginatedListDataTable
                 listQuery={listQuery}
+                transformVariables={transformVariables}
                 customizeColumns={customizeColumns}
                 defaultVisibility={defaultVisibility}
                 onSearchTermChange={onSearchTermChange}

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

@@ -18,6 +18,7 @@ import { Route as AuthenticatedIndexImport } from './routes/_authenticated/index
 import { Route as AuthenticatedDashboardImport } from './routes/_authenticated/dashboard';
 import { Route as AuthenticatedProductsProductsImport } from './routes/_authenticated/_products/products';
 import { Route as AuthenticatedProductVariantsProductVariantsImport } from './routes/_authenticated/_product-variants/product-variants';
+import { Route as AuthenticatedFacetsFacetsImport } from './routes/_authenticated/_facets/facets';
 import { Route as AuthenticatedProductsProductsIdImport } from './routes/_authenticated/_products/products_.$id';
 import { Route as AuthenticatedProductVariantsProductVariantsIdImport } from './routes/_authenticated/_product-variants/product-variants_.$id';
 
@@ -65,6 +66,12 @@ const AuthenticatedProductVariantsProductVariantsRoute =
         getParentRoute: () => AuthenticatedRoute,
     } as any);
 
+const AuthenticatedFacetsFacetsRoute = AuthenticatedFacetsFacetsImport.update({
+    id: '/_facets/facets',
+    path: '/facets',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
 const AuthenticatedProductsProductsIdRoute = AuthenticatedProductsProductsIdImport.update({
     id: '/_products/products_/$id',
     path: '/products/$id',
@@ -117,6 +124,13 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedIndexImport;
             parentRoute: typeof AuthenticatedImport;
         };
+        '/_authenticated/_facets/facets': {
+            id: '/_authenticated/_facets/facets';
+            path: '/facets';
+            fullPath: '/facets';
+            preLoaderRoute: typeof AuthenticatedFacetsFacetsImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
         '/_authenticated/_product-variants/product-variants': {
             id: '/_authenticated/_product-variants/product-variants';
             path: '/product-variants';
@@ -153,6 +167,7 @@ declare module '@tanstack/react-router' {
 interface AuthenticatedRouteChildren {
     AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute;
     AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
+    AuthenticatedFacetsFacetsRoute: typeof AuthenticatedFacetsFacetsRoute;
     AuthenticatedProductVariantsProductVariantsRoute: typeof AuthenticatedProductVariantsProductVariantsRoute;
     AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
     AuthenticatedProductVariantsProductVariantsIdRoute: typeof AuthenticatedProductVariantsProductVariantsIdRoute;
@@ -162,6 +177,7 @@ interface AuthenticatedRouteChildren {
 const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
     AuthenticatedIndexRoute: AuthenticatedIndexRoute,
+    AuthenticatedFacetsFacetsRoute: AuthenticatedFacetsFacetsRoute,
     AuthenticatedProductVariantsProductVariantsRoute: AuthenticatedProductVariantsProductVariantsRoute,
     AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
     AuthenticatedProductVariantsProductVariantsIdRoute: AuthenticatedProductVariantsProductVariantsIdRoute,
@@ -176,6 +192,7 @@ export interface FileRoutesByFullPath {
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/': typeof AuthenticatedIndexRoute;
+    '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
     '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
@@ -187,6 +204,7 @@ export interface FileRoutesByTo {
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/': typeof AuthenticatedIndexRoute;
+    '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
     '/product-variants/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
@@ -200,6 +218,7 @@ export interface FileRoutesById {
     '/login': typeof LoginRoute;
     '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute;
     '/_authenticated/': typeof AuthenticatedIndexRoute;
+    '/_authenticated/_facets/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/_authenticated/_product-variants/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
     '/_authenticated/_product-variants/product-variants_/$id': typeof AuthenticatedProductVariantsProductVariantsIdRoute;
@@ -214,6 +233,7 @@ export interface FileRouteTypes {
         | '/login'
         | '/dashboard'
         | '/'
+        | '/facets'
         | '/product-variants'
         | '/products'
         | '/product-variants/$id'
@@ -224,6 +244,7 @@ export interface FileRouteTypes {
         | '/login'
         | '/dashboard'
         | '/'
+        | '/facets'
         | '/product-variants'
         | '/products'
         | '/product-variants/$id'
@@ -235,6 +256,7 @@ export interface FileRouteTypes {
         | '/login'
         | '/_authenticated/dashboard'
         | '/_authenticated/'
+        | '/_authenticated/_facets/facets'
         | '/_authenticated/_product-variants/product-variants'
         | '/_authenticated/_products/products'
         | '/_authenticated/_product-variants/product-variants_/$id'
@@ -272,6 +294,7 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "children": [
         "/_authenticated/dashboard",
         "/_authenticated/",
+        "/_authenticated/_facets/facets",
         "/_authenticated/_product-variants/product-variants",
         "/_authenticated/_products/products",
         "/_authenticated/_product-variants/product-variants_/$id",
@@ -292,6 +315,10 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/index.tsx",
       "parent": "/_authenticated"
     },
+    "/_authenticated/_facets/facets": {
+      "filePath": "_authenticated/_facets/facets.tsx",
+      "parent": "/_authenticated"
+    },
     "/_authenticated/_product-variants/product-variants": {
       "filePath": "_authenticated/_product-variants/product-variants.tsx",
       "parent": "/_authenticated"

+ 97 - 0
packages/dashboard/src/routes/_authenticated/_facets/components/facet-values-sheet.tsx

@@ -0,0 +1,97 @@
+import { PaginatedListDataTable } from '@/components/shared/paginated-list-data-table.js';
+import {
+    Sheet,
+    SheetContent,
+    SheetDescription,
+    SheetHeader,
+    SheetTitle,
+    SheetTrigger,
+} from '@/components/ui/sheet.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Trans } from '@lingui/react/macro';
+import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
+import { PanelLeftOpen } from 'lucide-react';
+import { useState } from 'react';
+
+const facetValueListDocument = graphql(`
+    query FacetValueList($options: FacetValueListOptions) {
+        facetValues(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                name
+                code
+                customFields
+            }
+            totalItems
+        }
+    }
+`);
+
+export interface FacetValuesSheetProps {
+    facetName: string;
+    facetId: string;
+}
+
+export function FacetValuesSheet({ facetName, facetId }: FacetValuesSheetProps) {
+    const [sorting, setSorting] = useState<SortingState>([]);
+    const [page, setPage] = useState(1);
+    const [pageSize, setPageSize] = useState(10);
+    const [filters, setFilters] = useState<ColumnFiltersState>([]);
+    return (
+        <Sheet>
+            <SheetTrigger>
+                <PanelLeftOpen className="w-4 h-4" />
+            </SheetTrigger>
+            <SheetContent className="min-w-[90vw] lg:min-w-[800px]">
+                <SheetHeader>
+                    <SheetTitle>
+                        <Trans>Facet values for {facetName}</Trans>
+                    </SheetTitle>
+                    <SheetDescription>
+                        <PaginatedListDataTable
+                            listQuery={addCustomFields(facetValueListDocument)}
+                            page={page}
+                            itemsPerPage={pageSize}
+                            sorting={sorting}
+                            columnFilters={filters}
+                            onPageChange={(_, page, perPage) => {
+                                setPage(page);
+                                setPageSize(perPage);
+                            }}
+                            onSortChange={(_, sorting) => {
+                                setSorting(sorting);
+                            }}
+                            onFilterChange={(_, filters) => {
+                                setFilters(filters);
+                            }}
+                            transformVariables={variables => {
+                                const filter = variables.options?.filter ?? {};
+                                return {
+                                    options: {
+                                        filter: {
+                                            ...filter,
+                                            facetId: { eq: facetId },
+                                        },
+                                        sort: variables.options?.sort,
+                                        take: pageSize,
+                                        skip: (page - 1) * pageSize,
+                                    },
+                                };
+                            }}
+                            onSearchTermChange={searchTerm => {
+                                return {
+                                    name: {
+                                        contains: searchTerm,
+                                    },
+                                };
+                            }}
+                        />
+                    </SheetDescription>
+                </SheetHeader>
+            </SheetContent>
+        </Sheet>
+    );
+}

+ 59 - 0
packages/dashboard/src/routes/_authenticated/_facets/facets.graphql.ts

@@ -0,0 +1,59 @@
+import { graphql } from '@/graphql/graphql.js';
+
+export const facetValueFragment = graphql(`
+    fragment FacetValue on FacetValue {
+        id
+        createdAt
+        updatedAt
+        languageCode
+        code
+        name
+        translations {
+            id
+            languageCode
+            name
+        }
+        facet {
+            id
+            createdAt
+            updatedAt
+            name
+            code
+        }
+    }
+`);
+
+export const facetWithValuesFragment = graphql(
+    `
+        fragment FacetWithValueList on Facet {
+            id
+            createdAt
+            updatedAt
+            name
+            code
+            languageCode
+            isPrivate
+            valueList(options: $facetValueListOptions) {
+                totalItems
+                items {
+                    ...FacetValue
+                }
+            }
+        }
+    `,
+    [facetValueFragment],
+);
+
+export const facetListDocument = graphql(
+    `
+        query FacetList($options: FacetListOptions, $facetValueListOptions: FacetValueListOptions) {
+            facets(options: $options) {
+                items {
+                    ...FacetWithValueList
+                }
+                totalItems
+            }
+        }
+    `,
+    [facetWithValuesFragment],
+);

+ 98 - 0
packages/dashboard/src/routes/_authenticated/_facets/facets.tsx

@@ -0,0 +1,98 @@
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { Button } from '@/components/ui/button.js';
+import { ListPage } from '@/framework/page/list-page.js';
+import { createFileRoute, Link } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
+import { Trans } from '@lingui/react/macro';
+import { facetListDocument } from './facets.graphql.js';
+import { FacetValueChip } from '@/components/shared/facet-value-chip.js';
+
+import { ResultOf } from 'gql.tada';
+import { FacetValuesSheet } from './components/facet-values-sheet.js';
+import { Badge } from '@/components/ui/badge.js';
+
+export const Route = createFileRoute('/_authenticated/_facets/facets')({
+    component: FacetListPage,
+    loader: () => ({ breadcrumb: () => <Trans>Facets</Trans> }),
+});
+
+export function FacetListPage() {
+    return (
+        <ListPage
+            title="Facets"
+            customizeColumns={{
+                name: {
+                    header: 'Facet Name',
+                    cell: ({ row }) => {
+                        return (
+                            <Link to={`./${row.original.id}`}>
+                                <Button variant="ghost">{row.original.name}</Button>
+                            </Link>
+                        );
+                    },
+                },
+                valueList: {
+                    header: () => <Trans>Values</Trans>,
+                    cell: ({ cell }) => {
+                        const value = cell.getValue();
+                        if (!value) return null;
+                        const list = value as any as ResultOf<
+                            typeof facetListDocument
+                        >['facets']['items'][0]['valueList'];
+                        return (
+                            <div className="flex flex-wrap gap-2 items-center">
+                                {list.items.map(item => {
+                                    return (
+                                        <FacetValueChip
+                                            key={item.id}
+                                            facetValue={item}
+                                            removable={false}
+                                            displayFacetName={false}
+                                        />
+                                    );
+                                })}
+                                <Badge variant="outline">
+                                    {list.totalItems > 3 && (
+                                        <div>
+                                            <Trans>+ {list.totalItems - 3} more</Trans>
+                                        </div>
+                                    )}
+                                    <FacetValuesSheet facetId={cell.row.original.id} facetName={cell.row.original.name} />
+                                </Badge>
+                            </div>
+                        );
+                    },
+                },
+            }}
+            onSearchTermChange={searchTerm => {
+                return {
+                    name: { contains: searchTerm },
+                };
+            }}
+            listQuery={addCustomFields(facetListDocument)}
+            transformVariables={variables => {
+                return {
+                    ...variables,
+                    facetValueListOptions: {
+                        take: 3,
+                    },
+                };
+            }}
+            route={Route}
+        >
+            <PageActionBar>
+                <div></div>
+                <PermissionGuard requires={['CreateFacet', 'CreateCatalog']}>
+                    <Button asChild>
+                        <Link to="./new">
+                            <PlusIcon className="mr-2 h-4 w-4" />
+                            <Trans>New Facet</Trans>
+                        </Link>
+                    </Button>
+                </PermissionGuard>
+            </PageActionBar>
+        </ListPage>
+    );
+}

+ 1 - 1
packages/dashboard/src/routes/_authenticated/_product-variants/product-variants.tsx

@@ -9,7 +9,7 @@ import { useLocalFormat } from '@/hooks/use-local-format.js';
 
 export const Route = createFileRoute('/_authenticated/_product-variants/product-variants')({
     component: ProductListPage,
-    loader: () => ({ breadcrumb: 'Product' }),
+    loader: () => ({ breadcrumb: () => <Trans>Product Variants</Trans> }),
 });
 
 export function ProductListPage() {

+ 2 - 1
packages/dashboard/src/routes/_authenticated/_products/products.tsx

@@ -6,10 +6,11 @@ import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { PlusIcon } from 'lucide-react';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { Trans } from '@lingui/react/macro';
 
 export const Route = createFileRoute('/_authenticated/_products/products')({
     component: ProductListPage,
-    loader: () => ({ breadcrumb: 'Products' }),
+    loader: () => ({ breadcrumb: () => <Trans>Products</Trans> }),
 });
 
 export function ProductListPage() {