From 50b9e59a50b737e34ee12ee48ab83d17c2b8744a Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Tue, 28 Mar 2023 14:05:30 +0000 Subject: [PATCH] feat(@schematics/angular): update app-shell schematic to support standalone applications This commit adds support to run `ng generate app-shell` in standalone applications. --- .../schematics/angular/app-shell/index.ts | 149 +++++++++++++++--- .../angular/app-shell/index_spec.ts | 84 ++++++++++ .../build/app-shell/app-shell-standalone.ts | 60 +------ 3 files changed, 218 insertions(+), 75 deletions(-) diff --git a/packages/schematics/angular/app-shell/index.ts b/packages/schematics/angular/app-shell/index.ts index 0e7e966368..0a0b549194 100644 --- a/packages/schematics/angular/app-shell/index.ts +++ b/packages/schematics/angular/app-shell/index.ts @@ -16,19 +16,20 @@ import { noop, schematic, } from '@angular-devkit/schematics'; -import { Schema as ComponentOptions } from '../component/schema'; +import { findBootstrapApplicationCall } from '../private/standalone'; import * as ts from '../third_party/github.com/Microsoft/TypeScript/lib/typescript'; import { addImportToModule, addSymbolToNgModuleMetadata, findNode, + findNodes, getDecoratorMetadata, getSourceNodes, insertImport, isImported, } from '../utility/ast-utils'; import { applyToUpdateRecorder } from '../utility/change'; -import { getAppModulePath } from '../utility/ng-ast-utils'; +import { getAppModulePath, isStandaloneApp } from '../utility/ng-ast-utils'; import { targetBuildNotFoundError } from '../utility/project-targets'; import { getWorkspace, updateWorkspace } from '../utility/workspace'; import { BrowserBuilderOptions, Builders, ServerBuilderOptions } from '../utility/workspace-models'; @@ -87,20 +88,34 @@ function getComponentTemplate(host: Tree, compPath: string, tmplInfo: TemplateIn } function getBootstrapComponentPath(host: Tree, mainPath: string): string { - const modulePath = getAppModulePath(host, mainPath); - const moduleSource = getSourceFile(host, modulePath); + const mainSource = getSourceFile(host, mainPath); + const bootstrapAppCall = findBootstrapApplicationCall(mainSource); - const metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0]; - const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap'); + let bootstrappingFilePath: string; + let bootstrappingSource: ts.SourceFile; + let componentName: string; - const arrLiteral = bootstrapProperty.initializer as ts.ArrayLiteralExpression; + if (bootstrapAppCall) { + // Standalone Application + componentName = bootstrapAppCall.arguments[0].getText(); + bootstrappingFilePath = mainPath; + bootstrappingSource = mainSource; + } else { + // NgModule Application + const modulePath = getAppModulePath(host, mainPath); + const moduleSource = getSourceFile(host, modulePath); + const metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0]; + const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap'); + const arrLiteral = bootstrapProperty.initializer as ts.ArrayLiteralExpression; + componentName = arrLiteral.elements[0].getText(); + bootstrappingSource = moduleSource; + bootstrappingFilePath = modulePath; + } - const componentSymbol = arrLiteral.elements[0].getText(); - - const relativePath = getSourceNodes(moduleSource) + const componentRelativeFilePath = getSourceNodes(bootstrappingSource) .filter(ts.isImportDeclaration) .filter((imp) => { - return findNode(imp, ts.SyntaxKind.Identifier, componentSymbol); + return findNode(imp, ts.SyntaxKind.Identifier, componentName); }) .map((imp) => { const pathStringLiteral = imp.moduleSpecifier as ts.StringLiteral; @@ -108,7 +123,7 @@ function getBootstrapComponentPath(host: Tree, mainPath: string): string { return pathStringLiteral.text; })[0]; - return join(dirname(normalize(modulePath)), relativePath + '.ts'); + return join(dirname(normalize(bootstrappingFilePath)), componentRelativeFilePath + '.ts'); } // end helper functions. @@ -300,14 +315,97 @@ function addServerRoutes(options: AppShellOptions): Rule { }; } -function addShellComponent(options: AppShellOptions): Rule { - const componentOptions: ComponentOptions = { - name: 'app-shell', - module: options.rootModuleFileName, - project: options.project, - }; +function addStandaloneServerRoute(options: AppShellOptions): Rule { + return async (host: Tree) => { + const workspace = await getWorkspace(host); + const project = workspace.projects.get(options.project); + if (!project) { + throw new SchematicsException(`Project name "${options.project}" doesn't not exist.`); + } - return schematic('component', componentOptions); + const configFilePath = join(normalize(project.sourceRoot ?? 'src'), 'app/app.config.server.ts'); + if (!host.exists(configFilePath)) { + throw new SchematicsException(`Cannot find "${configFilePath}".`); + } + + let configSourceFile = getSourceFile(host, configFilePath); + if (!isImported(configSourceFile, 'ROUTES', '@angular/router')) { + const routesChange = insertImport( + configSourceFile, + configFilePath, + 'ROUTES', + '@angular/router', + ); + + const recorder = host.beginUpdate(configFilePath); + if (routesChange) { + applyToUpdateRecorder(recorder, [routesChange]); + host.commitUpdate(recorder); + } + } + + configSourceFile = getSourceFile(host, configFilePath); + const providersLiteral = findNodes(configSourceFile, ts.isPropertyAssignment).find( + (n) => ts.isArrayLiteralExpression(n.initializer) && n.name.getText() === 'providers', + )?.initializer as ts.ArrayLiteralExpression | undefined; + if (!providersLiteral) { + throw new SchematicsException( + `Cannot find the "providers" configuration in "${configFilePath}".`, + ); + } + + // Add route to providers literal. + const newProvidersLiteral = ts.factory.updateArrayLiteralExpression(providersLiteral, [ + ...providersLiteral.elements, + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment('provide', ts.factory.createIdentifier('ROUTES')), + ts.factory.createPropertyAssignment('multi', ts.factory.createIdentifier('true')), + ts.factory.createPropertyAssignment( + 'useValue', + ts.factory.createArrayLiteralExpression( + [ + ts.factory.createObjectLiteralExpression( + [ + ts.factory.createPropertyAssignment( + 'path', + ts.factory.createIdentifier(`'${options.route}'`), + ), + ts.factory.createPropertyAssignment( + 'component', + ts.factory.createIdentifier('AppShellComponent'), + ), + ], + true, + ), + ], + true, + ), + ), + ], + true, + ), + ]); + + const recorder = host.beginUpdate(configFilePath); + recorder.remove(providersLiteral.getStart(), providersLiteral.getWidth()); + const printer = ts.createPrinter(); + recorder.insertRight( + providersLiteral.getStart(), + printer.printNode(ts.EmitHint.Unspecified, newProvidersLiteral, configSourceFile), + ); + + // Add AppShellComponent import + const appShellImportChange = insertImport( + configSourceFile, + configFilePath, + 'AppShellComponent', + './app-shell/app-shell.component', + ); + + applyToUpdateRecorder(recorder, [appShellImportChange]); + host.commitUpdate(recorder); + }; } export default function (options: AppShellOptions): Rule { @@ -324,13 +422,20 @@ export default function (options: AppShellOptions): Rule { const clientBuildOptions = (clientBuildTarget.options || {}) as unknown as BrowserBuilderOptions; + const isStandalone = isStandaloneApp(tree, clientBuildOptions.main); + return chain([ validateProject(clientBuildOptions.main), clientProject.targets.has('server') ? noop() : addUniversalTarget(options), addAppShellConfigToWorkspace(options), - addRouterModule(clientBuildOptions.main), - addServerRoutes(options), - addShellComponent(options), + isStandalone ? noop() : addRouterModule(clientBuildOptions.main), + isStandalone ? addStandaloneServerRoute(options) : addServerRoutes(options), + schematic('component', { + name: 'app-shell', + module: options.rootModuleFileName, + project: options.project, + standalone: isStandalone, + }), ]); }; } diff --git a/packages/schematics/angular/app-shell/index_spec.ts b/packages/schematics/angular/app-shell/index_spec.ts index b53a6efb74..35fd342538 100644 --- a/packages/schematics/angular/app-shell/index_spec.ts +++ b/packages/schematics/angular/app-shell/index_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import { tags } from '@angular-devkit/core'; import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; import { Schema as ApplicationOptions } from '../application/schema'; import { Schema as WorkspaceOptions } from '../workspace/schema'; @@ -185,4 +186,87 @@ describe('App Shell Schematic', () => { const content = tree.readContent('/projects/bar/src/app/app.server.module.ts'); expect(content).toMatch(/app-shell\.component/); }); + + describe('standalone application', () => { + const standaloneAppName = 'baz'; + const standaloneAppOptions: ApplicationOptions = { + ...appOptions, + name: standaloneAppName, + standalone: true, + }; + const defaultStandaloneOptions: AppShellOptions = { + project: standaloneAppName, + }; + + beforeEach(async () => { + appTree = await schematicRunner.runSchematic('application', standaloneAppOptions, appTree); + }); + + it('should ensure the client app has a router-outlet', async () => { + appTree = await schematicRunner.runSchematic('workspace', workspaceOptions); + appTree = await schematicRunner.runSchematic( + 'application', + { ...standaloneAppOptions, routing: false }, + appTree, + ); + await expectAsync( + schematicRunner.runSchematic('app-shell', defaultStandaloneOptions, appTree), + ).toBeRejected(); + }); + + it('should create the shell component', async () => { + const tree = await schematicRunner.runSchematic( + 'app-shell', + defaultStandaloneOptions, + appTree, + ); + expect(tree.exists('/projects/baz/src/app/app-shell/app-shell.component.ts')).toBe(true); + const content = tree.readContent('/projects/baz/src/app/app.config.server.ts'); + expect(content).toMatch(/app-shell\.component/); + }); + + it('should define a server route', async () => { + const tree = await schematicRunner.runSchematic( + 'app-shell', + defaultStandaloneOptions, + appTree, + ); + const filePath = '/projects/baz/src/app/app.config.server.ts'; + const content = tree.readContent(filePath); + expect(tags.oneLine`${content}`).toContain(tags.oneLine`{ + provide: ROUTES, + multi: true, + useValue: [ + { + path: 'shell', + component: AppShellComponent + } + ] + }`); + }); + + it(`should add import to 'ROUTES' token from '@angular/router'`, async () => { + const tree = await schematicRunner.runSchematic( + 'app-shell', + defaultStandaloneOptions, + appTree, + ); + const filePath = '/projects/baz/src/app/app.config.server.ts'; + const content = tree.readContent(filePath); + expect(content).toContain(`import { ROUTES } from '@angular/router';`); + }); + + it(`should add import to 'AppShellComponent'`, async () => { + const tree = await schematicRunner.runSchematic( + 'app-shell', + defaultStandaloneOptions, + appTree, + ); + const filePath = '/projects/baz/src/app/app.config.server.ts'; + const content = tree.readContent(filePath); + expect(content).toContain( + `import { AppShellComponent } from './app-shell/app-shell.component';`, + ); + }); + }); }); diff --git a/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts b/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts index 7abbbae9fa..d002da509a 100644 --- a/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts +++ b/tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts @@ -1,5 +1,5 @@ import { getGlobalVariable } from '../../../utils/env'; -import { appendToFile, expectFileToMatch, writeMultipleFiles } from '../../../utils/fs'; +import { expectFileToMatch } from '../../../utils/fs'; import { installPackage } from '../../../utils/packages'; import { ng } from '../../../utils/process'; import { updateJsonFile } from '../../../utils/project'; @@ -7,8 +7,8 @@ import { updateJsonFile } from '../../../utils/project'; const snapshots = require('../../../ng-snapshot/package.json'); export default async function () { - await appendToFile('src/app/app.component.html', ''); - await ng('generate', 'app-shell', '--project', 'test-project'); + await ng('generate', 'app', 'test-project-two', '--routing', '--standalone', '--skip-install'); + await ng('generate', 'app-shell', '--project', 'test-project-two'); const isSnapshotBuild = getGlobalVariable('argv')['ng-snapshots']; if (isSnapshotBuild) { @@ -30,55 +30,9 @@ export default async function () { } } - // TODO(alanagius): update the below once we have a standalone schematic. - await writeMultipleFiles({ - 'src/app/app.component.ts': ` - import { Component } from '@angular/core'; - import { RouterOutlet } from '@angular/router'; + await ng('run', 'test-project-two:app-shell:development'); + await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!'); - @Component({ - selector: 'app-root', - standalone: true, - template: '', - imports: [RouterOutlet], - }) - export class AppComponent {} - `, - 'src/main.ts': ` - import { bootstrapApplication } from '@angular/platform-browser'; - import { provideRouter } from '@angular/router'; - - import { AppComponent } from './app/app.component'; - - bootstrapApplication(AppComponent, { - providers: [ - provideRouter([]), - ], - }); - `, - 'src/main.server.ts': ` - import { importProvidersFrom } from '@angular/core'; - import { BrowserModule, bootstrapApplication } from '@angular/platform-browser'; - import { ServerModule } from '@angular/platform-server'; - - import { provideRouter } from '@angular/router'; - - import { AppShellComponent } from './app/app-shell/app-shell.component'; - import { AppComponent } from './app/app.component'; - - export default () => bootstrapApplication(AppComponent, { - providers: [ - importProvidersFrom(BrowserModule), - importProvidersFrom(ServerModule), - provideRouter([{ path: 'shell', component: AppShellComponent }]), - ], - }); - `, - }); - - await ng('run', 'test-project:app-shell:development'); - await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/); - - await ng('run', 'test-project:app-shell'); - await expectFileToMatch('dist/test-project/browser/index.html', /app-shell works!/); + await ng('run', 'test-project-two:app-shell'); + await expectFileToMatch('dist/test-project-two/browser/index.html', 'app-shell works!'); }