diff --git a/packages/ui-devkit/src/compiler/scaffold.ts b/packages/ui-devkit/src/compiler/scaffold.ts index fe51e64ec1..e74c5a2f58 100644 --- a/packages/ui-devkit/src/compiler/scaffold.ts +++ b/packages/ui-devkit/src/compiler/scaffold.ts @@ -8,13 +8,12 @@ import { GLOBAL_STYLES_OUTPUT_DIR, MODULES_OUTPUT_DIR, SHARED_EXTENSIONS_FILE, - STATIC_ASSETS_OUTPUT_DIR, } from './constants'; import { getAllTranslationFiles, mergeExtensionTranslations } from './translations'; import { - AdminUiExtension, AdminUiExtensionLazyModule, AdminUiExtensionSharedModule, + AdminUiExtensionWithId, Extension, GlobalStylesExtension, SassVariableOverridesExtension, @@ -35,10 +34,13 @@ import { export async function setupScaffold(outputPath: string, extensions: Extension[]) { deleteExistingExtensionModules(outputPath); - copyAdminUiSource(outputPath); const adminUiExtensions = extensions.filter(isAdminUiExtension); const normalizedExtensions = normalizeExtensions(adminUiExtensions); + + const modulePathMapping = generateModulePathMapping(normalizedExtensions); + copyAdminUiSource(outputPath, modulePathMapping); + await copyExtensionModules(outputPath, normalizedExtensions); const staticAssetExtensions = extensions.filter(isStaticAssetExtension); @@ -70,11 +72,30 @@ function deleteExistingExtensionModules(outputPath: string) { fs.removeSync(path.join(outputPath, MODULES_OUTPUT_DIR)); } +/** + * Generates a module path mapping object for all extensions with a "pathAlias" + * property declared (if any). + */ +function generateModulePathMapping(extensions: Array) { + const extensionsWithAlias = extensions.filter(e => e.pathAlias); + if (extensionsWithAlias.length === 0) { + return undefined; + } + + return extensionsWithAlias.reduce((acc, e) => { + // for imports from the index file if there is one + acc[e.pathAlias as string] = [`src/extensions/${e.id}`]; + // direct access to files / deep imports + acc[`${e.pathAlias as string}/*`] = [`src/extensions/${e.id}/*`]; + return acc; + }, {} as Record); +} + /** * Copies all files from the extensionPaths of the configured extensions into the * admin-ui source tree. */ -async function copyExtensionModules(outputPath: string, extensions: Array>) { +async function copyExtensionModules(outputPath: string, extensions: Array) { const extensionRoutesSource = generateLazyExtensionRoutes(extensions); fs.writeFileSync(path.join(outputPath, EXTENSION_ROUTES_FILE), extensionRoutesSource, 'utf8'); const sharedExtensionModulesSource = generateSharedExtensionModule(extensions); @@ -142,9 +163,9 @@ export async function copyGlobalStyleFile(outputPath: string, stylePath: string) await fs.copyFile(stylePath, styleOutputPath); } -function generateLazyExtensionRoutes(extensions: Array>): string { +function generateLazyExtensionRoutes(extensions: Array): string { const routes: string[] = []; - for (const extension of extensions as Array>) { + for (const extension of extensions as Array) { for (const module of extension.ngModules) { if (module.type === 'lazy') { routes.push(` { @@ -159,7 +180,7 @@ function generateLazyExtensionRoutes(extensions: Array>) { +function generateSharedExtensionModule(extensions: Array) { return `import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; ${extensions @@ -193,15 +214,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)) { + configureModulePathMapping(tsconfigFilePath, modulePathMapping); return; } + const scaffoldDir = path.join(__dirname, '../scaffold'); const adminUiSrc = path.join(require.resolve('@vendure/admin-ui'), '../../static'); @@ -216,6 +239,7 @@ function copyAdminUiSource(outputPath: string) { fs.removeSync(outputPath); fs.ensureDirSync(outputPath); fs.copySync(scaffoldDir, outputPath); + configureModulePathMapping(tsconfigFilePath, modulePathMapping); // copy source files from admin-ui package const outputSrc = path.join(outputPath, 'src'); @@ -223,6 +247,22 @@ 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 configureModulePathMapping( + tsconfigFilePath: string, + modulePathMapping: Record | undefined, +) { + if (!modulePathMapping) { + return; + } + + const tsconfig = require(tsconfigFilePath); + tsconfig.compilerOptions.paths = modulePathMapping; + 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..95fa359ca5 100644 --- a/packages/ui-devkit/src/compiler/types.ts +++ b/packages/ui-devkit/src/compiler/types.ts @@ -113,11 +113,99 @@ export interface AdminUiExtension * scss style sheets etc. */ extensionPath: string; + /** * @description * One or more Angular modules which extend the default Admin UI. */ ngModules: Array; + + /** + * @description + * An optional alias for the module so it can be referenced by other UI extension modules. + * + * By default, Angular modules declared in an AdminUiExtension do not have access to code outside the directory + * defined by the `extensionPath`. A scenario in which that can be useful though is in 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 + * ```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 = { + * pathAlias: '\@common-ui-module', // this is the important part + * extensionPath: path.join(__dirname, 'ui'), + * ngModules: [ + * { + * type: 'shared' as const, + * ngModuleFileName: 'ui-shared.module.ts', + * ngModuleName: 'CommonSharedUiModule', + * }, + * ], + * }; + * ``` + * + * ```json + * // tsconfig.json + * { + * "compilerOptions": { + * "baseUrl": ".", + * "paths": { + * "\@common-ui-module/*": ["packages/common-ui-module/src/ui/*"] + * } + * } + * } + * ``` + * + * ```ts + * // packages/sample-plugin/src/ui/ui-extension.module.ts + * import { NgModule } from '\@angular/core'; + * import { SharedModule } from '\@vendure/admin-ui/core'; + * // the import below works both in the context of the custom Admin UI app as well as the main project + * // '\@common-ui-module' is the value of "pathAlias" and 'ui-shared.module' is the file we want to reference inside "extensionPath" + * import { CommonSharedUiModule, CommonUiComponent } from '\@common-ui-module/ui-shared.module'; + * + * \@NgModule({ + * imports: [ + * SharedModule, + * CommonSharedUiModule, + * RouterModule.forChild([ + * { + * path: '', + * pathMatch: 'full', + * component: CommonUiComponent, + * }, + * ]), + * ], + * }) + * export class SampleUiExtensionModule {} + * ``` + */ + pathAlias?: string; } /** @@ -280,3 +368,7 @@ export interface BrandingOptions { largeLogoPath?: string; faviconPath?: string; } + +export interface AdminUiExtensionWithId extends AdminUiExtension { + id: string; +} diff --git a/packages/ui-devkit/src/compiler/utils.ts b/packages/ui-devkit/src/compiler/utils.ts index 5c2a8c3f36..4ec4a973a4 100644 --- a/packages/ui-devkit/src/compiler/utils.ts +++ b/packages/ui-devkit/src/compiler/utils.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import { STATIC_ASSETS_OUTPUT_DIR } from './constants'; import { AdminUiExtension, + AdminUiExtensionWithId, Extension, GlobalStylesExtension, SassVariableOverridesExtension, @@ -79,7 +80,7 @@ export async function copyStaticAsset(outputPath: string, staticAssetDef: Static * If not defined by the user, a deterministic ID is generated * from a hash of the extension config. */ -export function normalizeExtensions(extensions?: AdminUiExtension[]): Array> { +export function normalizeExtensions(extensions?: AdminUiExtension[]): Array { return (extensions || []).map(e => { let id = e.id; if (!id) {