Browse Source

feat(dashboard): Collection list view

Michael Bromley 10 months ago
parent
commit
941f7fbb41

+ 115 - 29
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -5,6 +5,7 @@ import {
     FieldInfo,
     getQueryName,
     getTypeFieldInfo,
+    getObjectPathToPaginatedList,
 } from '@/framework/document-introspection/get-document-structure.js';
 import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
 import { api } from '@/graphql/api.js';
@@ -20,21 +21,88 @@ import {
     SortingState,
     Table,
 } from '@tanstack/react-table';
-import { ColumnDef } from '@tanstack/table-core';
-import { ResultOf } from 'gql.tada';
+import { AccessorKeyColumnDef, ColumnDef } from '@tanstack/table-core';
+import { graphql, ResultOf } from '@/graphql/graphql.js';
 import React, { useMemo } from 'react';
 import { Delegate } from '@/framework/component-registry/delegate.js';
 
-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>];
+// Type that identifies a paginated list structure (has items array and totalItems)
+type IsPaginatedList<T> = T extends { items: any[]; totalItems: number } ? true : false;
+
+// Helper type to extract string keys from an object
+type StringKeys<T> = T extends object ? Extract<keyof T, string> : never;
+
+// Helper type to handle nullability
+type NonNullable<T> = T extends null | undefined ? never : T;
+
+
+// Non-recursive approach to find paginated list paths with max 2 levels
+// Level 0: Direct top-level check
+type Level0PaginatedLists<T> = T extends object
+    ? IsPaginatedList<T> extends true
+        ? ''
+        : never
+    : never;
+
+// Level 1: One level deep
+type Level1PaginatedLists<T> = T extends object
+    ? {
+          [K in StringKeys<T>]: NonNullable<T[K]> extends object
+              ? IsPaginatedList<NonNullable<T[K]>> extends true
+                  ? K
+                  : never
+              : never;
+      }[StringKeys<T>]
+    : never;
+
+// Level 2: Two levels deep
+type Level2PaginatedLists<T> = T extends object
+    ? {
+          [K1 in StringKeys<T>]: NonNullable<T[K1]> extends object
+              ? {
+                    [K2 in StringKeys<NonNullable<T[K1]>>]: NonNullable<NonNullable<T[K1]>[K2]> extends object
+                        ? IsPaginatedList<NonNullable<NonNullable<T[K1]>[K2]>> extends true
+                            ? `${K1}.${K2}`
+                            : never
+                        : never;
+                }[StringKeys<NonNullable<T[K1]>>]
+              : never;
+      }[StringKeys<T>]
+    : never;
+
+// Combine all levels
+type FindPaginatedListPaths<T> = 
+    | Level0PaginatedLists<T>
+    | Level1PaginatedLists<T> 
+    | Level2PaginatedLists<T>;
+
+// Extract all paths from a TypedDocumentNode
+export type PaginatedListPaths<T extends TypedDocumentNode<any, any>> =
+    FindPaginatedListPaths<ResultOf<T>> extends infer Paths ? (Paths extends '' ? never : Paths) : never;
+
+export type PaginatedListItemFields<
+    T extends TypedDocumentNode<any, any>,
+    Path extends PaginatedListPaths<T> = PaginatedListPaths<T>,
+> =
+    // split the path by '.' if it exists
+    Path extends `${infer First}.${infer Rest}`
+        ? NonNullable<ResultOf<T>[First]>[Rest]['items'][number]
+        : Path extends keyof ResultOf<T>
+          ? ResultOf<T>[Path] extends { items: Array<infer Item> }
+              ? ResultOf<T>[Path]['items'][number]
+              : never
+          : never;
+
+export type PaginatedListKeys<
+    T extends TypedDocumentNode<any, any>,
+    Path extends PaginatedListPaths<T> = PaginatedListPaths<T>,
+> = {
+    [K in keyof PaginatedListItemFields<T, Path>]: K;
+}[keyof PaginatedListItemFields<T, Path>];
+
 
 export type CustomizeColumnConfig<T extends TypedDocumentNode<any, any>> = {
-    [Key in keyof ListQueryFields<T>]?: Partial<ColumnDef<any>>;
+    [Key in keyof PaginatedListItemFields<T>]?: Partial<ColumnDef<any>>;
 };
 
 export type ListQueryShape = {
@@ -42,6 +110,13 @@ export type ListQueryShape = {
         items: any[];
         totalItems: number;
     };
+} | {
+    [key: string]: {
+        [key: string]: {
+            items: any[];
+            totalItems: number;
+        };
+    };
 };
 
 export type ListQueryOptionsShape = {
@@ -53,6 +128,7 @@ export type ListQueryOptionsShape = {
         };
         filter?: any;
     };
+    [key: string]: any;
 };
 
 export interface PaginatedListContext {
@@ -64,11 +140,11 @@ export const PaginatedListContext = React.createContext<PaginatedListContext | u
 /**
  * @description
  * Returns the context for the paginated list data table. Must be used within a PaginatedListDataTable.
- * 
+ *
  * @example
  * ```ts
  * const { refetchPaginatedList } = usePaginatedList();
- * 
+ *
  * const mutation = useMutation({
  *     mutationFn: api.mutate(updateFacetValueDocument),
  *     onSuccess: () => {
@@ -83,18 +159,19 @@ export function usePaginatedList() {
         throw new Error('usePaginatedList must be used within a PaginatedListDataTable');
     }
     return context;
-} 
+}
 
 export interface PaginatedListDataTableProps<
     T extends TypedDocumentNode<U, V>,
-    U extends ListQueryShape,
+    U extends any,
     V extends ListQueryOptionsShape,
 > {
     listQuery: T;
+    pathToListQuery?: PaginatedListPaths<T>;
     transformVariables?: (variables: V) => V;
     customizeColumns?: CustomizeColumnConfig<T>;
     additionalColumns?: ColumnDef<any>[];
-    defaultVisibility?: Partial<Record<keyof ListQueryFields<T>, boolean>>;
+    defaultVisibility?: Partial<Record<keyof PaginatedListItemFields<T>, boolean>>;
     onSearchTermChange?: (searchTerm: string) => NonNullable<V['options']>['filter'];
     page: number;
     itemsPerPage: number;
@@ -143,14 +220,7 @@ export function PaginatedListDataTable<
         ? { _and: columnFilters.map(f => ({ [f.id]: f.value })) }
         : undefined;
 
-    const queryKey = [
-        'PaginatedListDataTable',
-        listQuery,
-        page,
-        itemsPerPage,
-        sorting,
-        filter,
-    ];
+    const queryKey = ['PaginatedListDataTable', listQuery, page, itemsPerPage, sorting, filter];
 
     function refetchPaginatedList() {
         queryClient.invalidateQueries({ queryKey });
@@ -176,7 +246,13 @@ export function PaginatedListDataTable<
     });
 
     const fields = useListQueryFields(listQuery);
-    const queryName = getQueryName(listQuery);
+    const paginatedListObjectPath = getObjectPathToPaginatedList(listQuery);
+
+    let listData = data as any;
+    for (const path of paginatedListObjectPath) {
+        listData = listData?.[path];
+    }
+
     const columnHelper = createColumnHelper();
 
     const columns = useMemo(() => {
@@ -197,9 +273,9 @@ export function PaginatedListDataTable<
         }
 
         const queryBasedColumns = columnConfigs.map(({ fieldInfo, isCustomField }) => {
-            const customConfig = customizeColumns?.[fieldInfo.name as keyof ListQueryFields<T>] ?? {};
+            const customConfig = customizeColumns?.[fieldInfo.name as keyof PaginatedListItemFields<T>] ?? {};
             const { header, ...customConfigRest } = customConfig;
-            return columnHelper.accessor(fieldInfo.name as any, {
+            return columnHelper.accessor(fieldInfo.name, {
                 meta: { fieldInfo, isCustomField },
                 enableColumnFilter: fieldInfo.isScalar,
                 enableSorting: fieldInfo.isScalar,
@@ -236,7 +312,16 @@ export function PaginatedListDataTable<
             });
         });
 
-        return [...queryBasedColumns, ...(additionalColumns?.map(def => columnHelper.accessor(def.id, def)) ?? [])];
+        const finalColumns: AccessorKeyColumnDef<unknown, never>[] = [...queryBasedColumns];
+
+        for (const column of additionalColumns ?? []) {
+            if (!column.id) {
+                throw new Error('Column id is required');
+            }
+            finalColumns.push(columnHelper.accessor(column.id, column));
+        }
+
+        return finalColumns;
     }, [fields, customizeColumns]);
 
     const columnVisibility = getColumnVisibility(fields, defaultVisibility);
@@ -245,12 +330,12 @@ export function PaginatedListDataTable<
         <PaginatedListContext.Provider value={{ refetchPaginatedList }}>
             <DataTable
                 columns={columns}
-                data={(data as any)?.[queryName]?.items ?? []}
+                data={listData?.items ?? []}
                 page={page}
                 itemsPerPage={itemsPerPage}
                 sorting={sorting}
                 columnFilters={columnFilters}
-                totalItems={(data as any)?.[queryName]?.totalItems ?? 0}
+                totalItems={listData?.totalItems ?? 0}
                 onPageChange={onPageChange}
                 onSortChange={onSortChange}
                 onFilterChange={onFilterChange}
@@ -261,6 +346,7 @@ export function PaginatedListDataTable<
     );
 }
 
+
 /**
  * Returns the default column visibility configuration.
  */

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

@@ -25,6 +25,11 @@ navMenu({
                     title: 'Facets',
                     url: '/facets',
                 },
+                {
+                    id: 'collections',
+                    title: 'Collections',
+                    url: '/collections',
+                },
             ],
         },
         {

+ 50 - 0
packages/dashboard/src/framework/document-introspection/get-document-structure.spec.ts

@@ -10,9 +10,21 @@ vi.mock('virtual:admin-api-schema', () => {
                 Query: {
                     products: ['ProductList', false, false, true],
                     product: ['Product', false, false, false],
+                    collection: ['Collection', false, false, false],
                 },
                 Mutation: {},
 
+                Collection: {
+                    id: ['ID', false, false, false],
+                    name: ['String', false, false, false],
+                    productVariants: ['ProductVariantList', false, false, true],
+                },
+
+                ProductVariantList: {
+                    items: ['ProductVariant', false, true, false],
+                    totalItems: ['Int', false, false, false],
+                },
+
                 Product: {
                     channels: ['Channel', false, true, false],
                     id: ['ID', false, false, false],
@@ -257,4 +269,42 @@ describe('getListQueryFields', () => {
         const fields = getListQueryFields(doc);
         expect(fields).toEqual([]);
     });
+
+    it.only('should handle a fragment of a nested entity in the query', () => {
+        const doc = graphql(/* graphql*/ `
+            query GetCollectionWithProductVariants {
+                collection {
+                    id
+                    name
+                    productVariants {
+                        items {
+                            id
+                            sku
+                        }
+                        totalItems
+                    }
+                }
+            }
+        `);
+
+        const fields = getListQueryFields(doc);
+        expect(fields).toEqual([
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'id',
+                nullable: false,
+                type: 'ID',
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'sku',
+                nullable: false,
+                type: 'String',
+            },
+        ]);
+    });
 });

+ 133 - 24
packages/dashboard/src/framework/document-introspection/get-document-structure.ts

@@ -50,21 +50,10 @@ export function getListQueryFields(documentNode: DocumentNode): FieldInfo[] {
             const queryField = query;
             const fieldInfo = getQueryInfo(queryField.name.value);
             if (fieldInfo.isPaginatedList) {
-                const itemsField = queryField.selectionSet?.selections.find(
-                    selection => selection.kind === 'Field' && selection.name.value === 'items',
-                ) as FieldNode;
-                if (!itemsField) {
-                    continue;
-                }
-                const typeName = getPaginatedListType(fieldInfo.name);
-                if (!typeName) {
-                    throw new Error(`Could not determine type of items in ${fieldInfo.name}`);
-                }
-                for (const item of itemsField.selectionSet?.selections ?? []) {
-                    if (item.kind === 'Field' || item.kind === 'FragmentSpread') {
-                        collectFields(typeName, item, fields, fragments);
-                    }
-                }
+                processPaginatedList(queryField, fieldInfo, fields, fragments);
+            } else if (queryField.selectionSet) {
+                // Check for nested paginated lists
+                findNestedPaginatedLists(queryField, fieldInfo.type, fields, fragments);
             }
         }
     }
@@ -72,6 +61,66 @@ export function getListQueryFields(documentNode: DocumentNode): FieldInfo[] {
     return fields;
 }
 
+function processPaginatedList(
+    field: FieldNode,
+    fieldInfo: FieldInfo,
+    fields: FieldInfo[],
+    fragments: Record<string, FragmentDefinitionNode>,
+) {
+    const itemsField = field.selectionSet?.selections.find(
+        selection => selection.kind === 'Field' && selection.name.value === 'items',
+    ) as FieldNode;
+    if (!itemsField) {
+        return;
+    }
+    const typeFields = schemaInfo.types[fieldInfo.type];
+    const isPaginatedList = typeFields.hasOwnProperty('items') && typeFields.hasOwnProperty('totalItems');
+    if (!isPaginatedList) {
+        throw new Error(`Could not determine type of items in ${fieldInfo.name}`);
+    }
+    const itemsType = getObjectFieldInfo(fieldInfo.type, 'items').type;
+    for (const item of itemsField.selectionSet?.selections ?? []) {
+        if (item.kind === 'Field' || item.kind === 'FragmentSpread') {
+            collectFields(itemsType, item, fields, fragments);
+        }
+    }
+}
+
+function findNestedPaginatedLists(
+    field: FieldNode,
+    parentType: string,
+    fields: FieldInfo[],
+    fragments: Record<string, FragmentDefinitionNode>,
+) {
+    for (const selection of field.selectionSet?.selections ?? []) {
+        if (selection.kind === 'Field') {
+            const fieldInfo = getObjectFieldInfo(parentType, selection.name.value);
+            if (fieldInfo.isPaginatedList) {
+                processPaginatedList(selection, fieldInfo, fields, fragments);
+            } else if (selection.selectionSet && !fieldInfo.isScalar) {
+                // Continue recursion
+                findNestedPaginatedLists(selection, fieldInfo.type, fields, fragments);
+            }
+        } else if (selection.kind === 'FragmentSpread') {
+            // Handle fragment spread on the parent type
+            const fragmentName = selection.name.value;
+            const fragment = fragments[fragmentName];
+            if (fragment && fragment.typeCondition.name.value === parentType) {
+                for (const fragmentSelection of fragment.selectionSet.selections) {
+                    if (fragmentSelection.kind === 'Field') {
+                        const fieldInfo = getObjectFieldInfo(parentType, fragmentSelection.name.value);
+                        if (fieldInfo.isPaginatedList) {
+                            processPaginatedList(fragmentSelection, fieldInfo, fields, fragments);
+                        } else if (fragmentSelection.selectionSet && !fieldInfo.isScalar) {
+                            findNestedPaginatedLists(fragmentSelection, fieldInfo.type, fields, fragments);
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
 /**
  * @description
  * This function is used to get the fields of the operation variables from a DocumentNode.
@@ -167,6 +216,69 @@ export function getQueryTypeFieldInfo(documentNode: DocumentNode): FieldInfo {
     return getQueryInfo(name);
 }
 
+/**
+ * @description
+ * This function is used to get the path to the paginated list from a DocumentNode.
+ *
+ * For example, in the following query:
+ *
+ * ```graphql
+ * query GetProductList($options: ProductListOptions) {
+ *   products(options: $options) {
+ *     items {
+ *       ...ProductDetail
+ *     }
+ *     totalCount
+ *   }
+ * }
+ * ```
+ *
+ * The path to the paginated list is `['products']`.
+ */
+export function getObjectPathToPaginatedList(
+    documentNode: DocumentNode,
+    currentPath: string[] = [],
+): string[] {
+    // get the query OperationDefinition
+    const operationDefinition = documentNode.definitions.find(
+        (def): def is OperationDefinitionNode =>
+            def.kind === 'OperationDefinition' && def.operation === 'query',
+    );
+    if (!operationDefinition) {
+        throw new Error('Could not find query operation definition');
+    }
+
+    return findPaginatedListPath(operationDefinition.selectionSet, 'Query', currentPath);
+}
+
+function findPaginatedListPath(
+    selectionSet: SelectionSetNode,
+    parentType: string,
+    currentPath: string[] = [],
+): string[] {
+    for (const selection of selectionSet.selections) {
+        if (selection.kind === 'Field') {
+            const fieldNode = selection;
+            const fieldInfo = getObjectFieldInfo(parentType, fieldNode.name.value);
+            const newPath = [...currentPath, fieldNode.name.value];
+
+            if (fieldInfo.isPaginatedList) {
+                return newPath;
+            }
+
+            // If this field has a selection set, recursively search it
+            if (fieldNode.selectionSet && !fieldInfo.isScalar) {
+                const result = findPaginatedListPath(fieldNode.selectionSet, fieldInfo.type, newPath);
+                if (result.length > 0) {
+                    return result;
+                }
+            }
+        }
+    }
+
+    return [];
+}
+
 /**
  * @description
  * This function is used to get the name of the mutation from a DocumentNode.
@@ -235,7 +347,7 @@ function getQueryInfo(name: string): FieldInfo {
 }
 
 function getInputTypeInfo(name: string): FieldInfo[] {
-    return Object.entries(schemaInfo.inputs[name]).map(([fieldName, fieldInfo]) => {
+    return Object.entries(schemaInfo.inputs[name]).map(([fieldName, fieldInfo]: [string, any]) => {
         const type = fieldInfo[0];
         const isScalar = isScalarType(type);
         const isEnum = isEnumType(type);
@@ -259,14 +371,6 @@ export function isEnumType(type: string): boolean {
     return schemaInfo.enums[type] != null;
 }
 
-function getPaginatedListType(name: string): string | undefined {
-    const queryInfo = getQueryInfo(name);
-    if (queryInfo.isPaginatedList) {
-        const paginagedListType = getObjectFieldInfo(queryInfo.type, 'items').type;
-        return paginagedListType;
-    }
-}
-
 function getObjectFieldInfo(typeName: string, fieldName: string): FieldInfo {
     const fieldInfo = schemaInfo.types[typeName][fieldName];
     const type = fieldInfo[0];
@@ -297,6 +401,11 @@ function collectFields(
                 } else if (subSelection.kind === 'FragmentSpread') {
                     const fragmentName = subSelection.name.value;
                     const fragment = fragments[fragmentName];
+                    if (!fragment) {
+                        throw new Error(
+                            `Fragment "${fragmentName}" not found. Make sure to include it in the "${typeName}" type query.`,
+                        );
+                    }
                     // We only want to collect fields from the fragment if it's the same type as
                     // the field we're collecting from
                     if (fragment.name.value !== typeName) {

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

@@ -8,7 +8,7 @@ import {
 } from '@/components/shared/paginated-list-data-table.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { AnyRouter, useNavigate } from '@tanstack/react-router';
-import { ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
+import { ColumnDef, ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
 import { ResultOf } from 'gql.tada';
 import { Page, PageActionBar, PageTitle } from '../layout-engine/page-layout.js';
 
@@ -29,6 +29,7 @@ export interface ListPageProps<
     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>>;
     children?: React.ReactNode;
@@ -43,6 +44,7 @@ export function ListPage<
     listQuery,
     transformVariables,
     customizeColumns,
+    additionalColumns,
     route: routeOrFn,
     defaultVisibility,
     onSearchTermChange,
@@ -95,6 +97,7 @@ export function ListPage<
                 listQuery={listQuery}
                 transformVariables={transformVariables}
                 customizeColumns={customizeColumns}
+                additionalColumns={additionalColumns}
                 defaultVisibility={defaultVisibility}
                 onSearchTermChange={onSearchTermChange}
                 page={pagination.page}

+ 8 - 3
packages/dashboard/src/framework/page/use-detail-page.ts

@@ -20,6 +20,7 @@ export interface DetailPageOptions<
     C extends TypedDocumentNode<any, any>,
     U extends TypedDocumentNode<any, any>,
     EntityField extends keyof ResultOf<T> = keyof ResultOf<T>,
+    VarNameCreate extends keyof VariablesOf<C> = 'input',
     VarNameUpdate extends keyof VariablesOf<U> = 'input',
 > {
     /**
@@ -54,6 +55,8 @@ export interface DetailPageOptions<
      * The function to set the values for the update document.
      */
     setValuesForUpdate: (entity: NonNullable<ResultOf<T>[EntityField]>) => VariablesOf<U>[VarNameUpdate];
+    transformCreateInput?: (input: VariablesOf<C>[VarNameCreate]) => VariablesOf<C>[VarNameCreate];
+    transformUpdateInput?: (input: VariablesOf<U>[VarNameUpdate]) => VariablesOf<U>[VarNameUpdate];
     /**
      * @description
      * The function to call when the update is successful.
@@ -89,12 +92,14 @@ export function useDetailPage<
     EntityField extends keyof ResultOf<T> = keyof ResultOf<T>,
     VarNameUpdate extends keyof VariablesOf<U> = 'input',
     VarNameCreate extends keyof VariablesOf<C> = 'input',
->(options: DetailPageOptions<T, C, U, EntityField, VarNameUpdate>) {
+>(options: DetailPageOptions<T, C, U, EntityField, VarNameCreate, VarNameUpdate>) {
     const {
         queryDocument,
         createDocument,
         updateDocument,
         setValuesForUpdate,
+        transformCreateInput,
+        transformUpdateInput,
         params,
         entityField,
         onSuccess,
@@ -130,9 +135,9 @@ export function useDetailPage<
         setValues: setValuesForUpdate,
         onSubmit(values: any) {
             if (isNew) {
-                createMutation.mutate({ input: values });
+                createMutation.mutate({ input: transformCreateInput?.(values) ?? values });
             } else {
-                updateMutation.mutate({ input: values });
+                updateMutation.mutate({ input: transformUpdateInput?.(values) ?? values });
             }
         },
     });

+ 0 - 1
packages/dashboard/src/providers/auth.tsx

@@ -103,7 +103,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     const logoutMutation = useMutation({
         mutationFn: logoutMutationFn,
         onSuccess: async data => {
-            console.log(data);
             if (data?.logout.success === true) {
                 setStatus('unauthenticated');
                 onLogoutSuccessFn.current();

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

@@ -19,6 +19,7 @@ import { Route as AuthenticatedDashboardImport } from './routes/_authenticated/d
 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 AuthenticatedCollectionsCollectionsImport } from './routes/_authenticated/_collections/collections';
 import { Route as AuthenticatedProductsProductsIdImport } from './routes/_authenticated/_products/products_.$id';
 import { Route as AuthenticatedProductVariantsProductVariantsIdImport } from './routes/_authenticated/_product-variants/product-variants_.$id';
 import { Route as AuthenticatedFacetsFacetsIdImport } from './routes/_authenticated/_facets/facets_.$id';
@@ -73,6 +74,12 @@ const AuthenticatedFacetsFacetsRoute = AuthenticatedFacetsFacetsImport.update({
     getParentRoute: () => AuthenticatedRoute,
 } as any);
 
+const AuthenticatedCollectionsCollectionsRoute = AuthenticatedCollectionsCollectionsImport.update({
+    id: '/_collections/collections',
+    path: '/collections',
+    getParentRoute: () => AuthenticatedRoute,
+} as any);
+
 const AuthenticatedProductsProductsIdRoute = AuthenticatedProductsProductsIdImport.update({
     id: '/_products/products_/$id',
     path: '/products/$id',
@@ -131,6 +138,13 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedIndexImport;
             parentRoute: typeof AuthenticatedImport;
         };
+        '/_authenticated/_collections/collections': {
+            id: '/_authenticated/_collections/collections';
+            path: '/collections';
+            fullPath: '/collections';
+            preLoaderRoute: typeof AuthenticatedCollectionsCollectionsImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
         '/_authenticated/_facets/facets': {
             id: '/_authenticated/_facets/facets';
             path: '/facets';
@@ -181,6 +195,7 @@ declare module '@tanstack/react-router' {
 interface AuthenticatedRouteChildren {
     AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute;
     AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
+    AuthenticatedCollectionsCollectionsRoute: typeof AuthenticatedCollectionsCollectionsRoute;
     AuthenticatedFacetsFacetsRoute: typeof AuthenticatedFacetsFacetsRoute;
     AuthenticatedProductVariantsProductVariantsRoute: typeof AuthenticatedProductVariantsProductVariantsRoute;
     AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
@@ -192,6 +207,7 @@ interface AuthenticatedRouteChildren {
 const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
     AuthenticatedIndexRoute: AuthenticatedIndexRoute,
+    AuthenticatedCollectionsCollectionsRoute: AuthenticatedCollectionsCollectionsRoute,
     AuthenticatedFacetsFacetsRoute: AuthenticatedFacetsFacetsRoute,
     AuthenticatedProductVariantsProductVariantsRoute: AuthenticatedProductVariantsProductVariantsRoute,
     AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
@@ -208,6 +224,7 @@ export interface FileRoutesByFullPath {
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/': typeof AuthenticatedIndexRoute;
+    '/collections': typeof AuthenticatedCollectionsCollectionsRoute;
     '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
@@ -221,6 +238,7 @@ export interface FileRoutesByTo {
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/': typeof AuthenticatedIndexRoute;
+    '/collections': typeof AuthenticatedCollectionsCollectionsRoute;
     '/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
@@ -236,6 +254,7 @@ export interface FileRoutesById {
     '/login': typeof LoginRoute;
     '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute;
     '/_authenticated/': typeof AuthenticatedIndexRoute;
+    '/_authenticated/_collections/collections': typeof AuthenticatedCollectionsCollectionsRoute;
     '/_authenticated/_facets/facets': typeof AuthenticatedFacetsFacetsRoute;
     '/_authenticated/_product-variants/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
@@ -252,6 +271,7 @@ export interface FileRouteTypes {
         | '/login'
         | '/dashboard'
         | '/'
+        | '/collections'
         | '/facets'
         | '/product-variants'
         | '/products'
@@ -264,6 +284,7 @@ export interface FileRouteTypes {
         | '/login'
         | '/dashboard'
         | '/'
+        | '/collections'
         | '/facets'
         | '/product-variants'
         | '/products'
@@ -277,6 +298,7 @@ export interface FileRouteTypes {
         | '/login'
         | '/_authenticated/dashboard'
         | '/_authenticated/'
+        | '/_authenticated/_collections/collections'
         | '/_authenticated/_facets/facets'
         | '/_authenticated/_product-variants/product-variants'
         | '/_authenticated/_products/products'
@@ -316,6 +338,7 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "children": [
         "/_authenticated/dashboard",
         "/_authenticated/",
+        "/_authenticated/_collections/collections",
         "/_authenticated/_facets/facets",
         "/_authenticated/_product-variants/product-variants",
         "/_authenticated/_products/products",
@@ -338,6 +361,10 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/index.tsx",
       "parent": "/_authenticated"
     },
+    "/_authenticated/_collections/collections": {
+      "filePath": "_authenticated/_collections/collections.tsx",
+      "parent": "/_authenticated"
+    },
     "/_authenticated/_facets/facets": {
       "filePath": "_authenticated/_facets/facets.tsx",
       "parent": "/_authenticated"

+ 34 - 0
packages/dashboard/src/routes/_authenticated/_collections/collections.graphql.ts

@@ -0,0 +1,34 @@
+import { assetFragment } from '@/graphql/fragments.js';
+import { graphql } from '@/graphql/graphql.js';
+
+export const collectionListDocument = graphql(
+    `
+        query CollectionList($options: CollectionListOptions) {
+            collections(options: $options) {
+                items {
+                    id
+                    createdAt
+                    updatedAt
+                    featuredAsset {
+                        ...Asset
+                    }
+                    name
+                    slug
+                    breadcrumbs {
+                        id
+                        name
+                        slug
+                    }
+                    position
+                    isPrivate
+                    parentId
+                    productVariants {
+                        totalItems
+                    }
+                }
+                totalItems
+            }
+        }
+    `,
+    [assetFragment],
+);

+ 90 - 0
packages/dashboard/src/routes/_authenticated/_collections/collections.tsx

@@ -0,0 +1,90 @@
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { Button } from '@/components/ui/button.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { ListPage } from '@/framework/page/list-page.js';
+import { Trans } from '@lingui/react/macro';
+import { createFileRoute, Link } from '@tanstack/react-router';
+import { PlusIcon } from 'lucide-react';
+import { collectionListDocument } from './collections.graphql.js';
+import { CollectionContentsSheet } from './components/collection-contents-sheet.js';
+import { Badge } from '@/components/ui/badge.js';
+
+export const Route = createFileRoute('/_authenticated/_collections/collections')({
+    component: CollectionListPage,
+    loader: () => ({ breadcrumb: () => <Trans>Collections</Trans> }),
+});
+
+export function CollectionListPage() {
+    return (
+        <ListPage
+            title="Collections"
+            customizeColumns={{
+                name: {
+                    header: 'Collection Name',
+                    cell: ({ row }) => {
+                        return (
+                            <Link to={`./${row.original.id}`}>
+                                <Button variant="ghost">{row.original.name}</Button>
+                            </Link>
+                        );
+                    },
+                },
+                breadcrumbs: {
+                    cell: ({ cell }) => {
+                        const value = cell.getValue();
+                        if (!Array.isArray(value)) return null;
+                        return (
+                            <div>
+                                {value
+                                    .slice(1)
+                                    .map(breadcrumb => breadcrumb.name)
+                                    .join(' / ')}
+                            </div>
+                        );
+                    },
+                },
+                productVariants: {
+                    header: 'Contents',
+                    cell: ({ row }) => {
+                        return (
+                            <Badge variant="outline">
+                                <div><Trans>{row.original.productVariants.totalItems} variants</Trans></div>
+                                <CollectionContentsSheet
+                                    collectionId={row.original.id}
+                                    collectionName={row.original.name}
+                                />
+                            </Badge>
+                        );
+                    },
+                },
+            }}
+            defaultVisibility={{
+                id: false,
+                createdAt: false,
+                updatedAt: false,
+                position: false,
+                parentId: false,
+            }}
+            onSearchTermChange={searchTerm => {
+                return {
+                    name: { contains: searchTerm },
+                };
+            }}
+            listQuery={addCustomFields(collectionListDocument)}
+            route={Route}
+        >
+            <PageActionBar>
+                <div></div>
+                <PermissionGuard requires={['CreateCollection', 'CreateCatalog']}>
+                    <Button asChild>
+                        <Link to="./new">
+                            <PlusIcon className="mr-2 h-4 w-4" />
+                            <Trans>New Collection</Trans>
+                        </Link>
+                    </Button>
+                </PermissionGuard>
+            </PageActionBar>
+        </ListPage>
+    );
+}

+ 41 - 0
packages/dashboard/src/routes/_authenticated/_collections/components/collection-contents-sheet.tsx

@@ -0,0 +1,41 @@
+import {
+    Sheet,
+    SheetContent,
+    SheetDescription,
+    SheetHeader,
+    SheetTitle,
+    SheetTrigger
+} from '@/components/ui/sheet.js';
+import { Trans } from '@lingui/react/macro';
+import { PanelLeftOpen } from 'lucide-react';
+import { CollectionContentsTable } from './collection-contents-table.js';
+
+export interface CollectionContentsSheetProps {
+    collectionId: string;
+    collectionName: string;
+}
+
+export function CollectionContentsSheet({ collectionId, collectionName }: CollectionContentsSheetProps) {
+    return (
+        <Sheet>
+            <SheetTrigger>
+                <PanelLeftOpen className="w-4 h-4" />
+            </SheetTrigger>
+            <SheetContent className="min-w-[90vw] lg:min-w-[800px]">
+                <SheetHeader>
+                    <SheetTitle>
+                        <Trans>Collection contents for {collectionName}</Trans>
+                    </SheetTitle>
+                    <SheetDescription>
+                        <Trans>
+                            This is the contents of the <strong>{collectionName}</strong> collection.
+                        </Trans>
+                    </SheetDescription>
+                </SheetHeader>
+                <div className="px-4">
+                    <CollectionContentsTable collectionId={collectionId} />
+                </div>
+            </SheetContent>
+        </Sheet>
+    );
+}

+ 82 - 0
packages/dashboard/src/routes/_authenticated/_collections/components/collection-contents-table.tsx

@@ -0,0 +1,82 @@
+import { PaginatedListDataTable } from '@/components/shared/paginated-list-data-table.js';
+import { Button } from '@/components/ui/button.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Link } from '@tanstack/react-router';
+import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
+import { useState } from 'react';
+
+export const collectionContentsDocument = graphql(`
+    query CollectionContentsList($collectionId: ID!, $options: ProductVariantListOptions) {
+        collection(id: $collectionId) {
+            id
+            productVariants(options: $options) {
+                items {
+                    id
+                    createdAt
+                    updatedAt
+                    name
+                    sku
+                }
+                totalItems
+            }
+        }
+    }
+`);
+
+
+export interface CollectionContentsTableProps {
+    collectionId: string;
+}
+
+export function CollectionContentsTable({ collectionId }: CollectionContentsTableProps) {
+    const [sorting, setSorting] = useState<SortingState>([]);
+    const [page, setPage] = useState(1);
+    const [pageSize, setPageSize] = useState(10);
+    const [filters, setFilters] = useState<ColumnFiltersState>([]);
+    
+    return (
+        <PaginatedListDataTable
+            listQuery={addCustomFields(collectionContentsDocument)}
+            transformVariables={(variables) => {
+                return {
+                    ...variables,
+                    collectionId: collectionId,
+                };
+            }}
+            customizeColumns={{
+                name: {
+                    header: 'Variant name',
+                    cell: ({ row }) => {
+                        return (
+                            <Button asChild variant="ghost">
+                                <Link to={`../../product-variants/${row.original.id}`}>{row.original.name} </Link>
+                            </Button>
+                        );
+                    },
+                },
+            }}
+            page={page}
+            itemsPerPage={pageSize}
+            sorting={sorting}
+            columnFilters={filters}
+            onPageChange={(_, page, perPage) => {
+                setPage(page);
+                setPageSize(perPage);
+            }}
+            onSortChange={(_, sorting) => {
+                setSorting(sorting);
+            }}
+            onFilterChange={(_, filters) => {
+                setFilters(filters);
+            }}
+            onSearchTermChange={searchTerm => {
+                return {
+                    name: {
+                        contains: searchTerm,
+                    },
+                };
+            }}
+        />
+    );
+}

+ 3 - 1
packages/dashboard/src/routes/_authenticated/_facets/components/edit-facet-value.tsx

@@ -37,9 +37,10 @@ const updateFacetValueDocument = graphql(`
 
 export interface EditFacetValueProps {
     facetValueId: string;
+    onSuccess?: () => void;
 }
 
-export function EditFacetValue({ facetValueId }: EditFacetValueProps) {
+export function EditFacetValue({ facetValueId, onSuccess }: EditFacetValueProps) {
     const {
         settings: { contentLanguage },
     } = useUserSettings();
@@ -52,6 +53,7 @@ export function EditFacetValue({ facetValueId }: EditFacetValueProps) {
         mutationFn: api.mutate(updateFacetValueDocument),
         onSuccess: () => {
             refetchPaginatedList();
+            onSuccess?.();
         },
     });
     const facetValue = facetValues?.facetValues.items[0];

+ 9 - 1
packages/dashboard/src/routes/_authenticated/_facets/components/facet-values-sheet.tsx

@@ -1,6 +1,7 @@
 import {
     Sheet,
     SheetContent,
+    SheetDescription,
     SheetHeader,
     SheetTitle,
     SheetTrigger
@@ -25,8 +26,15 @@ export function FacetValuesSheet({ facetName, facetId }: FacetValuesSheetProps)
                     <SheetTitle>
                         <Trans>Facet values for {facetName}</Trans>
                     </SheetTitle>
-                        <FacetValuesTable facetId={facetId} />
+                    <SheetDescription>
+                        <Trans>
+                            These are the facet values for the <strong>{facetName}</strong> facet.
+                        </Trans>
+                    </SheetDescription>
                 </SheetHeader>
+                <div className="px-4">
+                    <FacetValuesTable facetId={facetId} />
+                </div>
             </SheetContent>
         </Sheet>
     );

+ 3 - 2
packages/dashboard/src/routes/_authenticated/_facets/components/facet-values-table.tsx

@@ -78,14 +78,15 @@ export function FacetValuesTable({ facetId }: FacetValuesTableProps) {
                     id: 'actions',
                     header: 'Actions',
                     cell: ({ row }) => {
+                        const [open, setOpen] = useState(false);
                         const facetValue = row.original;
                         return (
-                            <Popover>
+                            <Popover open={open} onOpenChange={setOpen}>
                                 <PopoverTrigger asChild>
                                     <Button type="button" variant="outline" size="sm"><Trans>Edit</Trans></Button>
                                 </PopoverTrigger>
                                 <PopoverContent className="w-80">
-                                    <EditFacetValue facetValueId={facetValue.id} />
+                                    <EditFacetValue facetValueId={facetValue.id} onSuccess={() => setOpen(false)} />
                                 </PopoverContent>
                             </Popover>
                         );

+ 15 - 10
packages/dashboard/src/routes/_authenticated/_facets/facets_.$id.tsx

@@ -76,17 +76,24 @@ export function FacetDetailPage() {
                     name: translation.name,
                     customFields: translation.customFields,
                 })),
+                values: [],
                 customFields: entity.customFields,
             };
         },
+        transformCreateInput: values => {
+            return {
+                ...values,
+                values: [],
+            };
+        },
         params: { id: params.id },
-        onSuccess: data => {
+        onSuccess: async data => {
             toast(i18n.t('Successfully updated facet'), {
                 position: 'top-right',
             });
             form.reset(form.getValues());
             if (creatingNewEntity) {
-                navigate({ to: `../${data?.[0]?.id}`, from: Route.id });
+                await navigate({ to: `../${data?.id}`, from: Route.id });
             }
         },
         onError: err => {
@@ -174,14 +181,12 @@ export function FacetDetailPage() {
                                 </div>
                             </div>
                         </PageBlock>
-                        <CustomFieldsPageBlock
-                            column="main"
-                            entityType="Facet"
-                            control={form.control}
-                        />
-                        <PageBlock column="main" title={<Trans>Facet values</Trans>}>
-                            <FacetValuesTable facetId={entity?.id} />
-                        </PageBlock>
+                        <CustomFieldsPageBlock column="main" entityType="Facet" control={form.control} />
+                        {!creatingNewEntity && (
+                            <PageBlock column="main" title={<Trans>Facet values</Trans>}>
+                                <FacetValuesTable facetId={entity?.id} />
+                            </PageBlock>
+                        )}
                     </PageLayout>
                 </form>
             </Form>

+ 2 - 2
packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx

@@ -87,13 +87,13 @@ export function ProductDetailPage() {
             };
         },
         params: { id: params.id },
-        onSuccess: data => {
+        onSuccess: async data => {
             toast(i18n.t('Successfully updated product'), {
                 position: 'top-right',
             });
             form.reset(form.getValues());
             if (creatingNewEntity) {
-                navigate({ to: `../${data.id}`, from: Route.id });
+                await navigate({ to: `../${data.id}`, from: Route.id });
             }
         },
         onError: err => {