From 2c86f2027c759daff70c7b7d49ccc49ebef4b205 Mon Sep 17 00:00:00 2001 From: Vinicius Rosa Date: Tue, 17 Jan 2023 09:36:47 +1100 Subject: [PATCH] feat(ui-devkit): Support module path mappings for UI extensions --- packages/ui-devkit/src/compiler/scaffold.ts | 41 ++++-- packages/ui-devkit/src/compiler/types.ts | 138 +++++++++++++++++++- packages/ui-devkit/src/compiler/utils.ts | 5 + 3 files changed, 174 insertions(+), 10 deletions(-) diff --git a/packages/ui-devkit/src/compiler/scaffold.ts b/packages/ui-devkit/src/compiler/scaffold.ts index fe51e64ec1..dfe580559e 100644 --- a/packages/ui-devkit/src/compiler/scaffold.ts +++ b/packages/ui-devkit/src/compiler/scaffold.ts @@ -8,7 +8,6 @@ import { GLOBAL_STYLES_OUTPUT_DIR, MODULES_OUTPUT_DIR, SHARED_EXTENSIONS_FILE, - STATIC_ASSETS_OUTPUT_DIR, } from './constants'; import { getAllTranslationFiles, mergeExtensionTranslations } from './translations'; import { @@ -25,6 +24,7 @@ import { copyUiDevkit, isAdminUiExtension, isGlobalStylesExtension, + isModulePathMappingExtension, isSassVariableOverridesExtension, isStaticAssetExtension, isTranslationExtension, @@ -35,7 +35,9 @@ import { export async function setupScaffold(outputPath: string, extensions: Extension[]) { deleteExistingExtensionModules(outputPath); - copyAdminUiSource(outputPath); + + const modulePathMappingExtension = extensions.find(isModulePathMappingExtension); + copyAdminUiSource(outputPath, modulePathMappingExtension?.modulePathMapping); const adminUiExtensions = extensions.filter(isAdminUiExtension); const normalizedExtensions = normalizeExtensions(adminUiExtensions); @@ -193,15 +195,17 @@ function getModuleFilePath( } /** - * Copy the Admin UI sources & static assets to the outputPath if it does not already - * exists there. + * Copies the Admin UI sources & static assets to the outputPath if it does not already + * exist there. */ -function copyAdminUiSource(outputPath: string) { - const angularJsonFile = path.join(outputPath, 'angular.json'); - const indexFile = path.join(outputPath, '/src/index.html'); - if (fs.existsSync(angularJsonFile) && fs.existsSync(indexFile)) { +function copyAdminUiSource(outputPath: string, modulePathMapping: Record | undefined) { + const tsconfigFilePath = path.join(outputPath, 'tsconfig.json'); + const indexFilePath = path.join(outputPath, '/src/index.html'); + if (fs.existsSync(tsconfigFilePath) && fs.existsSync(indexFilePath)) { + addModulePathMappingIfDefined(tsconfigFilePath, modulePathMapping); return; } + const scaffoldDir = path.join(__dirname, '../scaffold'); const adminUiSrc = path.join(require.resolve('@vendure/admin-ui'), '../../static'); @@ -216,6 +220,7 @@ function copyAdminUiSource(outputPath: string) { fs.removeSync(outputPath); fs.ensureDirSync(outputPath); fs.copySync(scaffoldDir, outputPath); + addModulePathMappingIfDefined(tsconfigFilePath, modulePathMapping); // copy source files from admin-ui package const outputSrc = path.join(outputPath, 'src'); @@ -223,6 +228,26 @@ function copyAdminUiSource(outputPath: string) { fs.copySync(adminUiSrc, outputSrc); } +/** + * Adds module path mapping to the bundled tsconfig.json file if defined as a UI extension. + */ +function addModulePathMappingIfDefined( + tsconfigFilePath: string, + modulePathMapping: Record | undefined, +) { + if (!modulePathMapping) { + return; + } + + const tsconfig = require(tsconfigFilePath); + const tsPaths = Object.entries(modulePathMapping).reduce((acc, [key, value]) => { + acc[key] = [`src/extensions/${value}`]; + return acc; + }, {} as Record); + tsconfig.compilerOptions.paths = tsPaths; + fs.writeFileSync(tsconfigFilePath, JSON.stringify(tsconfig, null, 2)); +} + /** * Attempts to find out it the ngcc compiler has been run on the Angular packages, and if not, * attemps to run it. This is done this way because attempting to run ngcc from a sub-directory diff --git a/packages/ui-devkit/src/compiler/types.ts b/packages/ui-devkit/src/compiler/types.ts index 2aa6fe5aba..a93e75099a 100644 --- a/packages/ui-devkit/src/compiler/types.ts +++ b/packages/ui-devkit/src/compiler/types.ts @@ -5,7 +5,8 @@ export type Extension = | TranslationExtension | StaticAssetExtension | GlobalStylesExtension - | SassVariableOverridesExtension; + | SassVariableOverridesExtension + | ModulePathMappingExtension; /** * @description @@ -82,6 +83,138 @@ export interface SassVariableOverridesExtension { sassVariableOverrides: string; } +/** + * @description + * Defines an extension which specifies module path mapping to allow an {@link AdminUiExtension} import code + * from another AdminUiExtension. + * + * By default, Angular modules declared in an AdminUiExtension do not have access to code outside the directory + * defined by the `extensionPath` property except for `node_modules`. A scenario in which that can be useful though + * is on a monorepo codebase where a common NgModule is shared across different plugins, each defined in its own + * package. An example can be found below - note that the main `tsconfig.json` also maps the target module but using + * a path relative to the project's root folder. The UI module is not part of the main TypeScript build task as explained + * in [Extending the Admin UI](https://www.vendure.io/docs/plugins/extending-the-admin-ui/) but having `paths` + * properly configured helps with usual IDE code editing features such as code completion and quick navigation, as + * well as linting. + * + * @example + * ```json + * // tsconfig.json + * { + * "compilerOptions": { + * "baseUrl": ".", + * "paths": { + * "@common-ui-module/ui": ["packages/common-ui-module/src/ui/ui-shared.module.ts"] + * } + * } + * } + * ``` + * + * ```ts + * // packages/common-ui-module/src/ui/ui-shared.module.ts + * import { NgModule } from '@angular/core'; + * import { SharedModule } from '@vendure/admin-ui/core'; + * + * import { CommonUiComponent } from './components/common-ui/common-ui.component'; + * + * export { CommonUiComponent }; + * + * \@NgModule({ + * imports: [SharedModule], + * exports: [CommonUiComponent], + * declarations: [CommonUiComponent], + * }) + * export class CommonSharedUiModule {} + * ``` + * + * ```ts + * // packages/common-ui-module/src/index.ts + * import path from 'path'; + * + * import { AdminUiExtension } from '@vendure/ui-devkit/compiler'; + * + * export const uiExtensions: AdminUiExtension = { + * id: 'common-ui', // this is important + * extensionPath: path.join(__dirname, 'ui'), + * ngModules: [ + * { + * type: 'shared' as const, + * ngModuleFileName: 'ui-shared.module.ts', + * ngModuleName: 'CommonSharedUiModule', + * }, + * ], + * }; + * ``` + * + * ```ts + * // packages/sample-plugin/src/ui/ui-extension.module.ts + * import { NgModule } from '@angular/core'; + * import { SharedModule } from '@vendure/admin-ui/core'; + * import { CommonSharedUiModule, CommonUiComponent } from '@common-ui-module/ui'; + * + * \@NgModule({ + * imports: [ + * SharedModule, + * CommonSharedUiModule, + * RouterModule.forChild([ + * { + * path: '', + * pathMatch: 'full', + * component: CommonUiComponent, + * }, + * ]), + * ], + * }) + * export class SampleUiExtensionModule {} + * ``` + * + * ```ts + * // vendure-config.ts + * import path from 'path'; + * import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; + * import { VendureConfig } from '@vendure/core'; + * import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; + * import { uiExtensions as commonUiExtensions } from '@common-ui-module/ui'; + * + * export const config: VendureConfig = { + * // ... + * plugins: [ + * AdminUiPlugin.init({ + * app: compileUiExtensions({ + * outputPath: path.join(__dirname, '../admin-ui'), + * extensions: [{ + * modulePathMapping: { + * // 'common-ui' is the id given to the common-ui-module UI extension + * // 'ui-shared.module.ts' is the file in 'ui' directory that we want to import from + * '@common-module/ui': 'common-ui/ui-shared.module.ts', + * }, + * }], + * commonUiExtensions, + * // UI extensions for SamplePlugin, which uses CommonSharedUiModule, should also be imported + * // and declared here + * }), + * }), + * ], + * }; + * ``` + * + * @docsCategory UiDevkit + * @docsPage AdminUiExtension + */ +export interface ModulePathMappingExtension { + /** + * @description + * Optional object which defines one or more module name mappings in a similar way to TypeScript's + * [path mapping](https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping) with + * the following differences: + * + * * The path value is a single string instead of an array of strings + * * Paths are resolved relative to the "extensions" folder of the compiled Admin UI app and reference the id + * property of the target AdminUiExtension + */ + modulePathMapping: Record; +} + /** * @description * Defines extensions to the Admin UI application by specifying additional @@ -102,7 +235,8 @@ export interface AdminUiExtension /** * @description * An optional ID for the extension module. Only used internally for generating - * import paths to your module. If not specified, a unique hash will be used as the id. + * import paths to your module or if {@link ModulePathMappingExtension} is defined. + * If not specified, a unique hash will be used as the id. */ id?: string; diff --git a/packages/ui-devkit/src/compiler/utils.ts b/packages/ui-devkit/src/compiler/utils.ts index 5c2a8c3f36..96f93056c8 100644 --- a/packages/ui-devkit/src/compiler/utils.ts +++ b/packages/ui-devkit/src/compiler/utils.ts @@ -10,6 +10,7 @@ import { AdminUiExtension, Extension, GlobalStylesExtension, + ModulePathMappingExtension, SassVariableOverridesExtension, StaticAssetDefinition, StaticAssetExtension, @@ -111,3 +112,7 @@ export function isGlobalStylesExtension(input: Extension): input is GlobalStyles export function isSassVariableOverridesExtension(input: Extension): input is SassVariableOverridesExtension { return input.hasOwnProperty('sassVariableOverrides'); } + +export function isModulePathMappingExtension(input: Extension): input is ModulePathMappingExtension { + return input.hasOwnProperty('modulePathMapping'); +}