Browse Source

feat(dashboard): Implement add command for Dashboard extensions (#3864)

Michael Bromley 3 months ago
parent
commit
8c2359318f

+ 4 - 3
packages/cli/build.ts

@@ -3,7 +3,7 @@ import path from 'path';
 
 // This build script copies all .template.ts files from the "src" directory to the "dist" directory.
 // This is necessary because the .template.ts files are used to generate the actual source files.
-const templateFiles = findFilesWithSuffix(path.join(__dirname, 'src'), '.template.ts');
+const templateFiles = findFilesWithSuffix(path.join(__dirname, 'src'), ['.template.ts', '.template.tsx']);
 for (const file of templateFiles) {
     // copy to the equivalent path in the "dist" rather than "src" directory
     const relativePath = path.relative(path.join(__dirname, 'src'), file);
@@ -12,8 +12,9 @@ for (const file of templateFiles) {
     fs.copyFileSync(file, distPath);
 }
 
-function findFilesWithSuffix(directory: string, suffix: string): string[] {
+function findFilesWithSuffix(directory: string, suffix: string | string[]): string[] {
     const files: string[] = [];
+    const suffixes = Array.isArray(suffix) ? suffix : [suffix];
 
     function traverseDirectory(dir: string) {
         const dirContents = fs.readdirSync(dir);
@@ -25,7 +26,7 @@ function findFilesWithSuffix(directory: string, suffix: string): string[] {
             if (stats.isDirectory()) {
                 traverseDirectory(itemPath);
             } else {
-                if (item.endsWith(suffix)) {
+                if (suffixes.some(s => item.endsWith(s))) {
                     files.push(itemPath);
                 }
             }

+ 19 - 1
packages/cli/src/commands/add/add.ts

@@ -7,6 +7,7 @@ import { cliCommands } from '../command-declarations';
 
 import { addApiExtension } from './api-extension/add-api-extension';
 import { addCodegen } from './codegen/add-codegen';
+import { addDashboard } from './dashboard/add-dashboard';
 import { addEntity } from './entity/add-entity';
 import { addJobQueue } from './job-queue/add-job-queue';
 import { createNewPlugin } from './plugin/create-new-plugin';
@@ -28,8 +29,10 @@ export interface AddOptions {
     codegen?: string | boolean;
     /** Add an API extension scaffold to the specified plugin */
     apiExtension?: string | boolean;
-    /** Add Admin-UI or Storefront UI extensions to the specified plugin */
+    /** Add Admin-UI extensions to the specified plugin */
     uiExtensions?: string | boolean;
+    /** Add Dashboard UI extensions to the specified plugin */
+    dashboard?: string | boolean;
     /** Specify the path to a custom Vendure config file */
     config?: string;
     /** Name for the job queue (used with jobQueue) */
@@ -218,6 +221,21 @@ async function handleNonInteractiveMode(options: AddOptions) {
                 pluginName,
             });
             log.success('UI extensions added successfully');
+        } else if (options.dashboard) {
+            const pluginName = typeof options.dashboard === 'string' ? options.dashboard : undefined;
+            // For UI extensions, if a boolean true is passed, plugin selection will be handled interactively
+            // If a string is passed, it should be a valid plugin name
+            if (typeof options.uiExtensions === 'string' && !options.uiExtensions.trim()) {
+                throw new Error(
+                    'Plugin name cannot be empty when specified. Usage: vendure add --dashboard [plugin-name]',
+                );
+            }
+            await addDashboard({
+                isNonInteractive: true,
+                config: options.config,
+                pluginName,
+            });
+            log.success('Dashboard extensions added successfully');
         } else {
             log.error('No valid add operation specified');
             process.exit(1);

+ 73 - 0
packages/cli/src/commands/add/dashboard/add-dashboard.ts

@@ -0,0 +1,73 @@
+import { log } from '@clack/prompts';
+import fs from 'fs-extra';
+import path from 'path';
+
+import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
+import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
+import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
+import { createFile, getPluginClasses } from '../../../utilities/ast-utils';
+
+export interface AddDashboardOptions {
+    plugin?: VendurePluginRef;
+    pluginName?: string;
+    config?: string;
+    isNonInteractive?: boolean;
+}
+
+export const addDashboardCommand = new CliCommand({
+    id: 'add-dashboard',
+    category: 'Plugin: Dashboard',
+    description: 'Add Dashboard extensions',
+    run: addDashboard,
+});
+
+export async function addDashboard(options?: AddDashboardOptions): Promise<CliCommandReturnVal> {
+    const providedVendurePlugin = options?.plugin;
+    const { project } = await analyzeProject({ providedVendurePlugin, config: options?.config });
+
+    // Detect non-interactive mode
+    const isNonInteractive = options?.isNonInteractive === true;
+
+    let vendurePlugin: VendurePluginRef | undefined = providedVendurePlugin;
+
+    // If a plugin name was provided, try to find it
+    if (!vendurePlugin && options?.pluginName) {
+        const pluginClasses = getPluginClasses(project);
+        const foundPlugin = pluginClasses.find(p => p.getName() === options.pluginName);
+
+        if (!foundPlugin) {
+            // List available plugins if the specified one wasn't found
+            const availablePlugins = pluginClasses.map(p => p.getName()).filter(Boolean);
+            throw new Error(
+                `Plugin "${options.pluginName}" not found. Available plugins:\n` +
+                    availablePlugins.map(name => `  - ${name as string}`).join('\n'),
+            );
+        }
+
+        vendurePlugin = new VendurePluginRef(foundPlugin);
+    }
+
+    // In non-interactive mode, we need a plugin specified
+    if (isNonInteractive && !vendurePlugin) {
+        throw new Error('Plugin must be specified when running in non-interactive mode');
+    }
+    vendurePlugin = vendurePlugin ?? (await selectPlugin(project, 'Add UI extensions cancelled'));
+
+    try {
+        vendurePlugin.addMetadataProperty('dashboard', './dashboard/index.tsx');
+        log.success('Updated the plugin class');
+    } catch (e) {
+        log.error(e instanceof Error ? e.message : String(e));
+        return { project, modifiedSourceFiles: [] };
+    }
+
+    const pluginDir = vendurePlugin.getPluginDir().getPath();
+    const dashboardEntrypointFile = path.join(pluginDir, 'dashboard', 'index.tsx');
+    if (!fs.existsSync(dashboardEntrypointFile)) {
+        createFile(project, path.join(__dirname, 'templates/index.template.tsx'), dashboardEntrypointFile);
+    }
+    log.success('Created Dashboard extension scaffold');
+
+    await project.save();
+    return { project, modifiedSourceFiles: [vendurePlugin.classDeclaration.getSourceFile()] };
+}

+ 47 - 0
packages/cli/src/commands/add/dashboard/templates/index.template.tsx

@@ -0,0 +1,47 @@
+import { Button, defineDashboardExtension } from '@vendure/dashboard';
+import { useState } from 'react';
+
+defineDashboardExtension({
+    pageBlocks: [
+        // Here's an example of a page block extension. If you visit a product detail page,
+        // you should see the block in action.
+        {
+            id: 'example-page-block',
+            location: {
+                pageId: 'product-detail',
+                position: {
+                    blockId: 'product-variants-table',
+                    order: 'after',
+                },
+                column: 'main',
+            },
+            component: () => {
+                const [count, setCount] = useState(0);
+                return (
+                    <div>
+                        <p>This is an example custom component.</p>
+                        <p className="text-muted-foreground mb-4">
+                            As is traditional, let's include counter functionality:
+                        </p>
+                        <Button variant="secondary" onClick={() => setCount(c => c + 1)}>
+                            Clicked {count} times
+                        </Button>
+                    </div>
+                );
+            },
+        },
+    ],
+    // The following extension points are only listed here
+    // to give you an idea of all the ways that the Dashboard
+    // can be extended. Feel free to delete any that you don't need.
+    routes: [],
+    navSections: [],
+    actionBarItems: [],
+    alerts: [],
+    widgets: [],
+    customFormComponents: {},
+    dataTables: [],
+    detailForms: [],
+    login: {},
+    historyEntries: [],
+});

+ 2 - 0
packages/cli/src/commands/add/plugin/create-new-plugin.ts

@@ -12,6 +12,7 @@ import { addImportsToFile, createFile, getPluginClasses } from '../../../utiliti
 import { pauseForPromptDisplay, withInteractiveTimeout } from '../../../utilities/utils';
 import { addApiExtensionCommand } from '../api-extension/add-api-extension';
 import { addCodegenCommand } from '../codegen/add-codegen';
+import { addDashboardCommand } from '../dashboard/add-dashboard';
 import { addEntityCommand } from '../entity/add-entity';
 import { addJobQueueCommand } from '../job-queue/add-job-queue';
 import { addServiceCommand } from '../service/add-service';
@@ -113,6 +114,7 @@ export async function createNewPlugin(
         addApiExtensionCommand,
         addJobQueueCommand,
         addUiExtensionsCommand,
+        addDashboardCommand,
         addCodegenCommand,
     ];
     let allModifiedSourceFiles = [...modifiedSourceFiles];

+ 12 - 5
packages/cli/src/commands/command-declarations.ts

@@ -2,11 +2,11 @@ import { CliCommandDefinition } from '../shared/cli-command-definition';
 
 import { addApiExtension } from './add/api-extension/add-api-extension';
 import { addCodegen } from './add/codegen/add-codegen';
+import { addDashboard } from './add/dashboard/add-dashboard';
 import { addEntity } from './add/entity/add-entity';
 import { addJobQueue } from './add/job-queue/add-job-queue';
 import { createNewPlugin } from './add/plugin/create-new-plugin';
 import { addService } from './add/service/add-service';
-import { addUiExtensions } from './add/ui-extensions/add-ui-extensions';
 
 export const cliCommands: CliCommandDefinition[] = [
     {
@@ -136,14 +136,21 @@ export const cliCommands: CliCommandDefinition[] = [
                     },
                 ],
             },
+            {
+                short: '-d',
+                long: '--dashboard [plugin]',
+                description: 'Add Dashboard extensions to a plugin',
+                required: false,
+                interactiveId: 'add-dashboard',
+                interactiveCategory: 'Plugin: Dashboard',
+                interactiveFn: addDashboard,
+            },
             {
                 short: '-u',
                 long: '--ui-extensions [plugin]',
-                description: 'Add UI extensions to a plugin',
+                description:
+                    'Add UI extensions to a plugin (deprecated: considering migrating to the new Dashboard)',
                 required: false,
-                interactiveId: 'add-ui-extensions',
-                interactiveCategory: 'Plugin: UI',
-                interactiveFn: addUiExtensions,
             },
         ],
         action: async options => {

+ 1 - 0
packages/cli/src/shared/cli-command.ts

@@ -5,6 +5,7 @@ import { VendurePluginRef } from './vendure-plugin-ref';
 export type CommandCategory =
     | `Plugin`
     | `Plugin: UI`
+    | `Plugin: Dashboard`
     | `Plugin: Entity`
     | `Plugin: Service`
     | `Plugin: API`

+ 64 - 2
packages/cli/src/shared/vendure-plugin-ref.ts

@@ -1,11 +1,10 @@
+import { VendurePluginMetadata } from '@vendure/core';
 import {
     ClassDeclaration,
     InterfaceDeclaration,
     Node,
     PropertyAssignment,
-    StructureKind,
     SyntaxKind,
-    Type,
     VariableDeclaration,
 } from 'ts-morph';
 
@@ -164,6 +163,28 @@ export class VendurePluginRef {
         }
     }
 
+    addMetadataProperty<K extends keyof VendurePluginMetadata>(prop: K, value: string) {
+        const pluginOptions = this.getMetadataOptions();
+        const existingProperty = pluginOptions.getProperty(prop);
+        if (existingProperty) {
+            const existingValue = Node.isPropertyAssignment(existingProperty)
+                ? this.normalizeStringValue(existingProperty.getInitializer()?.getText())
+                : existingProperty.getText();
+            if (existingValue === value) {
+                // Value is already set to the same, so we can
+                // just return without changing anything
+                return;
+            }
+            throw new Error(`Property '${prop}' already exists with a value of ${existingValue}`);
+        }
+        pluginOptions
+            .addPropertyAssignment({
+                name: prop,
+                initializer: `'${value}'`,
+            })
+            .formatText();
+    }
+
     getEntities(): EntityRef[] {
         const metadataOptions = this.getMetadataOptions();
         const entitiesProperty = metadataOptions.getProperty('entities');
@@ -202,4 +223,45 @@ export class VendurePluginRef {
             .getStaticProperties()
             .find(prop => prop.getType().getSymbol()?.getName() === AdminUiExtensionTypeName);
     }
+
+    /**
+     * Normalizes string values for comparison by removing quotes and handling nested string literals.
+     *
+     * When working with AST nodes via ts-morph, property initializers are returned as raw text
+     * including their original quotation marks. For example:
+     * - A property `foo: './path'` returns `"'./path'"` from getText()
+     * - A property `foo: "./path"` returns `'"./path"'` from getText()
+     *
+     * This method ensures that when comparing these AST-derived strings with expected values,
+     * we compare the actual semantic content rather than the literal representation including quotes.
+     * This allows us to properly detect when a property already has the desired value and avoid
+     * duplicate property assignments or incorrect error messages.
+     *
+     * @param value The raw string value from an AST node's getText() method
+     * @returns The normalized string value without surrounding quotes
+     */
+    private normalizeStringValue(value: string | undefined): string {
+        if (!value) return '';
+
+        // Remove outer quotes (single or double)
+        let normalized = value.trim();
+        if (
+            (normalized.startsWith('"') && normalized.endsWith('"')) ||
+            (normalized.startsWith("'") && normalized.endsWith("'"))
+        ) {
+            normalized = normalized.slice(1, -1);
+        }
+
+        // Handle nested quoted strings by trying to parse as JSON
+        try {
+            const parsed = JSON.parse(`"${normalized}"`);
+            if (typeof parsed === 'string') {
+                return parsed;
+            }
+        } catch {
+            // If JSON parsing fails, just return the normalized string
+        }
+
+        return normalized;
+    }
 }