From fd61a1e62b28fa54ec2b55c9eb775f166b6229a3 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:19:21 +0700 Subject: [PATCH] Task/enable strict null checks in apps/client (#7309) * fix(client): resolve type errors * feat(ui): resolve type error in assistant component * feat(client): enable strict null checks * feat(client): enforce encapsulation * feat(client): enforce immutability * feat(client): replace constructor based DI with inject functions * fix(client): prevent race condition in nested subscription --- .../rule-settings-dialog.html | 4 +- .../activities/activities-page.component.ts | 203 +++++++++--------- ...ate-or-update-activity-dialog.component.ts | 8 +- .../interfaces/interfaces.ts | 4 +- .../activities/interfaces/interfaces.ts | 7 + apps/client/tsconfig.json | 1 + .../src/lib/assistant/assistant.component.ts | 2 +- 7 files changed, 124 insertions(+), 105 deletions(-) create mode 100644 apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts diff --git a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html index d81a3c1f3..618626b63 100644 --- a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html +++ b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html @@ -7,8 +7,8 @@ >
@if ( - data.rule.configuration.thresholdMin && - data.rule.configuration.thresholdMax + data.rule.configuration?.thresholdMin && + data.rule.configuration?.thresholdMax ) {
diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index 91f4b6210..397fdce57 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -20,6 +20,7 @@ import { ChangeDetectorRef, Component, DestroyRef, + inject, OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -31,12 +32,14 @@ import { MatTableDataSource } from '@angular/material/table'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { format, parseISO } from 'date-fns'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Subscription } from 'rxjs'; +import { of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; import { GfCreateOrUpdateActivityDialogComponent } from './create-or-update-activity-dialog/create-or-update-activity-dialog.component'; import { CreateOrUpdateActivityDialogParams } from './create-or-update-activity-dialog/interfaces/interfaces'; import { GfImportActivitiesDialogComponent } from './import-activities-dialog/import-activities-dialog.component'; import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; +import { ActivitiesPageParams } from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -51,54 +54,53 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa templateUrl: './activities-page.html' }) export class GfActivitiesPageComponent implements OnInit { - public activityTypesFilter: string[] = []; - public dataSource: MatTableDataSource; - public deviceType: string; - public hasImpersonationId: boolean; - public hasPermissionToCreateActivity: boolean; - public hasPermissionToDeleteActivity: boolean; - public pageIndex = 0; - public pageSize = DEFAULT_PAGE_SIZE; - public routeQueryParams: Subscription; - public sortColumn = 'date'; - public sortDirection: SortDirection = 'desc'; - public totalItems: number | undefined; - public user: User; - - public constructor( - private changeDetectorRef: ChangeDetectorRef, - private dataService: DataService, - private destroyRef: DestroyRef, - private deviceDetectorService: DeviceDetectorService, - private dialog: MatDialog, - private icsService: IcsService, - private impersonationStorageService: ImpersonationStorageService, - private route: ActivatedRoute, - private router: Router, - private userService: UserService - ) { - this.routeQueryParams = route.queryParams - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((params) => { - if (params['createDialog']) { - if (params['activityId']) { - this.dataService - .fetchActivity(params['activityId']) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((activity) => { - this.openCreateActivityDialog(activity); - }); - } else { - this.openCreateActivityDialog(); + protected dataSource: MatTableDataSource | undefined; + protected deviceType: string; + protected hasImpersonationId: boolean; + protected hasPermissionToCreateActivity: boolean; + protected hasPermissionToDeleteActivity: boolean; + protected pageIndex = 0; + protected readonly pageSize = DEFAULT_PAGE_SIZE; + protected sortColumn = 'date'; + protected sortDirection: SortDirection = 'desc'; + protected totalItems: number | undefined; + protected user: User; + + private activityTypesFilter: string[] = []; + + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + private readonly icsService = inject(IcsService); + private readonly impersonationStorageService = inject( + ImpersonationStorageService + ); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly userService = inject(UserService); + + public constructor() { + this.route.queryParams + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((params: ActivitiesPageParams) => { + if (params.activityId && (params.createDialog || params.editDialog)) { + return this.dataService + .fetchActivity(params.activityId) + .pipe(map((activity) => ({ activity, params }))); } - } else if (params['editDialog']) { - if (params['activityId']) { - this.dataService - .fetchActivity(params['activityId']) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((activity) => { - this.openUpdateActivityDialog(activity); - }); + + return of({ params, activity: undefined }); + }) + ) + .subscribe(({ activity, params }) => { + if (params.createDialog) { + this.openCreateActivityDialog(activity); + } else if (params.editDialog) { + if (activity) { + this.openUpdateActivityDialog(activity); } else { this.router.navigate(['.'], { relativeTo: this.route }); } @@ -131,49 +133,13 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public fetchActivities() { - // Reset dataSource and totalItems to show loading state - this.dataSource = undefined; - this.totalItems = undefined; - - const dateRange = this.user?.settings?.dateRange; - const range = this.isCalendarYear(dateRange) ? dateRange : undefined; - - this.dataService - .fetchActivities({ - range, - activityTypes: this.activityTypesFilter.length - ? this.activityTypesFilter - : undefined, - filters: this.userService.getFilters(), - skip: this.pageIndex * this.pageSize, - sortColumn: this.sortColumn, - sortDirection: this.sortDirection, - take: this.pageSize - }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ activities, count }) => { - this.dataSource = new MatTableDataSource(activities); - this.totalItems = count; - - if ( - this.hasPermissionToCreateActivity && - this.user?.activitiesCount === 0 - ) { - this.router.navigate([], { queryParams: { createDialog: true } }); - } - - this.changeDetectorRef.markForCheck(); - }); - } - - public onChangePage(page: PageEvent) { + protected onChangePage(page: PageEvent) { this.pageIndex = page.pageIndex; this.fetchActivities(); } - public onClickActivity({ dataSource, symbol }: AssetProfileIdentifier) { + protected onClickActivity({ dataSource, symbol }: AssetProfileIdentifier) { this.router.navigate([], { queryParams: { dataSource, @@ -183,11 +149,11 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onCloneActivity(aActivity: Activity) { + protected onCloneActivity(aActivity: Activity) { this.openCreateActivityDialog(aActivity); } - public onDeleteActivities() { + protected onDeleteActivities() { this.dataService .deleteActivities({ filters: this.userService.getFilters() @@ -205,7 +171,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onDeleteActivity(aId: string) { + protected onDeleteActivity(aId: string) { this.dataService .deleteActivity(aId) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -221,7 +187,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onExport(activityIds?: string[]) { + protected onExport(activityIds?: string[]) { let fetchExportParams: any = { activityIds }; if (!activityIds) { @@ -238,7 +204,7 @@ export class GfActivitiesPageComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((data) => { for (const activity of data.activities) { - delete activity.id; + delete (activity as Omit & { id?: string }).id; } downloadAsFile({ @@ -252,7 +218,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onExportDrafts(activityIds?: string[]) { + protected onExportDrafts(activityIds?: string[]) { this.dataService .fetchExport({ activityIds }) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -270,7 +236,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onImport() { + protected onImport() { const dialogRef = this.dialog.open< GfImportActivitiesDialogComponent, ImportActivitiesDialogParams @@ -298,7 +264,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onImportDividends() { + protected onImportDividends() { const dialogRef = this.dialog.open< GfImportActivitiesDialogComponent, ImportActivitiesDialogParams @@ -327,7 +293,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - public onSortChanged({ active, direction }: Sort) { + protected onSortChanged({ active, direction }: Sort) { this.pageIndex = 0; this.sortColumn = active; this.sortDirection = direction; @@ -335,20 +301,56 @@ export class GfActivitiesPageComponent implements OnInit { this.fetchActivities(); } - public onTypesFilterChanged(aTypes: string[]) { + protected onTypesFilterChanged(aTypes: string[]) { this.activityTypesFilter = aTypes; this.pageIndex = 0; this.fetchActivities(); } - public onUpdateActivity(aActivity: Activity) { + protected onUpdateActivity(aActivity: Activity) { this.router.navigate([], { queryParams: { activityId: aActivity.id, editDialog: true } }); } - public openUpdateActivityDialog(aActivity: Activity) { + private fetchActivities() { + // Reset dataSource and totalItems to show loading state + this.dataSource = undefined; + this.totalItems = undefined; + + const dateRange = this.user?.settings?.dateRange; + const range = this.isCalendarYear(dateRange) ? dateRange : undefined; + + this.dataService + .fetchActivities({ + range, + activityTypes: this.activityTypesFilter.length + ? this.activityTypesFilter + : undefined, + filters: this.userService.getFilters(), + skip: this.pageIndex * this.pageSize, + sortColumn: this.sortColumn, + sortDirection: this.sortDirection, + take: this.pageSize + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ activities, count }) => { + this.dataSource = new MatTableDataSource(activities); + this.totalItems = count; + + if ( + this.hasPermissionToCreateActivity && + this.user?.activitiesCount === 0 + ) { + this.router.navigate([], { queryParams: { createDialog: true } }); + } + + this.changeDetectorRef.markForCheck(); + }); + } + + private openUpdateActivityDialog(aActivity: Activity) { const dialogRef = this.dialog.open< GfCreateOrUpdateActivityDialogComponent, CreateOrUpdateActivityDialogParams @@ -383,7 +385,7 @@ export class GfActivitiesPageComponent implements OnInit { }); } - private isCalendarYear(dateRange: DateRange) { + private isCalendarYear(dateRange?: DateRange) { if (!dateRange) { return false; } @@ -410,11 +412,12 @@ export class GfActivitiesPageComponent implements OnInit { date: new Date(), id: null, fee: 0, + SymbolProfile: null, type: aActivity?.type ?? 'BUY', unitPrice: null }, user: this.user - }, + } satisfies CreateOrUpdateActivityDialogParams, height: this.deviceType === 'mobile' ? '98vh' : '80vh', width: this.deviceType === 'mobile' ? '100vw' : '50rem' }); diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index 4dbf6ad9f..022a8f2a2 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -536,7 +536,13 @@ export class GfCreateOrUpdateActivityDialogComponent { this.dialogRef.close(activity); } else { - (activity as UpdateOrderDto).id = this.data.activity?.id; + const activityId = this.data.activity?.id; + + if (!activityId) { + throw new Error('Activity ID is required for update'); + } + + (activity as UpdateOrderDto).id = activityId; await validateObjectForForm({ classDto: UpdateOrderDto, diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/interfaces/interfaces.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/interfaces/interfaces.ts index 322bcc076..37faff91f 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/interfaces/interfaces.ts @@ -4,8 +4,10 @@ import { Account } from '@prisma/client'; export interface CreateOrUpdateActivityDialogParams { accounts: Account[]; - activity: Omit & { + activity: Partial> & { + id: string | null; SymbolProfile: Activity['SymbolProfile'] | null; + unitPrice: number | null; }; user: User; } diff --git a/apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts b/apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts new file mode 100644 index 000000000..51f240cb5 --- /dev/null +++ b/apps/client/src/app/pages/portfolio/activities/interfaces/interfaces.ts @@ -0,0 +1,7 @@ +import { Params } from '@angular/router'; + +export interface ActivitiesPageParams extends Params { + activityId?: string; + createDialog?: string; + editDialog?: string; +} diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json index d207f5966..ae0aaf61a 100644 --- a/apps/client/tsconfig.json +++ b/apps/client/tsconfig.json @@ -22,6 +22,7 @@ "compilerOptions": { "lib": ["dom", "es2022"], "module": "preserve", + "strictNullChecks": true, "target": "es2020" } } diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index d69e5a13a..16a66ff31 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -713,7 +713,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { return { routerLink, mode: SearchMode.QUICK_LINK as const, - name: title + name: title ?? '' }; }); }