From 22fe5608e78a533b3aee7103ea2e3ea226207a90 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 5 May 2023 12:00:00 +0100 Subject: [PATCH] feat(angular): add ngrx feature store generator --- docs/generated/manifests/menus.json | 8 + docs/generated/manifests/packages.json | 9 + docs/generated/packages-metadata.json | 9 + .../generators/ngrx-feature-store.json | 80 ++ packages/angular/generators.json | 5 + packages/angular/generators.ts | 1 + .../ngrx-feature-store.spec.ts.snap | 961 ++++++++++++++++++ .../__fileName__.actions.ts__tmpl__ | 16 + .../__fileName__.effects.spec.ts__tmpl__ | 37 + .../__fileName__.effects.ts__tmpl__ | 22 + .../__fileName__.facade.spec.ts__tmpl__ | 99 ++ .../__fileName__.facade.ts__tmpl__ | 27 + .../__fileName__.models.ts__tmpl__ | 7 + .../__fileName__.reducer.spec.ts__tmpl__ | 37 + .../__fileName__.reducer.ts__tmpl__ | 41 + .../__fileName__.selectors.spec.ts__tmpl__ | 58 ++ .../__fileName__.selectors.ts__tmpl__ | 38 + .../__fileName__.effects.ts__tmpl__ | 22 + .../__fileName__.facade.ts__tmpl__ | 27 + .../lib/add-exports-barrel.ts | 87 ++ .../ngrx-feature-store/lib/add-imports.ts | 186 ++++ .../lib/add-ngrx-to-package-json.ts | 33 + .../ngrx-feature-store/lib/generate-files.ts | 55 + .../ngrx-feature-store/lib/index.ts | 6 + .../lib/normalize-options.ts | 34 + .../lib/validate-options.ts | 50 + .../ngrx-feature-store.spec.ts | 521 ++++++++++ .../ngrx-feature-store/ngrx-feature-store.ts | 38 + .../generators/ngrx-feature-store/schema.d.ts | 12 + .../generators/ngrx-feature-store/schema.json | 72 ++ 30 files changed, 2598 insertions(+) create mode 100644 docs/generated/packages/angular/generators/ngrx-feature-store.json create mode 100644 packages/angular/src/generators/ngrx-feature-store/__snapshots__/ngrx-feature-store.spec.ts.snap create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.actions.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.effects.spec.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.effects.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.facade.spec.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.facade.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.models.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.reducer.spec.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.reducer.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.selectors.spec.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.selectors.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/no-inject/__directory__/__fileName__.effects.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/files/no-inject/__directory__/__fileName__.facade.ts__tmpl__ create mode 100644 packages/angular/src/generators/ngrx-feature-store/lib/add-exports-barrel.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/lib/add-imports.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/lib/add-ngrx-to-package-json.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/lib/generate-files.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/lib/index.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/lib/normalize-options.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/lib/validate-options.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/ngrx-feature-store.spec.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/ngrx-feature-store.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/schema.d.ts create mode 100644 packages/angular/src/generators/ngrx-feature-store/schema.json diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 302d336a490a7..d539de7a8f929 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -4014,6 +4014,14 @@ "isExternal": false, "disableCollapsible": false }, + { + "id": "ngrx-feature-store", + "path": "/packages/angular/generators/ngrx-feature-store", + "name": "ngrx-feature-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 7a2c691edc2bf..60cce4acc3bcb 100644 --- a/docs/generated/manifests/packages.json +++ b/docs/generated/manifests/packages.json @@ -258,6 +258,15 @@ "path": "/packages/angular/generators/ngrx", "type": "generator" }, + "/packages/angular/generators/ngrx-feature-store": { + "description": "Adds an NgRx Feature Store to an application or library.", + "file": "generated/packages/angular/generators/ngrx-feature-store.json", + "hidden": false, + "name": "ngrx-feature-store", + "originalFilePath": "/packages/angular/src/generators/ngrx-feature-store/schema.json", + "path": "/packages/angular/generators/ngrx-feature-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 18a63c5124f0e..a7afabe4c7045 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -253,6 +253,15 @@ "path": "angular/generators/ngrx", "type": "generator" }, + { + "description": "Adds an NgRx Feature Store to an application or library.", + "file": "generated/packages/angular/generators/ngrx-feature-store.json", + "hidden": false, + "name": "ngrx-feature-store", + "originalFilePath": "/packages/angular/src/generators/ngrx-feature-store/schema.json", + "path": "angular/generators/ngrx-feature-store", + "type": "generator" + }, { "description": "Generate an Angular Pipe", "file": "generated/packages/angular/generators/pipe.json", diff --git a/docs/generated/packages/angular/generators/ngrx-feature-store.json b/docs/generated/packages/angular/generators/ngrx-feature-store.json new file mode 100644 index 0000000000000..b153f50db15d4 --- /dev/null +++ b/docs/generated/packages/angular/generators/ngrx-feature-store.json @@ -0,0 +1,80 @@ +{ + "name": "ngrx-feature-store", + "factory": "./src/generators/ngrx-feature-store/ngrx-feature-store", + "schema": { + "$schema": "http://json-schema.org/schema", + "$id": "NxNgrxFeatureStoreGenerator", + "title": "NgRx Feature Store Generator", + "description": "Add an NgRx Feature Store to an application or library.", + "cli": "nx", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the NgRx feature state, such as `products` or `users`. Recommended to use the plural form of the name.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What name would you like to use for the NgRx feature state? An example would be `users`.", + "x-priority": "important" + }, + "parent": { + "type": "string", + "description": "The path to the file where the state will be registered. For NgModule usage, this will be your Feature Module. For Standalone API usage, this will be your Routes definition file for your feature state. The host directory will create/use the new state directory. _Note: The Standalone API usage is only supported in Angular versions >= 14.1.0_.", + "x-prompt": "What is the path to the module or Routes definition where this NgRx state should be registered?", + "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": "''" + }, + "minimal": { + "type": "boolean", + "default": false, + "description": "Only register the feature state.", + "x-priority": "important" + }, + "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" + }, + "barrels": { + "type": "boolean", + "default": false, + "description": "Use barrels to re-export actions, state and selectors." + } + }, + "additionalProperties": false, + "required": ["name"], + "presets": [] + }, + "description": "Adds an NgRx Feature Store to an application or library.", + "implementation": "/packages/angular/src/generators/ngrx-feature-store/ngrx-feature-store.ts", + "aliases": [], + "hidden": false, + "path": "/packages/angular/src/generators/ngrx-feature-store/schema.json", + "type": "generator" +} diff --git a/packages/angular/generators.json b/packages/angular/generators.json index 790ded7555f39..516a0dfed7f2f 100644 --- a/packages/angular/generators.json +++ b/packages/angular/generators.json @@ -254,6 +254,11 @@ "schema": "./src/generators/ngrx/schema.json", "description": "Adds NgRx support to an application or library." }, + "ngrx-feature-store": { + "factory": "./src/generators/ngrx-feature-store/ngrx-feature-store", + "schema": "./src/generators/ngrx-feature-store/schema.json", + "description": "Adds an NgRx Feature Store to an application or library." + }, "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 95a89e5217648..a9bfa3f2530bd 100644 --- a/packages/angular/generators.ts +++ b/packages/angular/generators.ts @@ -11,6 +11,7 @@ export * from './src/generators/library-secondary-entry-point/library-secondary- 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/pipe/pipe'; export * from './src/generators/remote/remote'; export * from './src/generators/scam-directive/scam-directive'; diff --git a/packages/angular/src/generators/ngrx-feature-store/__snapshots__/ngrx-feature-store.spec.ts.snap b/packages/angular/src/generators/ngrx-feature-store/__snapshots__/ngrx-feature-store.spec.ts.snap new file mode 100644 index 0000000000000..5909e0f5aa892 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/__snapshots__/ngrx-feature-store.spec.ts.snap @@ -0,0 +1,961 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ngrx-feature-store NgModule should generate the files with the correct content 1`] = ` +"import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import * as fromUsers from './+state/users.reducer'; +import { UsersEffects } from './+state/users.effects'; +import { UsersFacade } from './+state/users.facade'; + +@NgModule({ + imports: [ + CommonModule, + StoreModule.forFeature(fromUsers.USERS_FEATURE_KEY, fromUsers.usersReducer), + EffectsModule.forFeature([UsersEffects]), + ], + providers: [UsersFacade], +}) +export class FeatureModuleModule {} +" +`; + +exports[`ngrx-feature-store NgModule should generate the files with the correct content 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[`ngrx-feature-store NgModule should generate the files with the correct content 3`] = ` +"import { NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule, Store } from '@ngrx/store'; +import { readFirst } from '@nx/angular/testing'; + +import * as UsersActions from './users.actions'; +import { UsersEffects } from './users.effects'; +import { UsersFacade } from './users.facade'; +import { UsersEntity } from './users.models'; +import { + USERS_FEATURE_KEY, + UsersState, + initialUsersState, + usersReducer, +} from './users.reducer'; +import * as UsersSelectors from './users.selectors'; + +interface TestSchema { + users: UsersState; +} + +describe('UsersFacade', () => { + let facade: UsersFacade; + let store: Store; + const createUsersEntity = (id: string, name = ''): UsersEntity => ({ + id, + name: name || \`name-\${id}\`, + }); + + describe('used in NgModule', () => { + beforeEach(() => { + @NgModule({ + imports: [ + StoreModule.forFeature(USERS_FEATURE_KEY, usersReducer), + EffectsModule.forFeature([UsersEffects]), + ], + providers: [UsersFacade], + }) + class CustomFeatureModule {} + + @NgModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + CustomFeatureModule, + ], + }) + class RootModule {} + TestBed.configureTestingModule({ imports: [RootModule] }); + + store = TestBed.inject(Store); + facade = TestBed.inject(UsersFacade); + }); + + /** + * The initially generated facade::loadAll() returns empty array + */ + it('loadAll() should return empty list with loaded == true', async () => { + let list = await readFirst(facade.allUsers$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + facade.init(); + + list = await readFirst(facade.allUsers$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(true); + }); + + /** + * Use \`loadUsersSuccess\` to manually update list + */ + it('allUsers$ should return the loaded list; and loaded flag == true', async () => { + let list = await readFirst(facade.allUsers$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + store.dispatch( + UsersActions.loadUsersSuccess({ + users: [createUsersEntity('AAA'), createUsersEntity('BBB')], + }) + ); + + list = await readFirst(facade.allUsers$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(2); + expect(isLoaded).toBe(true); + }); + }); +}); +" +`; + +exports[`ngrx-feature-store NgModule should generate the files with the correct content 4`] = ` +"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[`ngrx-feature-store NgModule should generate the files with the correct content 5`] = ` +"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[`ngrx-feature-store NgModule should generate the files with the correct content 6`] = ` +"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[`ngrx-feature-store NgModule should generate the files with the correct content 7`] = ` +"/** + * Interface for the 'Users' data + */ +export interface UsersEntity { + id: string | number; // Primary ID + name: string; +} +" +`; + +exports[`ngrx-feature-store NgModule should generate the files with the correct content 8`] = ` +"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[`ngrx-feature-store NgModule should generate the files with the correct content 9`] = ` +"import { Action } from '@ngrx/store'; + +import * as UsersActions from './users.actions'; +import { UsersEntity } from './users.models'; +import { UsersState, initialUsersState, usersReducer } from './users.reducer'; + +describe('Users Reducer', () => { + const createUsersEntity = (id: string, name = ''): UsersEntity => ({ + id, + name: name || \`name-\${id}\`, + }); + + describe('valid Users actions', () => { + it('loadUsersSuccess should return the list of known Users', () => { + const users = [ + createUsersEntity('PRODUCT-AAA'), + createUsersEntity('PRODUCT-zzz'), + ]; + const action = UsersActions.loadUsersSuccess({ users }); + + const result: UsersState = usersReducer(initialUsersState, action); + + expect(result.loaded).toBe(true); + expect(result.ids.length).toBe(2); + }); + }); + + describe('unknown action', () => { + it('should return the previous state', () => { + const action = {} as Action; + + const result = usersReducer(initialUsersState, action); + + expect(result).toBe(initialUsersState); + }); + }); +}); +" +`; + +exports[`ngrx-feature-store NgModule should generate the files with the correct content 10`] = ` +"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[`ngrx-feature-store NgModule should generate the files with the correct content 11`] = ` +"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[`ngrx-feature-store NgModule should have the correct entry point when --barrels=false 1`] = ` +"export * from './lib/+state/users.facade'; +export * from './lib/+state/users.models'; +export * from './lib/+state/users.selectors'; +export * from './lib/+state/users.reducer'; +export * from './lib/+state/users.actions'; +export * from './lib/feature-module.module'; +" +`; + +exports[`ngrx-feature-store NgModule should have the correct entry point when --barrels=true 1`] = ` +"import * as UsersActions from './lib/+state/users.actions'; + +import * as UsersFeature from './lib/+state/users.reducer'; + +import * as UsersSelectors from './lib/+state/users.selectors'; + +export * from './lib/+state/users.facade'; + +export * from './lib/+state/users.models'; + +export { UsersActions, UsersFeature, UsersSelectors }; +export * from './lib/feature-module.module'; +" +`; + +exports[`ngrx-feature-store Standalone APIs should generate the files with the correct content 1`] = ` +"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[`ngrx-feature-store Standalone APIs should generate the files with the correct content 2`] = ` +"import { NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule, Store } from '@ngrx/store'; +import { readFirst } from '@nx/angular/testing'; + +import * as UsersActions from './users.actions'; +import { UsersEffects } from './users.effects'; +import { UsersFacade } from './users.facade'; +import { UsersEntity } from './users.models'; +import { + USERS_FEATURE_KEY, + UsersState, + initialUsersState, + usersReducer, +} from './users.reducer'; +import * as UsersSelectors from './users.selectors'; + +interface TestSchema { + users: UsersState; +} + +describe('UsersFacade', () => { + let facade: UsersFacade; + let store: Store; + const createUsersEntity = (id: string, name = ''): UsersEntity => ({ + id, + name: name || \`name-\${id}\`, + }); + + describe('used in NgModule', () => { + beforeEach(() => { + @NgModule({ + imports: [ + StoreModule.forFeature(USERS_FEATURE_KEY, usersReducer), + EffectsModule.forFeature([UsersEffects]), + ], + providers: [UsersFacade], + }) + class CustomFeatureModule {} + + @NgModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + CustomFeatureModule, + ], + }) + class RootModule {} + TestBed.configureTestingModule({ imports: [RootModule] }); + + store = TestBed.inject(Store); + facade = TestBed.inject(UsersFacade); + }); + + /** + * The initially generated facade::loadAll() returns empty array + */ + it('loadAll() should return empty list with loaded == true', async () => { + let list = await readFirst(facade.allUsers$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + facade.init(); + + list = await readFirst(facade.allUsers$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(true); + }); + + /** + * Use \`loadUsersSuccess\` to manually update list + */ + it('allUsers$ should return the loaded list; and loaded flag == true', async () => { + let list = await readFirst(facade.allUsers$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + store.dispatch( + UsersActions.loadUsersSuccess({ + users: [createUsersEntity('AAA'), createUsersEntity('BBB')], + }) + ); + + list = await readFirst(facade.allUsers$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(2); + expect(isLoaded).toBe(true); + }); + }); +}); +" +`; + +exports[`ngrx-feature-store Standalone APIs should generate the files with the correct content 3`] = ` +"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[`ngrx-feature-store Standalone APIs should generate the files with the correct content 4`] = ` +"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[`ngrx-feature-store Standalone APIs should generate the files with the correct content 5`] = ` +"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[`ngrx-feature-store Standalone APIs should generate the files with the correct content 6`] = ` +"/** + * Interface for the 'Users' data + */ +export interface UsersEntity { + id: string | number; // Primary ID + name: string; +} +" +`; + +exports[`ngrx-feature-store Standalone APIs should generate the files with the correct content 7`] = ` +"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[`ngrx-feature-store Standalone APIs should generate the files with the correct content 8`] = ` +"import { Action } from '@ngrx/store'; + +import * as UsersActions from './users.actions'; +import { UsersEntity } from './users.models'; +import { UsersState, initialUsersState, usersReducer } from './users.reducer'; + +describe('Users Reducer', () => { + const createUsersEntity = (id: string, name = ''): UsersEntity => ({ + id, + name: name || \`name-\${id}\`, + }); + + describe('valid Users actions', () => { + it('loadUsersSuccess should return the list of known Users', () => { + const users = [ + createUsersEntity('PRODUCT-AAA'), + createUsersEntity('PRODUCT-zzz'), + ]; + const action = UsersActions.loadUsersSuccess({ users }); + + const result: UsersState = usersReducer(initialUsersState, action); + + expect(result.loaded).toBe(true); + expect(result.ids.length).toBe(2); + }); + }); + + describe('unknown action', () => { + it('should return the previous state', () => { + const action = {} as Action; + + const result = usersReducer(initialUsersState, action); + + expect(result).toBe(initialUsersState); + }); + }); +}); +" +`; + +exports[`ngrx-feature-store Standalone APIs should generate the files with the correct content 9`] = ` +"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[`ngrx-feature-store Standalone APIs should generate the files with the correct content 10`] = ` +"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[`ngrx-feature-store Standalone APIs should generate the files with the correct content 11`] = ` +"import { Route } from '@angular/router'; +import { FeatureComponent } from './feature/feature.component'; +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 featureRoutes: Route[] = [ + { + path: '', + component: FeatureComponent, + providers: [ + UsersFacade, + provideState(fromUsers.USERS_FEATURE_KEY, fromUsers.usersReducer), + provideEffects(UsersEffects), + ], + }, +]; +" +`; + +exports[`ngrx-feature-store Standalone APIs should have the correct entry point when --barrels=false 1`] = ` +"export * from './lib/+state/users.facade'; +export * from './lib/+state/users.models'; +export * from './lib/+state/users.selectors'; +export * from './lib/+state/users.reducer'; +export * from './lib/+state/users.actions'; +export * from './lib/lib.routes'; + +export * from './lib/feature/feature.component'; +" +`; + +exports[`ngrx-feature-store Standalone APIs should have the correct entry point when --barrels=true 1`] = ` +"import * as UsersActions from './lib/+state/users.actions'; + +import * as UsersFeature from './lib/+state/users.reducer'; + +import * as UsersSelectors from './lib/+state/users.selectors'; + +export * from './lib/+state/users.facade'; + +export * from './lib/+state/users.models'; + +export { UsersActions, UsersFeature, UsersSelectors }; +export * from './lib/lib.routes'; + +export * from './lib/feature/feature.component'; +" +`; diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.actions.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.actions.ts__tmpl__ new file mode 100644 index 0000000000000..6b626b0dc2a52 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.actions.ts__tmpl__ @@ -0,0 +1,16 @@ +import { createAction, props } from '@ngrx/store'; +import { <%= className %>Entity } from './<%= fileName %>.models'; + +export const init<%= className %> = createAction( + '[<%= className %> Page] Init' +); + +export const load<%= className %>Success = createAction( + '[<%= className %>/API] Load <%= className %> Success', + props<{ <%= propertyName %>: <%= className %>Entity[] }>() +); + +export const load<%= className %>Failure = createAction( + '[<%= className %>/API] Load <%= className %> Failure', + props<{ error: any }>() +); diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.effects.spec.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.effects.spec.ts__tmpl__ new file mode 100644 index 0000000000000..827d18f04c626 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.effects.spec.ts__tmpl__ @@ -0,0 +1,37 @@ +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 <%= className %>Actions from './<%= fileName %>.actions'; +import { <%= className %>Effects } from './<%= fileName %>.effects'; + +describe('<%= className %>Effects', () => { + let actions: Observable; + let effects: <%= className %>Effects; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + <%= className %>Effects, + provideMockActions(() => actions), + provideMockStore() + ], + }); + + effects = TestBed.inject(<%= className %>Effects); + }); + + describe('init$', () => { + it('should work', () => { + actions = hot('-a-|', { a: <%= className %>Actions.init<%= className %>() }); + + const expected = hot('-a-|', { a: <%= className %>Actions.load<%= className %>Success({ <%= propertyName %>: [] }) }); + + expect(effects.init$).toBeObservable(expected); + }); + }); +}); diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.effects.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.effects.ts__tmpl__ new file mode 100644 index 0000000000000..f443e2c172de1 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.effects.ts__tmpl__ @@ -0,0 +1,22 @@ +import { Injectable, inject } from '@angular/core'; +import { createEffect, Actions, ofType } from '@ngrx/effects';<% if (!importFromOperators) { %> +import { switchMap, catchError, of } from 'rxjs';<% } else { %> +import { of } from 'rxjs'; +import { switchMap, catchError } from 'rxjs/operators';<% } %> +import * as <%= className %>Actions from './<%= fileName %>.actions'; +import * as <%= className %>Feature from './<%= fileName %>.reducer'; + +@Injectable() +export class <%= className %>Effects { + private actions$ = inject(Actions); + + init$ = createEffect(() => this.actions$.pipe( + ofType(<%= className %>Actions.init<%= className %>), + switchMap(() => of(<%= className %>Actions.load<%= className %>Success({ <%= propertyName %>: [] }))), + catchError((error) => { + console.error('Error', error); + return of(<%= className %>Actions.load<%= className %>Failure({ error })); + } + ) + )); +} diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.facade.spec.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.facade.spec.ts__tmpl__ new file mode 100644 index 0000000000000..ccd1b5263a73b --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.facade.spec.ts__tmpl__ @@ -0,0 +1,99 @@ +import { NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule, Store } from '@ngrx/store'; +import { readFirst } from '@nx/angular/testing'; + +import * as <%= className %>Actions from './<%= fileName %>.actions'; +import { <%= className %>Effects } from './<%= fileName %>.effects'; +import { <%= className %>Facade } from './<%= fileName %>.facade'; +import { <%= className %>Entity } from './<%= fileName %>.models'; +import { + <%= constantName %>_FEATURE_KEY, + <%= className %>State, + initial<%= className %>State, + <%= propertyName %>Reducer +} from './<%= fileName %>.reducer'; +import * as <%= className %>Selectors from './<%= fileName %>.selectors'; + +interface TestSchema { + <%= propertyName %>: <%= className %>State; +} + +describe('<%= className %>Facade', () => { + let facade: <%= className %>Facade; + let store: Store; + const create<%= className %>Entity = (id: string, name = ''): <%= className %>Entity => ({ + id, + name: name || `name-${id}` + }); + + describe('used in NgModule', () => { + beforeEach(() => { + @NgModule({ + imports: [ + StoreModule.forFeature(<%= constantName %>_FEATURE_KEY, <%= propertyName %>Reducer), + EffectsModule.forFeature([<%= className %>Effects]) + ], + providers: [<%= className %>Facade] + }) + class CustomFeatureModule {} + + @NgModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + CustomFeatureModule, + ] + }) + class RootModule {} + TestBed.configureTestingModule({ imports: [RootModule] }); + + store = TestBed.inject(Store); + facade = TestBed.inject(<%= className %>Facade); + }); + + /** + * The initially generated facade::loadAll() returns empty array + */ + it('loadAll() should return empty list with loaded == true', async () => { + let list = await readFirst(facade.all<%= className %>$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + facade.init(); + + list = await readFirst(facade.all<%= className %>$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(true); + }); + + /** + * Use `load<%= className %>Success` to manually update list + */ + it('all<%= className %>$ should return the loaded list; and loaded flag == true', async () => { + let list = await readFirst(facade.all<%= className %>$); + let isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(0); + expect(isLoaded).toBe(false); + + store.dispatch(<%= className %>Actions.load<%= className %>Success({ + <%= propertyName %>: [ + create<%= className %>Entity('AAA'), + create<%= className %>Entity('BBB') + ]}) + ); + + list = await readFirst(facade.all<%= className %>$); + isLoaded = await readFirst(facade.loaded$); + + expect(list.length).toBe(2); + expect(isLoaded).toBe(true); + }); + }); +}); diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.facade.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.facade.ts__tmpl__ new file mode 100644 index 0000000000000..5e358f4f4f6b5 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.facade.ts__tmpl__ @@ -0,0 +1,27 @@ +import { Injectable, inject } from '@angular/core'; +import { select, Store, Action } from '@ngrx/store'; + +import * as <%= className %>Actions from './<%= fileName %>.actions'; +import * as <%= className %>Feature from './<%= fileName %>.reducer'; +import * as <%= className %>Selectors from './<%= fileName %>.selectors'; + +@Injectable() +export class <%= className %>Facade { + private readonly store = inject(Store); + + /** + * Combine pieces of state using createSelector, + * and expose them as observables through the facade. + */ + loaded$ = this.store.pipe(select(<%= className %>Selectors.select<%= className %>Loaded)); + all<%= className %>$ = this.store.pipe(select(<%= className %>Selectors.selectAll<%= className %>)); + selected<%= className %>$ = this.store.pipe(select(<%= className %>Selectors.selectEntity)); + + /** + * Use the initialization action to perform one + * or more tasks in your Effects. + */ + init() { + this.store.dispatch(<%= className %>Actions.init<%= className %>()); + } +} diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.models.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.models.ts__tmpl__ new file mode 100644 index 0000000000000..225df94d2c2a7 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.models.ts__tmpl__ @@ -0,0 +1,7 @@ +/** + * Interface for the '<%= className %>' data + */ +export interface <%= className %>Entity { + id: string | number; // Primary ID + name: string; +}; \ No newline at end of file diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.reducer.spec.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.reducer.spec.ts__tmpl__ new file mode 100644 index 0000000000000..edf70509efb12 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.reducer.spec.ts__tmpl__ @@ -0,0 +1,37 @@ +import { Action } from '@ngrx/store'; + +import * as <%= className %>Actions from './<%= fileName %>.actions'; +import { <%= className %>Entity } from './<%= fileName %>.models'; +import { <%= className %>State, initial<%= className %>State, <%= propertyName %>Reducer } from './<%= fileName %>.reducer'; + +describe('<%= className %> Reducer', () => { + const create<%= className %>Entity = (id: string, name = ''): <%= className %>Entity => ({ + id, + name: name || `name-${id}` + }); + + describe('valid <%= className %> actions', () => { + it('load<%= className %>Success should return the list of known <%= className %>', () => { + const <%= propertyName %> = [ + create<%= className %>Entity('PRODUCT-AAA'), + create<%= className %>Entity('PRODUCT-zzz') + ]; + const action = <%= className %>Actions.load<%= className %>Success({ <%= propertyName %> }); + + const result: <%= className %>State = <%= propertyName %>Reducer(initial<%= className %>State, action); + + expect(result.loaded).toBe(true); + expect(result.ids.length).toBe(2); + }); + }); + + describe('unknown action', () => { + it('should return the previous state', () => { + const action = {} as Action; + + const result = <%= propertyName %>Reducer(initial<%= className %>State, action); + + expect(result).toBe(initial<%= className %>State); + }); + }); +}); diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.reducer.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.reducer.ts__tmpl__ new file mode 100644 index 0000000000000..6e925716a7d66 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.reducer.ts__tmpl__ @@ -0,0 +1,41 @@ +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { createReducer, on, Action } from '@ngrx/store'; + +import * as <%= className %>Actions from './<%= fileName %>.actions'; +import { <%= className %>Entity } from './<%= fileName %>.models'; + +export const <%= constantName %>_FEATURE_KEY = '<%= propertyName %>'; + +export interface <%= className %>State extends EntityState<<%= className %>Entity> { + selectedId?: string | number; // which <%= className %> record has been selected + loaded: boolean; // has the <%= className %> list been loaded + error?: string | null; // last known error (if any) +} + +export interface <%= className %>PartialState { + readonly [<%= constantName %>_FEATURE_KEY]: <%= className %>State; +} + +export const <%= propertyName %>Adapter: EntityAdapter<<%= className %>Entity> = createEntityAdapter<<%= className %>Entity>(); + +export const initial<%= className %>State: <%= className %>State = <%= propertyName %>Adapter.getInitialState({ + // set initial required properties + loaded: false +}); + +const reducer = createReducer( + initial<%= className %>State, + on(<%= className %>Actions.init<%= className %>, + state => ({ ...state, loaded: false, error: null }) + ), + on(<%= className %>Actions.load<%= className %>Success, + (state, { <%= propertyName %> }) => <%= propertyName %>Adapter.setAll(<%= propertyName %>, { ...state, loaded: true }) + ), + on(<%= className %>Actions.load<%= className %>Failure, + (state, { error }) => ({ ...state, error }) + ), +); + +export function <%= propertyName %>Reducer(state: <%= className %>State | undefined, action: Action) { + return reducer(state, action); +} diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.selectors.spec.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.selectors.spec.ts__tmpl__ new file mode 100644 index 0000000000000..6d75b21e89928 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.selectors.spec.ts__tmpl__ @@ -0,0 +1,58 @@ +import { <%= className %>Entity } from './<%= fileName %>.models'; +import { <%= propertyName %>Adapter, <%= className %>PartialState, initial<%= className %>State } from './<%= fileName %>.reducer'; +import * as <%= className %>Selectors from './<%= fileName %>.selectors'; + +describe('<%= className %> Selectors', () => { + const ERROR_MSG = 'No Error Available'; + const get<%= className %>Id = (it: <%= className %>Entity) => it.id; + const create<%= className %>Entity = (id: string, name = '') => ({ + id, + name: name || `name-${id}` + }) as <%= className %>Entity; + + let state: <%= className %>PartialState; + + beforeEach(() => { + state = { + <%= propertyName %>: <%= propertyName %>Adapter.setAll([ + create<%= className %>Entity('PRODUCT-AAA'), + create<%= className %>Entity('PRODUCT-BBB'), + create<%= className %>Entity('PRODUCT-CCC') + ], { + ...initial<%= className %>State, + selectedId : 'PRODUCT-BBB', + error: ERROR_MSG, + loaded: true + }) + }; + }); + + describe('<%= className %> Selectors', () => { + it('selectAll<%= className %>() should return the list of <%= className %>', () => { + const results = <%= className %>Selectors.selectAll<%= className %>(state); + const selId = get<%= className %>Id(results[1]); + + expect(results.length).toBe(3); + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('selectEntity() should return the selected Entity', () => { + const result = <%= className %>Selectors.selectEntity(state) as <%= className %>Entity; + const selId = get<%= className %>Id(result); + + expect(selId).toBe('PRODUCT-BBB'); + }); + + it('select<%= className %>Loaded() should return the current "loaded" status', () => { + const result = <%= className %>Selectors.select<%= className %>Loaded(state); + + expect(result).toBe(true); + }); + + it('select<%= className %>Error() should return the current "error" state', () => { + const result = <%= className %>Selectors.select<%= className %>Error(state); + + expect(result).toBe(ERROR_MSG); + }); + }); +}); diff --git a/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.selectors.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.selectors.ts__tmpl__ new file mode 100644 index 0000000000000..25e5f81884bdb --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/base/__directory__/__fileName__.selectors.ts__tmpl__ @@ -0,0 +1,38 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { <%= constantName %>_FEATURE_KEY, <%= className %>State, <%= propertyName %>Adapter } from './<%= fileName %>.reducer'; + +// Lookup the '<%= className %>' feature state managed by NgRx +export const select<%= className %>State = createFeatureSelector<<%= className %>State>(<%= constantName %>_FEATURE_KEY); + +const { selectAll, selectEntities } = <%= propertyName %>Adapter.getSelectors(); + +export const select<%= className %>Loaded = createSelector( + select<%= className %>State, + (state: <%= className %>State) => state.loaded +); + +export const select<%= className %>Error = createSelector( + select<%= className %>State, + (state: <%= className %>State) => state.error +); + +export const selectAll<%= className %> = createSelector( + select<%= className %>State, + (state: <%= className %>State) => selectAll(state) +); + +export const select<%= className %>Entities = createSelector( + select<%= className %>State, + (state: <%= className %>State) => selectEntities(state) +); + +export const selectSelectedId = createSelector( + select<%= className %>State, + (state: <%= className %>State) => state.selectedId +); + +export const selectEntity = createSelector( + select<%= className %>Entities, + selectSelectedId, + (entities, selectedId) => (selectedId ? entities[selectedId] : undefined) +); diff --git a/packages/angular/src/generators/ngrx-feature-store/files/no-inject/__directory__/__fileName__.effects.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/no-inject/__directory__/__fileName__.effects.ts__tmpl__ new file mode 100644 index 0000000000000..508c619520756 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/no-inject/__directory__/__fileName__.effects.ts__tmpl__ @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { createEffect, Actions, ofType } from '@ngrx/effects'; + +import * as <%= className %>Actions from './<%= fileName %>.actions'; +import * as <%= className %>Feature from './<%= fileName %>.reducer'; + +import {switchMap, catchError, of} from 'rxjs'; + +@Injectable() +export class <%= className %>Effects { + init$ = createEffect(() => this.actions$.pipe( + ofType(<%= className %>Actions.init<%= className %>), + switchMap(() => of(<%= className %>Actions.load<%= className %>Success({ <%= propertyName %>: [] }))), + catchError((error) => { + console.error('Error', error); + return of(<%= className %>Actions.load<%= className %>Failure({ error })); + } + ) + )); + + constructor(private readonly actions$: Actions) {} +} diff --git a/packages/angular/src/generators/ngrx-feature-store/files/no-inject/__directory__/__fileName__.facade.ts__tmpl__ b/packages/angular/src/generators/ngrx-feature-store/files/no-inject/__directory__/__fileName__.facade.ts__tmpl__ new file mode 100644 index 0000000000000..b5bd3607baa4f --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/files/no-inject/__directory__/__fileName__.facade.ts__tmpl__ @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import { select, Store, Action } from '@ngrx/store'; + +import * as <%= className %>Actions from './<%= fileName %>.actions'; +import * as <%= className %>Feature from './<%= fileName %>.reducer'; +import * as <%= className %>Selectors from './<%= fileName %>.selectors'; + +@Injectable() +export class <%= className %>Facade { + /** + * Combine pieces of state using createSelector, + * and expose them as observables through the facade. + */ + loaded$ = this.store.pipe(select(<%= className %>Selectors.select<%= className %>Loaded)); + all<%= className %>$ = this.store.pipe(select(<%= className %>Selectors.selectAll<%= className %>)); + selected<%= className %>$ = this.store.pipe(select(<%= className %>Selectors.selectEntity)); + + constructor(private readonly store: Store) {} + + /** + * Use the initialization action to perform one + * or more tasks in your Effects. + */ + init() { + this.store.dispatch(<%= className %>Actions.init<%= className %>()); + } +} diff --git a/packages/angular/src/generators/ngrx-feature-store/lib/add-exports-barrel.ts b/packages/angular/src/generators/ngrx-feature-store/lib/add-exports-barrel.ts new file mode 100644 index 0000000000000..633d6905e420c --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/lib/add-exports-barrel.ts @@ -0,0 +1,87 @@ +import type { Tree } from '@nx/devkit'; +import { joinPathFragments, names } from '@nx/devkit'; +import { NormalizedNgRxFeatureStoreGeneratorOptions } from './normalize-options'; +import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; + +import { addGlobal } from '@nx/js'; + +let tsModule: typeof import('typescript'); + +export function addExportsToBarrel( + tree: Tree, + options: NormalizedNgRxFeatureStoreGeneratorOptions +): void { + const indexFilePath = joinPathFragments( + options.parentDirectory, + '..', + 'index.ts' + ); + if (!tree.exists(indexFilePath)) { + return; + } + + if (!tsModule) { + tsModule = ensureTypescript(); + } + const indexSourceText = tree.read(indexFilePath, 'utf-8'); + let sourceFile = tsModule.createSourceFile( + indexFilePath, + indexSourceText, + tsModule.ScriptTarget.Latest, + true + ); + + // Public API for the feature interfaces, selectors, and facade + const { className, fileName } = names(options.name); + const statePath = `./lib/${options.directory}/${fileName}`; + + sourceFile = addGlobal( + tree, + sourceFile, + indexFilePath, + options.barrels + ? `import * as ${className}Actions from '${statePath}.actions';` + : `export * from '${statePath}.actions';` + ); + sourceFile = addGlobal( + tree, + sourceFile, + indexFilePath, + options.barrels + ? `import * as ${className}Feature from '${statePath}.reducer';` + : `export * from '${statePath}.reducer';` + ); + sourceFile = addGlobal( + tree, + sourceFile, + indexFilePath, + options.barrels + ? `import * as ${className}Selectors from '${statePath}.selectors';` + : `export * from '${statePath}.selectors';` + ); + + if (options.barrels) { + sourceFile = addGlobal( + tree, + sourceFile, + indexFilePath, + `export { ${className}Actions, ${className}Feature, ${className}Selectors };` + ); + } + + sourceFile = addGlobal( + tree, + sourceFile, + indexFilePath, + `export * from '${statePath}.models';` + ); + + if (options.facade) { + sourceFile = addGlobal( + tree, + sourceFile, + indexFilePath, + `export * from '${statePath}.facade';` + ); + } +} diff --git a/packages/angular/src/generators/ngrx-feature-store/lib/add-imports.ts b/packages/angular/src/generators/ngrx-feature-store/lib/add-imports.ts new file mode 100644 index 0000000000000..752b3ec046e28 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/lib/add-imports.ts @@ -0,0 +1,186 @@ +import type { Tree } from '@nx/devkit'; +import { names } from '@nx/devkit'; +import type { SourceFile } from 'typescript'; +import { + addImportToModule, + addProviderToAppConfig, + addProviderToBootstrapApplication, + addProviderToModule, +} from '../../../utils/nx-devkit/ast-utils'; +import { addProviderToRoute } from '../../../utils/nx-devkit/route-utils'; +import type { NormalizedNgRxFeatureStoreGeneratorOptions } from './normalize-options'; +import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; +import { insertImport } from '@nx/js'; + +let tsModule: typeof import('typescript'); + +function addStoreForFeatureImport( + tree: Tree, + isParentStandalone, + route: string, + sourceFile: SourceFile, + parentPath: string, + provideStoreForFeature: string, + storeForFeature: string +) { + if (isParentStandalone) { + const parentContents = tree.read(parentPath, 'utf-8'); + if (parentContents.includes('ApplicationConfig')) { + addProviderToAppConfig(tree, parentPath, provideStoreForFeature); + } else if (parentContents.includes('bootstrapApplication')) { + addProviderToBootstrapApplication( + tree, + parentPath, + provideStoreForFeature + ); + } else { + addProviderToRoute(tree, parentPath, route, provideStoreForFeature); + } + } else { + sourceFile = addImportToModule( + tree, + sourceFile, + parentPath, + storeForFeature + ); + } + return sourceFile; +} + +function addEffectsForFeatureImport( + tree: Tree, + isParentStandalone, + route: string, + sourceFile: SourceFile, + parentPath: string, + provideEffectsForFeature: string, + effectsForFeature: string +) { + if (isParentStandalone) { + const parentContents = tree.read(parentPath, 'utf-8'); + if (parentContents.includes('ApplicationConfig')) { + addProviderToAppConfig(tree, parentPath, provideEffectsForFeature); + } else if (parentContents.includes('bootstrapApplication')) { + addProviderToBootstrapApplication( + tree, + parentPath, + provideEffectsForFeature + ); + } else { + addProviderToRoute(tree, parentPath, route, provideEffectsForFeature); + } + } else { + sourceFile = addImportToModule( + tree, + sourceFile, + parentPath, + effectsForFeature + ); + } + return sourceFile; +} + +export function addImportsToModule( + tree: Tree, + options: NormalizedNgRxFeatureStoreGeneratorOptions +): 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 dir = `./${names(options.directory).fileName}`; + const pathPrefix = `${dir}/${names(options.name).fileName}`; + const reducerPath = `${pathPrefix}.reducer`; + const effectsPath = `${pathPrefix}.effects`; + const facadePath = `${pathPrefix}.facade`; + + const constantName = `${names(options.name).constantName}`; + const effectsName = `${names(options.name).className}Effects`; + const facadeName = `${names(options.name).className}Facade`; + const className = `${names(options.name).className}`; + const propertyName = `${names(options.name).propertyName}`; + const reducerImports = `* as from${className}`; + + const storeForFeature = `StoreModule.forFeature(from${className}.${constantName}_FEATURE_KEY, from${className}.${propertyName}Reducer)`; + const effectsForFeature = `EffectsModule.forFeature([${effectsName}])`; + + const provideEffectsForFeature = `provideEffects(${effectsName})`; + const provideStoreForFeature = `provideState(from${className}.${constantName}_FEATURE_KEY, from${className}.${propertyName}Reducer)`; + + if (isParentStandalone) { + sourceFile = addImport(sourceFile, 'provideStore', '@ngrx/store'); + if (!options.minimal) { + sourceFile = addImport(sourceFile, 'provideState', '@ngrx/store'); + } + sourceFile = addImport(sourceFile, 'provideEffects', '@ngrx/effects'); + } else { + sourceFile = addImport(sourceFile, 'StoreModule', '@ngrx/store'); + sourceFile = addImport(sourceFile, 'EffectsModule', '@ngrx/effects'); + } + + sourceFile = addImport(sourceFile, reducerImports, reducerPath, true); + sourceFile = addImport(sourceFile, effectsName, effectsPath); + + if (options.facade) { + sourceFile = addImport(sourceFile, facadeName, facadePath); + if (isParentStandalone) { + if (tree.read(parentPath, 'utf-8').includes('ApplicationConfig')) { + addProviderToAppConfig(tree, parentPath, facadeName); + } else { + addProviderToRoute(tree, parentPath, options.route, facadeName); + } + } else { + sourceFile = addProviderToModule( + tree, + sourceFile, + parentPath, + facadeName + ); + } + } + + sourceFile = addStoreForFeatureImport( + tree, + isParentStandalone, + options.route, + sourceFile, + parentPath, + provideStoreForFeature, + storeForFeature + ); + sourceFile = addEffectsForFeatureImport( + tree, + isParentStandalone, + options.route, + sourceFile, + parentPath, + provideEffectsForFeature, + effectsForFeature + ); +} diff --git a/packages/angular/src/generators/ngrx-feature-store/lib/add-ngrx-to-package-json.ts b/packages/angular/src/generators/ngrx-feature-store/lib/add-ngrx-to-package-json.ts new file mode 100644 index 0000000000000..d501aab02845d --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-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 { NormalizedNgRxFeatureStoreGeneratorOptions } from './normalize-options'; + +export function addNgRxToPackageJson( + tree: Tree, + options: NormalizedNgRxFeatureStoreGeneratorOptions +): 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-feature-store/lib/generate-files.ts b/packages/angular/src/generators/ngrx-feature-store/lib/generate-files.ts new file mode 100644 index 0000000000000..dd5c7dce91829 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/lib/generate-files.ts @@ -0,0 +1,55 @@ +import type { Tree } from '@nx/devkit'; +import { generateFiles, joinPathFragments, names } from '@nx/devkit'; +import type { NormalizedNgRxFeatureStoreGeneratorOptions } from './normalize-options'; +import { lt } from 'semver'; +import { getInstalledAngularVersion } from '../../utils/version-utils'; + +export function generateFilesFromTemplates( + tree: Tree, + options: NormalizedNgRxFeatureStoreGeneratorOptions +) { + const projectNames = names(options.name); + + generateFiles( + tree, + joinPathFragments(__dirname, '..', 'files', 'base'), + options.parentDirectory, + { + ...options, + ...projectNames, + importFromOperators: lt(options.rxjsVersion, '7.2.0'), + tmpl: '', + } + ); + + const angularVersion = getInstalledAngularVersion(tree); + if (lt(angularVersion, '14.1.0')) { + generateFiles( + tree, + joinPathFragments(__dirname, '..', 'files', 'no-inject'), + options.parentDirectory, + { + ...options, + ...projectNames, + tmpl: '', + } + ); + } + + if (!options.facade) { + tree.delete( + joinPathFragments( + options.parentDirectory, + options.directory, + `${projectNames.fileName}.facade.ts` + ) + ); + tree.delete( + joinPathFragments( + options.parentDirectory, + options.directory, + `${projectNames.fileName}.facade.spec.ts` + ) + ); + } +} diff --git a/packages/angular/src/generators/ngrx-feature-store/lib/index.ts b/packages/angular/src/generators/ngrx-feature-store/lib/index.ts new file mode 100644 index 0000000000000..1ff7df4da974f --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/lib/index.ts @@ -0,0 +1,6 @@ +export * from './add-imports'; +export * from './add-exports-barrel'; +export * from './add-ngrx-to-package-json'; +export * from './generate-files'; +export * from './normalize-options'; +export * from './validate-options'; diff --git a/packages/angular/src/generators/ngrx-feature-store/lib/normalize-options.ts b/packages/angular/src/generators/ngrx-feature-store/lib/normalize-options.ts new file mode 100644 index 0000000000000..1a06b431628d3 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/lib/normalize-options.ts @@ -0,0 +1,34 @@ +import type { Tree } from '@nx/devkit'; +import { names, readJson } from '@nx/devkit'; +import { checkAndCleanWithSemver } from '@nx/devkit/src/utils/semver'; +import { dirname } from 'path'; +import { rxjsVersion as defaultRxjsVersion } from '../../../utils/versions'; +import type { Schema } from '../schema'; + +export type NormalizedNgRxFeatureStoreGeneratorOptions = Schema & { + parentDirectory: string; + rxjsVersion: string; +}; + +export function normalizeOptions( + tree: Tree, + options: Schema +): NormalizedNgRxFeatureStoreGeneratorOptions { + let rxjsVersion: string; + try { + rxjsVersion = checkAndCleanWithSemver( + 'rxjs', + readJson(tree, 'package.json').dependencies['rxjs'] + ); + } catch { + rxjsVersion = checkAndCleanWithSemver('rxjs', defaultRxjsVersion); + } + + return { + ...options, + parentDirectory: options.parent ? dirname(options.parent) : undefined, + route: options.route === '' ? `''` : options.route ?? `''`, + directory: names(options.directory).fileName, + rxjsVersion, + }; +} diff --git a/packages/angular/src/generators/ngrx-feature-store/lib/validate-options.ts b/packages/angular/src/generators/ngrx-feature-store/lib/validate-options.ts new file mode 100644 index 0000000000000..a13e9dddc46d7 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/lib/validate-options.ts @@ -0,0 +1,50 @@ +import type { Tree } 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 { tsquery } from '@phenomnomnominal/tsquery'; + +export function validateOptions(tree: Tree, options: Schema): void { + if (!options.parent) { + throw new Error('Please provide a value for "--parent"!'); + } + + if (options.parent && !tree.exists(options.parent)) { + throw new Error(`Parent does not exist: ${options.parent}.`); + } + + const angularVersionInfo = getInstalledAngularVersionInfo(tree); + const intendedNgRxVersionForAngularMajor = + getPkgVersionForAngularMajorVersion( + 'ngrxVersion', + angularVersionInfo.major + ); + + const ngrxMajorVersion = + getInstalledPackageVersionInfo(tree, '@ngrx/store')?.major ?? + major(coerce(intendedNgRxVersionForAngularMajor)); + + if (lt(angularVersionInfo.version, '14.1.0') || ngrxMajorVersion < 15) { + const parentContent = tree.read(options.parent, 'utf-8'); + const { tsquery } = require('@phenomnomnominal/tsquery'); + const ast = tsquery.ast(parentContent); + + const NG_MODULE_DECORATOR_SELECTOR = + 'ClassDeclaration > Decorator > CallExpression:has(Identifier[name=NgModule])'; + const nodes = tsquery(ast, NG_MODULE_DECORATOR_SELECTOR, { + visitAllChildren: true, + }); + if (nodes.length === 0) { + throw new Error( + `The provided parent path "${options.parent}" does not contain an "NgModule". ` + + 'Please make sure to provide a path to an "NgModule" where the state will be registered. ' + + 'If you are trying to use a "Routes" definition file (for Standalone API usage), ' + + 'please note this is not supported in Angular versions lower than 14.1.0.' + ); + } + } +} diff --git a/packages/angular/src/generators/ngrx-feature-store/ngrx-feature-store.spec.ts b/packages/angular/src/generators/ngrx-feature-store/ngrx-feature-store.spec.ts new file mode 100644 index 0000000000000..19a8b20ea2416 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/ngrx-feature-store.spec.ts @@ -0,0 +1,521 @@ +import type { Tree } from '@nx/devkit'; +import { readJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import libraryGenerator from '../library/library'; +import ngrxFeatureStoreGenerator from './ngrx-feature-store'; +import { ngrxVersion } from '../../utils/versions'; + +describe('ngrx-feature-store', () => { + describe('NgModule', () => { + const parent = 'feature-module/src/lib/feature-module.module.ts'; + it('should error when parent cannot be found', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + // ACT && ASSERT + await expect( + ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: true, + directory: '+state', + parent, + }) + ).rejects.toThrowError( + `Parent does not exist: feature-module/src/lib/feature-module.module.ts.` + ); + }); + + it('should update package.json', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addNgModuleLib(tree); + + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: true, + directory: '+state', + parent, + }); + + // 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 addNgModuleLib(tree); + + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: true, + directory: '+state', + skipPackageJson: true, + parent, + }); + + // 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(); + }); + + it('should generate files without a facade by default', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addNgModuleLib(tree); + const statePath = 'feature-module/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + directory: '+state', + parent, + }); + + // ASSERT + expect(tree.exists(`${statePath}/users.facade.ts`)).not.toBeTruthy(); + expect(tree.exists(`${statePath}/users.facade.spec.ts`)).not.toBeTruthy(); + expect(tree.exists(`${statePath}/users.actions.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.models.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.spec.ts`)).toBeTruthy(); + }); + + it('should generate files with a facade when --facade=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addNgModuleLib(tree); + const statePath = 'feature-module/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + directory: '+state', + facade: true, + parent, + }); + + // ASSERT + expect(tree.exists(`${statePath}/users.facade.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.facade.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.actions.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.models.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.spec.ts`)).toBeTruthy(); + }); + + it('should generate files in a custom directory when --directory=custom', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addNgModuleLib(tree); + const statePath = 'feature-module/src/lib/custom'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + directory: 'custom', + facade: true, + parent, + }); + + // ASSERT + expect(tree.exists(`${statePath}/users.facade.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.facade.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.actions.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.models.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.spec.ts`)).toBeTruthy(); + }); + + it('should generate the files with the correct content', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addNgModuleLib(tree); + const statePath = 'feature-module/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + directory: '+state', + facade: true, + parent, + }); + + // ASSERT + expect(tree.read(parent, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.facade.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.facade.spec.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.actions.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.effects.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.effects.spec.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.models.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.reducer.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.reducer.spec.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.selectors.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.selectors.spec.ts`, 'utf-8') + ).toMatchSnapshot(); + }); + + it('should have the correct entry point when --barrels=false', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addNgModuleLib(tree); + const statePath = 'feature-module/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + directory: '+state', + facade: true, + parent, + }); + + // ASSERT + expect( + tree.read(`feature-module/src/index.ts`, 'utf-8') + ).toMatchSnapshot(); + }); + + it('should have the correct entry point when --barrels=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addNgModuleLib(tree); + const statePath = 'feature-module/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + directory: '+state', + facade: true, + parent, + barrels: true, + }); + + // ASSERT + expect( + tree.read(`feature-module/src/index.ts`, 'utf-8') + ).toMatchSnapshot(); + }); + }); + + describe('Standalone APIs', () => { + const parent = 'feature/src/lib/lib.routes.ts'; + it('should error when parent cannot be found', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + + // ACT && ASSERT + await expect( + ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: true, + directory: '+state', + parent, + }) + ).rejects.toThrowError( + `Parent does not exist: feature/src/lib/lib.routes.ts` + ); + }); + + it('should update package.json', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addStandaloneLib(tree); + + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: true, + parent, + directory: '+state', + }); + + // 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 addStandaloneLib(tree); + + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: true, + parent, + directory: '+state', + 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(); + }); + + it('should generate files without a facade by default', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addStandaloneLib(tree); + const statePath = 'feature/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + parent, + directory: '+state', + }); + + // ASSERT + expect(tree.exists(`${statePath}/users.facade.ts`)).not.toBeTruthy(); + expect(tree.exists(`${statePath}/users.facade.spec.ts`)).not.toBeTruthy(); + expect(tree.exists(`${statePath}/users.actions.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.models.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.spec.ts`)).toBeTruthy(); + }); + + it('should generate files with a facade when --facade=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addStandaloneLib(tree); + const statePath = 'feature/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + parent, + directory: '+state', + facade: true, + }); + + // ASSERT + expect(tree.exists(`${statePath}/users.facade.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.facade.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.actions.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.models.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.spec.ts`)).toBeTruthy(); + }); + + it('should generate files in a custom directory when --directory=custom', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addStandaloneLib(tree); + const statePath = 'feature/src/lib/custom'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + parent, + directory: 'custom', + facade: true, + }); + + // ASSERT + expect(tree.exists(`${statePath}/users.facade.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.facade.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.actions.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.effects.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.models.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.reducer.spec.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.ts`)).toBeTruthy(); + expect(tree.exists(`${statePath}/users.selectors.spec.ts`)).toBeTruthy(); + }); + + it('should generate the files with the correct content', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addStandaloneLib(tree); + const statePath = 'feature/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + parent, + directory: '+state', + facade: true, + }); + + // ASSERT + expect( + tree.read(`${statePath}/users.facade.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.facade.spec.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.actions.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.effects.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.effects.spec.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.models.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.reducer.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.reducer.spec.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.selectors.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`${statePath}/users.selectors.spec.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`feature/src/lib/lib.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + }); + + it('should have the correct entry point when --barrels=false', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addStandaloneLib(tree); + const statePath = 'feature/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + parent, + directory: '+state', + facade: true, + }); + + // ASSERT + expect(tree.read(`feature/src/index.ts`, 'utf-8')).toMatchSnapshot(); + }); + + it('should have the correct entry point when --barrels=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace(); + await addStandaloneLib(tree); + const statePath = 'feature/src/lib/+state'; + // ACT + await ngrxFeatureStoreGenerator(tree, { + name: 'users', + minimal: false, + parent, + directory: '+state', + facade: true, + barrels: true, + }); + + // ASSERT + expect(tree.read(`feature/src/index.ts`, 'utf-8')).toMatchSnapshot(); + }); + }); +}); + +async function addNgModuleLib(tree: Tree, name = 'feature-module') { + await libraryGenerator(tree, { + name, + standalone: false, + }); +} + +async function addStandaloneLib(tree: Tree, name = 'feature') { + await libraryGenerator(tree, { + name, + standalone: true, + routing: true, + }); +} diff --git a/packages/angular/src/generators/ngrx-feature-store/ngrx-feature-store.ts b/packages/angular/src/generators/ngrx-feature-store/ngrx-feature-store.ts new file mode 100644 index 0000000000000..563529bea7de3 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/ngrx-feature-store.ts @@ -0,0 +1,38 @@ +import type { Tree } from '@nx/devkit'; +import { formatFiles, GeneratorCallback } from '@nx/devkit'; +import type { Schema } from './schema'; +import { + addExportsToBarrel, + addImportsToModule, + addNgRxToPackageJson, + generateFilesFromTemplates, + normalizeOptions, + validateOptions, +} from './lib'; + +export async function ngrxFeatureStoreGenerator(tree: Tree, schema: Schema) { + validateOptions(tree, schema); + const options = normalizeOptions(tree, schema); + + if (!options.minimal) { + generateFilesFromTemplates(tree, options); + } + + if (!options.skipImport) { + addImportsToModule(tree, options); + addExportsToBarrel(tree, options); + } + + let packageInstallationTask: GeneratorCallback = () => {}; + if (!options.skipPackageJson) { + packageInstallationTask = addNgRxToPackageJson(tree, options); + } + + if (!options.skipFormat) { + await formatFiles(tree); + } + + return packageInstallationTask; +} + +export default ngrxFeatureStoreGenerator; diff --git a/packages/angular/src/generators/ngrx-feature-store/schema.d.ts b/packages/angular/src/generators/ngrx-feature-store/schema.d.ts new file mode 100644 index 0000000000000..c12e8c814b347 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/schema.d.ts @@ -0,0 +1,12 @@ +export interface Schema { + name: string; + minimal: boolean; + parent: string; + directory?: string; + route?: string; + barrels?: boolean; + facade?: boolean; + skipFormat?: boolean; + skipImport?: boolean; + skipPackageJson?: boolean; +} diff --git a/packages/angular/src/generators/ngrx-feature-store/schema.json b/packages/angular/src/generators/ngrx-feature-store/schema.json new file mode 100644 index 0000000000000..70915740f7c91 --- /dev/null +++ b/packages/angular/src/generators/ngrx-feature-store/schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NxNgrxFeatureStoreGenerator", + "title": "NgRx Feature Store Generator", + "description": "Add an NgRx Feature Store to an application or library.", + "cli": "nx", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the NgRx feature state, such as `products` or `users`. Recommended to use the plural form of the name.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the NgRx feature state? An example would be `users`.", + "x-priority": "important" + }, + "parent": { + "type": "string", + "description": "The path to the file where the state will be registered. For NgModule usage, this will be your Feature Module. For Standalone API usage, this will be your Routes definition file for your feature state. The host directory will create/use the new state directory. _Note: The Standalone API usage is only supported in Angular versions >= 14.1.0_.", + "x-prompt": "What is the path to the module or Routes definition where this NgRx state should be registered?", + "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": "''" + }, + "minimal": { + "type": "boolean", + "default": false, + "description": "Only register the feature state.", + "x-priority": "important" + }, + "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" + }, + "barrels": { + "type": "boolean", + "default": false, + "description": "Use barrels to re-export actions, state and selectors." + } + }, + "additionalProperties": false, + "required": ["name"] +}