Adding a Feature

A new lazy-loaded page

1. Create the module

src/app/views/pages/my-feature/
├── my-feature.module.ts
├── my-feature.component.ts / .html / .scss
├── my-feature.service.ts        (provided by the module)
└── my-feature.config.ts         (constants, route strings)
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { MaterialModule } from '@pyb-core/material/material.module';
import { PartialsModule } from '@pyb-views/partials/partials.module';

import { MyFeatureComponent } from './my-feature.component';
import { MyFeatureService } from './my-feature.service';

const routes: Routes = [{ path: '', component: MyFeatureComponent }];

@NgModule({
    declarations: [MyFeatureComponent],
    imports: [
        CommonModule,
        RouterModule.forChild(routes),
        TranslateModule.forChild(),
        MaterialModule,
        PartialsModule,
    ],
    providers: [MyFeatureService],
})
export class MyFeatureModule {}

2. Register the route string

Add it to URLS_ROUTER in src/app/core/_config/urls-route.const.ts — never hardcode a URL elsewhere.

MY_FEATURE: { ROOT: 'my-feature' },

3. Add the route

In app-routing.module.ts, as a child of the BaseComponent shell:

{
    path: 'my-feature',
    loadChildren: () => import('../app/views/pages/my-feature/my-feature.module').then(m => m.MyFeatureModule),
    canActivate: [/* guards */],
}

4. Add the menu entry

In MenuConfig (core/_config/menu.config.ts), with page from URLS_ROUTER, a translate key and a permission if gated.

5. Add translations

Add a namespace to src/assets/i18n/en.ts and the MENU.* label.

6. Add the component

  • ChangeDetectionStrategy.OnPush

  • extend Subscriber so subscriptions are torn down for you

  • set the page title via SubheaderService

  • select from the store; dispatch a load action in ngOnInit

import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { select, Store } from '@ngrx/store';
import { AppState } from '@pyb-core/reducers';
import { Subscriber } from '@pyb-core/pyb/utils/subscriber.class';
import { SubheaderService } from '@pyb-core/_base/layout';

import { loadMyFeature } from '@pyb-core/pyb/features/my-feature/my-feature.actions';
import { selectMyFeatureData, selectMyFeatureLoading } from '@pyb-core/pyb/features/my-feature/my-feature.selectors';

@Component({
    selector: 'pyb-my-feature',
    templateUrl: './my-feature.component.html',
    styleUrls: ['./my-feature.component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MyFeatureComponent extends Subscriber implements OnInit {
    readonly translateNamespace = 'MY_FEATURE.';

    readonly data$ = this.store.pipe(select(selectMyFeatureData));
    readonly loading$ = this.store.pipe(select(selectMyFeatureLoading));

    constructor(
        private readonly store: Store<AppState>,
        private readonly subheader: SubheaderService,
    ) {
        super();
    }

    ngOnInit(): void {
        this.subheader.setTitle(`${this.translateNamespace}TITLE`);
        this.store.dispatch(loadMyFeature());
    }
}

7. Add specs

At minimum a component spec and a service spec. Use the shared mocks from @pyb-tests/*.

A new NgRX slice

src/app/core/pyb/features/my-feature/
├── my-feature.actions.ts
├── my-feature.reducer.ts     featureKey, State, initialState, createReducer + reducer wrapper
├── my-feature.selectors.ts
├── my-feature.effects.ts
├── my-feature.module.ts      StoreModule.forFeature + EffectsModule.forFeature
└── index.ts

Then register it in FeaturesModule — in both imports and exports.

Conventions:

  • actions named [MyFeature] Description,

  • loading / loaded / error flags if any guard or widget must wait,

  • selectors are the only read surface,

  • export a reducer function wrapper around createReducer for AOT.

A full scaffold — action types, actions, reducer, selectors, effects and the registration module:

Action types and actions
// my-feature.enums.ts
export enum MyFeatureActionTypes {
    LoadMyFeature = '[MyFeature] Load',
    LoadMyFeatureSuccess = '[MyFeature] Load Success',
    LoadMyFeatureFailed = '[MyFeature] Load Failed',
}

// my-feature.actions.ts
import { createAction, props } from '@ngrx/store';
import { MyFeatureActionTypes } from './my-feature.enums';
import { MyFeatureData } from './my-feature.interfaces';

export const loadMyFeature = createAction(MyFeatureActionTypes.LoadMyFeature);

export const loadMyFeatureSuccess = createAction(
    MyFeatureActionTypes.LoadMyFeatureSuccess,
    props<{ data: MyFeatureData }>(),
);

export const loadMyFeatureFailed = createAction(MyFeatureActionTypes.LoadMyFeatureFailed);
Reducer
// my-feature.reducer.ts
import { Action, createReducer, on } from '@ngrx/store';
import * as MyFeatureActions from './my-feature.actions';

export const myFeatureFeatureKey = 'myFeature';

export interface State {
    data: MyFeatureData | null;
    isLoading: boolean;
    hasError: boolean;
}

export const initialState: State = {
    data: null,
    isLoading: false,
    hasError: false,
};

const myFeatureReducer = createReducer(
    initialState,
    on(MyFeatureActions.loadMyFeature, state => ({ ...state, isLoading: true, hasError: false })),
    on(MyFeatureActions.loadMyFeatureSuccess, (state, { data }) => ({ ...state, isLoading: false, data })),
    on(MyFeatureActions.loadMyFeatureFailed, state => ({ ...state, isLoading: false, hasError: true })),
);

// The wrapper function is required: AOT cannot resolve `createReducer`'s
// return value directly in a `StoreModule.forFeature()` call.
export const reducer = (state: State | undefined, action: Action) => myFeatureReducer(state, action);
Selectors
// my-feature.selectors.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import * as fromMyFeature from './my-feature.reducer';

const selectMyFeatureState = createFeatureSelector<fromMyFeature.State>(fromMyFeature.myFeatureFeatureKey);

const selectMyFeatureData = createSelector(selectMyFeatureState, state => state.data);
const selectMyFeatureLoading = createSelector(selectMyFeatureState, state => state.isLoading);
const selectMyFeatureError = createSelector(selectMyFeatureState, state => state.hasError);

export { selectMyFeatureState, selectMyFeatureData, selectMyFeatureLoading, selectMyFeatureError };
Effects
// my-feature.effects.ts
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, switchMap } from 'rxjs/operators';
import { of } from 'rxjs';
import { MyFeatureApiService } from '@payability/pyb-ng2-api';
import * as MyFeatureActions from './my-feature.actions';

@Injectable()
export class MyFeatureEffects {
    load$ = createEffect(() =>
        this.actions$.pipe(
            ofType(MyFeatureActions.loadMyFeature),
            switchMap(() =>
                this.api.get().pipe(
                    map(data => MyFeatureActions.loadMyFeatureSuccess({ data })),
                    catchError(() => of(MyFeatureActions.loadMyFeatureFailed())),
                ),
            ),
        ),
    );

    constructor(private readonly actions$: Actions, private readonly api: MyFeatureApiService) {}
}
Registration module
// my-feature.module.ts
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { MyFeatureEffects } from './my-feature.effects';
import * as fromMyFeatureReducer from './my-feature.reducer';

@NgModule({
    imports: [
        EffectsModule.forFeature([MyFeatureEffects]),
        StoreModule.forFeature(fromMyFeatureReducer.myFeatureFeatureKey, fromMyFeatureReducer.reducer),
    ],
})
export class MyFeatureFeatureModule {}

A new permission

  1. Append to PermissionsEnum — never insert in the middle; values are positional.

  2. Add it to the tree in permissions.const.ts, nested if it has a parent.

  3. Grant it from getDefaultPermissions() or calculatePermissionsFromUserState().

  4. Use the full dotted name in guards, *ngxPermissionsOnly and MenuConfig.

  5. Add a case to permissions.service.spec.ts.

A permission absent from permissions.const.ts never reaches NgxPermissionsService, so guards and directives cannot see it — even though the store has it. BETA is the existing example of this.

A new analytics event

  1. Add the member to the right enum in event-types.enum.ts, matching that enum’s naming style.

  2. Inject EventLoggerService and call track() with the enum member.

  3. Add it to Event Catalog.

Segment event values are effectively permanent — renaming one breaks every downstream dashboard. Add a new member instead of editing an existing value.

A new DocuSign flow

  1. Add the member to DocusignFlowEnum.

  2. Add initialize<Name>Flow() following the closest existing pattern — advance-style flows require event === SingingComplete before acting; contract flows run the handler on any event and branch inside.

  3. Add handle<Name>() with the API confirmation and the business action.

  4. Add the case to initializeFlow().

  5. Build the return URL with generateRedirectUrl(), targeting dashboard or product-offerings — the only routes carrying DocuSignGuard.

  6. Always leave via redirectToDocusign() so the replay flag is set.

  7. End with userStatusUpdateRequested() if eligibility could change.

A new multi-step flow

Use the tasks engine when there are three or more steps, backward navigation, state-dependent step order, or an external redirect. Otherwise open a dialog directly.

  1. Add FlowTask implementations under core/pyb/tasks/<domain>/ and register them in TasksModule.

  2. Add the flow under core/pyb/flows/<domain>/ and register it in FlowsModule.

  3. Seed the shared context with setContextData().

  4. Map Done → runNext(), Cancel → runPrevious(), Terminated → stop.

A new marketplace connector

  1. Create core/api-connectors/<marketplace>/.

  2. Extend AbstractConnectorApi with bannerSuccessKey, bannerFailKey, bannerComponent, modalComponent, storeKey and handleModalResult().

  3. Add a banner implementing AbstractConnectorBanner and a modal implementing AbstractConnectorModal.

  4. Add MODALS.AP_<NAME>.SUCCESS / .FAIL translation keys.

  5. Add a permission and grant it.

  6. Initialize the connector in ApiConnectorsService.init() behind that permission.

A new dashboard notification

  1. Add the member to DashboardNotificationType with the API’s string value.

  2. Add the field to DashboardNotifications and map it in fromDto().

  3. Choose the surface:

    CTA card

    Add a strategy under content/cta/cta-factory/cta-strategy/ and a case in CtaFactory.getCtaConfig().

    Auto-opened modal

    Add a subscribeTo…() in GlobalSubscriptionsService.init() with take(1).

  4. If the notification is accepted, add an entry to phraseKeyByType in dashboard-notifications.effects.ts — otherwise it silently shows the Pre Instant Advance success copy.

A new report

  1. Create views/pages/reports/<report>-page/.

  2. Extend ReportBase; implement isDesktopReportVisible(), pageSize, pageIndex and filterConfiguration, plus exportCSV() / applyFilter() / clearFilter() if the report offers them — the base throws NotImplementedError otherwise.

  3. Define the column set in a *.config.ts and provide it through a DI token from ReportsModule.

  4. Add the route to reports-routing.module.ts and the string to URLS_ROUTER.REPORTS.

  5. Add the menu entry with the right permission.

  6. Provide a mobile layout via ReportsMobileContentRowComponent.

See Reports.

A new environment setting

  1. Add it to config/environment.template.

  2. Add it to every profile in config/config.js (prod, dynamic, relative).

  3. Add it to EnvironmentService in src/environments/environment.service.ts.

  4. Regenerate with npm run pre-build:proxy.

Never edit src/environments/environment.ts. It is generated by config/pre-build.js from config/environment.template and overwritten on every build. A new setting has to be added in four places: config/environment.template, every profile in config/config.js (prod, dynamic, relative), EnvironmentService, and then regenerated with npm run pre-build:proxy.

Pre-merge checklist

  • Route string in URLS_ROUTER, not hardcoded

  • Menu entry with the correct permission

  • All copy in en.ts, including any computed keys

  • Segment event for every user-facing action

  • OnPush change detection

  • Subscriptions cleaned up via Subscriber + takeUntil

  • Path aliases, no cross-module relative imports

  • Reducers pure — storeFreeze will catch mutations in dev

  • Specs for the branching logic

  • npm run lint and npm test clean

  • userStatusUpdateRequested() dispatched if eligibility can change

  • Documentation updated in this component, and the page added to nav.adoc