From fdc6882e65fb7a88afd40fb0850399808c6ed1d6 Mon Sep 17 00:00:00 2001 From: Manfred Steyer Date: Sat, 28 Sep 2024 18:41:26 +0200 Subject: [PATCH] feat: add signal store to hotspot and team alignment analysis --- .detective/hash | 2 +- .detective/log | 5 +- .husky/pre-commit | 2 +- .../features/hotspot/hotspot.component.html | 10 +- .../app/features/hotspot/hotspot.component.ts | 206 ++++++------------ .../src/app/features/hotspot/hotspot.store.ts | 123 +++++++++++ .../team-alignment.component.html | 7 +- .../team-alignment.component.ts | 71 +++--- .../team-alignment/team-alignment.store.ts | 56 +++++ apps/frontend/src/app/ui/graph/graph.ts | 2 +- 10 files changed, 287 insertions(+), 197 deletions(-) create mode 100644 apps/frontend/src/app/features/hotspot/hotspot.store.ts create mode 100644 apps/frontend/src/app/features/team-alignment/team-alignment.store.ts diff --git a/.detective/hash b/.detective/hash index 83124a1..8c8ec7b 100644 --- a/.detective/hash +++ b/.detective/hash @@ -1 +1 @@ -f9232bdefbe89cefc2725a2280d1e7bc976277d1, v1.1.2 \ No newline at end of file +01d56f87bc03708a2aa60ad75afeeaa3ac7c27ea, v1.1.2 \ No newline at end of file diff --git a/.detective/log b/.detective/log index 871906b..fb75a26 100644 --- a/.detective/log +++ b/.detective/log @@ -1,6 +1,7 @@ -"Manfred Steyer ,Sat Sep 28 10:48:17 2024 +0200 ce7f9291f8db589484a10d04077ebab2c32253e7,chore: configure vscode to use single quotes" +"Manfred Steyer ,Sat Sep 28 10:48:17 2024 +0200 854bde8ff89203de89c8a8f667162cfb93f6a2c4,chore: configure vscode to use single quotes" +1 1 .detective/hash +11 0 .detective/log 4 0 .vscode/settings.json -1 1 apps/frontend/src/app/features/coupling/coupling.component.ts "Manfred Steyer ,Sat Sep 28 10:44:18 2024 +0200 28b7c31784b0353ad33a72f2e5cd6c8e7bcf6947,feat: add store to coupling feature" 1 1 .detective/hash diff --git a/.husky/pre-commit b/.husky/pre-commit index 9b8b10f..fd7020f 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,3 @@ -nx run-many --target=lint --all --fix || exit 1 +npm run lint nx format:write --all git add . diff --git a/apps/frontend/src/app/features/hotspot/hotspot.component.html b/apps/frontend/src/app/features/hotspot/hotspot.component.html index af0301d..d265151 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.component.html +++ b/apps/frontend/src/app/features/hotspot/hotspot.component.html @@ -5,7 +5,8 @@ @@ -13,7 +14,10 @@ Complexity Metric - + @for (option of metricOptions; track option.id) { {{ option.label }} } @@ -63,7 +67,7 @@ mat-row *matRowDef="let row; columns: columnsToDisplay; let i = index" (click)="selectRow(row, i)" - [class.selected]="selectedRow === row" + [class.selected]="isSelected(i)" > } diff --git a/apps/frontend/src/app/features/hotspot/hotspot.component.ts b/apps/frontend/src/app/features/hotspot/hotspot.component.ts index 3663f49..65c7a42 100644 --- a/apps/frontend/src/app/features/hotspot/hotspot.component.ts +++ b/apps/frontend/src/app/features/hotspot/hotspot.component.ts @@ -3,11 +3,10 @@ import { computed, effect, inject, - Signal, - signal, + untracked, viewChild, } from '@angular/core'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; @@ -18,52 +17,28 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSortModule } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { - catchError, - combineLatest, - filter, - Observable, - of, - startWith, - switchMap, - tap, -} from 'rxjs'; +import { combineLatest, startWith } from 'rxjs'; -import { HotspotService } from '../../data/hotspot.service'; import { LimitsStore } from '../../data/limits.store'; import { StatusStore } from '../../data/status.store'; import { AggregatedHotspot, - AggregatedHotspotsResult, ComplexityMetric, FlatHotspot, - HotspotCriteria, - HotspotResult, - initAggregatedHotspotsResult, - initHotspotResult, } from '../../model/hotspot-result'; -import { initLimits, Limits } from '../../model/limits'; +import { Limits } from '../../model/limits'; import { LimitsComponent } from '../../ui/limits/limits.component'; import { debounceTimeSkipFirst } from '../../utils/debounce'; -import { injectShowError } from '../../utils/error-handler'; import { EventService } from '../../utils/event.service'; import { lastSegments } from '../../utils/segments'; +import { HotspotFilter, HotspotStore } from './hotspot.store'; + interface Option { id: ComplexityMetric; label: string; } -type LoadAggregateOptions = { - minScore: number; - limits: Limits; - metric: ComplexityMetric; -}; - -type LoadHotspotOptions = LoadAggregateOptions & { - selectedModule: string; -}; - @Component({ selector: 'app-hotspot', standalone: true, @@ -84,64 +59,52 @@ type LoadHotspotOptions = LoadAggregateOptions & { styleUrl: './hotspot.component.css', }) export class HotspotComponent { + private statusStore = inject(StatusStore); private limitsStore = inject(LimitsStore); + private hotspotStore = inject(HotspotStore); - private hotspotService = inject(HotspotService); private eventService = inject(EventService); - private statusStore = inject(StatusStore); - private showError = injectShowError(); + paginator = viewChild(MatPaginator); - dataSource = new MatTableDataSource(); detailDataSource = new MatTableDataSource(); - aggregatedResult: Signal; - hotspotResult: Signal; - - formattedAggregated = computed(() => - this.formatAggregated(this.aggregatedResult().aggregated) - ); - formattedHotspots = computed(() => - this.formatHotspots(this.hotspotResult().hotspots) - ); - - // TODO: Decide on this vs the "rendering" effects below - // hotspotsDataSource = computed(() => { - // const dataSource = new MatTableDataSource(this.formattedHotspots()); - // const paginator = this.paginator(); - // if (paginator) { - // dataSource.paginator = paginator; - // } - // return dataSource; - // }); - - selectedRow: AggregatedHotspot | null = null; - columnsToDisplay = ['module', 'count']; detailColumns = ['fileName', 'commits', 'complexity', 'score']; - totalCommits = this.statusStore.commits; - minScoreControl = signal(10); - limits = this.limitsStore.limits; - metric = signal('Length'); - metricOptions: Option[] = [ { id: 'Length', label: 'File Length' }, { id: 'McCabe', label: 'Cyclomatic Complexity' }, ]; - selectedModule = signal(''); - loadingAggregated = signal(false); - loadingHotspots = signal(false); + totalCommits = this.statusStore.commits; + limits = this.limitsStore.limits; - paginator = viewChild(MatPaginator); + minScore = this.hotspotStore.filter.minScore; + metric = this.hotspotStore.filter.metric; + selectedModule = this.hotspotStore.filter.selectedModule; + + loadingAggregated = this.hotspotStore.loadingAggregated; + loadingHotspots = this.hotspotStore.loadingHotspots; + + aggregatedResult = this.hotspotStore.aggregatedResult; + hotspotResult = this.hotspotStore.hotspotResult; + + formattedAggregated = computed(() => + formatAggregated(this.aggregatedResult().aggregated) + ); + + formattedHotspots = computed(() => + formatHotspots( + this.hotspotResult().hotspots, + untracked(() => this.selectedModule()) + ) + ); constructor() { const loadAggregatedEvents = { filterChanged: this.eventService.filterChanged.pipe(startWith(null)), - minScore: toObservable(this.minScoreControl).pipe( - debounceTimeSkipFirst(300) - ), + minScore: toObservable(this.minScore).pipe(debounceTimeSkipFirst(300)), limits: toObservable(this.limits).pipe(debounceTimeSkipFirst(300)), metric: toObservable(this.metric), }; @@ -151,27 +114,16 @@ export class HotspotComponent { selectedModule: toObservable(this.selectedModule), }; - const aggregated$ = combineLatest(loadAggregatedEvents).pipe( - switchMap((combi) => this.loadAggregated(combi)) + const loadAggregatedOptions$ = combineLatest(loadAggregatedEvents).pipe( + takeUntilDestroyed() ); - const hotspots$ = combineLatest(loadHotspotEvent).pipe( - filter((combi) => !!combi.selectedModule), - switchMap((combi) => this.loadHotspots(combi)) + const loadHotspotOptions$ = combineLatest(loadHotspotEvent).pipe( + takeUntilDestroyed() ); - this.aggregatedResult = toSignal(aggregated$, { - initialValue: initAggregatedHotspotsResult, - }); - - this.hotspotResult = toSignal(hotspots$, { - initialValue: initHotspotResult, - }); - - // effect(() => { - // const aggregated = this.formattedAggregated(); - // this.dataSource.data = aggregated; - // }); + this.hotspotStore.rxLoadAggregated(loadAggregatedOptions$); + this.hotspotStore.rxLoadHotspots(loadHotspotOptions$); effect(() => { const hotspots = this.formattedHotspots(); @@ -186,72 +138,42 @@ export class HotspotComponent { }); } - updateLimits(limits: Limits) { + updateLimits(limits: Limits): void { this.limitsStore.updateLimits(limits); } - selectRow(row: AggregatedHotspot, index: number) { - const selectModule = this.aggregatedResult().aggregated[index].module; - this.selectedRow = row; - this.selectedModule.set(selectModule); - } - - formatAggregated(hotspot: AggregatedHotspot[]): AggregatedHotspot[] { - return hotspot.map((hs) => ({ - ...hs, - module: lastSegments(hs.module, 3), - })); + updateFilter(filter: Partial): void { + this.hotspotStore.updateFilter(filter); } - formatHotspots(hotspot: FlatHotspot[]): FlatHotspot[] { - return hotspot.map((hs) => ({ - ...hs, - fileName: trimSegments(hs.fileName, this.selectedRow?.module || ''), - })); + selectRow(row: AggregatedHotspot, index: number) { + const selectedModule = this.aggregatedResult().aggregated[index].module; + this.hotspotStore.updateFilter({ + selectedModule, + }); } - private loadAggregated( - options: LoadAggregateOptions - ): Observable { - const criteria: HotspotCriteria = { - metric: options.metric, - minScore: options.minScore, - module: '', - }; - - this.loadingAggregated.set(true); - return this.hotspotService.loadAggregated(criteria, options.limits).pipe( - tap(() => { - this.loadingAggregated.set(false); - }), - catchError((err) => { - this.loadingAggregated.set(false); - this.showError(err); - return of(initAggregatedHotspotsResult); - }) - ); + isSelected(index: number) { + const module = this.aggregatedResult().aggregated[index].module; + return module === this.selectedModule(); } +} - private loadHotspots(options: LoadHotspotOptions): Observable { - const criteria: HotspotCriteria = { - metric: options.metric, - minScore: options.minScore, - module: options.selectedModule, - }; - - this.loadingHotspots.set(true); +function formatAggregated(hotspot: AggregatedHotspot[]): AggregatedHotspot[] { + return hotspot.map((hs) => ({ + ...hs, + module: lastSegments(hs.module, 3), + })); +} - return this.hotspotService.load(criteria, options.limits).pipe( - tap(() => { - this.loadingHotspots.set(false); - }), - catchError((err) => { - this.loadingHotspots.set(false); - this.showError(err); - return of(initHotspotResult); - }) - ); - } +function formatHotspots( + hotspot: FlatHotspot[], + selectedModule: string +): FlatHotspot[] { + return hotspot.map((hs) => ({ + ...hs, + fileName: trimSegments(hs.fileName, selectedModule), + })); } function trimSegments(fileName: string, prefix: string): string { diff --git a/apps/frontend/src/app/features/hotspot/hotspot.store.ts b/apps/frontend/src/app/features/hotspot/hotspot.store.ts new file mode 100644 index 0000000..b0cd1fc --- /dev/null +++ b/apps/frontend/src/app/features/hotspot/hotspot.store.ts @@ -0,0 +1,123 @@ +import { inject } from '@angular/core'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { catchError, filter, Observable, of, pipe, switchMap, tap } from 'rxjs'; + +import { HotspotService } from '../../data/hotspot.service'; +import { + AggregatedHotspotsResult, + ComplexityMetric, + HotspotCriteria, + HotspotResult, + initAggregatedHotspotsResult, + initHotspotResult, +} from '../../model/hotspot-result'; +import { Limits } from '../../model/limits'; +import { injectShowError } from '../../utils/error-handler'; + +export type HotspotFilter = { + minScore: number; + metric: ComplexityMetric; + selectedModule: string; +}; + +export type LoadAggregateOptions = { + minScore: number; + metric: ComplexityMetric; + limits: Limits; +}; + +export type LoadHotspotOptions = LoadAggregateOptions & { + selectedModule: string; +}; + +export const HotspotStore = signalStore( + { providedIn: 'root' }, + withState({ + filter: { + minScore: 1, + metric: 'Length', + selectedModule: '', + } as HotspotFilter, + aggregatedResult: initAggregatedHotspotsResult, + hotspotResult: initHotspotResult, + loadingAggregated: false, + loadingHotspots: false, + }), + withMethods( + ( + store, + hotspotService = inject(HotspotService), + showError = injectShowError() + ) => ({ + _loadAggregated( + options: LoadAggregateOptions + ): Observable { + const criteria: HotspotCriteria = { + metric: options.metric, + minScore: options.minScore, + module: '', + }; + + patchState(store, { loadingAggregated: true }); + + return hotspotService.loadAggregated(criteria, options.limits).pipe( + tap(() => { + patchState(store, { loadingAggregated: false }); + }), + catchError((err) => { + patchState(store, { loadingAggregated: false }); + showError(err); + return of(initAggregatedHotspotsResult); + }) + ); + }, + + _loadHotspots(options: LoadHotspotOptions): Observable { + const criteria: HotspotCriteria = { + metric: options.metric, + minScore: options.minScore, + module: options.selectedModule, + }; + + patchState(store, { loadingHotspots: true }); + + return hotspotService.load(criteria, options.limits).pipe( + tap(() => { + patchState(store, { loadingHotspots: false }); + }), + catchError((err) => { + patchState(store, { loadingHotspots: false }); + showError(err); + return of(initHotspotResult); + }) + ); + }, + }) + ), + withMethods((store) => ({ + updateFilter(filter: Partial) { + patchState(store, (state) => ({ + filter: { + ...state.filter, + ...filter, + }, + })); + }, + + rxLoadAggregated: rxMethod( + pipe( + switchMap((combi) => store._loadAggregated(combi)), + tap((aggregatedResult) => patchState(store, { aggregatedResult })) + ) + ), + + rxLoadHotspots: rxMethod( + pipe( + filter((combi) => !!combi.selectedModule), + switchMap((combi) => store._loadHotspots(combi)), + tap((hotspotResult) => patchState(store, { hotspotResult })) + ) + ), + })) +); diff --git a/apps/frontend/src/app/features/team-alignment/team-alignment.component.html b/apps/frontend/src/app/features/team-alignment/team-alignment.component.html index c4078d0..97d7566 100644 --- a/apps/frontend/src/app/features/team-alignment/team-alignment.component.html +++ b/apps/frontend/src/app/features/team-alignment/team-alignment.component.html @@ -1,6 +1,11 @@
- By User + By User this.toColors(this.teams().length)); + + loadOptions$ = combineLatest({ limits: toObservable(this.limits).pipe(debounceTimeSkipFirst(300)), byUser: toObservable(this.byUser), filterChanged: this.eventService.filterChanged.pipe(startWith(null)), - }).pipe(switchMap((combi) => this.loadTeamAlignment(combi))); - - teamAlignmentResult = toSignal(this.alignment$, { - initialValue: initTeamAlignmentResult, - }); + }).pipe(takeUntilDestroyed()); - teams = computed(() => this.teamAlignmentResult().teams); - colors = computed(() => this.toColors(this.teams().length)); chartConfigs = computed(() => toAlignmentChartConfigs(this.teamAlignmentResult(), this.colors()) ); - updateLimits(limits: Limits) { + constructor() { + this.taStore.rxLoad(this.loadOptions$); + } + + updateLimits(limits: Limits): void { this.limitsStore.updateLimits(limits); } - private toColors(count: number): string[] { - return quantize(interpolateRainbow, count + 1); + updateFilter(byUser: boolean): void { + this.taStore.updateFilter(byUser); } - private loadTeamAlignment( - options: LoadTeamAlignmentOptions - ): Observable { - return this.taService.load(options.byUser, options.limits).pipe( - catchError((err) => { - this.showError(err); - return of(initTeamAlignmentResult); - }) - ); + private toColors(count: number): string[] { + return quantize(interpolateRainbow, count + 1); } } diff --git a/apps/frontend/src/app/features/team-alignment/team-alignment.store.ts b/apps/frontend/src/app/features/team-alignment/team-alignment.store.ts new file mode 100644 index 0000000..054eeca --- /dev/null +++ b/apps/frontend/src/app/features/team-alignment/team-alignment.store.ts @@ -0,0 +1,56 @@ +import { inject } from '@angular/core'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { catchError, Observable, of, pipe, switchMap, tap } from 'rxjs'; + +import { TeamAlignmentService } from '../../data/team-alignment.service'; +import { Limits } from '../../model/limits'; +import { + initTeamAlignmentResult, + TeamAlignmentResult, +} from '../../model/team-alignment-result'; +import { injectShowError } from '../../utils/error-handler'; + +export type LoadOptions = { + limits: Limits; + byUser: boolean; +}; + +export const TeamAlignmentStore = signalStore( + { providedIn: 'root' }, + withState({ + filter: { + byUser: false, + }, + result: initTeamAlignmentResult, + }), + withMethods( + ( + _store, + taService = inject(TeamAlignmentService), + showError = injectShowError() + ) => ({ + _loadTeamAlignment( + options: LoadOptions + ): Observable { + return taService.load(options.byUser, options.limits).pipe( + catchError((err) => { + showError(err); + return of(initTeamAlignmentResult); + }) + ); + }, + }) + ), + withMethods((store) => ({ + updateFilter(byUser: boolean) { + patchState(store, { filter: { byUser } }); + }, + rxLoad: rxMethod( + pipe( + switchMap((combi) => store._loadTeamAlignment(combi)), + tap((result) => patchState(store, { result })) + ) + ), + })) +); diff --git a/apps/frontend/src/app/ui/graph/graph.ts b/apps/frontend/src/app/ui/graph/graph.ts index 2022df1..bc0f397 100644 --- a/apps/frontend/src/app/ui/graph/graph.ts +++ b/apps/frontend/src/app/ui/graph/graph.ts @@ -85,7 +85,7 @@ function createGraph(container: HTMLElement, graph: Graph): cytoscape.Core { 'min-zoomed-font-size': 8, 'text-wrap': 'wrap', 'text-max-width': '100px', - } as any, + } as never, }, { selector: 'edge',