From e59c930ff99a35d932b68e6a01ec8bbe5e186f7e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 11 May 2023 13:38:21 +0100 Subject: [PATCH] feat(angular): add ngrx root store generator (#16811) --- docs/generated/manifests/menus.json | 8 + docs/generated/manifests/packages.json | 9 + docs/generated/packages-metadata.json | 9 + .../angular/generators/ngrx-root-store.json | 74 ++ packages/angular/generators.json | 5 + packages/angular/generators.ts | 1 + .../ngrx-root-store.spec.ts.snap | 740 ++++++++++++++++++ .../ngrx-root-store/lib/add-imports.ts | 167 ++++ .../lib/add-ngrx-to-package-json.ts | 33 + .../generators/ngrx-root-store/lib/index.ts | 4 + .../ngrx-root-store/lib/normalize-options.ts | 61 ++ .../ngrx-root-store/lib/validate-options.ts | 53 ++ .../ngrx-root-store/ngrx-root-store.spec.ts | 381 +++++++++ .../ngrx-root-store/ngrx-root-store.ts | 48 ++ .../generators/ngrx-root-store/schema.d.ts | 10 + .../generators/ngrx-root-store/schema.json | 66 ++ 16 files changed, 1669 insertions(+) create mode 100644 docs/generated/packages/angular/generators/ngrx-root-store.json create mode 100644 packages/angular/src/generators/ngrx-root-store/__snapshots__/ngrx-root-store.spec.ts.snap create mode 100644 packages/angular/src/generators/ngrx-root-store/lib/add-imports.ts create mode 100644 packages/angular/src/generators/ngrx-root-store/lib/add-ngrx-to-package-json.ts create mode 100644 packages/angular/src/generators/ngrx-root-store/lib/index.ts create mode 100644 packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts create mode 100644 packages/angular/src/generators/ngrx-root-store/lib/validate-options.ts create mode 100644 packages/angular/src/generators/ngrx-root-store/ngrx-root-store.spec.ts create mode 100644 packages/angular/src/generators/ngrx-root-store/ngrx-root-store.ts create mode 100644 packages/angular/src/generators/ngrx-root-store/schema.d.ts create mode 100644 packages/angular/src/generators/ngrx-root-store/schema.json diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 3684540685..eb4d2d89bc 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -4246,6 +4246,14 @@ "isExternal": false, "disableCollapsible": false }, + { + "id": "ngrx-root-store", + "path": "/packages/angular/generators/ngrx-root-store", + "name": "ngrx-root-store", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, { "id": "pipe", "path": "/packages/angular/generators/pipe", diff --git a/docs/generated/manifests/packages.json b/docs/generated/manifests/packages.json index 5610cbefa1..53704f8840 100644 --- a/docs/generated/manifests/packages.json +++ b/docs/generated/manifests/packages.json @@ -267,6 +267,15 @@ "path": "/packages/angular/generators/ngrx-feature-store", "type": "generator" }, + "/packages/angular/generators/ngrx-root-store": { + "description": "Adds an NgRx Root Store to an application.", + "file": "generated/packages/angular/generators/ngrx-root-store.json", + "hidden": false, + "name": "ngrx-root-store", + "originalFilePath": "/packages/angular/src/generators/ngrx-root-store/schema.json", + "path": "/packages/angular/generators/ngrx-root-store", + "type": "generator" + }, "/packages/angular/generators/pipe": { "description": "Generate an Angular Pipe", "file": "generated/packages/angular/generators/pipe.json", diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 4bbd4e6df0..8770b0085f 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -262,6 +262,15 @@ "path": "angular/generators/ngrx-feature-store", "type": "generator" }, + { + "description": "Adds an NgRx Root Store to an application.", + "file": "generated/packages/angular/generators/ngrx-root-store.json", + "hidden": false, + "name": "ngrx-root-store", + "originalFilePath": "/packages/angular/src/generators/ngrx-root-store/schema.json", + "path": "angular/generators/ngrx-root-store", + "type": "generator" + }, { "description": "Generate an Angular Pipe", "file": "generated/packages/angular/generators/pipe.json", diff --git a/docs/generated/packages/angular/generators/ngrx-root-store.json b/docs/generated/packages/angular/generators/ngrx-root-store.json new file mode 100644 index 0000000000..b477149e50 --- /dev/null +++ b/docs/generated/packages/angular/generators/ngrx-root-store.json @@ -0,0 +1,74 @@ +{ + "name": "ngrx-root-store", + "factory": "./src/generators/ngrx-root-store/ngrx-root-store", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxNgrxRootStoreGenerator", + "title": "Add NgRx support to an application.", + "description": "Adds NgRx support to an application.", + "cli": "nx", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the application to generate the NgRx configuration for.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What app would you like to generate a NgRx configuration for?", + "x-dropdown": "projects" + }, + "minimal": { + "type": "boolean", + "default": true, + "description": "Only register the root state management setup or also generate a global feature state.", + "x-priority": "important" + }, + "name": { + "type": "string", + "description": "Name of the NgRx state, such as `products` or `users`. Recommended to use the plural form of the name.", + "x-priority": "important" + }, + "route": { + "type": "string", + "description": "The route that the Standalone NgRx Providers should be added to. _Note: This is only supported in Angular versions >= 14.1.0_.", + "default": "''" + }, + "directory": { + "type": "string", + "default": "+state", + "description": "The name of the folder used to contain/group the generated NgRx files." + }, + "facade": { + "type": "boolean", + "default": false, + "description": "Create a Facade class for the the feature.", + "x-prompt": "Would you like to use a Facade with your NgRx state?" + }, + "skipImport": { + "type": "boolean", + "default": false, + "description": "Generate NgRx feature files without registering the feature in the NgModule." + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false, + "x-priority": "internal" + }, + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": "Do not update the `package.json` with NgRx dependencies.", + "x-priority": "internal" + } + }, + "additionalProperties": false, + "required": ["project"], + "presets": [] + }, + "description": "Adds an NgRx Root Store to an application.", + "implementation": "/packages/angular/src/generators/ngrx-root-store/ngrx-root-store.ts", + "aliases": [], + "hidden": false, + "path": "/packages/angular/src/generators/ngrx-root-store/schema.json", + "type": "generator" +} diff --git a/packages/angular/generators.json b/packages/angular/generators.json index 516a0dfed7..65d3ce51fd 100644 --- a/packages/angular/generators.json +++ b/packages/angular/generators.json @@ -259,6 +259,11 @@ "schema": "./src/generators/ngrx-feature-store/schema.json", "description": "Adds an NgRx Feature Store to an application or library." }, + "ngrx-root-store": { + "factory": "./src/generators/ngrx-root-store/ngrx-root-store", + "schema": "./src/generators/ngrx-root-store/schema.json", + "description": "Adds an NgRx Root Store to an application." + }, "pipe": { "factory": "./src/generators/pipe/pipe", "schema": "./src/generators/pipe/schema.json", diff --git a/packages/angular/generators.ts b/packages/angular/generators.ts index a9bfa3f253..f8a41e3493 100644 --- a/packages/angular/generators.ts +++ b/packages/angular/generators.ts @@ -12,6 +12,7 @@ export * from './src/generators/library/library'; export * from './src/generators/move/move'; export * from './src/generators/ngrx/ngrx'; export * from './src/generators/ngrx-feature-store/ngrx-feature-store'; +export * from './src/generators/ngrx-root-store/ngrx-root-store'; export * from './src/generators/pipe/pipe'; export * from './src/generators/remote/remote'; export * from './src/generators/scam-directive/scam-directive'; diff --git a/packages/angular/src/generators/ngrx-root-store/__snapshots__/ngrx-root-store.spec.ts.snap b/packages/angular/src/generators/ngrx-root-store/__snapshots__/ngrx-root-store.spec.ts.snap new file mode 100644 index 0000000000..0a0834e5be --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/__snapshots__/ngrx-root-store.spec.ts.snap @@ -0,0 +1,740 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NgRxRootStoreGenerator NgModule should add a facade when --facade=true 1`] = ` +"import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app.component'; +import { appRoutes } from './app.routes'; +import { NxWelcomeComponent } from './nx-welcome.component'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreRouterConnectingModule } from '@ngrx/router-store'; +import * as fromUsers from './+state/users.reducer'; +import { UsersEffects } from './+state/users.effects'; +import { UsersFacade } from './+state/users.facade'; + +@NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [ + BrowserModule, + RouterModule.forRoot(appRoutes, { initialNavigation: 'enabledBlocking' }), + StoreModule.forRoot( + {}, + { + metaReducers: [], + runtimeChecks: { + strictActionImmutability: true, + strictStateImmutability: true, + }, + } + ), + EffectsModule.forRoot([]), + StoreRouterConnectingModule.forRoot(), + StoreModule.forFeature(fromUsers.USERS_FEATURE_KEY, fromUsers.usersReducer), + EffectsModule.forFeature([UsersEffects]), + ], + providers: [UsersFacade], + bootstrap: [AppComponent], +}) +export class AppModule {} +" +`; + +exports[`NgRxRootStoreGenerator NgModule should add a facade when --facade=true 2`] = ` +"import { Injectable, inject } from '@angular/core'; +import { select, Store, Action } from '@ngrx/store'; + +import * as UsersActions from './users.actions'; +import * as UsersFeature from './users.reducer'; +import * as UsersSelectors from './users.selectors'; + +@Injectable() +export class UsersFacade { + private readonly store = inject(Store); + + /** + * Combine pieces of state using createSelector, + * and expose them as observables through the facade. + */ + loaded$ = this.store.pipe(select(UsersSelectors.selectUsersLoaded)); + allUsers$ = this.store.pipe(select(UsersSelectors.selectAllUsers)); + selectedUsers$ = this.store.pipe(select(UsersSelectors.selectEntity)); + + /** + * Use the initialization action to perform one + * or more tasks in your Effects. + */ + init() { + this.store.dispatch(UsersActions.initUsers()); + } +} +" +`; + +exports[`NgRxRootStoreGenerator NgModule should add a root module and root state when --minimal=false 1`] = ` +"import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app.component'; +import { appRoutes } from './app.routes'; +import { NxWelcomeComponent } from './nx-welcome.component'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreRouterConnectingModule } from '@ngrx/router-store'; +import * as fromUsers from './+state/users.reducer'; +import { UsersEffects } from './+state/users.effects'; + +@NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [ + BrowserModule, + RouterModule.forRoot(appRoutes, { initialNavigation: 'enabledBlocking' }), + StoreModule.forRoot( + {}, + { + metaReducers: [], + runtimeChecks: { + strictActionImmutability: true, + strictStateImmutability: true, + }, + } + ), + EffectsModule.forRoot([]), + StoreRouterConnectingModule.forRoot(), + StoreModule.forFeature(fromUsers.USERS_FEATURE_KEY, fromUsers.usersReducer), + EffectsModule.forFeature([UsersEffects]), + ], + providers: [], + bootstrap: [AppComponent], +}) +export class AppModule {} +" +`; + +exports[`NgRxRootStoreGenerator NgModule should add a root module and root state when --minimal=false 2`] = ` +"import { createAction, props } from '@ngrx/store'; +import { UsersEntity } from './users.models'; + +export const initUsers = createAction('[Users Page] Init'); + +export const loadUsersSuccess = createAction( + '[Users/API] Load Users Success', + props<{ users: UsersEntity[] }>() +); + +export const loadUsersFailure = createAction( + '[Users/API] Load Users Failure', + props<{ error: any }>() +); +" +`; + +exports[`NgRxRootStoreGenerator NgModule should add a root module and root state when --minimal=false 3`] = ` +"import { Injectable, inject } from '@angular/core'; +import { createEffect, Actions, ofType } from '@ngrx/effects'; +import { switchMap, catchError, of } from 'rxjs'; +import * as UsersActions from './users.actions'; +import * as UsersFeature from './users.reducer'; + +@Injectable() +export class UsersEffects { + private actions$ = inject(Actions); + + init$ = createEffect(() => + this.actions$.pipe( + ofType(UsersActions.initUsers), + switchMap(() => of(UsersActions.loadUsersSuccess({ users: [] }))), + catchError((error) => { + console.error('Error', error); + return of(UsersActions.loadUsersFailure({ error })); + }) + ) + ); +} +" +`; + +exports[`NgRxRootStoreGenerator NgModule should add a root module and root state when --minimal=false 4`] = ` +"import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { hot } from 'jasmine-marbles'; +import { Observable } from 'rxjs'; + +import * as UsersActions from './users.actions'; +import { UsersEffects } from './users.effects'; + +describe('UsersEffects', () => { + let actions: Observable; + let effects: UsersEffects; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + UsersEffects, + provideMockActions(() => actions), + provideMockStore(), + ], + }); + + effects = TestBed.inject(UsersEffects); + }); + + describe('init$', () => { + it('should work', () => { + actions = hot('-a-|', { a: UsersActions.initUsers() }); + + const expected = hot('-a-|', { + a: UsersActions.loadUsersSuccess({ users: [] }), + }); + + expect(effects.init$).toBeObservable(expected); + }); + }); +}); +" +`; + +exports[`NgRxRootStoreGenerator NgModule should add a root module and root state when --minimal=false 5`] = ` +"import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { createReducer, on, Action } from '@ngrx/store'; + +import * as UsersActions from './users.actions'; +import { UsersEntity } from './users.models'; + +export const USERS_FEATURE_KEY = 'users'; + +export interface UsersState extends EntityState { + selectedId?: string | number; // which Users record has been selected + loaded: boolean; // has the Users list been loaded + error?: string | null; // last known error (if any) +} + +export interface UsersPartialState { + readonly [USERS_FEATURE_KEY]: UsersState; +} + +export const usersAdapter: EntityAdapter = + createEntityAdapter(); + +export const initialUsersState: UsersState = usersAdapter.getInitialState({ + // set initial required properties + loaded: false, +}); + +const reducer = createReducer( + initialUsersState, + on(UsersActions.initUsers, (state) => ({ + ...state, + loaded: false, + error: null, + })), + on(UsersActions.loadUsersSuccess, (state, { users }) => + usersAdapter.setAll(users, { ...state, loaded: true }) + ), + on(UsersActions.loadUsersFailure, (state, { error }) => ({ ...state, error })) +); + +export function usersReducer(state: UsersState | undefined, action: Action) { + return reducer(state, action); +} +" +`; + +exports[`NgRxRootStoreGenerator NgModule should add a root module and root state when --minimal=false 6`] = ` +"import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { USERS_FEATURE_KEY, UsersState, usersAdapter } from './users.reducer'; + +// Lookup the 'Users' feature state managed by NgRx +export const selectUsersState = + createFeatureSelector(USERS_FEATURE_KEY); + +const { selectAll, selectEntities } = usersAdapter.getSelectors(); + +export const selectUsersLoaded = createSelector( + selectUsersState, + (state: UsersState) => state.loaded +); + +export const selectUsersError = createSelector( + selectUsersState, + (state: UsersState) => state.error +); + +export const selectAllUsers = createSelector( + selectUsersState, + (state: UsersState) => selectAll(state) +); + +export const selectUsersEntities = createSelector( + selectUsersState, + (state: UsersState) => selectEntities(state) +); + +export const selectSelectedId = createSelector( + selectUsersState, + (state: UsersState) => state.selectedId +); + +export const selectEntity = createSelector( + selectUsersEntities, + selectSelectedId, + (entities, selectedId) => (selectedId ? entities[selectedId] : undefined) +); +" +`; + +exports[`NgRxRootStoreGenerator NgModule should add a root module and root state when --minimal=false 7`] = ` +"import { UsersEntity } from './users.models'; +import { + usersAdapter, + UsersPartialState, + initialUsersState, +} from './users.reducer'; +import * as UsersSelectors from './users.selectors'; + +describe('Users Selectors', () => { + const ERROR_MSG = 'No Error Available'; + const getUsersId = (it: UsersEntity) => it.id; + const createUsersEntity = (id: string, name = '') => + ({ + id, + name: name || \`name-\${id}\`, + } as UsersEntity); + + let state: UsersPartialState; + + beforeEach(() => { + state = { + users: usersAdapter.setAll( + [ + createUsersEntity('PRODUCT-AAA'), + createUsersEntity('PRODUCT-BBB'), + createUsersEntity('PRODUCT-CCC'), + ], + { + ...initialUsersState, + selectedId: 'PRODUCT-BBB', + error: ERROR_MSG, + loaded: true, + } + ), + }; + }); + + describe('Users Selectors', () => { + it('selectAllUsers() should return the list of Users', () => { + const results = UsersSelectors.selectAllUsers(state); + const selId = getUsersId(results[1]); + + expect(results.length).toBe(3); + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('selectEntity() should return the selected Entity', () => { + const result = UsersSelectors.selectEntity(state) as UsersEntity; + const selId = getUsersId(result); + + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('selectUsersLoaded() should return the current "loaded" status', () => { + const result = UsersSelectors.selectUsersLoaded(state); + + expect(result).toBe(true); + }); + + it('selectUsersError() should return the current "error" state', () => { + const result = UsersSelectors.selectUsersError(state); + + expect(result).toBe(ERROR_MSG); + }); + }); +}); +" +`; + +exports[`NgRxRootStoreGenerator NgModule should add an empty root module when --minimal=true 1`] = ` +"import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app.component'; +import { appRoutes } from './app.routes'; +import { NxWelcomeComponent } from './nx-welcome.component'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreRouterConnectingModule } from '@ngrx/router-store'; + +@NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [ + BrowserModule, + RouterModule.forRoot(appRoutes, { initialNavigation: 'enabledBlocking' }), + StoreModule.forRoot( + {}, + { + metaReducers: [], + runtimeChecks: { + strictActionImmutability: true, + strictStateImmutability: true, + }, + } + ), + EffectsModule.forRoot([]), + StoreRouterConnectingModule.forRoot(), + ], + providers: [], + bootstrap: [AppComponent], +}) +export class AppModule {} +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add a facade when --facade=true 1`] = ` +"import { ApplicationConfig } from '@angular/core'; +import { + provideRouter, + withEnabledBlockingInitialNavigation, +} from '@angular/router'; +import { appRoutes } from './app.routes'; +import { provideStore, provideState } from '@ngrx/store'; +import { provideEffects } from '@ngrx/effects'; +import * as fromUsers from './+state/users.reducer'; +import { UsersEffects } from './+state/users.effects'; +import { UsersFacade } from './+state/users.facade'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideEffects(UsersEffects), + provideState(fromUsers.USERS_FEATURE_KEY, fromUsers.usersReducer), + UsersFacade, + provideEffects(), + provideStore(), + provideRouter(appRoutes, withEnabledBlockingInitialNavigation()), + ], +}; +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add a facade when --facade=true 2`] = ` +"import { Injectable, inject } from '@angular/core'; +import { select, Store, Action } from '@ngrx/store'; + +import * as UsersActions from './users.actions'; +import * as UsersFeature from './users.reducer'; +import * as UsersSelectors from './users.selectors'; + +@Injectable() +export class UsersFacade { + private readonly store = inject(Store); + + /** + * Combine pieces of state using createSelector, + * and expose them as observables through the facade. + */ + loaded$ = this.store.pipe(select(UsersSelectors.selectUsersLoaded)); + allUsers$ = this.store.pipe(select(UsersSelectors.selectAllUsers)); + selectedUsers$ = this.store.pipe(select(UsersSelectors.selectEntity)); + + /** + * Use the initialization action to perform one + * or more tasks in your Effects. + */ + init() { + this.store.dispatch(UsersActions.initUsers()); + } +} +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add a root module and root state when --minimal=false 1`] = ` +"import { ApplicationConfig } from '@angular/core'; +import { + provideRouter, + withEnabledBlockingInitialNavigation, +} from '@angular/router'; +import { appRoutes } from './app.routes'; +import { provideStore, provideState } from '@ngrx/store'; +import { provideEffects } from '@ngrx/effects'; +import * as fromUsers from './+state/users.reducer'; +import { UsersEffects } from './+state/users.effects'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideEffects(UsersEffects), + provideState(fromUsers.USERS_FEATURE_KEY, fromUsers.usersReducer), + provideEffects(), + provideStore(), + provideRouter(appRoutes, withEnabledBlockingInitialNavigation()), + ], +}; +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add a root module and root state when --minimal=false 2`] = ` +"import { createAction, props } from '@ngrx/store'; +import { UsersEntity } from './users.models'; + +export const initUsers = createAction('[Users Page] Init'); + +export const loadUsersSuccess = createAction( + '[Users/API] Load Users Success', + props<{ users: UsersEntity[] }>() +); + +export const loadUsersFailure = createAction( + '[Users/API] Load Users Failure', + props<{ error: any }>() +); +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add a root module and root state when --minimal=false 3`] = ` +"import { Injectable, inject } from '@angular/core'; +import { createEffect, Actions, ofType } from '@ngrx/effects'; +import { switchMap, catchError, of } from 'rxjs'; +import * as UsersActions from './users.actions'; +import * as UsersFeature from './users.reducer'; + +@Injectable() +export class UsersEffects { + private actions$ = inject(Actions); + + init$ = createEffect(() => + this.actions$.pipe( + ofType(UsersActions.initUsers), + switchMap(() => of(UsersActions.loadUsersSuccess({ users: [] }))), + catchError((error) => { + console.error('Error', error); + return of(UsersActions.loadUsersFailure({ error })); + }) + ) + ); +} +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add a root module and root state when --minimal=false 4`] = ` +"import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { hot } from 'jasmine-marbles'; +import { Observable } from 'rxjs'; + +import * as UsersActions from './users.actions'; +import { UsersEffects } from './users.effects'; + +describe('UsersEffects', () => { + let actions: Observable; + let effects: UsersEffects; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + UsersEffects, + provideMockActions(() => actions), + provideMockStore(), + ], + }); + + effects = TestBed.inject(UsersEffects); + }); + + describe('init$', () => { + it('should work', () => { + actions = hot('-a-|', { a: UsersActions.initUsers() }); + + const expected = hot('-a-|', { + a: UsersActions.loadUsersSuccess({ users: [] }), + }); + + expect(effects.init$).toBeObservable(expected); + }); + }); +}); +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add a root module and root state when --minimal=false 5`] = ` +"import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { createReducer, on, Action } from '@ngrx/store'; + +import * as UsersActions from './users.actions'; +import { UsersEntity } from './users.models'; + +export const USERS_FEATURE_KEY = 'users'; + +export interface UsersState extends EntityState { + selectedId?: string | number; // which Users record has been selected + loaded: boolean; // has the Users list been loaded + error?: string | null; // last known error (if any) +} + +export interface UsersPartialState { + readonly [USERS_FEATURE_KEY]: UsersState; +} + +export const usersAdapter: EntityAdapter = + createEntityAdapter(); + +export const initialUsersState: UsersState = usersAdapter.getInitialState({ + // set initial required properties + loaded: false, +}); + +const reducer = createReducer( + initialUsersState, + on(UsersActions.initUsers, (state) => ({ + ...state, + loaded: false, + error: null, + })), + on(UsersActions.loadUsersSuccess, (state, { users }) => + usersAdapter.setAll(users, { ...state, loaded: true }) + ), + on(UsersActions.loadUsersFailure, (state, { error }) => ({ ...state, error })) +); + +export function usersReducer(state: UsersState | undefined, action: Action) { + return reducer(state, action); +} +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add a root module and root state when --minimal=false 6`] = ` +"import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { USERS_FEATURE_KEY, UsersState, usersAdapter } from './users.reducer'; + +// Lookup the 'Users' feature state managed by NgRx +export const selectUsersState = + createFeatureSelector(USERS_FEATURE_KEY); + +const { selectAll, selectEntities } = usersAdapter.getSelectors(); + +export const selectUsersLoaded = createSelector( + selectUsersState, + (state: UsersState) => state.loaded +); + +export const selectUsersError = createSelector( + selectUsersState, + (state: UsersState) => state.error +); + +export const selectAllUsers = createSelector( + selectUsersState, + (state: UsersState) => selectAll(state) +); + +export const selectUsersEntities = createSelector( + selectUsersState, + (state: UsersState) => selectEntities(state) +); + +export const selectSelectedId = createSelector( + selectUsersState, + (state: UsersState) => state.selectedId +); + +export const selectEntity = createSelector( + selectUsersEntities, + selectSelectedId, + (entities, selectedId) => (selectedId ? entities[selectedId] : undefined) +); +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add a root module and root state when --minimal=false 7`] = ` +"import { UsersEntity } from './users.models'; +import { + usersAdapter, + UsersPartialState, + initialUsersState, +} from './users.reducer'; +import * as UsersSelectors from './users.selectors'; + +describe('Users Selectors', () => { + const ERROR_MSG = 'No Error Available'; + const getUsersId = (it: UsersEntity) => it.id; + const createUsersEntity = (id: string, name = '') => + ({ + id, + name: name || \`name-\${id}\`, + } as UsersEntity); + + let state: UsersPartialState; + + beforeEach(() => { + state = { + users: usersAdapter.setAll( + [ + createUsersEntity('PRODUCT-AAA'), + createUsersEntity('PRODUCT-BBB'), + createUsersEntity('PRODUCT-CCC'), + ], + { + ...initialUsersState, + selectedId: 'PRODUCT-BBB', + error: ERROR_MSG, + loaded: true, + } + ), + }; + }); + + describe('Users Selectors', () => { + it('selectAllUsers() should return the list of Users', () => { + const results = UsersSelectors.selectAllUsers(state); + const selId = getUsersId(results[1]); + + expect(results.length).toBe(3); + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('selectEntity() should return the selected Entity', () => { + const result = UsersSelectors.selectEntity(state) as UsersEntity; + const selId = getUsersId(result); + + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('selectUsersLoaded() should return the current "loaded" status', () => { + const result = UsersSelectors.selectUsersLoaded(state); + + expect(result).toBe(true); + }); + + it('selectUsersError() should return the current "error" state', () => { + const result = UsersSelectors.selectUsersError(state); + + expect(result).toBe(ERROR_MSG); + }); + }); +}); +" +`; + +exports[`NgRxRootStoreGenerator Standalone APIs should add an empty root module when --minimal=true 1`] = ` +"import { ApplicationConfig } from '@angular/core'; +import { + provideRouter, + withEnabledBlockingInitialNavigation, +} from '@angular/router'; +import { appRoutes } from './app.routes'; +import { provideStore } from '@ngrx/store'; +import { provideEffects } from '@ngrx/effects'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideEffects(), + provideStore(), + provideRouter(appRoutes, withEnabledBlockingInitialNavigation()), + ], +}; +" +`; diff --git a/packages/angular/src/generators/ngrx-root-store/lib/add-imports.ts b/packages/angular/src/generators/ngrx-root-store/lib/add-imports.ts new file mode 100644 index 0000000000..013a8cd2ba --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/lib/add-imports.ts @@ -0,0 +1,167 @@ +import type { Tree } from '@nx/devkit'; +import type { SourceFile } from 'typescript'; +import { + addImportToModule, + addProviderToAppConfig, + addProviderToBootstrapApplication, +} from '../../../utils/nx-devkit/ast-utils'; +import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; +import { insertImport } from '@nx/js'; +import { NormalizedNgRxRootStoreGeneratorOptions } from './normalize-options'; + +let tsModule: typeof import('typescript'); + +function addRootStoreImport( + tree: Tree, + isParentStandalone: boolean, + sourceFile: SourceFile, + parentPath: string, + provideRootStore: string, + storeForRoot: string +) { + if (isParentStandalone) { + if (tree.read(parentPath, 'utf-8').includes('ApplicationConfig')) { + addProviderToAppConfig(tree, parentPath, provideRootStore); + } else { + addProviderToBootstrapApplication(tree, parentPath, provideRootStore); + } + } else { + sourceFile = addImportToModule(tree, sourceFile, parentPath, storeForRoot); + } + return sourceFile; +} + +function addRootEffectsImport( + tree: Tree, + isParentStandalone: boolean, + sourceFile: SourceFile, + parentPath: string, + provideRootEffects: string, + effectsForEmptyRoot: string +) { + if (isParentStandalone) { + if (tree.read(parentPath, 'utf-8').includes('ApplicationConfig')) { + addProviderToAppConfig(tree, parentPath, provideRootEffects); + } else { + addProviderToBootstrapApplication(tree, parentPath, provideRootEffects); + } + } else { + sourceFile = addImportToModule( + tree, + sourceFile, + parentPath, + effectsForEmptyRoot + ); + } + + return sourceFile; +} + +function addRouterStoreImport( + tree: Tree, + sourceFile: SourceFile, + addImport: ( + source: SourceFile, + symbolName: string, + fileName: string, + isDefault?: boolean + ) => SourceFile, + parentPath: string, + storeRouterModule: string +) { + sourceFile = addImport( + sourceFile, + 'StoreRouterConnectingModule', + '@ngrx/router-store' + ); + return addImportToModule(tree, sourceFile, parentPath, storeRouterModule); +} + +export function addImportsToModule( + tree: Tree, + options: NormalizedNgRxRootStoreGeneratorOptions +): void { + if (!tsModule) { + tsModule = ensureTypescript(); + } + const parentPath = options.parent; + const sourceText = tree.read(parentPath, 'utf-8'); + let sourceFile = tsModule.createSourceFile( + parentPath, + sourceText, + tsModule.ScriptTarget.Latest, + true + ); + + const isParentStandalone = !sourceText.includes('@NgModule'); + + const addImport = ( + source: SourceFile, + symbolName: string, + fileName: string, + isDefault = false + ): SourceFile => { + return insertImport( + tree, + source, + parentPath, + symbolName, + fileName, + isDefault + ); + }; + + const storeMetaReducers = `metaReducers: []`; + + const storeForRoot = `StoreModule.forRoot({}, { + ${storeMetaReducers}, + runtimeChecks: { + strictActionImmutability: true, + strictStateImmutability: true + } + })`; + const effectsForEmptyRoot = `EffectsModule.forRoot([])`; + const storeRouterModule = 'StoreRouterConnectingModule.forRoot()'; + + const provideRootStore = `provideStore()`; + const provideRootEffects = `provideEffects()`; + + if (isParentStandalone) { + sourceFile = addImport(sourceFile, 'provideStore', '@ngrx/store'); + sourceFile = addImport(sourceFile, 'provideEffects', '@ngrx/effects'); + } else { + sourceFile = addImport(sourceFile, 'StoreModule', '@ngrx/store'); + sourceFile = addImport(sourceFile, 'EffectsModule', '@ngrx/effects'); + } + + sourceFile = addRootStoreImport( + tree, + isParentStandalone, + sourceFile, + parentPath, + provideRootStore, + storeForRoot + ); + + sourceFile = addRootEffectsImport( + tree, + isParentStandalone, + sourceFile, + parentPath, + provideRootEffects, + effectsForEmptyRoot + ); + + // this is just a heuristic + const hasRouter = sourceText.indexOf('RouterModule') > -1; + + if (hasRouter && !isParentStandalone) { + sourceFile = addRouterStoreImport( + tree, + sourceFile, + addImport, + parentPath, + storeRouterModule + ); + } +} diff --git a/packages/angular/src/generators/ngrx-root-store/lib/add-ngrx-to-package-json.ts b/packages/angular/src/generators/ngrx-root-store/lib/add-ngrx-to-package-json.ts new file mode 100644 index 0000000000..496deb8177 --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/lib/add-ngrx-to-package-json.ts @@ -0,0 +1,33 @@ +import type { GeneratorCallback, Tree } from '@nx/devkit'; +import { addDependenciesToPackageJson } from '@nx/devkit'; +import { gte } from 'semver'; +import { versions } from '../../utils/version-utils'; +import { NormalizedNgRxRootStoreGeneratorOptions } from './normalize-options'; + +export function addNgRxToPackageJson( + tree: Tree, + options: NormalizedNgRxRootStoreGeneratorOptions +): GeneratorCallback { + const jasmineMarblesVersion = gte(options.rxjsVersion, '7.0.0') + ? '~0.9.1' + : '~0.8.3'; + const ngrxVersion = versions(tree).ngrxVersion; + + process.env.npm_config_legacy_peer_deps ??= 'true'; + + return addDependenciesToPackageJson( + tree, + { + '@ngrx/store': ngrxVersion, + '@ngrx/effects': ngrxVersion, + '@ngrx/entity': ngrxVersion, + '@ngrx/router-store': ngrxVersion, + '@ngrx/component-store': ngrxVersion, + }, + { + '@ngrx/schematics': ngrxVersion, + '@ngrx/store-devtools': ngrxVersion, + 'jasmine-marbles': jasmineMarblesVersion, + } + ); +} diff --git a/packages/angular/src/generators/ngrx-root-store/lib/index.ts b/packages/angular/src/generators/ngrx-root-store/lib/index.ts new file mode 100644 index 0000000000..7e4fb68a0d --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/lib/index.ts @@ -0,0 +1,4 @@ +export * from './add-imports'; +export * from './add-ngrx-to-package-json'; +export * from './normalize-options'; +export * from './validate-options'; diff --git a/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts b/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts new file mode 100644 index 0000000000..b70ba1d26a --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/lib/normalize-options.ts @@ -0,0 +1,61 @@ +import type { Tree } from '@nx/devkit'; +import { + joinPathFragments, + names, + readJson, + readProjectConfiguration, +} from '@nx/devkit'; +import { checkAndCleanWithSemver } from '@nx/devkit/src/utils/semver'; +import { rxjsVersion as defaultRxjsVersion } from '../../../utils/versions'; +import type { Schema } from '../schema'; +import { isNgStandaloneApp } from '../../../utils/nx-devkit/ast-utils'; + +export type NormalizedNgRxRootStoreGeneratorOptions = Schema & { + parent: string; + rxjsVersion: string; +}; + +export function normalizeOptions( + tree: Tree, + options: Schema +): NormalizedNgRxRootStoreGeneratorOptions { + let rxjsVersion: string; + try { + rxjsVersion = checkAndCleanWithSemver( + 'rxjs', + readJson(tree, 'package.json').dependencies['rxjs'] + ); + } catch { + rxjsVersion = checkAndCleanWithSemver('rxjs', defaultRxjsVersion); + } + + const project = readProjectConfiguration(tree, options.project); + const isStandalone = isNgStandaloneApp(tree, options.project); + const appConfigPath = joinPathFragments( + project.sourceRoot, + 'app/app.config.ts' + ); + const appMainPath = project.targets.build.options.main; + + /** If NgModule App + * -> Use App Module + * If Standalone + * -> Check Config File exists (v16+) + * --> If so, use that + * --> If not, use main.ts + */ + const parent = !isStandalone + ? joinPathFragments(project.sourceRoot, 'app/app.module.ts') + : tree.exists(appConfigPath) + ? appConfigPath + : appMainPath; + + options.directory = options.directory ?? '+state'; + + return { + ...options, + parent, + directory: names(options.directory).fileName, + rxjsVersion, + }; +} diff --git a/packages/angular/src/generators/ngrx-root-store/lib/validate-options.ts b/packages/angular/src/generators/ngrx-root-store/lib/validate-options.ts new file mode 100644 index 0000000000..48f8ab6fc5 --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/lib/validate-options.ts @@ -0,0 +1,53 @@ +import type { Tree } from '@nx/devkit'; +import { getProjects, readProjectConfiguration } from '@nx/devkit'; +import { Schema } from '../schema'; +import { + getInstalledAngularVersionInfo, + getInstalledPackageVersionInfo, +} from '../../utils/version-utils'; +import { getPkgVersionForAngularMajorVersion } from '../../../utils/version-utils'; +import { coerce, lt, major } from 'semver'; +import { isNgStandaloneApp } from '../../../utils/nx-devkit/ast-utils'; + +export function validateOptions(tree: Tree, options: Schema): void { + if (!getProjects(tree).has(options.project)) { + throw new Error( + `Could not find project '${options.project}'. Please ensure the project name is correct and exists.` + ); + } + + const project = readProjectConfiguration(tree, options.project); + if (project.projectType !== 'application') { + throw new Error( + `NgRx Root Stores can only be added to applications, please ensure the project you use is an application.` + ); + } + + if (!options.minimal && !options.name) { + throw new Error( + `If generating a global feature state with your root store, you must provide a name for it with '--name'.` + ); + } + + const angularVersionInfo = getInstalledAngularVersionInfo(tree); + const intendedNgRxVersionForAngularMajor = + getPkgVersionForAngularMajorVersion( + 'ngrxVersion', + angularVersionInfo.major + ); + + const ngrxMajorVersion = + getInstalledPackageVersionInfo(tree, '@ngrx/store')?.major ?? + major(coerce(intendedNgRxVersionForAngularMajor)); + + const isStandalone = isNgStandaloneApp(tree, options.project); + + if (lt(angularVersionInfo.version, '14.1.0') || ngrxMajorVersion < 15) { + if (isStandalone) { + throw new Error( + `The provided project '${options.project}' is set up to use Standalone APIs, however your workspace is not configured to support Standalone APIs. ` + + 'Please make sure to provide a path to an "NgModule" where the state will be registered. ' + ); + } + } +} diff --git a/packages/angular/src/generators/ngrx-root-store/ngrx-root-store.spec.ts b/packages/angular/src/generators/ngrx-root-store/ngrx-root-store.spec.ts new file mode 100644 index 0000000000..0cecee57cc --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/ngrx-root-store.spec.ts @@ -0,0 +1,381 @@ +import type { Tree } from '@nx/devkit'; +import { readJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import applicationGenerator from '../application/application'; +import ngrxRootStoreGenerator from './ngrx-root-store'; +import { ngrxVersion } from '../../utils/versions'; + +describe('NgRxRootStoreGenerator', () => { + describe('NgModule', () => { + it('should error when project does not exist', async () => { + const tree = createTreeWithEmptyWorkspace(); + + await expect( + ngrxRootStoreGenerator(tree, { + project: 'non-exist', + minimal: true, + name: '', + }) + ).rejects.toThrowError(); + }); + + it('should error when minimal false, but name is undefined or falsy', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createNgModuleApp(tree); + + // ACT & ASSERT + await expect( + ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: false, + name: undefined, + }) + ).rejects.toThrowError(); + }); + + it('should add an empty root module when --minimal=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createNgModuleApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: true, + }); + + // ASSERT + expect( + tree.read('my-app/src/app/app.module.ts', 'utf-8') + ).toMatchSnapshot(); + expect(tree.exists('/my-app/src/app/+state/users.actions.ts')).toBe( + false + ); + expect(tree.exists('/my-app/src/app/+state/users.effects.ts')).toBe( + false + ); + expect(tree.exists('/my-app/src/app/+state/users.effects.spec.ts')).toBe( + false + ); + expect(tree.exists('/my-app/src/app/+state/users.reducer.ts')).toBe( + false + ); + expect(tree.exists('/my-app/src/app/+state/users.selectors.ts')).toBe( + false + ); + expect( + tree.exists('/my-app/src/app/+state/users.selectors.spec.ts') + ).toBe(false); + }); + + it('should add a root module and root state when --minimal=false', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createNgModuleApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: false, + name: 'users', + }); + + // ASSERT + expect( + tree.read('my-app/src/app/app.module.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.actions.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.effects.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.effects.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.reducer.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.selectors.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.selectors.spec.ts', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should add a facade when --facade=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createNgModuleApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: false, + name: 'users', + facade: true, + }); + + // ASSERT + expect( + tree.read('my-app/src/app/app.module.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.facade.ts', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should update package.json', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createNgModuleApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: true, + }); + + // ASSERT + const packageJson = readJson(tree, 'package.json'); + expect(packageJson.dependencies['@ngrx/store']).toEqual(ngrxVersion); + expect(packageJson.dependencies['@ngrx/effects']).toEqual(ngrxVersion); + expect(packageJson.dependencies['@ngrx/entity']).toEqual(ngrxVersion); + expect(packageJson.dependencies['@ngrx/router-store']).toEqual( + ngrxVersion + ); + expect(packageJson.dependencies['@ngrx/component-store']).toEqual( + ngrxVersion + ); + expect(packageJson.devDependencies['@ngrx/schematics']).toEqual( + ngrxVersion + ); + expect(packageJson.devDependencies['@ngrx/store-devtools']).toEqual( + ngrxVersion + ); + expect(packageJson.devDependencies['jasmine-marbles']).toBeDefined(); + }); + + it('should not update package.json when --skipPackageJson=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createNgModuleApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: true, + skipPackageJson: true, + }); + + // ASSERT + const packageJson = readJson(tree, 'package.json'); + expect(packageJson.dependencies['@ngrx/store']).toBeUndefined(); + expect(packageJson.dependencies['@ngrx/effects']).toBeUndefined(); + expect(packageJson.dependencies['@ngrx/entity']).toBeUndefined(); + expect(packageJson.dependencies['@ngrx/router-store']).toBeUndefined(); + expect(packageJson.dependencies['@ngrx/component-store']).toBeUndefined(); + expect(packageJson.devDependencies['@ngrx/schematics']).toBeUndefined(); + expect( + packageJson.devDependencies['@ngrx/store-devtools'] + ).toBeUndefined(); + expect(packageJson.devDependencies['jasmine-marbles']).toBeUndefined(); + }); + }); + describe('Standalone APIs', () => { + it('should error when project does not exist', async () => { + const tree = createTreeWithEmptyWorkspace(); + + await expect( + ngrxRootStoreGenerator(tree, { + project: 'non-exist', + minimal: true, + name: '', + }) + ).rejects.toThrowError(); + }); + + it('should error when minimal false, but name is undefined or falsy', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createStandaloneApp(tree); + + // ACT & ASSERT + await expect( + ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: false, + name: undefined, + }) + ).rejects.toThrowError(); + }); + + it('should add an empty root module when --minimal=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createStandaloneApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: true, + }); + + // ASSERT + expect( + tree.read('my-app/src/app/app.config.ts', 'utf-8') + ).toMatchSnapshot(); + expect(tree.exists('/my-app/src/app/+state/users.actions.ts')).toBe( + false + ); + expect(tree.exists('/my-app/src/app/+state/users.effects.ts')).toBe( + false + ); + expect(tree.exists('/my-app/src/app/+state/users.effects.spec.ts')).toBe( + false + ); + expect(tree.exists('/my-app/src/app/+state/users.reducer.ts')).toBe( + false + ); + expect(tree.exists('/my-app/src/app/+state/users.selectors.ts')).toBe( + false + ); + expect( + tree.exists('/my-app/src/app/+state/users.selectors.spec.ts') + ).toBe(false); + }); + + it('should add a root module and root state when --minimal=false', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createStandaloneApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: false, + name: 'users', + }); + + // ASSERT + expect( + tree.read('my-app/src/app/app.config.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.actions.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.effects.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.effects.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.reducer.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.selectors.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.selectors.spec.ts', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should add a facade when --facade=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createStandaloneApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: false, + name: 'users', + facade: true, + }); + + // ASSERT + expect( + tree.read('my-app/src/app/app.config.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('/my-app/src/app/+state/users.facade.ts', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should update package.json', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createStandaloneApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: true, + }); + + // ASSERT + const packageJson = readJson(tree, 'package.json'); + expect(packageJson.dependencies['@ngrx/store']).toEqual(ngrxVersion); + expect(packageJson.dependencies['@ngrx/effects']).toEqual(ngrxVersion); + expect(packageJson.dependencies['@ngrx/entity']).toEqual(ngrxVersion); + expect(packageJson.dependencies['@ngrx/router-store']).toEqual( + ngrxVersion + ); + expect(packageJson.dependencies['@ngrx/component-store']).toEqual( + ngrxVersion + ); + expect(packageJson.devDependencies['@ngrx/schematics']).toEqual( + ngrxVersion + ); + expect(packageJson.devDependencies['@ngrx/store-devtools']).toEqual( + ngrxVersion + ); + expect(packageJson.devDependencies['jasmine-marbles']).toBeDefined(); + }); + + it('should not update package.json when --skipPackageJson=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await createStandaloneApp(tree); + + // ACT + await ngrxRootStoreGenerator(tree, { + project: 'my-app', + minimal: true, + skipPackageJson: true, + }); + + // ASSERT + const packageJson = readJson(tree, 'package.json'); + expect(packageJson.dependencies['@ngrx/store']).toBeUndefined(); + expect(packageJson.dependencies['@ngrx/effects']).toBeUndefined(); + expect(packageJson.dependencies['@ngrx/entity']).toBeUndefined(); + expect(packageJson.dependencies['@ngrx/router-store']).toBeUndefined(); + expect(packageJson.dependencies['@ngrx/component-store']).toBeUndefined(); + expect(packageJson.devDependencies['@ngrx/schematics']).toBeUndefined(); + expect( + packageJson.devDependencies['@ngrx/store-devtools'] + ).toBeUndefined(); + expect(packageJson.devDependencies['jasmine-marbles']).toBeUndefined(); + }); + }); +}); + +async function createNgModuleApp(tree: Tree, name = 'my-app') { + await applicationGenerator(tree, { + name, + standalone: false, + routing: true, + }); +} + +async function createStandaloneApp(tree: Tree, name = 'my-app') { + await applicationGenerator(tree, { + name, + standalone: true, + routing: true, + }); +} diff --git a/packages/angular/src/generators/ngrx-root-store/ngrx-root-store.ts b/packages/angular/src/generators/ngrx-root-store/ngrx-root-store.ts new file mode 100644 index 0000000000..fe4828d212 --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/ngrx-root-store.ts @@ -0,0 +1,48 @@ +import type { Tree } from '@nx/devkit'; +import { formatFiles, GeneratorCallback } from '@nx/devkit'; +import type { Schema } from './schema'; + +import { + addImportsToModule, + addNgRxToPackageJson, + normalizeOptions, + validateOptions, +} from './lib'; + +import ngrxFeatureStoreGenerator from '../ngrx-feature-store/ngrx-feature-store'; + +export async function ngrxRootStoreGenerator(tree: Tree, schema: Schema) { + validateOptions(tree, schema); + const options = normalizeOptions(tree, schema); + + if (!options.skipImport) { + addImportsToModule(tree, options); + } + + if (!options.minimal) { + await ngrxFeatureStoreGenerator(tree, { + name: options.name, + parent: options.parent, + directory: options.directory, + minimal: false, + facade: options.facade, + barrels: false, + skipImport: false, + skipPackageJson: true, + skipFormat: true, + }); + } + + let packageInstallationTask: GeneratorCallback = () => {}; + if (!options.skipPackageJson) { + packageInstallationTask = addNgRxToPackageJson(tree, options); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return packageInstallationTask; +} + +export default ngrxRootStoreGenerator; diff --git a/packages/angular/src/generators/ngrx-root-store/schema.d.ts b/packages/angular/src/generators/ngrx-root-store/schema.d.ts new file mode 100644 index 0000000000..7a8fd2ff56 --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/schema.d.ts @@ -0,0 +1,10 @@ +export interface Schema { + project: string; + minimal: boolean; + name?: string; + directory?: string; + facade?: boolean; + skipFormat?: boolean; + skipImport?: boolean; + skipPackageJson?: boolean; +} diff --git a/packages/angular/src/generators/ngrx-root-store/schema.json b/packages/angular/src/generators/ngrx-root-store/schema.json new file mode 100644 index 0000000000..8021faa1b1 --- /dev/null +++ b/packages/angular/src/generators/ngrx-root-store/schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxNgrxRootStoreGenerator", + "title": "Add NgRx support to an application.", + "description": "Adds NgRx support to an application.", + "cli": "nx", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the application to generate the NgRx configuration for.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What app would you like to generate a NgRx configuration for?", + "x-dropdown": "projects" + }, + "minimal": { + "type": "boolean", + "default": true, + "description": "Only register the root state management setup or also generate a global feature state.", + "x-priority": "important" + }, + "name": { + "type": "string", + "description": "Name of the NgRx state, such as `products` or `users`. Recommended to use the plural form of the name.", + "x-priority": "important" + }, + "route": { + "type": "string", + "description": "The route that the Standalone NgRx Providers should be added to. _Note: This is only supported in Angular versions >= 14.1.0_.", + "default": "''" + }, + "directory": { + "type": "string", + "default": "+state", + "description": "The name of the folder used to contain/group the generated NgRx files." + }, + "facade": { + "type": "boolean", + "default": false, + "description": "Create a Facade class for the the feature.", + "x-prompt": "Would you like to use a Facade with your NgRx state?" + }, + "skipImport": { + "type": "boolean", + "default": false, + "description": "Generate NgRx feature files without registering the feature in the NgModule." + }, + "skipFormat": { + "description": "Skip formatting files.", + "type": "boolean", + "default": false, + "x-priority": "internal" + }, + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": "Do not update the `package.json` with NgRx dependencies.", + "x-priority": "internal" + } + }, + "additionalProperties": false, + "required": ["project"] +}