Просмотр исходного кода

feat(dashboard): Add custom field support to detail views

Michael Bromley 10 месяцев назад
Родитель
Сommit
c3f404d6e9

+ 5 - 0
packages/dashboard/README.md

@@ -3,3 +3,8 @@
 This is an admin dashboard for managing Vendure applications. It is designed to supersede the existing Admin UI.
 This is an admin dashboard for managing Vendure applications. It is designed to supersede the existing Admin UI.
 
 
 Current status: early work in progress
 Current status: early work in progress
+
+## Testing
+
+There are some unit tests in this repo (`.spec.ts` files). In order to run them, you need to go to the `../dev-server` dir
+and run `npx vitest`. This is a temporary work-around for some hard-to-debug path issue when trying to run from this dir.

+ 86 - 0
packages/dashboard/src/components/shared/custom-fields-form.tsx

@@ -0,0 +1,86 @@
+import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
+import { Control, ControllerRenderProps } from 'react-hook-form';
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from '@/components/ui/form.js';
+import { Input } from '@/components/ui/input.js';
+import { useUserSettings } from '@/hooks/use-user-settings.js';
+import { Switch } from '../ui/switch.js';
+import { CustomFieldType } from '@vendure/common/lib/shared-types';
+import { TranslatableFormField } from './translatable-form-field.js';
+import { customFieldConfigFragment } from '@/providers/server-config.js';
+import { ResultOf } from 'gql.tada';
+
+type CustomFieldConfig = ResultOf<typeof customFieldConfigFragment>;
+
+interface CustomFieldsFormProps {
+    entityType: string;
+    control: Control;
+}
+
+export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps) {
+    const {
+        settings: { displayLanguage },
+    } = useUserSettings();
+    function getTranslation(input: Array<{ languageCode: string; value: string }> | null | undefined) {
+        return input?.find(t => t.languageCode === displayLanguage)?.value;
+    }
+    const customFields = useCustomFieldConfig(entityType);
+    return (
+        <div className="grid grid-cols-2 gap-4">
+            {customFields?.map(fieldDef => (
+                <div key={fieldDef.name}>
+                    {fieldDef.type === 'localeString' || fieldDef.type === 'localeText' ? (
+                        <TranslatableFormField
+                            control={control}
+                            name={`customFields.${fieldDef.name}`}
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
+                                    <FormControl>
+                                        {fieldDef.readonly ? field.value : <FormInputForType fieldDef={fieldDef} field={field} />}
+                                    </FormControl>
+                                </FormItem>
+                            )}
+                        />
+                    ) : (
+                        <FormField
+                            control={control}
+                            name={`customFields.${fieldDef.name}`}
+                            render={({ field }) => (
+                            <FormItem>
+                                <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
+                                <FormControl>
+                                    {fieldDef.readonly ? field.value : <FormInputForType fieldDef={fieldDef} field={field} />}
+                                </FormControl>
+                                <FormDescription>{getTranslation(fieldDef.description)}</FormDescription>
+                                <FormMessage />
+                            </FormItem>
+                            )}
+                        />
+                    )}
+                </div>
+            ))}
+        </div>
+    );
+}
+
+function FormInputForType({ fieldDef, field }: { fieldDef: CustomFieldConfig, field: ControllerRenderProps }) {
+    switch (fieldDef.type as CustomFieldType) {    
+        case 'string':
+            return <Input {...field} />;
+        case 'float':
+        case 'int':
+            return <Input type="number" {...field}  onChange={(e) => field.onChange(e.target.valueAsNumber)} />;
+        case 'boolean':
+            return <Switch checked={field.value} onCheckedChange={field.onChange} />;
+        default:
+            return <Input {...field} />
+    }
+}

+ 0 - 1
packages/dashboard/src/components/shared/facet-value-selector.tsx

@@ -186,7 +186,6 @@ export function FacetValueSelector({
         ) < 1;
         ) < 1;
 
 
         if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
         if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
-            console.log('Fetching next page...');
             fetchNextPage();
             fetchNextPage();
         }
         }
     };
     };

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

@@ -15,6 +15,11 @@ navMenu({
                     title: 'Products',
                     title: 'Products',
                     url: '/products',
                     url: '/products',
                 },
                 },
+                {
+                    id: 'product-variants',
+                    title: 'Product Variants',
+                    url: '/product-variants',
+                },
             ],
             ],
         },
         },
         {
         {

+ 14 - 0
packages/dashboard/src/framework/document-introspection/add-custom-fields.ts

@@ -25,6 +25,12 @@ type RelationCustomFieldFragment = ResultOf<typeof relationCustomFieldFragment>;
 
 
 let globalCustomFieldsMap: Map<string, CustomFieldConfig[]>;
 let globalCustomFieldsMap: Map<string, CustomFieldConfig[]>;
 
 
+/**
+ * @description
+ * This function is used to set the global custom fields map.
+ * It is called when the server config is loaded, and then can
+ * be used in other parts of the app via the `getCustomFieldsMap` function.
+ */
 export function setCustomFieldsMap(
 export function setCustomFieldsMap(
     entityCustomFields: ResultOf<
     entityCustomFields: ResultOf<
         typeof getServerConfigDocument
         typeof getServerConfigDocument
@@ -39,6 +45,14 @@ export function setCustomFieldsMap(
     }
     }
 }
 }
 
 
+/**
+ * @description
+ * This function is used to get the global custom fields map.
+ */
+export function getCustomFieldsMap() {
+    return globalCustomFieldsMap;
+}
+
 /**
 /**
  * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured
  * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured
  * custom fields to those fragments.
  * custom fields to those fragments.

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

@@ -9,6 +9,9 @@ import {
 import { DefinitionNode, NamedTypeNode, SelectionSetNode, TypeNode } from 'graphql/language/ast.js';
 import { DefinitionNode, NamedTypeNode, SelectionSetNode, TypeNode } from 'graphql/language/ast.js';
 import { schemaInfo } from 'virtual:admin-api-schema';
 import { schemaInfo } from 'virtual:admin-api-schema';
 
 
+// for debug purposes
+(window as any).schemaInfo = schemaInfo;
+
 export interface FieldInfo {
 export interface FieldInfo {
     name: string;
     name: string;
     type: string;
     type: string;
@@ -20,6 +23,7 @@ export interface FieldInfo {
 }
 }
 
 
 /**
 /**
+ * @description
  * Given a DocumentNode of a PaginatedList query, returns information about each
  * Given a DocumentNode of a PaginatedList query, returns information about each
  * of the selected fields.
  * of the selected fields.
  *
  *
@@ -68,6 +72,22 @@ export function getListQueryFields(documentNode: DocumentNode): FieldInfo[] {
     return fields;
     return fields;
 }
 }
 
 
+/**
+ * @description
+ * This function is used to get the fields of the operation variables from a DocumentNode.
+ *
+ * For example, in the following query:
+ *
+ * ```graphql
+ * mutation UpdateProduct($input: UpdateProductInput!) {
+ *   updateProduct(input: $input) {
+ *     ...ProductDetail
+ *   }
+ * }
+ * ```
+ *
+ * The operation variables fields are the fields of the `UpdateProductInput` type.
+ */
 export function getOperationVariablesFields(documentNode: DocumentNode): FieldInfo[] {
 export function getOperationVariablesFields(documentNode: DocumentNode): FieldInfo[] {
     const fields: FieldInfo[] = [];
     const fields: FieldInfo[] = [];
 
 
@@ -97,6 +117,22 @@ function unwrapVariableDefinitionType(type: TypeNode): NamedTypeNode {
     return type;
     return type;
 }
 }
 
 
+/**
+ * @description
+ * This function is used to get the name of the query from a DocumentNode.
+ *
+ * For example, in the following query:
+ *
+ * ```graphql
+ * query ProductDetail($id: ID!) {
+ *   product(id: $id) {
+ *     ...ProductDetail
+ *   }
+ * }
+ * ```
+ *
+ * The query name is `product`.
+ */
 export function getQueryName(documentNode: DocumentNode): string {
 export function getQueryName(documentNode: DocumentNode): string {
     const operationDefinition = documentNode.definitions.find(
     const operationDefinition = documentNode.definitions.find(
         (def): def is OperationDefinitionNode =>
         (def): def is OperationDefinitionNode =>
@@ -110,6 +146,43 @@ export function getQueryName(documentNode: DocumentNode): string {
     }
     }
 }
 }
 
 
+/**
+ * @description
+ * This function is used to get the type information of the query from a DocumentNode.
+ *
+ * For example, in the following query:
+ *
+ * ```graphql
+ * query ProductDetail($id: ID!) {
+ *   product(id: $id) {
+ *     ...ProductDetail
+ *   }
+ * }
+ * ```
+ *
+ * The query type field will be the `Product` type.
+ */
+export function getQueryTypeFieldInfo(documentNode: DocumentNode): FieldInfo {
+    const name = getQueryName(documentNode);
+    return getQueryInfo(name);
+}
+
+/**
+ * @description
+ * This function is used to get the name of the mutation from a DocumentNode.
+ *
+ * For example, in the following mutation:
+ *
+ * ```graphql
+ * mutation CreateProduct($input: CreateProductInput!) {
+ *   createProduct(input: $input) {
+ *     ...ProductDetail
+ *   }
+ * }
+ * ```
+ *
+ * The mutation name is `createProduct`.
+ */
 export function getMutationName(documentNode: DocumentNode): string {
 export function getMutationName(documentNode: DocumentNode): string {
     const operationDefinition = documentNode.definitions.find(
     const operationDefinition = documentNode.definitions.find(
         (def): def is OperationDefinitionNode =>
         (def): def is OperationDefinitionNode =>
@@ -123,6 +196,10 @@ export function getMutationName(documentNode: DocumentNode): string {
     }
     }
 }
 }
 
 
+/**
+ * @description
+ * This function is used to get the type information of an operation from a DocumentNode.
+ */
 export function getOperationTypeInfo(
 export function getOperationTypeInfo(
     definitionNode: DefinitionNode | FieldNode,
     definitionNode: DefinitionNode | FieldNode,
     parentTypeName?: string,
     parentTypeName?: string,

+ 2 - 2
packages/dashboard/src/framework/form-engine/form-schema-tools.ts

@@ -58,7 +58,7 @@ export function getDefaultValueFromField(field: FieldInfo, defaultLanguageCode?:
         case 'Boolean':
         case 'Boolean':
             return false;
             return false;
         case 'ID':
         case 'ID':
-            return undefined;
+            return '';
         case 'LanguageCode':
         case 'LanguageCode':
             return defaultLanguageCode || 'en';
             return defaultLanguageCode || 'en';
         case 'JSON':
         case 'JSON':
@@ -92,7 +92,7 @@ export function getZodTypeFromField(field: FieldInfo): ZodTypeAny {
         zodType = z.array(zodType);
         zodType = z.array(zodType);
     }
     }
     if (field.nullable) {
     if (field.nullable) {
-        zodType = zodType.optional();
+        zodType = zodType.optional().nullable();
     }
     }
     return zodType;
     return zodType;
 }
 }

+ 7 - 1
packages/dashboard/src/framework/form-engine/use-generated-form.tsx

@@ -43,7 +43,13 @@ export function useGeneratedForm<
     const processedEntity = ensureTranslationsForAllLanguages(entity, availableLanguages);
     const processedEntity = ensureTranslationsForAllLanguages(entity, availableLanguages);
 
 
     const form = useForm({
     const form = useForm({
-        resolver: zodResolver(schema),
+        resolver: async (values, context, options) => {
+            const result = await zodResolver(schema)(values, context, options);
+            if (Object.keys(result.errors).length > 0) {
+                console.log('Zod form validation errors:', result.errors);
+            }
+            return result;
+        },
         defaultValues,
         defaultValues,
         values: processedEntity ? setValues(processedEntity) : defaultValues,
         values: processedEntity ? setValues(processedEntity) : defaultValues,
     });
     });

+ 6 - 1
packages/dashboard/src/framework/page/use-detail-page.ts

@@ -1,5 +1,6 @@
 import { NEW_ENTITY_PATH } from '@/constants.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
 import { api } from '@/graphql/api.js';
 import { api } from '@/graphql/api.js';
+import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { queryOptions, useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
 import { queryOptions, useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
 import { ResultOf, VariablesOf } from 'gql.tada';
 import { ResultOf, VariablesOf } from 'gql.tada';
@@ -7,7 +8,11 @@ import { DocumentNode } from 'graphql';
 import { Variables } from 'graphql-request';
 import { Variables } from 'graphql-request';
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
-import { getMutationName, getQueryName } from '../document-introspection/get-document-structure.js';
+import {
+    getMutationName,
+    getQueryName,
+    getQueryTypeFieldInfo,
+} from '../document-introspection/get-document-structure.js';
 import { useGeneratedForm } from '../form-engine/use-generated-form.js';
 import { useGeneratedForm } from '../form-engine/use-generated-form.js';
 
 
 export interface DetailPageOptions<
 export interface DetailPageOptions<

+ 36 - 2
packages/dashboard/src/routeTree.gen.ts

@@ -17,6 +17,7 @@ import { Route as AuthenticatedImport } from './routes/_authenticated';
 import { Route as AuthenticatedIndexImport } from './routes/_authenticated/index';
 import { Route as AuthenticatedIndexImport } from './routes/_authenticated/index';
 import { Route as AuthenticatedDashboardImport } from './routes/_authenticated/dashboard';
 import { Route as AuthenticatedDashboardImport } from './routes/_authenticated/dashboard';
 import { Route as AuthenticatedProductsProductsImport } from './routes/_authenticated/_products/products';
 import { Route as AuthenticatedProductsProductsImport } from './routes/_authenticated/_products/products';
+import { Route as AuthenticatedProductVariantsProductVariantsImport } from './routes/_authenticated/_product-variants/product-variants';
 import { Route as AuthenticatedProductsProductsIdImport } from './routes/_authenticated/_products/products_.$id';
 import { Route as AuthenticatedProductsProductsIdImport } from './routes/_authenticated/_products/products_.$id';
 
 
 // Create/Update Routes
 // Create/Update Routes
@@ -56,6 +57,13 @@ const AuthenticatedProductsProductsRoute = AuthenticatedProductsProductsImport.u
     getParentRoute: () => AuthenticatedRoute,
     getParentRoute: () => AuthenticatedRoute,
 } as any);
 } as any);
 
 
+const AuthenticatedProductVariantsProductVariantsRoute =
+    AuthenticatedProductVariantsProductVariantsImport.update({
+        id: '/_product-variants/product-variants',
+        path: '/product-variants',
+        getParentRoute: () => AuthenticatedRoute,
+    } as any);
+
 const AuthenticatedProductsProductsIdRoute = AuthenticatedProductsProductsIdImport.update({
 const AuthenticatedProductsProductsIdRoute = AuthenticatedProductsProductsIdImport.update({
     id: '/_products/products_/$id',
     id: '/_products/products_/$id',
     path: '/products/$id',
     path: '/products/$id',
@@ -101,6 +109,13 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedIndexImport;
             preLoaderRoute: typeof AuthenticatedIndexImport;
             parentRoute: typeof AuthenticatedImport;
             parentRoute: typeof AuthenticatedImport;
         };
         };
+        '/_authenticated/_product-variants/product-variants': {
+            id: '/_authenticated/_product-variants/product-variants';
+            path: '/product-variants';
+            fullPath: '/product-variants';
+            preLoaderRoute: typeof AuthenticatedProductVariantsProductVariantsImport;
+            parentRoute: typeof AuthenticatedImport;
+        };
         '/_authenticated/_products/products': {
         '/_authenticated/_products/products': {
             id: '/_authenticated/_products/products';
             id: '/_authenticated/_products/products';
             path: '/products';
             path: '/products';
@@ -123,6 +138,7 @@ declare module '@tanstack/react-router' {
 interface AuthenticatedRouteChildren {
 interface AuthenticatedRouteChildren {
     AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute;
     AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute;
     AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
     AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
+    AuthenticatedProductVariantsProductVariantsRoute: typeof AuthenticatedProductVariantsProductVariantsRoute;
     AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
     AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
     AuthenticatedProductsProductsIdRoute: typeof AuthenticatedProductsProductsIdRoute;
     AuthenticatedProductsProductsIdRoute: typeof AuthenticatedProductsProductsIdRoute;
 }
 }
@@ -130,6 +146,7 @@ interface AuthenticatedRouteChildren {
 const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
 const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
     AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
     AuthenticatedIndexRoute: AuthenticatedIndexRoute,
     AuthenticatedIndexRoute: AuthenticatedIndexRoute,
+    AuthenticatedProductVariantsProductVariantsRoute: AuthenticatedProductVariantsProductVariantsRoute,
     AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
     AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
     AuthenticatedProductsProductsIdRoute: AuthenticatedProductsProductsIdRoute,
     AuthenticatedProductsProductsIdRoute: AuthenticatedProductsProductsIdRoute,
 };
 };
@@ -142,6 +159,7 @@ export interface FileRoutesByFullPath {
     '/login': typeof LoginRoute;
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/': typeof AuthenticatedIndexRoute;
     '/': typeof AuthenticatedIndexRoute;
+    '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 }
@@ -151,6 +169,7 @@ export interface FileRoutesByTo {
     '/login': typeof LoginRoute;
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/': typeof AuthenticatedIndexRoute;
     '/': typeof AuthenticatedIndexRoute;
+    '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 }
@@ -162,15 +181,24 @@ export interface FileRoutesById {
     '/login': typeof LoginRoute;
     '/login': typeof LoginRoute;
     '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute;
     '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute;
     '/_authenticated/': typeof AuthenticatedIndexRoute;
     '/_authenticated/': typeof AuthenticatedIndexRoute;
+    '/_authenticated/_product-variants/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
     '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
     '/_authenticated/_products/products_/$id': typeof AuthenticatedProductsProductsIdRoute;
     '/_authenticated/_products/products_/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 }
 
 
 export interface FileRouteTypes {
 export interface FileRouteTypes {
     fileRoutesByFullPath: FileRoutesByFullPath;
     fileRoutesByFullPath: FileRoutesByFullPath;
-    fullPaths: '' | '/about' | '/login' | '/dashboard' | '/' | '/products' | '/products/$id';
+    fullPaths:
+        | ''
+        | '/about'
+        | '/login'
+        | '/dashboard'
+        | '/'
+        | '/product-variants'
+        | '/products'
+        | '/products/$id';
     fileRoutesByTo: FileRoutesByTo;
     fileRoutesByTo: FileRoutesByTo;
-    to: '/about' | '/login' | '/dashboard' | '/' | '/products' | '/products/$id';
+    to: '/about' | '/login' | '/dashboard' | '/' | '/product-variants' | '/products' | '/products/$id';
     id:
     id:
         | '__root__'
         | '__root__'
         | '/_authenticated'
         | '/_authenticated'
@@ -178,6 +206,7 @@ export interface FileRouteTypes {
         | '/login'
         | '/login'
         | '/_authenticated/dashboard'
         | '/_authenticated/dashboard'
         | '/_authenticated/'
         | '/_authenticated/'
+        | '/_authenticated/_product-variants/product-variants'
         | '/_authenticated/_products/products'
         | '/_authenticated/_products/products'
         | '/_authenticated/_products/products_/$id';
         | '/_authenticated/_products/products_/$id';
     fileRoutesById: FileRoutesById;
     fileRoutesById: FileRoutesById;
@@ -213,6 +242,7 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "children": [
       "children": [
         "/_authenticated/dashboard",
         "/_authenticated/dashboard",
         "/_authenticated/",
         "/_authenticated/",
+        "/_authenticated/_product-variants/product-variants",
         "/_authenticated/_products/products",
         "/_authenticated/_products/products",
         "/_authenticated/_products/products_/$id"
         "/_authenticated/_products/products_/$id"
       ]
       ]
@@ -231,6 +261,10 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/index.tsx",
       "filePath": "_authenticated/index.tsx",
       "parent": "/_authenticated"
       "parent": "/_authenticated"
     },
     },
+    "/_authenticated/_product-variants/product-variants": {
+      "filePath": "_authenticated/_product-variants/product-variants.tsx",
+      "parent": "/_authenticated"
+    },
     "/_authenticated/_products/products": {
     "/_authenticated/_products/products": {
       "filePath": "_authenticated/_products/products.tsx",
       "filePath": "_authenticated/_products/products.tsx",
       "parent": "/_authenticated"
       "parent": "/_authenticated"

+ 29 - 0
packages/dashboard/src/routes/_authenticated/_product-variants/product-variants.graphql.ts

@@ -0,0 +1,29 @@
+import { assetFragment } from '@/graphql/fragments.js';
+import { graphql } from '@/graphql/graphql.js';
+
+export const productVariantListDocument = graphql(
+    `
+        query ProductVariantList {
+            productVariants {
+                items {
+                    id
+                    createdAt
+                    updatedAt
+                    featuredAsset {
+                        ...Asset
+                    }
+                    name
+                    sku
+                    price
+                    priceWithTax
+                    stockLevels {
+                        id
+                        stockOnHand
+                        stockAllocated
+                    }
+                }
+            }
+        }
+    `,
+    [assetFragment],
+);

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

@@ -0,0 +1,41 @@
+import { Button } from '@/components/ui/button.js';
+import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
+import { ListPage } from '@/framework/page/list-page.js';
+import { createFileRoute, Link } from '@tanstack/react-router';
+import { productVariantListDocument } from './product-variants.graphql.js';
+
+export const Route = createFileRoute('/_authenticated/_product-variants/product-variants')({
+    component: ProductListPage,
+    loader: () => ({ breadcrumb: 'Products' }),
+});
+
+export function ProductListPage() {
+    return (
+        <ListPage
+            title="Products"
+            customizeColumns={{
+               /*  name: {
+                    header: 'Product Name',
+                    cell: ({ row }) => {
+                        return (
+                            <Link to={`./${row.original.id}`}>
+                                <Button variant="ghost">{row.original.name}</Button>
+                            </Link>
+                        );
+                    },
+                }, */
+            }}
+            onSearchTermChange={searchTerm => {
+                return {
+                    name: { contains: searchTerm },
+                };
+            }}
+            listQuery={productVariantListDocument}
+            route={Route}
+        >
+            <PageActionBar>
+                <div></div>
+            </PageActionBar>
+        </ListPage>
+    );
+}

+ 18 - 1
packages/dashboard/src/routes/_authenticated/_products/components/option-value-input.tsx

@@ -6,6 +6,23 @@ import { Button } from "@/components/ui/button.js";
 import { Badge } from "@/components/ui/badge.js";
 import { Badge } from "@/components/ui/badge.js";
 import { Plus, X } from "lucide-react";
 import { Plus, X } from "lucide-react";
 
 
+interface OptionValue {
+    value: string;
+    id: string;
+}
+
+interface FormValues {
+    optionGroups: {
+        name: string;
+        values: OptionValue[];
+    }[];
+    variants: Record<string, {
+        enabled: boolean;
+        sku: string;
+        price: string;
+        stock: string;
+    }>;
+}
 
 
 interface OptionValueInputProps {
 interface OptionValueInputProps {
     groupName: string;
     groupName: string;
@@ -14,7 +31,7 @@ interface OptionValueInputProps {
 }
 }
 
 
 export function OptionValueInput({ groupName, groupIndex, disabled = false }: OptionValueInputProps) {
 export function OptionValueInput({ groupName, groupIndex, disabled = false }: OptionValueInputProps) {
-    const { control, watch } = useFormContext();
+    const { control, watch } = useFormContext<FormValues>();
     const { fields, append, remove } = useFieldArray({
     const { fields, append, remove } = useFieldArray({
         control,
         control,
         name: `optionGroups.${groupIndex}.values`,
         name: `optionGroups.${groupIndex}.values`,

+ 13 - 18
packages/dashboard/src/routes/_authenticated/_products/products.graphql.ts

@@ -55,6 +55,7 @@ export const productDetailFragment = graphql(
                     code
                     code
                 }
                 }
             }
             }
+            customFields
         }
         }
     `,
     `,
     [assetFragment],
     [assetFragment],
@@ -89,24 +90,18 @@ export const productDetailDocument = graphql(
     [productDetailFragment],
     [productDetailFragment],
 );
 );
 
 
-export const createProductDocument = graphql(
-    `
-        mutation CreateProduct($input: CreateProductInput!) {
-            createProduct(input: $input) {
-                ...ProductDetail
-            }
+export const createProductDocument = graphql(`
+    mutation CreateProduct($input: CreateProductInput!) {
+        createProduct(input: $input) {
+            id
         }
         }
-    `,
-    [productDetailFragment],
-);
+    }
+`);
 
 
-export const updateProductDocument = graphql(
-    `
-        mutation UpdateProduct($input: UpdateProductInput!) {
-            updateProduct(input: $input) {
-                ...ProductDetail
-            }
+export const updateProductDocument = graphql(`
+    mutation UpdateProduct($input: UpdateProductInput!) {
+        updateProduct(input: $input) {
+            id
         }
         }
-    `,
-    [productDetailFragment],
-);
+    }
+`);

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

@@ -5,6 +5,7 @@ import { productListDocument } from './products.graphql.js';
 import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { PageActionBar } from '@/framework/layout-engine/page-layout.js';
 import { PlusIcon } from 'lucide-react';
 import { PlusIcon } from 'lucide-react';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 
 
 export const Route = createFileRoute('/_authenticated/_products/products')({
 export const Route = createFileRoute('/_authenticated/_products/products')({
     component: ProductListPage,
     component: ProductListPage,
@@ -32,7 +33,7 @@ export function ProductListPage() {
                     name: { contains: searchTerm },
                     name: { contains: searchTerm },
                 };
                 };
             }}
             }}
-            listQuery={productListDocument}
+            listQuery={addCustomFields(productListDocument)}
             route={Route}
             route={Route}
         >
         >
             <PageActionBar>
             <PageActionBar>

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

@@ -33,6 +33,7 @@ import { CreateProductVariantsDialog } from './components/create-product-variant
 import { ProductVariantsTable } from './components/product-variants-table.js';
 import { ProductVariantsTable } from './components/product-variants-table.js';
 import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
 import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
 
 
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
     component: ProductDetailPage,
@@ -80,7 +81,9 @@ export function ProductDetailPage() {
                     name: translation.name,
                     name: translation.name,
                     slug: translation.slug,
                     slug: translation.slug,
                     description: translation.description,
                     description: translation.description,
+                    customFields: translation.customFields,
                 })),
                 })),
+                customFields: entity.customFields,
             };
             };
         },
         },
         params: { id: params.id },
         params: { id: params.id },
@@ -194,6 +197,9 @@ export function ProductDetailPage() {
                                 )}
                                 )}
                             />
                             />
                         </PageBlock>
                         </PageBlock>
+                        <PageBlock column="main">
+                            <CustomFieldsForm entityType="Product" control={form.control} />
+                        </PageBlock>
                         {entity && entity.variantList.totalItems > 0 && (
                         {entity && entity.variantList.totalItems > 0 && (
                             <PageBlock column="main">
                             <PageBlock column="main">
                                 <ProductVariantsTable productId={params.id} />
                                 <ProductVariantsTable productId={params.id} />

+ 0 - 1
packages/dashboard/src/routes/login.tsx

@@ -26,7 +26,6 @@ export default function LoginPage() {
 
 
     const onFormSubmit = (username: string, password: string) => {
     const onFormSubmit = (username: string, password: string) => {
         auth.login(username, password, () => {
         auth.login(username, password, () => {
-            console.log(`Redirecting to ${search.redirect || fallback}`);
             navigate({ to: search.redirect || fallback });
             navigate({ to: search.redirect || fallback });
         });
         });
     };
     };

+ 15 - 0
packages/dashboard/vitest.config.mts

@@ -0,0 +1,15 @@
+import { defineConfig } from 'vitest/config';
+import { vendureDashboardPlugin } from './dist/plugin/index.js';
+import { pathToFileURL } from 'url';
+
+export default defineConfig({
+    test: {
+        globals: true,
+        environment: 'jsdom',
+    },
+    plugins: [
+        vendureDashboardPlugin({
+            vendureConfigPath: pathToFileURL('../dev-server/dev-config.ts'),
+        }) as any,
+    ],
+});