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.
6. Add the component
-
ChangeDetectionStrategy.OnPush -
extend
Subscriberso 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());
}
}
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/errorflags if any guard or widget must wait, -
selectors are the only read surface,
-
export a
reducerfunction wrapper aroundcreateReducerfor AOT.
A full scaffold — action types, actions, reducer, selectors, effects and the registration module:
// 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);
// 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);
// 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 };
// 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) {}
}
// 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 {}
See NgRX Overview.
A new permission
-
Append to
PermissionsEnum— never insert in the middle; values are positional. -
Add it to the tree in
permissions.const.ts, nested if it has a parent. -
Grant it from
getDefaultPermissions()orcalculatePermissionsFromUserState(). -
Use the full dotted name in guards,
*ngxPermissionsOnlyandMenuConfig. -
Add a case to
permissions.service.spec.ts.
|
A permission absent from |
See Permissions System.
A new analytics event
-
Add the member to the right enum in
event-types.enum.ts, matching that enum’s naming style. -
Inject
EventLoggerServiceand calltrack()with the enum member. -
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
-
Add the member to
DocusignFlowEnum. -
Add
initialize<Name>Flow()following the closest existing pattern — advance-style flows requireevent === SingingCompletebefore acting; contract flows run the handler on any event and branch inside. -
Add
handle<Name>()with the API confirmation and the business action. -
Add the
casetoinitializeFlow(). -
Build the return URL with
generateRedirectUrl(), targetingdashboardorproduct-offerings— the only routes carryingDocuSignGuard. -
Always leave via
redirectToDocusign()so the replay flag is set. -
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.
-
Add
FlowTaskimplementations undercore/pyb/tasks/<domain>/and register them inTasksModule. -
Add the flow under
core/pyb/flows/<domain>/and register it inFlowsModule. -
Seed the shared context with
setContextData(). -
Map
Done→runNext(),Cancel→runPrevious(),Terminated→ stop.
See Flows & Tasks Engine.
A new marketplace connector
-
Create
core/api-connectors/<marketplace>/. -
Extend
AbstractConnectorApiwithbannerSuccessKey,bannerFailKey,bannerComponent,modalComponent,storeKeyandhandleModalResult(). -
Add a banner implementing
AbstractConnectorBannerand a modal implementingAbstractConnectorModal. -
Add
MODALS.AP_<NAME>.SUCCESS/.FAILtranslation keys. -
Add a permission and grant it.
-
Initialize the connector in
ApiConnectorsService.init()behind that permission.
A new dashboard notification
-
Add the member to
DashboardNotificationTypewith the API’s string value. -
Add the field to
DashboardNotificationsand map it infromDto(). -
Choose the surface:
- CTA card
-
Add a strategy under
content/cta/cta-factory/cta-strategy/and acaseinCtaFactory.getCtaConfig(). - Auto-opened modal
-
Add a
subscribeTo…()inGlobalSubscriptionsService.init()withtake(1).
-
If the notification is accepted, add an entry to
phraseKeyByTypeindashboard-notifications.effects.ts— otherwise it silently shows the Pre Instant Advance success copy.
A new report
-
Create
views/pages/reports/<report>-page/. -
Extend
ReportBase; implementisDesktopReportVisible(),pageSize,pageIndexandfilterConfiguration, plusexportCSV()/applyFilter()/clearFilter()if the report offers them — the base throwsNotImplementedErrorotherwise. -
Define the column set in a
*.config.tsand provide it through a DI token fromReportsModule. -
Add the route to
reports-routing.module.tsand the string toURLS_ROUTER.REPORTS. -
Add the menu entry with the right permission.
-
Provide a mobile layout via
ReportsMobileContentRowComponent.
See Reports.
A new environment setting
-
Add it to
config/environment.template. -
Add it to every profile in
config/config.js(prod,dynamic,relative). -
Add it to
EnvironmentServiceinsrc/environments/environment.service.ts. -
Regenerate with
npm run pre-build:proxy.
|
Never edit |
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
-
OnPushchange detection -
Subscriptions cleaned up via
Subscriber+takeUntil -
Path aliases, no cross-module relative imports
-
Reducers pure —
storeFreezewill catch mutations in dev -
Specs for the branching logic
-
npm run lintandnpm testclean -
userStatusUpdateRequested()dispatched if eligibility can change -
Documentation updated in this component, and the page added to
nav.adoc