Skip to content

Commit

Permalink
feat(@schematics/angular): update app-shell schematic to support stan…
Browse files Browse the repository at this point in the history
…dalone applications

This commit adds support to run `ng generate app-shell` in standalone applications.
  • Loading branch information
alan-agius4 authored and angular-robot[bot] committed Mar 29, 2023
1 parent dd02caa commit 50b9e59
Show file tree
Hide file tree
Showing 3 changed files with 220 additions and 77 deletions.
153 changes: 129 additions & 24 deletions packages/schematics/angular/app-shell/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,28 +88,42 @@ 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 metadataNode = getDecoratorMetadata(moduleSource, 'NgModule', '@angular/core')[0];
const bootstrapProperty = getMetadataProperty(metadataNode, 'bootstrap');

const arrLiteral = bootstrapProperty.initializer as ts.ArrayLiteralExpression;

const componentSymbol = arrLiteral.elements[0].getText();
const mainSource = getSourceFile(host, mainPath);
const bootstrapAppCall = findBootstrapApplicationCall(mainSource);

let bootstrappingFilePath: string;
let bootstrappingSource: ts.SourceFile;
let componentName: string;

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

return pathStringLiteral.text;
})[0];

return join(dirname(normalize(modulePath)), relativePath + '.ts');
return join(dirname(normalize(bootstrappingFilePath)), componentRelativeFilePath + '.ts');
}
// end helper functions.

Expand Down Expand Up @@ -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.`);
}

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',
);

return schematic('component', componentOptions);
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 {
Expand All @@ -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,
}),
]);
};
}
84 changes: 84 additions & 0 deletions packages/schematics/angular/app-shell/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';`,
);
});
});
});
60 changes: 7 additions & 53 deletions tests/legacy-cli/e2e/tests/build/app-shell/app-shell-standalone.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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';

const snapshots = require('../../../ng-snapshot/package.json');

export default async function () {
await appendToFile('src/app/app.component.html', '<router-outlet></router-outlet>');
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) {
Expand All @@ -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: '<router-outlet></router-outlet>',
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!');
}

0 comments on commit 50b9e59

Please sign in to comment.