فهرست منبع

feat(dashboard): Improve support for dashboard extensions

This refactor changes the way we load dashboard extensions, so that
it should work even in scenarios where ts config paths are being used.
Michael Bromley 9 ماه پیش
والد
کامیت
8763eb7338

+ 1 - 1
packages/dashboard/vite.config.mts

@@ -10,7 +10,7 @@ import { defineConfig } from 'vitest/config';
 export default ({ mode }: { mode: string }) => {
 export default ({ mode }: { mode: string }) => {
     process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
     process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
 
 
-    const adminApiHost = process.env.VITE_ADMIN_API_HOST || 'http://localhost';
+    const adminApiHost = process.env.VITE_ADMIN_API_HOST || 'http://localhost:3000';
     const adminApiPort = process.env.VITE_ADMIN_API_PORT ? +process.env.VITE_ADMIN_API_PORT : 'auto';
     const adminApiPort = process.env.VITE_ADMIN_API_PORT ? +process.env.VITE_ADMIN_API_PORT : 'auto';
 
 
     process.env.IS_LOCAL_DEV = adminApiHost.includes('localhost') ? 'true' : 'false';
     process.env.IS_LOCAL_DEV = adminApiHost.includes('localhost') ? 'true' : 'false';

+ 128 - 0
packages/dashboard/vite/utils/ast-utils.spec.ts

@@ -0,0 +1,128 @@
+import ts from 'typescript';
+import { describe, it, expect } from 'vitest';
+
+import { getPluginInfo, findConfigExport } from './ast-utils.js';
+
+describe('getPluginInfo', () => {
+    it('should return undefined when no plugin class is found', () => {
+        const sourceText = `
+            class NotAPlugin {
+                constructor() {}
+            }
+        `;
+        const sourceFile = ts.createSourceFile('path/to/test.ts', sourceText, ts.ScriptTarget.Latest, true);
+        const result = getPluginInfo(sourceFile);
+        expect(result).toBeUndefined();
+    });
+
+    it('should return plugin info when a valid plugin class is found', () => {
+        const sourceText = `
+            @VendurePlugin({
+                imports: [],
+                providers: []
+            })
+            class TestPlugin {
+                constructor() {}
+            }
+        `;
+        const sourceFile = ts.createSourceFile('path/to/test.ts', sourceText, ts.ScriptTarget.Latest, true);
+        const result = getPluginInfo(sourceFile);
+        expect(result).toEqual({
+            name: 'TestPlugin',
+            path: 'path/to',
+            dashboardEntryPath: undefined,
+        });
+    });
+
+    it('should handle multiple classes but only return the plugin one', () => {
+        const sourceText = `
+            class NotAPlugin {
+                constructor() {}
+            }
+
+            @VendurePlugin({
+                imports: [],
+                providers: []
+            })
+            class TestPlugin {
+                constructor() {}
+            }
+
+            class AnotherClass {
+                constructor() {}
+            }
+        `;
+        const sourceFile = ts.createSourceFile('path/to/test.ts', sourceText, ts.ScriptTarget.Latest, true);
+        const result = getPluginInfo(sourceFile);
+        expect(result).toEqual({
+            name: 'TestPlugin',
+            path: 'path/to',
+            dashboardEntryPath: undefined,
+        });
+    });
+
+    it('should determine the dashboard entry path when it is provided', () => {
+        const sourceText = `
+            @VendurePlugin({
+                imports: [],
+                providers: [],
+                dashboard: './dashboard/index.tsx',
+            })
+            class TestPlugin {
+                constructor() {}
+            }
+        `;
+        const sourceFile = ts.createSourceFile('path/to/test.ts', sourceText, ts.ScriptTarget.Latest, true);
+        const result = getPluginInfo(sourceFile);
+        expect(result).toEqual({
+            name: 'TestPlugin',
+            path: 'path/to',
+            dashboardEntryPath: './dashboard/index.tsx',
+        });
+    });
+});
+
+describe('findConfigExport', () => {
+    it('should return undefined when no VendureConfig export is found', () => {
+        const sourceText = `
+            export const notConfig = {
+                some: 'value'
+            };
+        `;
+        const sourceFile = ts.createSourceFile('path/to/test.ts', sourceText, ts.ScriptTarget.Latest, true);
+        const result = findConfigExport(sourceFile);
+        expect(result).toBeUndefined();
+    });
+
+    it('should find exported variable with VendureConfig type', () => {
+        const sourceText = `
+            import { VendureConfig } from '@vendure/core';
+            
+            export const config: VendureConfig = {
+                authOptions: {
+                    tokenMethod: 'bearer'
+                }
+            };
+        `;
+        const sourceFile = ts.createSourceFile('path/to/test.ts', sourceText, ts.ScriptTarget.Latest, true);
+        const result = findConfigExport(sourceFile);
+        expect(result).toBe('config');
+    });
+
+    it('should find exported variable with VendureConfig type among other exports', () => {
+        const sourceText = `
+            import { VendureConfig } from '@vendure/core';
+            
+            export const otherExport = 'value';
+            export const config: VendureConfig = {
+                authOptions: {
+                    tokenMethod: 'bearer'
+                }
+            };
+            export const anotherExport = 123;
+        `;
+        const sourceFile = ts.createSourceFile('path/to/test.ts', sourceText, ts.ScriptTarget.Latest, true);
+        const result = findConfigExport(sourceFile);
+        expect(result).toBe('config');
+    });
+});

+ 119 - 0
packages/dashboard/vite/utils/ast-utils.ts

@@ -0,0 +1,119 @@
+import path from 'path';
+import ts from 'typescript';
+
+import { PluginInfo } from './config-loader.js';
+
+/**
+ * Get the plugin info from the source file.
+ */
+export function getPluginInfo(sourceFile: ts.SourceFile): PluginInfo | undefined {
+    const classDeclaration = sourceFile.statements.find(statement => {
+        return (
+            statement.kind === ts.SyntaxKind.ClassDeclaration &&
+            statement.getText().includes('@VendurePlugin(')
+        );
+    });
+    if (classDeclaration) {
+        const identifier = classDeclaration.getChildren().find(child => {
+            return child.kind === ts.SyntaxKind.Identifier;
+        });
+        const dashboardEntryPath = classDeclaration
+            .getChildren()
+            .map(child => {
+                if (child.kind === ts.SyntaxKind.SyntaxList) {
+                    const pluginDecorator = child.getChildren().find(_child => {
+                        return _child.kind === ts.SyntaxKind.Decorator;
+                    });
+                    if (pluginDecorator) {
+                        const callExpression = findFirstDescendantOfKind(
+                            pluginDecorator,
+                            ts.SyntaxKind.CallExpression,
+                        );
+                        if (callExpression) {
+                            const objectLiteral = findFirstDescendantOfKind(
+                                callExpression,
+                                ts.SyntaxKind.ObjectLiteralExpression,
+                            );
+                            if (objectLiteral && ts.isObjectLiteralExpression(objectLiteral)) {
+                                // Now find the specific 'dashboard' property
+                                const dashboardProperty = objectLiteral.properties.find(
+                                    prop =>
+                                        ts.isPropertyAssignment(prop) && prop.name?.getText() === 'dashboard',
+                                );
+
+                                if (
+                                    dashboardProperty &&
+                                    ts.isPropertyAssignment(dashboardProperty) &&
+                                    ts.isStringLiteral(dashboardProperty.initializer)
+                                ) {
+                                    const dashboardPath = dashboardProperty.initializer.text;
+                                    return dashboardPath;
+                                }
+                            }
+                        }
+                    }
+                }
+            })
+            .filter(Boolean)?.[0];
+        if (identifier) {
+            return {
+                name: identifier.getText(),
+                pluginPath: path.dirname(sourceFile.fileName),
+                dashboardEntryPath,
+            };
+        }
+    }
+}
+
+/**
+ * Given the AST of a TypeScript file, finds the name of the variable exported as VendureConfig.
+ */
+export function findConfigExport(sourceFile: ts.SourceFile): string | undefined {
+    let exportedSymbolName: string | undefined;
+
+    function visit(node: ts.Node) {
+        if (
+            ts.isVariableStatement(node) &&
+            node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
+        ) {
+            node.declarationList.declarations.forEach(declaration => {
+                if (ts.isVariableDeclaration(declaration)) {
+                    const typeNode = declaration.type;
+                    if (typeNode && ts.isTypeReferenceNode(typeNode)) {
+                        const typeName = typeNode.typeName;
+                        if (ts.isIdentifier(typeName) && typeName.text === 'VendureConfig') {
+                            if (ts.isIdentifier(declaration.name)) {
+                                exportedSymbolName = declaration.name.text;
+                            }
+                        }
+                    }
+                }
+            });
+        }
+        ts.forEachChild(node, visit);
+    }
+
+    visit(sourceFile);
+    return exportedSymbolName;
+}
+
+function findFirstDescendantOfKind(node: ts.Node, kind: ts.SyntaxKind): ts.Node | undefined {
+    let foundNode: ts.Node | undefined;
+
+    function visit(_node: ts.Node) {
+        if (foundNode) {
+            // Stop searching if we already found it
+            return;
+        }
+        if (_node.kind === kind) {
+            foundNode = _node;
+            return;
+        }
+        // Recursively visit children
+        ts.forEachChild(_node, visit);
+    }
+
+    // Start the traversal from the initial node's children
+    ts.forEachChild(node, visit);
+    return foundNode;
+}

+ 54 - 69
packages/dashboard/vite/config-loader.ts → packages/dashboard/vite/utils/config-loader.ts

@@ -5,12 +5,20 @@ import tsConfigPaths from 'tsconfig-paths';
 import * as ts from 'typescript';
 import * as ts from 'typescript';
 import { pathToFileURL } from 'url';
 import { pathToFileURL } from 'url';
 
 
+import { findConfigExport, getPluginInfo } from './ast-utils.js';
+
 type Logger = {
 type Logger = {
     info: (message: string) => void;
     info: (message: string) => void;
     warn: (message: string) => void;
     warn: (message: string) => void;
     debug: (message: string) => void;
     debug: (message: string) => void;
 };
 };
 
 
+export type PluginInfo = {
+    name: string;
+    pluginPath: string;
+    dashboardEntryPath: string | undefined;
+};
+
 const defaultLogger: Logger = {
 const defaultLogger: Logger = {
     info: (message: string) => {
     info: (message: string) => {
         /* noop */
         /* noop */
@@ -30,6 +38,12 @@ export interface ConfigLoaderOptions {
     logger?: Logger;
     logger?: Logger;
 }
 }
 
 
+export interface LoadVendureConfigResult {
+    vendureConfig: VendureConfig;
+    exportedSymbolName: string;
+    pluginInfo: PluginInfo[];
+}
+
 /**
 /**
  * @description
  * @description
  * This function compiles the given Vendure config file and any imported relative files (i.e.
  * This function compiles the given Vendure config file and any imported relative files (i.e.
@@ -46,16 +60,14 @@ export interface ConfigLoaderOptions {
  * which fully supports these experimental decorators. The compiled files are then loaded by Vite, which is able
  * which fully supports these experimental decorators. The compiled files are then loaded by Vite, which is able
  * to handle the compiled JavaScript output.
  * to handle the compiled JavaScript output.
  */
  */
-export async function loadVendureConfig(
-    options: ConfigLoaderOptions,
-): Promise<{ vendureConfig: VendureConfig; exportedSymbolName: string }> {
+export async function loadVendureConfig(options: ConfigLoaderOptions): Promise<LoadVendureConfigResult> {
     const { vendureConfigPath, vendureConfigExport, tempDir } = options;
     const { vendureConfigPath, vendureConfigExport, tempDir } = options;
     const logger = options.logger || defaultLogger;
     const logger = options.logger || defaultLogger;
     const outputPath = tempDir;
     const outputPath = tempDir;
     const configFileName = path.basename(vendureConfigPath);
     const configFileName = path.basename(vendureConfigPath);
     const inputRootDir = path.dirname(vendureConfigPath);
     const inputRootDir = path.dirname(vendureConfigPath);
     await fs.remove(outputPath);
     await fs.remove(outputPath);
-    await compileFile(inputRootDir, vendureConfigPath, outputPath, logger);
+    const pluginInfo = await compileFile(inputRootDir, vendureConfigPath, outputPath, logger);
     const compiledConfigFilePath = pathToFileURL(path.join(outputPath, configFileName)).href.replace(
     const compiledConfigFilePath = pathToFileURL(path.join(outputPath, configFileName)).href.replace(
         /.ts$/,
         /.ts$/,
         '.js',
         '.js',
@@ -94,39 +106,7 @@ export async function loadVendureConfig(
             `Could not find a variable exported as VendureConfig with the name "${configExportedSymbolName}".`,
             `Could not find a variable exported as VendureConfig with the name "${configExportedSymbolName}".`,
         );
         );
     }
     }
-    return { vendureConfig: config, exportedSymbolName: configExportedSymbolName };
-}
-
-/**
- * Given the AST of a TypeScript file, finds the name of the variable exported as VendureConfig.
- */
-function findConfigExport(sourceFile: ts.SourceFile): string | undefined {
-    let exportedSymbolName: string | undefined;
-
-    function visit(node: ts.Node) {
-        if (
-            ts.isVariableStatement(node) &&
-            node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
-        ) {
-            node.declarationList.declarations.forEach(declaration => {
-                if (ts.isVariableDeclaration(declaration)) {
-                    const typeNode = declaration.type;
-                    if (typeNode && ts.isTypeReferenceNode(typeNode)) {
-                        const typeName = typeNode.typeName;
-                        if (ts.isIdentifier(typeName) && typeName.text === 'VendureConfig') {
-                            if (ts.isIdentifier(declaration.name)) {
-                                exportedSymbolName = declaration.name.text;
-                            }
-                        }
-                    }
-                }
-            });
-        }
-        ts.forEachChild(node, visit);
-    }
-
-    visit(sourceFile);
-    return exportedSymbolName;
+    return { vendureConfig: config, exportedSymbolName: configExportedSymbolName, pluginInfo };
 }
 }
 
 
 /**
 /**
@@ -194,10 +174,11 @@ export async function compileFile(
     logger: Logger = defaultLogger,
     logger: Logger = defaultLogger,
     compiledFiles = new Set<string>(),
     compiledFiles = new Set<string>(),
     isRoot = true,
     isRoot = true,
-): Promise<void> {
+    pluginInfo: PluginInfo[] = [],
+): Promise<PluginInfo[]> {
     const absoluteInputPath = path.resolve(inputPath);
     const absoluteInputPath = path.resolve(inputPath);
     if (compiledFiles.has(absoluteInputPath)) {
     if (compiledFiles.has(absoluteInputPath)) {
-        return;
+        return pluginInfo;
     }
     }
     compiledFiles.add(absoluteInputPath);
     compiledFiles.add(absoluteInputPath);
 
 
@@ -333,13 +314,18 @@ export async function compileFile(
     // Start collecting imports from the source file
     // Start collecting imports from the source file
     await collectImports(sourceFile);
     await collectImports(sourceFile);
 
 
+    const extractedPluginInfo = getPluginInfo(sourceFile);
+    if (extractedPluginInfo) {
+        pluginInfo.push(extractedPluginInfo);
+    }
+
     // Store the tsConfigInfo on the first call if found
     // Store the tsConfigInfo on the first call if found
     const rootTsConfigInfo = isRoot ? tsConfigInfo : undefined;
     const rootTsConfigInfo = isRoot ? tsConfigInfo : undefined;
 
 
     // Recursively collect all files that need to be compiled
     // Recursively collect all files that need to be compiled
     for (const importPath of importPaths) {
     for (const importPath of importPaths) {
         // Pass rootTsConfigInfo down, but set isRoot to false
         // Pass rootTsConfigInfo down, but set isRoot to false
-        await compileFile(inputRootDir, importPath, outputDir, logger, compiledFiles, false);
+        await compileFile(inputRootDir, importPath, outputDir, logger, compiledFiles, false, pluginInfo);
     }
     }
 
 
     // If this is the root file (the one that started the compilation),
     // If this is the root file (the one that started the compilation),
@@ -381,38 +367,10 @@ export async function compileFile(
 
 
         // Create a Program to represent the compilation context
         // Create a Program to represent the compilation context
         const program = ts.createProgram(allFiles, compilerOptions);
         const program = ts.createProgram(allFiles, compilerOptions);
-
-        // Perform the compilation and emit files
         logger.info(`Emitting compiled files to ${outputDir}`);
         logger.info(`Emitting compiled files to ${outputDir}`);
         const emitResult = program.emit();
         const emitResult = program.emit();
 
 
-        // Report diagnostics (errors and warnings)
-        const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
-
-        let hasEmitErrors = emitResult.emitSkipped;
-        allDiagnostics.forEach(diagnostic => {
-            if (diagnostic.file && diagnostic.start) {
-                const { line, character } = ts.getLineAndCharacterOfPosition(
-                    diagnostic.file,
-                    diagnostic.start,
-                );
-                const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
-                const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
-                logFn(
-                    `TS${diagnostic.code} ${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`,
-                );
-                if (diagnostic.category === ts.DiagnosticCategory.Error) {
-                    hasEmitErrors = true;
-                }
-            } else {
-                const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
-                const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
-                logFn(`TS${diagnostic.code}: ${message}`);
-                if (diagnostic.category === ts.DiagnosticCategory.Error) {
-                    hasEmitErrors = true;
-                }
-            }
-        });
+        const hasEmitErrors = reportDiagnostics(program, emitResult, logger);
 
 
         if (hasEmitErrors) {
         if (hasEmitErrors) {
             throw new Error('TypeScript compilation failed with errors.');
             throw new Error('TypeScript compilation failed with errors.');
@@ -420,4 +378,31 @@ export async function compileFile(
 
 
         logger.info(`Successfully compiled ${allFiles.length} files to ${outputDir}`);
         logger.info(`Successfully compiled ${allFiles.length} files to ${outputDir}`);
     }
     }
+    return pluginInfo;
+}
+
+function reportDiagnostics(program: ts.Program, emitResult: ts.EmitResult, logger: Logger) {
+    const allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics);
+    let hasEmitErrors = emitResult.emitSkipped;
+    allDiagnostics.forEach(diagnostic => {
+        if (diagnostic.file && diagnostic.start) {
+            const { line, character } = ts.getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start);
+            const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
+            const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
+            logFn(
+                `TS${diagnostic.code} ${diagnostic.file.fileName} (${line + 1},${character + 1}): ${message}`,
+            );
+            if (diagnostic.category === ts.DiagnosticCategory.Error) {
+                hasEmitErrors = true;
+            }
+        } else {
+            const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
+            const logFn = diagnostic.category === ts.DiagnosticCategory.Error ? logger.warn : logger.info;
+            logFn(`TS${diagnostic.code}: ${message}`);
+            if (diagnostic.category === ts.DiagnosticCategory.Error) {
+                hasEmitErrors = true;
+            }
+        }
+    });
+    return hasEmitErrors;
 }
 }

+ 7 - 1
packages/dashboard/vite/schema-generator.ts → packages/dashboard/vite/utils/schema-generator.ts

@@ -11,14 +11,20 @@ import {
 import { buildSchema } from 'graphql';
 import { buildSchema } from 'graphql';
 import { GraphQLSchema } from 'graphql';
 import { GraphQLSchema } from 'graphql';
 
 
-let schemaPromise: Promise<GraphQLSchema>;
+let schemaPromise: Promise<GraphQLSchema> | undefined;
 
 
+/**
+ * @description
+ * This function generates a GraphQL schema from the Vendure config.
+ * It is used to generate the schema for the dashboard.
+ */
 export async function generateSchema({
 export async function generateSchema({
     vendureConfig,
     vendureConfig,
 }: {
 }: {
     vendureConfig: VendureConfig;
     vendureConfig: VendureConfig;
 }): Promise<GraphQLSchema> {
 }): Promise<GraphQLSchema> {
     if (!schemaPromise) {
     if (!schemaPromise) {
+        /* eslint-disable-next-line @typescript-eslint/no-misused-promises */
         schemaPromise = new Promise(async (resolve, reject) => {
         schemaPromise = new Promise(async (resolve, reject) => {
             resetConfig();
             resetConfig();
             await setConfig(vendureConfig ?? {});
             await setConfig(vendureConfig ?? {});

+ 2 - 2
packages/dashboard/vite/ui-config.ts → packages/dashboard/vite/utils/ui-config.ts

@@ -6,8 +6,8 @@ import {
 import { AdminUiConfig } from '@vendure/common/lib/shared-types';
 import { AdminUiConfig } from '@vendure/common/lib/shared-types';
 import { VendureConfig } from '@vendure/core';
 import { VendureConfig } from '@vendure/core';
 
 
-import { defaultAvailableLocales } from './constants.js';
-import { defaultLocale, defaultLanguage, defaultAvailableLanguages } from './constants.js';
+import { defaultAvailableLocales } from '../constants.js';
+import { defaultLocale, defaultLanguage, defaultAvailableLanguages } from '../constants.js';
 
 
 export function getAdminUiConfig(
 export function getAdminUiConfig(
     config: VendureConfig,
     config: VendureConfig,

+ 2 - 2
packages/dashboard/vite/vite-plugin-admin-api-schema.ts

@@ -21,7 +21,7 @@ import {
 } from 'graphql';
 } from 'graphql';
 import { Plugin } from 'vite';
 import { Plugin } from 'vite';
 
 
-import { generateSchema } from './schema-generator.js';
+import { generateSchema } from './utils/schema-generator.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 
 
 export type FieldInfoTuple = readonly [
 export type FieldInfoTuple = readonly [
@@ -61,7 +61,7 @@ export function adminApiSchemaPlugin(): Plugin {
             configLoaderApi = getConfigLoaderApi(plugins);
             configLoaderApi = getConfigLoaderApi(plugins);
         },
         },
         async buildStart() {
         async buildStart() {
-            const vendureConfig = await configLoaderApi.getVendureConfig();
+            const { vendureConfig } = await configLoaderApi.getVendureConfig();
             if (!schemaInfo) {
             if (!schemaInfo) {
                 const safeSchema = await generateSchema({ vendureConfig });
                 const safeSchema = await generateSchema({ vendureConfig });
                 schemaInfo = generateSchemaInfo(safeSchema);
                 schemaInfo = generateSchemaInfo(safeSchema);

+ 11 - 11
packages/dashboard/vite/vite-plugin-config-loader.ts

@@ -1,10 +1,9 @@
-import { VendureConfig } from '@vendure/core';
 import { Plugin } from 'vite';
 import { Plugin } from 'vite';
 
 
-import { ConfigLoaderOptions, loadVendureConfig } from './config-loader.js';
+import { ConfigLoaderOptions, loadVendureConfig, LoadVendureConfigResult } from './utils/config-loader.js';
 
 
 export interface ConfigLoaderApi {
 export interface ConfigLoaderApi {
-    getVendureConfig(): Promise<VendureConfig>;
+    getVendureConfig(): Promise<LoadVendureConfigResult>;
 }
 }
 
 
 export const configLoaderName = 'vendure:config-loader';
 export const configLoaderName = 'vendure:config-loader';
@@ -14,7 +13,7 @@ export const configLoaderName = 'vendure:config-loader';
  * makes it available to other plugins via the `ConfigLoaderApi`.
  * makes it available to other plugins via the `ConfigLoaderApi`.
  */
  */
 export function configLoaderPlugin(options: ConfigLoaderOptions): Plugin {
 export function configLoaderPlugin(options: ConfigLoaderOptions): Plugin {
-    let vendureConfig: VendureConfig;
+    let result: LoadVendureConfigResult;
     const onConfigLoaded: Array<() => void> = [];
     const onConfigLoaded: Array<() => void> = [];
     return {
     return {
         name: configLoaderName,
         name: configLoaderName,
@@ -24,7 +23,7 @@ export function configLoaderPlugin(options: ConfigLoaderOptions): Plugin {
             );
             );
             try {
             try {
                 const startTime = Date.now();
                 const startTime = Date.now();
-                const result = await loadVendureConfig({
+                result = await loadVendureConfig({
                     tempDir: options.tempDir,
                     tempDir: options.tempDir,
                     vendureConfigPath: options.vendureConfigPath,
                     vendureConfigPath: options.vendureConfigPath,
                     vendureConfigExport: options.vendureConfigExport,
                     vendureConfigExport: options.vendureConfigExport,
@@ -34,9 +33,10 @@ export function configLoaderPlugin(options: ConfigLoaderOptions): Plugin {
                         debug: (message: string) => this.debug(message),
                         debug: (message: string) => this.debug(message),
                     },
                     },
                 });
                 });
-                vendureConfig = result.vendureConfig;
                 const endTime = Date.now();
                 const endTime = Date.now();
                 const duration = endTime - startTime;
                 const duration = endTime - startTime;
+                const pluginNames = result.pluginInfo.map(p => p.name).join(', ');
+                this.info(`Found ${result.pluginInfo.length} plugins: ${pluginNames}`);
                 this.info(
                 this.info(
                     `Vendure config loaded (using export "${result.exportedSymbolName}") in ${duration}ms`,
                     `Vendure config loaded (using export "${result.exportedSymbolName}") in ${duration}ms`,
                 );
                 );
@@ -48,13 +48,13 @@ export function configLoaderPlugin(options: ConfigLoaderOptions): Plugin {
             onConfigLoaded.forEach(fn => fn());
             onConfigLoaded.forEach(fn => fn());
         },
         },
         api: {
         api: {
-            getVendureConfig(): Promise<VendureConfig> {
-                if (vendureConfig) {
-                    return Promise.resolve(vendureConfig);
+            getVendureConfig(): Promise<LoadVendureConfigResult> {
+                if (result) {
+                    return Promise.resolve(result);
                 } else {
                 } else {
-                    return new Promise<VendureConfig>(resolve => {
+                    return new Promise<LoadVendureConfigResult>(resolve => {
                         onConfigLoaded.push(() => {
                         onConfigLoaded.push(() => {
-                            resolve(vendureConfig);
+                            resolve(result);
                         });
                         });
                     });
                     });
                 }
                 }

+ 19 - 15
packages/dashboard/vite/vite-plugin-dashboard-metadata.ts

@@ -1,8 +1,7 @@
-import { VendureConfig } from '@vendure/core';
-import { getPluginDashboardExtensions } from '@vendure/core';
 import path from 'path';
 import path from 'path';
 import { Plugin } from 'vite';
 import { Plugin } from 'vite';
 
 
+import { LoadVendureConfigResult } from './utils/config-loader.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 
 
 const virtualModuleId = 'virtual:dashboard-extensions';
 const virtualModuleId = 'virtual:dashboard-extensions';
@@ -15,7 +14,7 @@ const resolvedVirtualModuleId = `\0${virtualModuleId}`;
  */
  */
 export function dashboardMetadataPlugin(options: { rootDir: string }): Plugin {
 export function dashboardMetadataPlugin(options: { rootDir: string }): Plugin {
     let configLoaderApi: ConfigLoaderApi;
     let configLoaderApi: ConfigLoaderApi;
-    let vendureConfig: VendureConfig;
+    let loadVendureConfigResult: LoadVendureConfigResult;
     return {
     return {
         name: 'vendure:dashboard-extensions-metadata',
         name: 'vendure:dashboard-extensions-metadata',
         configResolved({ plugins }) {
         configResolved({ plugins }) {
@@ -28,22 +27,27 @@ export function dashboardMetadataPlugin(options: { rootDir: string }): Plugin {
         },
         },
         async load(id) {
         async load(id) {
             if (id === resolvedVirtualModuleId) {
             if (id === resolvedVirtualModuleId) {
-                if (!vendureConfig) {
-                    vendureConfig = await configLoaderApi.getVendureConfig();
+                if (!loadVendureConfigResult) {
+                    loadVendureConfigResult = await configLoaderApi.getVendureConfig();
                 }
                 }
-                const extensions = getPluginDashboardExtensions(vendureConfig.plugins ?? []);
-                const extensionData: Array<{ importPath: string }> = extensions.map(extension => {
-                    const providedPath = typeof extension === 'string' ? extension : extension.location;
-                    const jsPath = normalizeImportPath(options.rootDir, providedPath);
-                    return { importPath: `./${jsPath}` };
-                });
+                const { pluginInfo } = loadVendureConfigResult;
+                const pluginsWithExtensions =
+                    pluginInfo
+                        ?.map(
+                            ({ dashboardEntryPath, pluginPath }) =>
+                                dashboardEntryPath && path.join(pluginPath, dashboardEntryPath),
+                        )
+                        .filter(x => x != null) ?? [];
 
 
-                this.info(`Found ${extensionData.length} Dashboard extensions`);
+                this.info(`Found ${pluginsWithExtensions.length} Dashboard extensions`);
                 return `
                 return `
                     export async function runDashboardExtensions() {
                     export async function runDashboardExtensions() {
-                        ${extensionData.map(extension => `await import('${extension.importPath}');`).join('\n')}
-                    }
-                `;
+                        ${pluginsWithExtensions
+                            .map(extension => {
+                                return `await import('${extension}');`;
+                            })
+                            .join('\n')}
+                }`;
             }
             }
         },
         },
     };
     };

+ 2 - 2
packages/dashboard/vite/vite-plugin-gql-tada.ts

@@ -4,7 +4,7 @@ import { printSchema } from 'graphql';
 import * as path from 'path';
 import * as path from 'path';
 import { Plugin } from 'vite';
 import { Plugin } from 'vite';
 
 
-import { generateSchema } from './schema-generator.js';
+import { generateSchema } from './utils/schema-generator.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 
 
 export function gqlTadaPlugin(options: {
 export function gqlTadaPlugin(options: {
@@ -20,7 +20,7 @@ export function gqlTadaPlugin(options: {
             configLoaderApi = getConfigLoaderApi(plugins);
             configLoaderApi = getConfigLoaderApi(plugins);
         },
         },
         async buildStart() {
         async buildStart() {
-            const vendureConfig = await configLoaderApi.getVendureConfig();
+            const { vendureConfig } = await configLoaderApi.getVendureConfig();
             const safeSchema = await generateSchema({ vendureConfig });
             const safeSchema = await generateSchema({ vendureConfig });
 
 
             const tsConfigContent = {
             const tsConfigContent = {

+ 3 - 2
packages/dashboard/vite/vite-plugin-ui-config.ts

@@ -2,7 +2,7 @@ import { AdminUiConfig, VendureConfig } from '@vendure/core';
 import path from 'path';
 import path from 'path';
 import { Plugin } from 'vite';
 import { Plugin } from 'vite';
 
 
-import { getAdminUiConfig } from './ui-config.js';
+import { getAdminUiConfig } from './utils/ui-config.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 import { ConfigLoaderApi, getConfigLoaderApi } from './vite-plugin-config-loader.js';
 
 
 const virtualModuleId = 'virtual:vendure-ui-config';
 const virtualModuleId = 'virtual:vendure-ui-config';
@@ -38,7 +38,8 @@ export function uiConfigPlugin({ adminUiConfig }: UiConfigPluginOptions): Plugin
         async load(id) {
         async load(id) {
             if (id === resolvedVirtualModuleId) {
             if (id === resolvedVirtualModuleId) {
                 if (!vendureConfig) {
                 if (!vendureConfig) {
-                    vendureConfig = await configLoaderApi.getVendureConfig();
+                    const result = await configLoaderApi.getVendureConfig();
+                    vendureConfig = result.vendureConfig;
                 }
                 }
 
 
                 const config = getAdminUiConfig(vendureConfig, adminUiConfig);
                 const config = getAdminUiConfig(vendureConfig, adminUiConfig);

+ 3 - 1
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -18,7 +18,9 @@ export default defineDashboardExtension({
         {
         {
             label: 'Custom Action Bar Item',
             label: 'Custom Action Bar Item',
             component: props => {
             component: props => {
-                return <Button>YOLO swag</Button>;
+                return <Button type="button" onClick={() => {
+                    console.log('Clicked custom action bar item');
+                }}>Test Button</Button>;
             },
             },
             locationId: 'product-detail',
             locationId: 'product-detail',
         },
         },

+ 1 - 1
packages/dev-server/test-plugins/reviews/reviews-plugin.ts

@@ -49,7 +49,7 @@ import { ProductReviewTranslation } from './entities/product-review-translation.
         });
         });
         return config;
         return config;
     },
     },
-    dashboard: './dashboard',
+    dashboard: './dashboard/index.tsx',
 })
 })
 export class ReviewsPlugin {
 export class ReviewsPlugin {
     static uiExtensions: AdminUiExtension = {
     static uiExtensions: AdminUiExtension = {