Frontend - masaku-portal¶
Wprowadzenie¶
Frontend systemu Masaku to aplikacja Angular 14 zbudowana jako Nx monorepo z ponad 32 bibliotekami.
Kluczowe technologie¶
| Technologia | Wersja | Zastosowanie |
|---|---|---|
| Angular | 14.x | Framework aplikacji |
| Nx | 15.8.6 | Monorepo management |
| NGXS | 3.8.1 | State management |
| Angular Material | 14.x | UI components |
| Transloco | 4.2.6 | Internationalization |
| MSAL Angular | 2.5.4 | Azure AD authentication |
| immer | - | Immutable state |
| zod | - | Validation |
| dayjs | - | Date manipulation |
| chart.js | - | Charts |
| Jest | - | Testing |
Struktura projektu¶
masaku-portal/
├── apps/
│ └── masaku/ # Główna aplikacja
│ ├── src/
│ │ ├── app/
│ │ │ ├── app.component.ts
│ │ │ ├── app.module.ts
│ │ │ └── app-routing.module.ts
│ │ ├── environments/
│ │ │ ├── de/ # Niemcy
│ │ │ └── at/ # Austria
│ │ ├── assets/
│ │ ├── index.html
│ │ └── main.ts
│ ├── project.json # Nx configuration
│ └── tsconfig.app.json
│
└── projects/ # Biblioteki (32+)
├── common/ # Współdzielone utilities
├── types/ # TypeScript interfaces
├── helpers/ # Helper functions
├── utils/ # Utilities
├── validators/ # Validation logic
├── environment/ # Environment config
├── storage/ # Browser storage
├── budgets/ # Budżety
├── clients/ # Klienci
├── employments/ # Zatrudnienie
├── expenses/ # Wydatki
├── invoices/ # Faktury
├── receipts/ # Paragony
├── orders/ # Zamówienia
├── members/ # Członkowie
├── userships/ # Relacje użytkowników
├── community/ # Społeczność
├── governance/ # Governance
├── months/ # Miesiące
├── cp/ # Client Portal
├── mp/ # Member Portal
├── op/ # Office Portal
├── ucp/ # Unified Client Portal
├── ui/ # UI components
├── i18n/ # Internationalization
├── micro-loader/ # Micro-frontend loader
├── entry/ # Entry points
├── store/ # NGXS state (settings, user)
├── options/ # Options state
├── destroy/ # Cleanup utilities
├── settings/ # Settings
└── list/ # List components
Struktura biblioteki¶
Każda biblioteka w projects/ ma spójną strukturę:
projects/<library-name>/
├── src/
│ ├── lib/
│ │ ├── components/ # Komponenty Angular
│ │ │ ├── component-name/
│ │ │ │ ├── component-name.component.ts
│ │ │ │ ├── component-name.component.html
│ │ │ │ ├── component-name.component.scss
│ │ │ │ └── component-name.component.spec.ts
│ │ │
│ │ ├── services/ # Serwisy
│ │ │ ├── service-name.service.ts
│ │ │ └── service-name.service.spec.ts
│ │ │
│ │ ├── state/ lub store/ # NGXS state management
│ │ │ ├── <name>.state.ts
│ │ │ ├── <name>.actions.ts
│ │ │ └── <name>.selectors.ts
│ │ │
│ │ ├── models/ # TypeScript interfaces/types
│ │ │ └── model-name.model.ts
│ │ │
│ │ ├── constants/ # Stałe
│ │ │ └── constants.ts
│ │ │
│ │ ├── utils/ # Funkcje pomocnicze
│ │ │ └── util-name.util.ts
│ │ │
│ │ ├── directives/ # Dyrektywy
│ │ ├── pipes/ # Pipes
│ │ ├── guards/ # Route guards
│ │ ├── interceptors/ # HTTP interceptors
│ │ ├── validators/ # Custom validators
│ │ │
│ │ └── index.ts # Public API
│ │
│ └── index.ts # Library entry point
│
├── project.json # Nx project configuration
├── tsconfig.json # TypeScript base config
├── tsconfig.lib.json # Library TypeScript config
├── tsconfig.spec.json # Tests TypeScript config
├── jest.config.ts # Jest configuration
├── .eslintrc.json # ESLint rules
├── package.json # Library metadata
└── README.md # Library documentation
Import aliases¶
Wszystkie biblioteki używają scope @msk/:
// Import z bibliotek
import { CommonService } from '@msk/common';
import { Budget } from '@msk/types';
import { BudgetService } from '@msk/budgets';
import { InputComponent } from '@msk/forms/input';
// Import wewnętrzny (w ramach biblioteki)
import { BudgetState } from './state/budget.state';
Konfiguracja w tsconfig.base.json:
{
"compilerOptions": {
"paths": {
"@msk/common": ["projects/common/src/index.ts"],
"@msk/types": ["projects/types/src/index.ts"],
"@msk/budgets": ["projects/budgets/src/index.ts"],
// ... pozostałe
}
}
}
State Management (NGXS)¶
Podstawy NGXS¶
System używa NGXS (nie NgRx!) do zarządzania stanem.
Struktura state¶
// projects/budgets/src/lib/store/budget.state.ts
export interface BudgetStateModel {
budgets: Budget[];
selectedBudget: Budget | null;
loading: boolean;
error: string | null;
}
@State<BudgetStateModel>({
name: 'budgets',
defaults: {
budgets: [],
selectedBudget: null,
loading: false,
error: null
}
})
@Injectable()
export class BudgetState {
constructor(private budgetService: BudgetService) {}
@Selector()
static getBudgets(state: BudgetStateModel): Budget[] {
return state.budgets;
}
@Selector()
static getSelectedBudget(state: BudgetStateModel): Budget | null {
return state.selectedBudget;
}
@Selector()
static isLoading(state: BudgetStateModel): boolean {
return state.loading;
}
@Action(LoadBudgets)
loadBudgets(ctx: StateContext<BudgetStateModel>) {
ctx.patchState({ loading: true });
return this.budgetService.getAll().pipe(
tap(budgets => {
ctx.patchState({
budgets,
loading: false,
error: null
});
}),
catchError(error => {
ctx.patchState({
loading: false,
error: error.message
});
return throwError(() => error);
})
);
}
@Action(SelectBudget)
selectBudget(ctx: StateContext<BudgetStateModel>, action: SelectBudget) {
ctx.patchState({ selectedBudget: action.budget });
}
}
Actions¶
// projects/budgets/src/lib/store/budget.actions.ts
export class LoadBudgets {
static readonly type = '[Budget] Load Budgets';
}
export class SelectBudget {
static readonly type = '[Budget] Select Budget';
constructor(public budget: Budget) {}
}
export class CreateBudget {
static readonly type = '[Budget] Create Budget';
constructor(public budget: Budget) {}
}
export class UpdateBudget {
static readonly type = '[Budget] Update Budget';
constructor(public id: string, public budget: Partial<Budget>) {}
}
export class DeleteBudget {
static readonly type = '[Budget] Delete Budget';
constructor(public id: string) {}
}
Użycie w komponencie¶
// Component
@Component({
selector: 'msk-budget-list',
template: `
<div *ngIf="loading$ | async">Loading...</div>
<div *ngIf="error$ | async as error">{{ error }}</div>
<div *ngFor="let budget of budgets$ | async">
{{ budget.name }}
</div>
`
})
export class BudgetListComponent implements OnInit {
budgets$ = this.store.select(BudgetState.getBudgets);
loading$ = this.store.select(BudgetState.isLoading);
error$ = this.store.select(state => state.budgets.error);
constructor(private store: Store) {}
ngOnInit() {
this.store.dispatch(new LoadBudgets());
}
selectBudget(budget: Budget) {
this.store.dispatch(new SelectBudget(budget));
}
}
Immutability z immer¶
Używaj immer do aktualizacji złożonych obiektów:
import { produce } from 'immer';
@Action(UpdateBudgetItems)
updateBudgetItems(ctx: StateContext<BudgetStateModel>, action: UpdateBudgetItems) {
ctx.setState(
produce(draft => {
const budget = draft.budgets.find(b => b.id === action.budgetId);
if (budget) {
budget.items = action.items;
budget.total = action.items.reduce((sum, item) => sum + item.amount, 0);
}
})
);
}
Reactive Programming (RxJS)¶
Operatory RxJS¶
Najczęściej używane operatory:
import {
map,
filter,
tap,
switchMap,
mergeMap,
catchError,
debounceTime,
distinctUntilChanged,
takeUntil,
combineLatest,
forkJoin
} from 'rxjs/operators';
// Transformacja danych
this.budgetService.getAll().pipe(
map(budgets => budgets.filter(b => b.active)),
tap(budgets => console.log('Active budgets:', budgets))
).subscribe();
// Debouncing (search)
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(query => this.search(query))
).subscribe();
// Łączenie wielu źródeł
combineLatest([
this.budgetService.getAll(),
this.clientService.getAll()
]).pipe(
map(([budgets, clients]) => ({
budgets,
clients
}))
).subscribe();
// Czekanie na wszystkie requesty
forkJoin({
budgets: this.budgetService.getAll(),
clients: this.clientService.getAll(),
invoices: this.invoiceService.getAll()
}).subscribe(result => {
console.log(result.budgets, result.clients, result.invoices);
});
Memory leaks prevention¶
Zawsze czyść subskrypcje:
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
export class MyComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
ngOnInit() {
this.budgetService.getAll()
.pipe(takeUntil(this.destroy$))
.subscribe(budgets => {
// ...
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
Lub użyj utility z @msk/destroy:
import { UntilDestroy, untilDestroyed } from '@msk/destroy';
@UntilDestroy()
export class MyComponent implements OnInit {
ngOnInit() {
this.budgetService.getAll()
.pipe(untilDestroyed(this))
.subscribe(budgets => {
// Automatycznie odsubskrybuje przy zniszczeniu komponentu
});
}
}
Abstract Base Classes¶
System definiuje abstrakcyjne klasy bazowe dla typowych wzorców:
AbstractApiService¶
// projects/common/src/lib/services/abstract-api.service.ts
export abstract class AbstractApiService<T> {
protected abstract apiUrl: string;
constructor(protected http: HttpClient) {}
getAll(): Observable<T[]> {
return this.http.get<T[]>(this.apiUrl);
}
getById(id: string): Observable<T> {
return this.http.get<T>(`${this.apiUrl}/${id}`);
}
create(item: T): Observable<T> {
return this.http.post<T>(this.apiUrl, item);
}
update(id: string, item: Partial<T>): Observable<T> {
return this.http.put<T>(`${this.apiUrl}/${id}`, item);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
// Implementacja
@Injectable()
export class BudgetApiService extends AbstractApiService<Budget> {
protected apiUrl = `${environment.apiUrl}/budgets`;
constructor(http: HttpClient) {
super(http);
}
// Dodatkowe metody specificzne dla budżetów
getByUserId(userId: string): Observable<Budget[]> {
return this.http.get<Budget[]>(`${this.apiUrl}/user/${userId}`);
}
}
AbstractFormService¶
// projects/common/src/lib/services/abstract-form.service.ts
export abstract class AbstractFormService<T> {
abstract createForm(item?: T): FormGroup;
protected createBaseForm(config: any): FormGroup {
return this.fb.group(config);
}
validate(form: FormGroup): boolean {
if (form.invalid) {
Object.keys(form.controls).forEach(key => {
form.get(key)?.markAsTouched();
});
return false;
}
return true;
}
protected fb = inject(FormBuilder);
}
Forms (Reactive Forms)¶
Tworzenie formularza¶
@Component({
selector: 'msk-budget-form',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<mat-form-field>
<input matInput formControlName="name" placeholder="Nazwa budżetu">
<mat-error *ngIf="form.get('name')?.hasError('required')">
Pole wymagane
</mat-error>
</mat-form-field>
<mat-form-field>
<input matInput type="number" formControlName="amount" placeholder="Kwota">
<mat-error *ngIf="form.get('amount')?.hasError('min')">
Minimalna wartość to 0
</mat-error>
</mat-form-field>
<button mat-raised-button type="submit" [disabled]="form.invalid">
Zapisz
</button>
</form>
`
})
export class BudgetFormComponent implements OnInit {
form!: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
amount: [0, [Validators.required, Validators.min(0)]],
description: [''],
startDate: [new Date(), Validators.required],
endDate: [null]
});
}
onSubmit() {
if (this.form.valid) {
const budget: Budget = this.form.value;
// Dispatch action lub wywołaj service
}
}
}
Custom Validators¶
// projects/validators/src/lib/custom-validators.ts
export class CustomValidators {
static dateRange(startDateField: string, endDateField: string): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const startDate = group.get(startDateField)?.value;
const endDate = group.get(endDateField)?.value;
if (startDate && endDate && startDate > endDate) {
return { dateRange: true };
}
return null;
};
}
static email(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value;
if (!value) return null;
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return emailRegex.test(value) ? null : { email: true };
};
}
}
// Użycie
this.form = this.fb.group({
startDate: ['', Validators.required],
endDate: [''],
}, {
validators: CustomValidators.dateRange('startDate', 'endDate')
});
Routing i Lazy Loading¶
Konfiguracja routing¶
// app-routing.module.ts
const routes: Routes = [
{
path: '',
redirectTo: 'dashboard',
pathMatch: 'full'
},
{
path: 'dashboard',
loadChildren: () => import('@msk/dashboard').then(m => m.DashboardModule)
},
{
path: 'budgets',
loadChildren: () => import('@msk/budgets').then(m => m.BudgetsModule),
canActivate: [AuthGuard]
},
{
path: 'clients',
loadChildren: () => import('@msk/clients').then(m => m.ClientsModule),
canActivate: [AuthGuard]
},
// ...
];
Route Guards¶
// guards/auth.guard.ts
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private store: Store,
private router: Router
) {}
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
return this.store.select(UserState.isAuthenticated).pipe(
map(isAuthenticated => {
if (!isAuthenticated) {
this.router.navigate(['/login']);
return false;
}
return true;
})
);
}
}
Internationalization (i18n)¶
System używa @ngneat/transloco:
// Component
@Component({
template: `
<h1>{{ 'budgets.title' | transloco }}</h1>
<p>{{ 'budgets.description' | transloco: { count: budgets.length } }}</p>
`
})
export class BudgetListComponent {}
// Service
@Injectable()
export class BudgetService {
constructor(private transloco: TranslocoService) {}
getErrorMessage(code: string): string {
return this.transloco.translate(`errors.${code}`);
}
}
Pliki tłumaczeń w src/assets/i18n/:
// de.json
{
"budgets": {
"title": "Budgets",
"description": "Sie haben {{count}} Budgets"
}
}
// en.json
{
"budgets": {
"title": "Budgets",
"description": "You have {{count}} budgets"
}
}
HTTP Interceptors¶
Auth Interceptor¶
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = localStorage.getItem('access_token');
if (token) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next.handle(req);
}
}
Error Interceptor¶
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
constructor(private snackBar: MatSnackBar) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
catchError((error: HttpErrorResponse) => {
let errorMessage = 'Unknown error';
if (error.error instanceof ErrorEvent) {
// Client-side error
errorMessage = error.error.message;
} else {
// Server-side error
errorMessage = `Error ${error.status}: ${error.message}`;
}
this.snackBar.open(errorMessage, 'Close', { duration: 5000 });
return throwError(() => error);
})
);
}
}
Testing¶
Unit testing (Jest)¶
// budget.service.spec.ts
describe('BudgetService', () => {
let service: BudgetService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [BudgetService]
});
service = TestBed.inject(BudgetService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('should fetch budgets', () => {
const mockBudgets: Budget[] = [
{ id: '1', name: 'Test Budget', amount: 1000 }
];
service.getAll().subscribe(budgets => {
expect(budgets).toEqual(mockBudgets);
});
const req = httpMock.expectOne('/api/budgets');
expect(req.request.method).toBe('GET');
req.flush(mockBudgets);
});
});
Component testing¶
// budget-list.component.spec.ts
describe('BudgetListComponent', () => {
let component: BudgetListComponent;
let fixture: ComponentFixture<BudgetListComponent>;
let store: Store;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [BudgetListComponent],
imports: [NgxsModule.forRoot([BudgetState])],
providers: [
{ provide: BudgetService, useValue: mockBudgetService }
]
}).compileComponents();
fixture = TestBed.createComponent(BudgetListComponent);
component = fixture.componentInstance;
store = TestBed.inject(Store);
});
it('should load budgets on init', () => {
spyOn(store, 'dispatch');
component.ngOnInit();
expect(store.dispatch).toHaveBeenCalledWith(new LoadBudgets());
});
});
Performance Optimization¶
Change Detection Strategy¶
@Component({
selector: 'msk-budget-item',
template: `...`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BudgetItemComponent {
@Input() budget!: Budget;
}
Track By Function¶
@Component({
template: `
<div *ngFor="let budget of budgets; trackBy: trackByBudgetId">
{{ budget.name }}
</div>
`
})
export class BudgetListComponent {
budgets: Budget[] = [];
trackByBudgetId(index: number, budget: Budget): string {
return budget.id;
}
}
Virtual Scrolling¶
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
template: `
<cdk-virtual-scroll-viewport itemSize="50" class="viewport">
<div *cdkVirtualFor="let budget of budgets">
{{ budget.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.viewport {
height: 400px;
}
`]
})
export class BudgetListComponent {}