Przejdź do treści

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 {}

Dalsze zasoby