Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui-devkit): Support module path mappings for UI extensions #1994

Merged
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 53 additions & 13 deletions packages/ui-devkit/src/compiler/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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<AdminUiExtensionWithId>) {
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<string, string[]>);
}

/**
* Copies all files from the extensionPaths of the configured extensions into the
* admin-ui source tree.
*/
async function copyExtensionModules(outputPath: string, extensions: Array<Required<AdminUiExtension>>) {
async function copyExtensionModules(outputPath: string, extensions: Array<AdminUiExtensionWithId>) {
const extensionRoutesSource = generateLazyExtensionRoutes(extensions);
fs.writeFileSync(path.join(outputPath, EXTENSION_ROUTES_FILE), extensionRoutesSource, 'utf8');
const sharedExtensionModulesSource = generateSharedExtensionModule(extensions);
Expand Down Expand Up @@ -142,9 +163,9 @@ export async function copyGlobalStyleFile(outputPath: string, stylePath: string)
await fs.copyFile(stylePath, styleOutputPath);
}

function generateLazyExtensionRoutes(extensions: Array<Required<AdminUiExtension>>): string {
function generateLazyExtensionRoutes(extensions: Array<AdminUiExtensionWithId>): string {
const routes: string[] = [];
for (const extension of extensions as Array<Required<AdminUiExtension>>) {
for (const extension of extensions as Array<AdminUiExtensionWithId>) {
for (const module of extension.ngModules) {
if (module.type === 'lazy') {
routes.push(` {
Expand All @@ -159,7 +180,7 @@ function generateLazyExtensionRoutes(extensions: Array<Required<AdminUiExtension
return `export const extensionRoutes = [${routes.join(',\n')}];\n`;
}

function generateSharedExtensionModule(extensions: Array<Required<AdminUiExtension>>) {
function generateSharedExtensionModule(extensions: Array<AdminUiExtensionWithId>) {
return `import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
${extensions
Expand Down Expand Up @@ -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<string, string[]> | 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');

Expand All @@ -216,13 +239,30 @@ 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');
fs.ensureDirSync(outputSrc);
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<string, string[]> | 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
Expand Down
92 changes: 92 additions & 0 deletions packages/ui-devkit/src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdminUiExtensionSharedModule | AdminUiExtensionLazyModule>;

/**
* @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';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to not break the docs, every @ character in a code block needs to be escaped.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done! Is there a way to check the final docs as they appear on the website? I can see the md file generated after running yarn docs:generate-typescript-docs locally but I don't think I have the right tool to visualise it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!
Unfortunately not at the moment. I plan to move the doc gen stuff back into the monorepo soon when we update our website.

* 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;
}

/**
Expand Down Expand Up @@ -280,3 +368,7 @@ export interface BrandingOptions {
largeLogoPath?: string;
faviconPath?: string;
}

export interface AdminUiExtensionWithId extends AdminUiExtension {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not very elegant but that was the only way I found to make id required but not pathAlias.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine - it communicates the type and it's internal anyway.

id: string;
}
3 changes: 2 additions & 1 deletion packages/ui-devkit/src/compiler/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as path from 'path';
import { STATIC_ASSETS_OUTPUT_DIR } from './constants';
import {
AdminUiExtension,
AdminUiExtensionWithId,
Extension,
GlobalStylesExtension,
SassVariableOverridesExtension,
Expand Down Expand Up @@ -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<Required<AdminUiExtension>> {
export function normalizeExtensions(extensions?: AdminUiExtension[]): Array<AdminUiExtensionWithId> {
return (extensions || []).map(e => {
let id = e.id;
if (!id) {
Expand Down