Browse Source

feat(dashboard): Experimental packaging setup

Michael Bromley 10 months ago
parent
commit
a203037e1d

+ 10 - 3
packages/core/src/plugin/plugin-metadata.ts

@@ -3,7 +3,7 @@ import { MODULE_METADATA } from '@nestjs/common/constants';
 import { Type } from '@vendure/common/lib/shared-types';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 
-import { APIExtensionDefinition, PluginConfigurationFn } from './vendure-plugin';
+import { APIExtensionDefinition, DashboardExtension, PluginConfigurationFn } from './vendure-plugin';
 
 export const PLUGIN_METADATA = {
     CONFIGURATION: 'configuration',
@@ -11,6 +11,7 @@ export const PLUGIN_METADATA = {
     ADMIN_API_EXTENSIONS: 'adminApiExtensions',
     ENTITIES: 'entities',
     COMPATIBILITY: 'compatibility',
+    DASHBOARD: 'dashboard',
 };
 
 export function getEntitiesFromPlugins(plugins?: Array<Type<any> | DynamicModule>): Array<Type<any>> {
@@ -20,7 +21,7 @@ export function getEntitiesFromPlugins(plugins?: Array<Type<any> | DynamicModule
     return plugins
         .map(p => reflectMetadata(p, PLUGIN_METADATA.ENTITIES))
         .reduce((all, entities) => {
-            const resolvedEntities = typeof entities === 'function' ? entities() : entities ?? [];
+            const resolvedEntities = typeof entities === 'function' ? entities() : (entities ?? []);
             return [...all, ...resolvedEntities];
         }, []);
 }
@@ -46,6 +47,12 @@ export function getPluginAPIExtensions(
     return extensions.filter(notNullOrUndefined);
 }
 
+export function getPluginDashboardExtensions(
+    plugins: Array<Type<any> | DynamicModule>,
+): DashboardExtension[] {
+    return plugins.map(p => reflectMetadata(p, PLUGIN_METADATA.DASHBOARD)).filter(notNullOrUndefined);
+}
+
 export function getCompatibility(plugin: Type<any> | DynamicModule): string | undefined {
     return reflectMetadata(plugin, PLUGIN_METADATA.COMPATIBILITY);
 }
@@ -68,7 +75,7 @@ export function graphQLResolversFor(
     return apiExtensions
         ? typeof apiExtensions.resolvers === 'function'
             ? apiExtensions.resolvers()
-            : apiExtensions.resolvers ?? []
+            : (apiExtensions.resolvers ?? [])
         : [];
 }
 

+ 7 - 0
packages/core/src/plugin/vendure-plugin.ts

@@ -43,6 +43,7 @@ export interface VendurePluginMetadata extends ModuleMetadata {
      * The plugin may define custom [TypeORM database entities](https://typeorm.io/#/entities).
      */
     entities?: Array<Type<any>> | (() => Array<Type<any>>);
+    dashboard?: DashboardExtension;
     /**
      * @description
      * The plugin should define a valid [semver version string](https://www.npmjs.com/package/semver) to indicate which versions of
@@ -118,6 +119,12 @@ export type PluginConfigurationFn = (
     config: RuntimeVendureConfig,
 ) => RuntimeVendureConfig | Promise<RuntimeVendureConfig>;
 
+export type DashboardExtension =
+    | string
+    | {
+          location: string;
+      };
+
 /**
  * @description
  * The VendurePlugin decorator is a means of configuring and/or extending the functionality of the Vendure server. A Vendure plugin is

+ 13 - 0
packages/dashboard/package.json

@@ -6,9 +6,22 @@
   "scripts": {
     "dev": "vite",
     "build": "vite build",
+    "build:lib": "tsc --project tsconfig.lib.json",
+    "build:plugin": "tsc --project tsconfig.plugin.json --watch",
     "lint": "eslint .",
     "preview": "vite preview"
   },
+  "module": "./dist/lib/index.js",
+  "main": "./dist/lib/index.js",
+  "exports": {
+    ".": "./dist/lib/index.js",
+    "./plugin": "./dist/plugin/index.js"
+  },
+  "files": [
+    "dist",
+    "src",
+    "lingui.config.js"
+  ],
   "dependencies": {
     "@lingui/core": "^5.2.0",
     "@lingui/react": "^5.2.0",

+ 5 - 3
packages/dashboard/src/auth.tsx

@@ -61,10 +61,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         retry: false,
     });
 
+    const loginMutationFn = api.mutate(LoginMutation);
     const loginMutation = useMutation({
-        mutationFn: api.mutate(LoginMutation),
+        mutationFn: loginMutationFn,
         onSuccess: async data => {
-            if (data?.login.__typename === 'CurrentUser') {
+            if (data.login.__typename === 'CurrentUser') {
                 setStatus('authenticated');
                 onLoginSuccessFn.current();
             } else {
@@ -78,8 +79,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         },
     });
 
+    const logoutMutationFn = api.mutate(LogOutMutation);
     const logoutMutation = useMutation({
-        mutationFn: api.mutate(LogOutMutation),
+        mutationFn: logoutMutationFn,
         onSuccess: async data => {
             console.log(data);
             if (data?.logout.success === true) {

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

@@ -20,7 +20,6 @@ import {
     Table,
 } from '@tanstack/react-table';
 import { ColumnDef } from '@tanstack/table-core';
-import { ListQueryOptions } from '@vendure/core/src/index.js';
 import { ResultOf } from 'gql.tada';
 import React, { useMemo } from 'react';
 
@@ -145,7 +144,7 @@ export function ListPage<
                     }
                     let Cmp: React.ComponentType<{ value: any }> | undefined = undefined;
                     if ((field.type === 'DateTime' && typeof value === 'string') || value instanceof Date) {
-                        Cmp = getComponent('boolean.display');
+                        Cmp = getComponent('dateTime.display');
                     }
                     if (field.type === 'Boolean') {
                         Cmp = getComponent('boolean.display');

+ 20 - 0
packages/dashboard/src/framework/internal/plugin-api/plugin-api.ts

@@ -0,0 +1,20 @@
+import { DocumentNode } from 'graphql';
+
+export interface DashboardBaseRouteDefinition {
+    id: string;
+    title?: string;
+}
+
+export interface DashboardListRouteDefinition extends DashboardBaseRouteDefinition {
+    listQuery: DocumentNode;
+}
+
+export type DashboardRouteDefinition = DashboardListRouteDefinition;
+
+export interface DashboardExtension {
+    routes: DashboardRouteDefinition[];
+}
+
+export function defineDashboardExtension(extension: DashboardExtension) {
+    return extension;
+}

+ 28 - 3
packages/dashboard/src/graphql/api.ts

@@ -24,17 +24,41 @@ function query<T, V extends Variables = Variables>(
     });
 }
 
-function mutate<T, V extends Variables = Variables>(
+function mutate2<T, V extends Variables = Variables>(
+    document: TypedDocumentNode<T, V>,
+): (variables: V) => Promise<T>;
+function mutate2<T, V extends Variables = Variables>(
     document: RequestDocument | TypedDocumentNode<T, V>,
+    maybeVariables?: V,
+): Promise<T> | ((variables: V) => Promise<T>) {
+    if (maybeVariables) {
+        return client.request<T>({
+            document,
+            variables: maybeVariables,
+        });
+    } else {
+        return (variables: V): Promise<T> => {
+            return client.request<T>({
+                document,
+                variables,
+            });
+        };
+    }
+}
+
+function mutate<T, V extends Variables = Variables>(
+    document: TypedDocumentNode<T, V>,
 ): (variables: V) => Promise<T>;
+function mutate(document: RequestDocument): (variables: Variables) => Promise<unknown>;
 function mutate<T, V extends Variables = Variables>(
-    document: RequestDocument | TypedDocumentNode<T, V>,
+    document: TypedDocumentNode<T, V>,
     variables: V,
 ): Promise<T>;
+function mutate(document: RequestDocument, variables: Variables): Promise<unknown>;
 function mutate<T, V extends Variables = Variables>(
     document: RequestDocument | TypedDocumentNode<T, V>,
     maybeVariables?: V,
-) {
+): Promise<T> | ((variables: V) => Promise<T>) {
     if (maybeVariables) {
         return client.request<T>({
             document,
@@ -53,4 +77,5 @@ function mutate<T, V extends Variables = Variables>(
 export const api = {
     query,
     mutate,
+    mutate2,
 };

+ 2 - 0
packages/dashboard/src/index.ts

@@ -0,0 +1,2 @@
+export * from './framework/internal/plugin-api/plugin-api.js';
+export const test = 'test';

+ 1 - 1
packages/dashboard/src/routes/__root.tsx

@@ -3,7 +3,7 @@ import { createRootRouteWithContext, Outlet, retainSearchParams } from '@tanstac
 import { TanStackRouterDevtools } from '@tanstack/router-devtools';
 import * as React from 'react';
 
-interface MyRouterContext {
+export interface MyRouterContext {
     auth: AuthContext;
 }
 

+ 21 - 34
packages/dashboard/src/routes/_authenticated/products.tsx

@@ -7,55 +7,42 @@ export const Route = createFileRoute('/_authenticated/products')({
     component: ProductListPage,
 });
 
-const productFragment = graphql(`
-    fragment ProductFragment on Product {
-        id
-        createdAt
-        updatedAt
-        featuredAsset {
-            id
-            preview
-            focalPoint {
-                x
-                y
+const productListDocument = graphql(`
+    query ProductList($options: ProductListOptions) {
+        products(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                featuredAsset {
+                    id
+                    preview
+                }
+                name
+                slug
+                enabled
             }
+            totalItems
         }
-        name
-        slug
-        enabled
     }
 `);
 
-const productListDocument = graphql(
-    `
-        query ProductList($options: ProductListOptions) {
-            products(options: $options) {
-                items {
-                    ...ProductFragment
-                }
-                totalItems
-            }
-        }
-    `,
-    [productFragment],
-);
-
 export function ProductListPage() {
     return (
         <ListPage
             title="Products"
-            listQuery={productListDocument}
             customizeColumns={{
                 name: { header: 'Product Name' },
-                featuredAsset: {
-                    header: 'Image',
-                    enableSorting: false,
-                },
             }}
-            defaultColumnOrder={['id', 'featuredAsset', 'name']}
             defaultVisibility={{
                 id: true,
             }}
+            onSearchTermChange={searchTerm => {
+                return {
+                    name: { contains: searchTerm },
+                };
+            }}
+            listQuery={productListDocument}
             route={Route}
         />
     );

+ 1 - 1
packages/dashboard/src/types.ts → packages/dashboard/src/virtual.d.ts

@@ -1,4 +1,4 @@
 declare module 'virtual:admin-api-schema' {
-    import { SchemaInfo } from '../vite/api-schema/vite-plugin-admin-api-schema.js';
+    import { SchemaInfo } from '../vite/vite-plugin-admin-api-schema.js';
     export const schemaInfo: SchemaInfo;
 }

+ 2 - 1
packages/dashboard/tsconfig.json

@@ -15,5 +15,6 @@
         "tadaOutputLocation": "./src/graphql/graphql-env.d.ts"
       }
     ]
-  }
+  },
+  "exclude": ["./lib"]
 }

+ 14 - 0
packages/dashboard/tsconfig.lib.json

@@ -0,0 +1,14 @@
+{
+  "extends": ["./tsconfig.json"],
+  "compilerOptions": {
+    "types": ["./src/virtual.d.ts", "node"],
+    "module": "umd",
+    "moduleResolution": "node",
+    "resolveJsonModule": false,
+    "skipLibCheck": true,
+    "plugins": [],
+    "declaration": true,
+    "outDir": "./dist/lib"
+  },
+  "include": ["src/**/*"],
+}

+ 13 - 0
packages/dashboard/tsconfig.plugin.json

@@ -0,0 +1,13 @@
+{
+  "extends": ["./tsconfig.json"],
+  "compilerOptions": {
+    "module": "NodeNext",
+    "moduleResolution": "nodenext",
+    "resolveJsonModule": false,
+    "skipLibCheck": true,
+    "plugins": [],
+    "declaration": true,
+    "outDir": "./dist/plugin"
+  },
+  "include": ["vite/**/*"],
+}

+ 2 - 2
packages/dashboard/vite.config.ts

@@ -5,11 +5,11 @@ import react from '@vitejs/plugin-react';
 import path from 'path';
 import { defineConfig } from 'vite';
 
-import { adminApiSchemaPlugin } from './vite/api-schema/vite-plugin-admin-api-schema.js';
+import { adminApiSchemaPlugin } from './vite/vite-plugin-admin-api-schema.js';
 
 // https://vite.dev/config/
 export default defineConfig(async () => {
-    const vendureConfig = await import('../dev-server/dev-config').then(m => m.devConfig);
+    const vendureConfig = await import('../dev-server/dev-config.js').then(m => m.devConfig);
     return {
         plugins: [
             TanStackRouterVite({ autoCodeSplitting: true }),

+ 1 - 0
packages/dashboard/vite/index.ts

@@ -0,0 +1 @@
+export { vendureDashboardPlugin } from './vite-plugin-vendure-dashboard.js';

+ 4 - 5
packages/dashboard/vite/api-schema/vite-plugin-admin-api-schema.ts → packages/dashboard/vite/vite-plugin-admin-api-schema.ts

@@ -101,8 +101,7 @@ function generateSchemaInfo(schema: GraphQLSchema): SchemaInfo {
 }
 
 const virtualModuleId = 'virtual:admin-api-schema';
-
-let defaultSchema: GraphQLSchema;
+const resolvedVirtualModuleId = `\0${virtualModuleId}`;
 let schemaInfo: SchemaInfo;
 
 export async function adminApiSchemaPlugin(options: { config: VendureConfig }): Promise<Plugin> {
@@ -125,13 +124,13 @@ export async function adminApiSchemaPlugin(options: { config: VendureConfig }):
 
     return {
         name: 'vendure-admin-api-schema',
-        resolveId(id, importer) {
+        resolveId(id) {
             if (id === virtualModuleId) {
-                return id;
+                return resolvedVirtualModuleId;
             }
         },
         load(id) {
-            if (id === virtualModuleId) {
+            if (id === resolvedVirtualModuleId) {
                 return `
                     export const schemaInfo = ${JSON.stringify(schemaInfo)};
                 `;

+ 39 - 0
packages/dashboard/vite/vite-plugin-dashboard-metadata.ts

@@ -0,0 +1,39 @@
+import { VendureConfig } from '@vendure/core';
+import { DocumentNode } from 'graphql/index.js';
+import { Plugin } from 'vite';
+
+const virtualModuleId = 'virtual:dashboard-extensions';
+const resolvedVirtualModuleId = `\0${virtualModuleId}`;
+
+export interface DashboardBaseRouteDefinition {
+    id: string;
+    title?: string;
+}
+
+export interface DashboardListRouteDefinition extends DashboardBaseRouteDefinition {
+    listQuery: DocumentNode;
+}
+
+export type DashboardRouteDefinition = DashboardListRouteDefinition;
+
+export interface DashboardExtension {
+    routes: DashboardRouteDefinition[];
+}
+
+export async function dashboardMetadataPlugin(options: { config: VendureConfig }): Promise<Plugin> {
+    return {
+        name: 'vendure-admin-api-schema',
+        resolveId(id, importer) {
+            if (id === virtualModuleId) {
+                return resolvedVirtualModuleId;
+            }
+        },
+        load(id) {
+            if (id === resolvedVirtualModuleId) {
+                return `
+
+                `;
+            }
+        },
+    };
+}

+ 49 - 0
packages/dashboard/vite/vite-plugin-vendure-dashboard.ts

@@ -0,0 +1,49 @@
+import { lingui } from '@lingui/vite-plugin';
+import tailwindcss from '@tailwindcss/vite';
+import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
+import { VendureConfig } from '@vendure/core';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+import { PluginOption, UserConfig } from 'vite';
+import { adminApiSchemaPlugin } from './vite-plugin-admin-api-schema.js';
+import { dashboardMetadataPlugin } from './vite-plugin-dashboard-metadata.js';
+
+export function vendureDashboardPlugin(config: VendureConfig): PluginOption[] {
+    const packageRoot = path
+        .join(import.meta.resolve('@vendure/dashboard'), '../../..')
+        .replace(/file:[\/\\]+/, '');
+    const linguiConfigPath = path.join(packageRoot, 'lingui.config.js');
+    console.log(`linguiConfigPath: ${linguiConfigPath}`);
+
+    const tanstackRoutesDirectory = path.join(packageRoot, 'src');
+
+    // (import.meta as any).env?.LINGUI_CONFIG = linguiConfigPath;
+    process.env.LINGUI_CONFIG = linguiConfigPath;
+    return [
+        // TanStackRouterVite({ routesDirectory: tanstackRoutesDirectory }),
+        lingui({
+            configPath: linguiConfigPath,
+        }),
+        react({
+            babel: {
+                plugins: ['@lingui/babel-plugin-lingui-macro'],
+            },
+        }),
+        tailwindcss(),
+        adminApiSchemaPlugin({ config }),
+        dashboardMetadataPlugin({ config }),
+        {
+            name: 'vendure-set-root-plugin',
+            config: (config: UserConfig) => {
+                console.log(`Setting root to ${packageRoot}`);
+                config.root = packageRoot;
+                config.resolve = {
+                    alias: {
+                        '@': path.resolve(packageRoot, './src'),
+                    },
+                };
+                return config;
+            },
+        },
+    ];
+}

+ 3 - 3
packages/dev-server/migration.ts

@@ -1,13 +1,13 @@
 import { generateMigration, revertLastMigration, runMigrations } from '@vendure/core';
-import program from 'commander';
+import { program } from 'commander';
 
 import { devConfig } from './dev-config';
 
 program
     .command('generate <name>')
     .description('Generate a new migration file with the given name')
-    .action(name => {
-        return generateMigration(devConfig, { name, outputDir: './migrations' });
+    .action(async name => {
+        await generateMigration(devConfig, { name, outputDir: './migrations' });
     });
 
 program

+ 46 - 0
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -0,0 +1,46 @@
+import { defineDashboardExtension } from '@vendure/dashboard';
+import { ListPage } from '@vendure/dashboard';
+import gql from 'graphql-tag';
+
+export function Test() {
+    return (
+        <div>
+            <ListPage></ListPage>
+        </div>
+    );
+}
+
+export default defineDashboardExtension({
+    id: 'review-list',
+    title: 'Product Reviews',
+    listQuery: gql`
+        query GetProductReviews {
+            productReviews {
+                items {
+                    id
+                    createdAt
+                    updatedAt
+                    product {
+                        id
+                        name
+                    }
+                    productVariant {
+                        id
+                        name
+                        sku
+                    }
+                    summary
+                    body
+                    rating
+                    authorName
+                    authorLocation
+                    upvotes
+                    downvotes
+                    state
+                    response
+                    responseCreatedAt
+                }
+            }
+        }
+    `,
+});

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

@@ -1,4 +1,5 @@
 import { LanguageCode, PluginCommonModule, VendurePlugin } from '@vendure/core';
+import gql from 'graphql-tag';
 
 import { ProductReview } from './entities/product-review.entity';
 import { adminApiExtensions, shopApiExtensions } from './api/api-extensions';
@@ -48,6 +49,7 @@ import { AdminUiExtension } from '@vendure/ui-devkit/compiler';
         });
         return config;
     },
+    dashboard: './dashboard/index.tsx',
 })
 export class ReviewsPlugin {
     static uiExtensions: AdminUiExtension = {

+ 9 - 0
packages/dev-server/vite.config.mts

@@ -0,0 +1,9 @@
+import { vendureDashboardPlugin } from '@vendure/dashboard/plugin';
+import { defineConfig, UserConfig } from 'vite';
+
+export default defineConfig(async () => {
+    const vendureConfig = await import('./dev-config.js').then(m => m.devConfig);
+    return {
+        plugins: [vendureDashboardPlugin(vendureConfig)],
+    } satisfies UserConfig;
+});