Skip to content

Commit

Permalink
feat(store): add support for standalone APIs in schematics
Browse files Browse the repository at this point in the history
  • Loading branch information
dmitry-stepanenko committed Oct 30, 2023
1 parent 7398dd9 commit 7f0080d
Show file tree
Hide file tree
Showing 14 changed files with 1,727 additions and 102 deletions.
124 changes: 124 additions & 0 deletions packages/store/schematics/src/ng-add/add-declaration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Rule, chain } from '@angular-devkit/schematics';
import { addRootImport, addRootProvider } from '../utils/ng-utils/standalone/rules';
import {
applyChangesToFile,
findBootstrapApplicationCall,
getMainFilePath,
getSourceFile
} from '../utils/ng-utils/standalone/util';
import { insertImport } from '@schematics/angular/utility/ast-utils';
import { findAppConfig } from '../utils/ng-utils/standalone/app_config';
import { LIBRARIES } from '../utils/common/lib.config';
import { NormalizedNgxsPackageSchema } from './ng-add.factory';

export function addDeclarationToStandaloneApp(options: NormalizedNgxsPackageSchema): Rule {
return async host => {
const mainFilePath = await getMainFilePath(host, options.project);
const bootstrapCall = findBootstrapApplicationCall(host, mainFilePath);
const appConfigFilePath =
findAppConfig(bootstrapCall, host, mainFilePath)?.filePath || mainFilePath;

const plugins = options.plugins
.filter(p => pluginData.has(p))
.map((p): [LIBRARIES, string] => [p, pluginData.get(p)!.standalone]);

const importPluginRules = plugins.map(([plugin, standaloneDeclaration]): Rule => {
return importTree => {
const change = insertImport(
getSourceFile(host, appConfigFilePath),
appConfigFilePath,
standaloneDeclaration,
plugin
);
applyChangesToFile(importTree, appConfigFilePath, [change]);
};
});
const pluginDeclarations = plugins
.map(([, standaloneDeclaration]) => `${standaloneDeclaration}()`)
.join(',\n');
return chain([
...importPluginRules,
addRootProvider(
options.project,
({ code, external }) =>
code`${external('provideStore', '@ngxs/store')}(\n[],\n${pluginDeclarations})`
)
]);
};
}

export function addDeclarationToNonStandaloneApp(options: NormalizedNgxsPackageSchema): Rule {
const pluginRules = options.plugins
.map(p => [p, pluginData.get(p)?.module])
.filter((v): v is [LIBRARIES, string] => !!v[1])
.map(([plugin, moduleName]) => {
return addRootImport(
options.project,
({ code, external }) => code`${external(moduleName, plugin)}.forRoot()`
);
});

const importPath = '@ngxs/store';

const moduleImportExtras =
'.forRoot([], { developmentMode: /** !environment.production */ false, selectorOptions: { suppressErrors: false, injectContainerState: false } })';

return chain([
addRootImport(
options.project,
({ code, external }) => code`${external('NgxsModule', importPath)}${moduleImportExtras}`
),
...pluginRules
]);
}

const pluginData: ReadonlyMap<LIBRARIES, { module?: string; standalone: string }> = new Map([
[
LIBRARIES.DEVTOOLS,
{
module: 'NgxsReduxDevtoolsPluginModule',
standalone: 'withNgxsReduxDevtoolsPlugin'
}
],
[
LIBRARIES.FORM,
{
module: 'NgxsFormPluginModule',
standalone: 'withNgxsFormPlugin'
}
],
[
LIBRARIES.LOGGER,
{
module: 'NgxsLoggerPluginModule',
standalone: 'withNgxsLoggerPlugin'
}
],
[
LIBRARIES.ROUTER,
{
module: 'NgxsRouterPluginModule',
standalone: 'withNgxsRouterPlugin'
}
],
[
LIBRARIES.STORAGE,
{
module: 'NgxsStoragePluginModule',
standalone: 'withNgxsStoragePlugin'
}
],
[
LIBRARIES.STORE,
{
standalone: 'provideStore'
}
],
[
LIBRARIES.WEBSOCKET,
{
module: 'NgxsWebsocketPluginModule',
standalone: 'withNgxsWebSocketPlugin'
}
]
]);
27 changes: 10 additions & 17 deletions packages/store/schematics/src/ng-add/ng-add.factory.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,8 @@ describe('Ngxs ng-add Schematic', () => {

let appTree: UnitTestTree;
beforeEach(async () => {
appTree = await angularSchematicRunner
.runSchematicAsync('workspace', workspaceOptions)
.toPromise();
appTree = await angularSchematicRunner
.runSchematicAsync('application', appOptions, appTree)
.toPromise();
appTree = await angularSchematicRunner.runSchematic('workspace', workspaceOptions);
appTree = await angularSchematicRunner.runSchematic('application', appOptions, appTree);
});

describe('importing the Ngxs module', () => {
Expand All @@ -57,9 +53,8 @@ describe('Ngxs ng-add Schematic', () => {
// Arrange
const options: NgxsPackageSchema = { ...defaultOptions, project };
// Act
const tree = await ngxsSchematicRunner
.runSchematicAsync('ngxs-init', options, appTree)
.toPromise();
const tree = await ngxsSchematicRunner.runSchematic('ngxs-init', options, appTree);

// Assert
const content = tree.readContent('/projects/foo/src/app/app.module.ts');
expect(content).toMatch(/import { NgxsModule } from '@ngxs\/store'/);
Expand All @@ -72,7 +67,7 @@ describe('Ngxs ng-add Schematic', () => {
// Arrange
const options: NgxsPackageSchema = { ...defaultOptions, project: 'hello' };
await expect(
ngxsSchematicRunner.runSchematicAsync('ng-add', options, appTree).toPromise()
ngxsSchematicRunner.runSchematic('ng-add', options, appTree)
).rejects.toThrow(`Project "${options.project}" does not exist.`);
});
});
Expand All @@ -81,9 +76,8 @@ describe('Ngxs ng-add Schematic', () => {
it('should add ngxs store with provided plugins in package.json', async () => {
const plugins = [LIBRARIES.DEVTOOLS, LIBRARIES.LOGGER];
const options: NgxsPackageSchema = { plugins };
appTree = await ngxsSchematicRunner
.runSchematicAsync('ng-add', options, appTree)
.toPromise();
appTree = await ngxsSchematicRunner.runSchematic('ng-add', options, appTree);

const packageJsonText = appTree.readContent('/package.json');
const packageJson = JSON.parse(packageJsonText);
expect(plugins.every(p => !!packageJson.dependencies[p])).toBeTruthy();
Expand All @@ -92,9 +86,8 @@ describe('Ngxs ng-add Schematic', () => {
it('should add ngxs store with all plugins in package.json', async () => {
const packages = Object.values(LIBRARIES).filter(v => v !== LIBRARIES.STORE);
const options: NgxsPackageSchema = { plugins: packages };
appTree = await ngxsSchematicRunner
.runSchematicAsync('ng-add', options, appTree)
.toPromise();
appTree = await ngxsSchematicRunner.runSchematic('ng-add', options, appTree);

const packageJsonText = appTree.readContent('/package.json');
const packageJson = JSON.parse(packageJsonText);
expect(packages.every(p => !!packageJson.dependencies[p])).toBeTruthy();
Expand All @@ -104,7 +97,7 @@ describe('Ngxs ng-add Schematic', () => {
const packages = ['who-am-i'];
const options: NgxsPackageSchema = { plugins: packages };
await expect(
ngxsSchematicRunner.runSchematicAsync('ng-add', options, appTree).toPromise()
ngxsSchematicRunner.runSchematic('ng-add', options, appTree)
).rejects.toThrow();
});
});
Expand Down
124 changes: 57 additions & 67 deletions packages/store/schematics/src/ng-add/ng-add.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,55 +7,65 @@ import {
noop
} from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
import * as ts from '@schematics/angular/third_party/github.com/Microsoft/TypeScript/lib/typescript';
import { addImportToModule } from '@schematics/angular/utility/ast-utils';
import { InsertChange } from '@schematics/angular/utility/change';
import {
NodeDependencyType,
addPackageJsonDependency,
getPackageJsonDependency
} from '@schematics/angular/utility/dependencies';
import { getAppModulePath } from '@schematics/angular/utility/ng-ast-utils';

import { LIBRARIES } from '../utils/common/lib.config';
import { getProject } from '../utils/project';

import { NgxsPackageSchema } from './ng-add.schema';
import { getProjectMainFile } from '../utils/ng-utils/project';
import { isStandaloneApp } from '../utils/ng-utils/ng-ast-utils';
import {
addDeclarationToNonStandaloneApp,
addDeclarationToStandaloneApp
} from './add-declaration';
import { getProject } from '../utils/project';

const versions = require('./../utils/versions.json');

export type NormalizedNgxsPackageSchema = {
skipInstall: boolean;
plugins: LIBRARIES[];
project: string;
};

export function ngAdd(options: NgxsPackageSchema): Rule {
return chain([
addNgxsPackageToPackageJson(options),
addDeclarationToNgModule(options),
options.skipInstall ? noop() : runNpmPackageInstall()
]);
return (host: Tree) => {
const normalizedSchema = normalizeSchema(host, options);

return chain([
addNgxsPackageToPackageJson(normalizedSchema),
addDeclaration(normalizedSchema),
normalizedSchema.skipInstall ? noop() : runNpmPackageInstall()
]);
};
}

function addNgxsPackageToPackageJson(options: NgxsPackageSchema): Rule {
function addNgxsPackageToPackageJson(schema: NormalizedNgxsPackageSchema): Rule {
return (host: Tree, context: SchematicContext) => {
const ngxsStoreVersion: string = versions['@ngxs/store'];
if (!ngxsStoreVersion) {
throw new SchematicsException('Could not resolve the version of "@ngxs/store"');
}

Object.values(LIBRARIES)
.filter(lib => options.plugins?.includes(lib))
.forEach(name => {
const packageExists = getPackageJsonDependency(host, name);
if (packageExists === null) {
addPackageJsonDependency(host, {
type: NodeDependencyType.Default,
name,
version: ngxsStoreVersion
});
context.logger.info(`✅️ Added "${name}" into ${NodeDependencyType.Default}`);
} else {
context.logger.warn(
`✅️ "${name}" already exists in the ${NodeDependencyType.Default}`
);
}
});
schema.plugins!.forEach(name => {
const packageExists = getPackageJsonDependency(host, name);
if (packageExists === null) {
addPackageJsonDependency(host, {
type: NodeDependencyType.Default,
name,
version: ngxsStoreVersion
});
context.logger.info(`✅️ Added "${name}" into ${NodeDependencyType.Default}`);
} else {
context.logger.warn(
`✅️ "${name}" already exists in the ${NodeDependencyType.Default}`
);
}
});
return host;
};
}
Expand All @@ -67,47 +77,27 @@ function runNpmPackageInstall(): Rule {
};
}

function addDeclarationToNgModule(options: NgxsPackageSchema): Rule {
return (host: Tree) => {
const project = getProject(host, { project: options.project });

if (typeof project === 'undefined' || project === null) {
const message = options.project
? `Project "${options.project}" does not exist.`
: 'Could not determine the project to update.';
throw new SchematicsException(message);
}

const modulePath = getAppModulePath(host, `${project.root}/src/main.ts`);
function addDeclaration(schema: NormalizedNgxsPackageSchema): Rule {
return async (host: Tree) => {
const mainFile = getProjectMainFile(host, schema.project);
const isStandalone = isStandaloneApp(host, mainFile);

if (typeof modulePath === 'undefined') {
throw new SchematicsException(
`Module path for project "${options.project}" does not exist.`
);
}

const importPath = '@ngxs/store';

const moduleImport =
'NgxsModule.forRoot([], { developmentMode: /** !environment.production */ false, selectorOptions: { suppressErrors: false, injectContainerState: false } })';

const sourceBuffer = host.read(modulePath);

if (sourceBuffer !== null) {
const sourceText = sourceBuffer.toString();
const source = ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true);

const changes = addImportToModule(source, modulePath, moduleImport, importPath);

const recorder = host.beginUpdate(modulePath);
for (const change of changes) {
if (change instanceof InsertChange) {
recorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(recorder);
if (isStandalone) {
return addDeclarationToStandaloneApp(schema);
} else {
return addDeclarationToNonStandaloneApp(schema);
}
};
}

return host;
function normalizeSchema(host: Tree, schema: NgxsPackageSchema): NormalizedNgxsPackageSchema {
const projectName = getProject(host, schema.project)?.name;
if (!projectName) {
throw new SchematicsException(`Project "${schema.project}" does not exist.`);
}
return {
skipInstall: !!schema.skipInstall,
plugins: Object.values(LIBRARIES).filter(lib => schema.plugins?.includes(lib)) ?? [],
project: projectName
};
}
6 changes: 3 additions & 3 deletions packages/store/schematics/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,16 @@ export interface AppConfig {
};
}

export function getWorkspacePath(host: Tree): string {
export function getWorkspacePath(host: Tree): string | undefined {
const possibleFiles = ['/angular.json', '/.angular.json', '/workspace.json'];
const path = possibleFiles.filter(path => host.exists(path))[0];
const path = possibleFiles.find(path => host.exists(path));

return path;
}

export function getWorkspace(host: Tree) {
const path = getWorkspacePath(host);
const configBuffer = host.read(path);
const configBuffer = path ? host.read(path) : null;
if (configBuffer === null) {
throw new SchematicsException(`Could not find (${path})`);
}
Expand Down
1 change: 1 addition & 0 deletions packages/store/schematics/src/utils/ng-utils/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ng-utils folder represents a set of files copied from the @schematics/angular package's latest version. These utilities can be removed once @ngxs supports @angular >=16 as the minimum required version.
Loading

0 comments on commit 7f0080d

Please sign in to comment.