npm/ng-packs/guides/DEVELOPMENT_GUIDE.md
This guide provides comprehensive instructions for developing ABP modules following the established patterns and conventions. The guide is based on the SaaS module structure and can be applied to any ABP module development.
package-name/
├── package.json # Package metadata and dependencies
├── ng-package.json # ng-packagr configuration
├── project.json # Nx workspace configuration
├── tsconfig.json # TypeScript root config
├── tsconfig.lib.json # Library-specific TS config
├── tsconfig.lib.prod.json # Production build config
├── tsconfig.spec.json # Test configuration
├── jest.config.ts # Jest test configuration
├── tslint.json # (optional) Linting rules
├── README.md # Package documentation
│
├── src/ # Main library source code
│ ├── lib/ # Core library implementation
│ │ ├── components/ # Angular components
│ │ ├── services/ # Business logic services
│ │ ├── models/ # TypeScript interfaces/models
│ │ ├── enums/ # Enumerations
│ │ ├── guards/ # Route guards
│ │ ├── resolvers/ # Route resolvers
│ │ ├── defaults/ # Default configurations
│ │ ├── tokens/ # Dependency injection tokens
│ │ ├── utils/ # Utility functions
│ │ ├── validators/ # Form validators
│ │ ├── [feature].routes.ts
│ └── public-api.ts # Public exports barrel file
│
├── config/ # Configuration sub-package (optional)
│ ├── ng-package.json
│ └── src/
│ ├── components/ # Config-specific components
│ ├── providers/ # Route/setting providers
│ ├── services/ # Config services
│ ├── models/ # Config models
│ ├── enums/ # Config enums
│ └── public-api.ts
│
├── proxy/ # API proxy sub-package (optional)
│ ├── ng-package.json
│ └── src/
│ ├── lib/
│ │ └── proxy/
│ │ ├── [feature]/ # Generated proxy services
│ │ ├── generate-proxy.json
│ │ └── README.md
│ └── public-api.ts
│
├── common/ # Common/shared sub-package (optional)
│ ├── ng-package.json
│ └── src/
│ ├── enums/
│ ├── tokens/
│ └── public-api.ts
│
└── admin/ # Admin-specific sub-package (optional)
├── ng-package.json
└── src/
└── ...
my-component.component.tsMyComponentmyVariable, myMethod()MY_CONSTANTexport interface MyConfigOptions {
entityActionContributors?: MyEntityActionContributors;
toolbarActionContributors?: MyToolbarActionContributors;
entityPropContributors?: MyEntityPropContributors;
createFormPropContributors?: MyCreateFormPropContributors;
editFormPropContributors?: MyEditFormPropContributors;
}
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ListService } from '@abp/ng.core';
import { MyService } from '../services';
@Component({
selector: 'app-my-component',
templateUrl: './my-component.component.html',
providers: [
ListService,
{
provide: EXTENSIONS_IDENTIFIER,
useValue: eMyComponents.MyComponent,
},
],
imports: [],
})
export class MyComponent implements OnInit, OnDestroy {
// Properties
data = this.list.getGrid();
isModalVisible = false;
public readonly list = inject(ListService);
private myService = inject(MyService);
ngOnInit() {
this.hookToQuery();
}
ngOnDestroy() {
this.list.hookToQuery = () => {};
}
// Methods
onEdit(id: string) {
// Implementation
}
onDelete(id: string) {
// Implementation
}
private hookToQuery() {
this.list
.hookToQuery(query => this.myService.getList({ ...query, ...this.filters }))
.subscribe(res => (this.data = res));
}
}
Example HTML template
<abp-page [title]="'AbpLocalizationKey::SubKey' | abpLocalization" [toolbar]="data.items">
<div>
<div class="mt-2 mt-sm-0">
<abp-advanced-entity-filters [list]="list" localizationSourceName="AbpLocalizationKey">
<abp-advanced-entity-filters-form>
<form #filterForm (keyup.enter)="list.get()">
<!-- ... -->
</form>
</abp-advanced-entity-filters-form>
</abp-advanced-entity-filters>
</div>
<div class="card">
<abp-extensible-table [data]="data.items" [recordsTotal]="data.totalCount" [list]="list" />
</div>
</div>
</abp-page>
<abp-modal [(visible)]="isModalVisible" [busy]="modalBusy" (disappear)="form = null">
<ng-template #abpHeader>
<h3>
@if (selected?.id) { {{ 'AbpLocalizationKey::Edit' | abpLocalization }} @if
(selected.userName) { - {{ selected.userName }} } } @else { {{ 'AbpLocalizationKey::New' |
abpLocalization }} }
</h3>
</ng-template>
<ng-template #abpBody>
@if (form) {
<form [formGroup]="form" id="myForm" (ngSubmit)="save()" validateOnSubmit>
<a ngbNavLink>{{ 'AbpLocalizationKey::MyInfo' | abpLocalization }}</a>
<ng-template ngbNavContent>
<div class="row">
<abp-extensible-form class="row gap-x2" [selectedRecord]="selected" />
</div>
</ng-template>
</form>
}
</ng-template>
<ng-template #abpFooter>
<button type="button" class="btn btn-outline-primary" abpClose>
{{ 'AbpUi::Cancel' | abpLocalization }}
</button>
<abp-button iconClass="fa fa-check" buttonType="submit" formName="myForm">
{{ 'AbpUi::Save' | abpLocalization }}
</abp-button>
</ng-template>
</abp-modal>
OnInit and OnDestroy when neededOnPush change detection when possibleimport { Injectable } from '@angular/core';
import { RestService } from '@abp/ng.core';
import { MyDto } from '../models';
@Injectable({
providedIn: 'root',
})
export class MyService extends RestService {
protected get url() {
return 'api/my-endpoint';
}
getList(query: any) {
return this.request<MyDto[]>({
method: 'GET',
params: query,
});
}
getById(id: string) {
return this.request<MyDto>({
method: 'GET',
url: `${this.url}/${id}`,
});
}
create(input: Partial<MyDto>) {
return this.request<MyDto>({
method: 'POST',
body: input,
});
}
update(id: string, input: Partial<MyDto>) {
return this.request<MyDto>({
method: 'PUT',
url: `${this.url}/${id}`,
body: input,
});
}
delete(id: string) {
return this.request<void>({
method: 'DELETE',
url: `${this.url}/${id}`,
});
}
}
import { Routes } from '@angular/router';
import { Provider } from '@angular/core';
import {
RouterOutletComponent,
authGuard,
permissionGuard,
ReplaceableRouteContainerComponent,
ReplaceableComponents,
} from '@abp/ng.core';
export function createRoutes(config: MyConfigOptions = {}): Routes {
return [
{ path: '', redirectTo: 'my-feature', pathMatch: 'full' },
{
path: '',
component: RouterOutletComponent,
providers: provideMyContributors(config),
canActivate: [authGuard, permissionGuard],
children: [
{
path: 'my-feature',
component: ReplaceableRouteContainerComponent,
data: {
requiredPolicy: 'My.Feature',
replaceableComponent: {
key: eMyComponents.MyFeature,
defaultComponent: MyFeatureComponent,
} as ReplaceableComponents.RouteData<MyFeatureComponent>,
},
title: 'My::Feature',
},
],
},
];
}
function provideMyContributors(options: MyConfigOptions = {}): Provider[] {
return [
// ... providers
];
}
// Using ABP's ConfigStateService
import { ConfigStateService } from '@abp/ng.core';
export class MyComponent {
private configState = inject(ConfigStateService);
getSettings() {
return this.configState.getSetting('My.Setting');
}
}
// Using reactive forms
import { FormBuilder, FormGroup } from '@angular/forms';
export class MyComponent {
form: FormGroup;
private fb = inject(FormBuilder);
constructor() {
this.form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
}
}
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MyService } from '../services';
@Component({
selector: 'app-my-form',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" />
<input formControlName="email" />
<button type="submit">Submit</button>
</form>
`,
imports: [],
})
export class MyFormComponent {
form: FormGroup;
private fb = inject(FormBuilder);
private myService = inject(MyService);
constructor() {
this.form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
}
onSubmit() {
if (this.form.valid) {
this.myService.create(this.form.value).subscribe(
result => {
// Handle success
},
error => {
// Handle error
},
);
}
}
}
import { Provider } from '@angular/core';
import { AsyncValidatorFn, FormGroup } from '@angular/forms';
import { of } from 'rxjs';
export const MY_VALIDATOR_PROVIDER: Provider = {
provide: MY_FORM_ASYNC_VALIDATORS_TOKEN,
multi: true,
useFactory: myCustomValidator,
};
export function myCustomValidator(): AsyncValidatorFn {
return (group: FormGroup) => {
// Validation logic
const field1 = group?.get('field1');
const field2 = group?.get('field2');
if (!field1 || !field2) {
return of(null);
}
if (field1.value && !field2.value) {
field2.setErrors({ required: true });
}
return of(null);
};
}
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';
import { MyService } from '../services';
describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;
let myService: jasmine.SpyObj<MyService>;
beforeEach(async () => {
const spy = jasmine.createSpyObj('MyService', ['getList']);
await TestBed.configureTestingModule({
declarations: [MyComponent],
providers: [{ provide: MyService, useValue: spy }],
}).compileComponents();
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
myService = TestBed.inject(MyService) as jasmine.SpyObj<MyService>;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should load data on init', () => {
// Test implementation
});
});
// Define extension tokens
export const MY_ENTITY_ACTION_CONTRIBUTORS = new InjectionToken<EntityActionContributors>(
'MY_ENTITY_ACTION_CONTRIBUTORS'
);
// Provide default implementations
export const DEFAULT_MY_ENTITY_ACTIONS = {
[eMyComponents.MyFeature]: DEFAULT_MY_FEATURE_ACTIONS,
};
// Use in module
{
provide: MY_ENTITY_ACTION_CONTRIBUTORS,
useValue: options.entityActionContributors || DEFAULT_MY_ENTITY_ACTIONS,
}
@Injectable({
providedIn: 'root',
})
export class MyModalService {
private modalRef: NgbModalRef;
private modalService = inject(NgbModal);
show(data?: any): NgbModalRef {
this.modalRef = this.modalService.open(MyModalComponent, {
size: 'lg',
backdrop: 'static',
});
if (data) {
this.modalRef.componentInstance.data = data;
}
return this.modalRef;
}
close() {
if (this.modalRef) {
this.modalRef.close();
}
}
}
export class MyListComponent {
data = this.list.getGrid();
readonly list = inject(ListService);
ngOnInit() {
this.hookToQuery();
}
private hookToQuery() {
this.list
.hookToQuery(query => this.pageService.getList({ ...query, ...this.filters }))
.subscribe(res => (this.data = res));
}
}
This guide provides a comprehensive overview of ABP module development patterns and best practices. Follow these guidelines to create maintainable, extensible, and performant modules that integrate seamlessly with the ABP framework.
Remember to:
For more information, refer to the official ABP documentation and community resources.