Преглед изворни кода

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

Michael Bromley пре 10 месеци
родитељ
комит
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.
 
 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;
 
         if (scrolledToBottom && hasNextPage && !isFetchingNextPage) {
-            console.log('Fetching next page...');
             fetchNextPage();
         }
     };

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

@@ -15,6 +15,11 @@ navMenu({
                     title: '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[]>;
 
+/**
+ * @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(
     entityCustomFields: ResultOf<
         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
  * 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 { schemaInfo } from 'virtual:admin-api-schema';
 
+// for debug purposes
+(window as any).schemaInfo = schemaInfo;
+
 export interface FieldInfo {
     name: string;
     type: string;
@@ -20,6 +23,7 @@ export interface FieldInfo {
 }
 
 /**
+ * @description
  * Given a DocumentNode of a PaginatedList query, returns information about each
  * of the selected fields.
  *
@@ -68,6 +72,22 @@ export function getListQueryFields(documentNode: DocumentNode): FieldInfo[] {
     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[] {
     const fields: FieldInfo[] = [];
 
@@ -97,6 +117,22 @@ function unwrapVariableDefinitionType(type: TypeNode): NamedTypeNode {
     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 {
     const operationDefinition = documentNode.definitions.find(
         (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 {
     const operationDefinition = documentNode.definitions.find(
         (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(
     definitionNode: DefinitionNode | FieldNode,
     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':
             return false;
         case 'ID':
-            return undefined;
+            return '';
         case 'LanguageCode':
             return defaultLanguageCode || 'en';
         case 'JSON':
@@ -92,7 +92,7 @@ export function getZodTypeFromField(field: FieldInfo): ZodTypeAny {
         zodType = z.array(zodType);
     }
     if (field.nullable) {
-        zodType = zodType.optional();
+        zodType = zodType.optional().nullable();
     }
     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 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,
         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 { api } from '@/graphql/api.js';
+import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { queryOptions, useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
 import { ResultOf, VariablesOf } from 'gql.tada';
@@ -7,7 +8,11 @@ import { DocumentNode } from 'graphql';
 import { Variables } from 'graphql-request';
 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';
 
 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 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 AuthenticatedProductsProductsIdImport } from './routes/_authenticated/_products/products_.$id';
 
 // Create/Update Routes
@@ -56,6 +57,13 @@ const AuthenticatedProductsProductsRoute = AuthenticatedProductsProductsImport.u
     getParentRoute: () => AuthenticatedRoute,
 } as any);
 
+const AuthenticatedProductVariantsProductVariantsRoute =
+    AuthenticatedProductVariantsProductVariantsImport.update({
+        id: '/_product-variants/product-variants',
+        path: '/product-variants',
+        getParentRoute: () => AuthenticatedRoute,
+    } as any);
+
 const AuthenticatedProductsProductsIdRoute = AuthenticatedProductsProductsIdImport.update({
     id: '/_products/products_/$id',
     path: '/products/$id',
@@ -101,6 +109,13 @@ declare module '@tanstack/react-router' {
             preLoaderRoute: typeof AuthenticatedIndexImport;
             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': {
             id: '/_authenticated/_products/products';
             path: '/products';
@@ -123,6 +138,7 @@ declare module '@tanstack/react-router' {
 interface AuthenticatedRouteChildren {
     AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute;
     AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute;
+    AuthenticatedProductVariantsProductVariantsRoute: typeof AuthenticatedProductVariantsProductVariantsRoute;
     AuthenticatedProductsProductsRoute: typeof AuthenticatedProductsProductsRoute;
     AuthenticatedProductsProductsIdRoute: typeof AuthenticatedProductsProductsIdRoute;
 }
@@ -130,6 +146,7 @@ interface AuthenticatedRouteChildren {
 const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
     AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
     AuthenticatedIndexRoute: AuthenticatedIndexRoute,
+    AuthenticatedProductVariantsProductVariantsRoute: AuthenticatedProductVariantsProductVariantsRoute,
     AuthenticatedProductsProductsRoute: AuthenticatedProductsProductsRoute,
     AuthenticatedProductsProductsIdRoute: AuthenticatedProductsProductsIdRoute,
 };
@@ -142,6 +159,7 @@ export interface FileRoutesByFullPath {
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/': typeof AuthenticatedIndexRoute;
+    '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
@@ -151,6 +169,7 @@ export interface FileRoutesByTo {
     '/login': typeof LoginRoute;
     '/dashboard': typeof AuthenticatedDashboardRoute;
     '/': typeof AuthenticatedIndexRoute;
+    '/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/products': typeof AuthenticatedProductsProductsRoute;
     '/products/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
@@ -162,15 +181,24 @@ export interface FileRoutesById {
     '/login': typeof LoginRoute;
     '/_authenticated/dashboard': typeof AuthenticatedDashboardRoute;
     '/_authenticated/': typeof AuthenticatedIndexRoute;
+    '/_authenticated/_product-variants/product-variants': typeof AuthenticatedProductVariantsProductVariantsRoute;
     '/_authenticated/_products/products': typeof AuthenticatedProductsProductsRoute;
     '/_authenticated/_products/products_/$id': typeof AuthenticatedProductsProductsIdRoute;
 }
 
 export interface FileRouteTypes {
     fileRoutesByFullPath: FileRoutesByFullPath;
-    fullPaths: '' | '/about' | '/login' | '/dashboard' | '/' | '/products' | '/products/$id';
+    fullPaths:
+        | ''
+        | '/about'
+        | '/login'
+        | '/dashboard'
+        | '/'
+        | '/product-variants'
+        | '/products'
+        | '/products/$id';
     fileRoutesByTo: FileRoutesByTo;
-    to: '/about' | '/login' | '/dashboard' | '/' | '/products' | '/products/$id';
+    to: '/about' | '/login' | '/dashboard' | '/' | '/product-variants' | '/products' | '/products/$id';
     id:
         | '__root__'
         | '/_authenticated'
@@ -178,6 +206,7 @@ export interface FileRouteTypes {
         | '/login'
         | '/_authenticated/dashboard'
         | '/_authenticated/'
+        | '/_authenticated/_product-variants/product-variants'
         | '/_authenticated/_products/products'
         | '/_authenticated/_products/products_/$id';
     fileRoutesById: FileRoutesById;
@@ -213,6 +242,7 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "children": [
         "/_authenticated/dashboard",
         "/_authenticated/",
+        "/_authenticated/_product-variants/product-variants",
         "/_authenticated/_products/products",
         "/_authenticated/_products/products_/$id"
       ]
@@ -231,6 +261,10 @@ export const routeTree = rootRoute._addFileChildren(rootRouteChildren)._addFileT
       "filePath": "_authenticated/index.tsx",
       "parent": "/_authenticated"
     },
+    "/_authenticated/_product-variants/product-variants": {
+      "filePath": "_authenticated/_product-variants/product-variants.tsx",
+      "parent": "/_authenticated"
+    },
     "/_authenticated/_products/products": {
       "filePath": "_authenticated/_products/products.tsx",
       "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 { 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 {
     groupName: string;
@@ -14,7 +31,7 @@ interface OptionValueInputProps {
 }
 
 export function OptionValueInput({ groupName, groupIndex, disabled = false }: OptionValueInputProps) {
-    const { control, watch } = useFormContext();
+    const { control, watch } = useFormContext<FormValues>();
     const { fields, append, remove } = useFieldArray({
         control,
         name: `optionGroups.${groupIndex}.values`,

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

@@ -55,6 +55,7 @@ export const productDetailFragment = graphql(
                     code
                 }
             }
+            customFields
         }
     `,
     [assetFragment],
@@ -89,24 +90,18 @@ export const productDetailDocument = graphql(
     [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 { PlusIcon } from 'lucide-react';
 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')({
     component: ProductListPage,
@@ -32,7 +33,7 @@ export function ProductListPage() {
                     name: { contains: searchTerm },
                 };
             }}
-            listQuery={productListDocument}
+            listQuery={addCustomFields(productListDocument)}
             route={Route}
         >
             <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 { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.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')({
     component: ProductDetailPage,
@@ -80,7 +81,9 @@ export function ProductDetailPage() {
                     name: translation.name,
                     slug: translation.slug,
                     description: translation.description,
+                    customFields: translation.customFields,
                 })),
+                customFields: entity.customFields,
             };
         },
         params: { id: params.id },
@@ -194,6 +197,9 @@ export function ProductDetailPage() {
                                 )}
                             />
                         </PageBlock>
+                        <PageBlock column="main">
+                            <CustomFieldsForm entityType="Product" control={form.control} />
+                        </PageBlock>
                         {entity && entity.variantList.totalItems > 0 && (
                             <PageBlock column="main">
                                 <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) => {
         auth.login(username, password, () => {
-            console.log(`Redirecting 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,
+    ],
+});