From d7abc9667daf099d9027f5c209cd1554a90aec99 Mon Sep 17 00:00:00 2001 From: sawankshrma Date: Tue, 28 Jul 2026 03:08:36 +0530 Subject: [PATCH 1/3] refactor: migrate CircularHeatmapComponent to Signals - Convert 10 mutable properties to signal(), hasTeamsFilter to computed() - Replace ngOnInit/requestAnimationFrame with afterNextRender - Replace theme$ RxJS subscription with effect() - Dissolve destroy$/takeUntil in favor of DestroyRef - Fix order-sensitive group highlight (set-based comparison) - Fix group selection wipe (new object refs via .set()) - Prevent group chip deselection via per-chip selectionChange guard --- .../circular-heatmap.component.html | 37 +-- .../circular-heatmap.component.ts | 286 ++++++++++-------- 2 files changed, 176 insertions(+), 147 deletions(-) diff --git a/src/app/pages/circular-heatmap/circular-heatmap.component.html b/src/app/pages/circular-heatmap/circular-heatmap.component.html index 7debf161..8527c78a 100644 --- a/src/app/pages/circular-heatmap/circular-heatmap.component.html +++ b/src/app/pages/circular-heatmap/circular-heatmap.component.html @@ -2,12 +2,12 @@
-
- @if (showActivityDetails) { +
+ @if (showActivityDetails()) {
@@ -28,14 +28,17 @@

Nothing to show

-
+
- - @for (group of filtersTeamGroups | keyvalue: unsorted; track group.key) { - + + @for (group of filtersTeamGroups() | keyvalue: unsorted; track group.key) { + {{ group.key }} } @@ -43,7 +46,7 @@

Nothing to show

- @for (team of filtersTeams | keyvalue; track team.key) { + @for (team of filtersTeams() | keyvalue; track team.key) { {{ team.key }} @@ -52,14 +55,14 @@

Nothing to show

- @if (showActivityCard) { + @if (showActivityCard()) { - {{ showActivityCard.dimension }} - Level {{ showActivityCard.level }} + {{ showActivityCard()?.dimension }} + Level {{ showActivityCard()?.level }} @for ( - activity of showActivityCard.activities; + activity of showActivityCard()?.activities; track activity; let activityIndex = $index ) { @@ -83,8 +86,8 @@

Nothing to show

    - @for (team of filtersTeams | keyvalue; track team) { - @if (team.value || !hasTeamsFilter) { + @for (team of filtersTeams() | keyvalue; track team) { + @if (team.value || !hasTeamsFilter()) {
  • = {}; - filtersTeamGroups: Record = {}; - teamGroups: TeamGroups = {}; - hasTeamsFilter: boolean = false; maxLevel: number = 0; dimensionLabels: string[] = []; progressStates: string[] = []; - allSectors: Sector[] = []; - selectedSector: Sector | null = null; - theme: string; theme_colors!: Record; - private destroy$ = new Subject(); + // ── Signals ── + readonly showOverlay = signal(false); + readonly showFilters = signal(true); + readonly showActivityCard = signal(null); + readonly showActivityDetails = signal(null); + readonly dataStore = signal(null); + readonly filtersTeams = signal>({}); + readonly filtersTeamGroups = signal>({}); + readonly teamGroups = signal({}); + readonly allSectors = signal([]); + readonly selectedSector = signal(null); + + // ── Computed ── + readonly hasTeamsFilter = computed(() => Object.values(this.filtersTeams()).some(v => v)); constructor() { - this.theme = this.themeService.getTheme(); - } - - ngOnInit(): void { - requestAnimationFrame(() => { - // Now the DOM has the correct class and CSS vars are live - console.log(`${perfNow()}s: ngOnInit: Initial theme:`, this.theme); - const css = getComputedStyle(document.body); - this.theme_colors = { - background: css.getPropertyValue('--heatmap-background').trim(), - filled: css.getPropertyValue('--heatmap-filled').trim(), - disabled: css.getPropertyValue('--heatmap-disabled').trim(), - cursor: css.getPropertyValue('--heatmap-cursor-hover').trim(), - stroke: css.getPropertyValue('--heatmap-stroke').trim(), - }; - console.debug(`${perfNow()}s: ngOnInit: Heatmap theme colors:`, this.theme_colors); - if (!this.theme_colors['background'] || !this.theme_colors['filled']) { - console.debug(css); - } + this.destroyRef.onDestroy(() => this.titleService.clearTitle()); + + // Wait for DOM to be ready, then load data and draw the heatmap + afterNextRender(() => { + this.readThemeColors(); console.log(`${perfNow()}: Heat: Loading yamls...`); - // Ensure that Levels and Teams load before MaturityData - // using promises, since ngOnInit does not support async/await this.loader .load() .then((dataStore: DataStore) => { @@ -136,12 +128,13 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { throw Error('No progressStore available'); } - this.filtersTeams = this.buildFilters(dataStore.meta?.teams as string[]); + this.filtersTeams.set(this.buildFilters(dataStore.meta?.teams as string[])); // Insert key: 'All' with value: [], in the first position of the meta.teamGroups Record const allTeamsGroupName: string = dataStore.getMetaString('allTeamsGroupName') || 'All'; - this.teamGroups = { [allTeamsGroupName]: [], ...(dataStore.meta?.teamGroups || {}) }; - this.filtersTeamGroups = this.buildFilters(Object.keys(this.teamGroups)); - this.filtersTeamGroups[allTeamsGroupName] = true; + this.teamGroups.set({ [allTeamsGroupName]: [], ...(dataStore.meta?.teamGroups || {}) }); + const groupFilters = this.buildFilters(Object.keys(this.teamGroups())); + groupFilters[allTeamsGroupName] = true; + this.filtersTeamGroups.set(groupFilters); let progressDefinition: ProgressDefinitions = dataStore.meta?.progressDefinition || {}; this.sectorService.init( @@ -155,7 +148,12 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { this.setYamlData(dataStore); // For now, just draw the sectors (no activities yet) - this.loadCircularHeatMap('#chart', this.allSectors, this.dimensionLabels, this.maxLevel); + this.loadCircularHeatMap( + '#chart', + this.allSectors(), + this.dimensionLabels, + this.maxLevel + ); console.log(`${perfNow()}: Page loaded: Circular Heatmap`); // Check if there's a URL fragment and open the corresponding activity @@ -168,36 +166,36 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { } }); }); - // Reactively handle theme changes (if user toggles later) - this.themeService.theme$.pipe(takeUntil(this.destroy$)).subscribe((theme: string) => { - console.log(`${perfNow()}s: themeService.pipe: Theme changed to:`, theme); - const css = getComputedStyle(document.body); - this.theme_colors = { - background: css.getPropertyValue('--heatmap-background').trim(), - filled: css.getPropertyValue('--heatmap-filled').trim(), - disabled: css.getPropertyValue('--heatmap-disabled').trim(), - cursor: css.getPropertyValue('--heatmap-cursor-hover').trim(), - stroke: css.getPropertyValue('--heatmap-stroke').trim(), - }; - console.debug(`${perfNow()}s: themeService.pipe: Heatmap theme colors:`, this.theme_colors); - // Repaint segments with new theme - this.reColorHeatmap(); + // Reactively repaint heatmap on theme changes + effect(() => { + const theme = this.themeService.theme(); // tracked dependency + this.readThemeColors(); + console.log(`${perfNow()}s: theme effect: Theme changed to:`, theme); + if (this.allSectors().length > 0) { + this.reColorHeatmap(); + } }); } - ngOnDestroy(): void { - this.titleService.clearTitle(); - this.destroy$.next(); - this.destroy$.complete(); + private readThemeColors(): void { + const css = getComputedStyle(document.body); + this.theme_colors = { + background: css.getPropertyValue('--heatmap-background').trim(), + filled: css.getPropertyValue('--heatmap-filled').trim(), + disabled: css.getPropertyValue('--heatmap-disabled').trim(), + cursor: css.getPropertyValue('--heatmap-cursor-hover').trim(), + stroke: css.getPropertyValue('--heatmap-stroke').trim(), + }; + console.debug(`${perfNow()}s: readThemeColors:`, this.theme_colors); } checkUrlFragmentForActivity() { // Check if there's a URL fragment that might be an activity UUID this.route.fragment - .pipe(takeUntil(this.destroy$), distinctUntilChanged()) + .pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()) .subscribe(fragment => { - if (fragment && this.dataStore) { + if (fragment && this.dataStore()) { this.navigateToActivityByUuid(fragment); } }); @@ -208,18 +206,17 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { } setYamlData(dataStore: DataStore) { - this.dataStore = dataStore; + this.dataStore.set(dataStore); this.maxLevel = this.settings?.getMaxLevel() || dataStore.getMaxLevel(); this.dimensionLabels = dataStore?.activityStore?.getAllDimensionNames() || []; // Prepare all sectors: one for each (dimension, level) pair - this.allSectors = []; + const sectors: Sector[] = []; for (let level = 1; level <= this.maxLevel; level++) { - // let DEBUG_DIM_INDEX = 0; for (let dimName of this.dimensionLabels) { const activities: Activity[] = dataStore?.activityStore?.getActivities(dimName, level) || []; - this.allSectors.push({ + sectors.push({ dimension: dimName, // dimensionIndex: DEBUG_DIM_INDEX++, level: level, @@ -227,6 +224,7 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { }); } } + this.allSectors.set(sectors); } buildFilters(names: string[]): Record { @@ -239,23 +237,31 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { return filters; } - toggleTeamGroupFilter(event: MatChipListboxChange) { - const teamGroup = event.value; - if (!teamGroup) return; + onGroupChipChange(event: MatChipSelectionChange, groupKey: string) { + if (!event.selected && event.isUserInput) { + event.source.select(); + return; + } - console.log(`${perfNow()}: Heat: Chip flip Group '${teamGroup}'`); + if (!event.selected || this.filtersTeamGroups()[groupKey]) return; - Object.keys(this.filtersTeamGroups).forEach(key => { - this.filtersTeamGroups[key] = key === teamGroup; + console.log(`${perfNow()}: Heat: Chip flip Group '${groupKey}'`); + + const newGroupFilters: Record = {}; + Object.keys(this.filtersTeamGroups()).forEach(key => { + newGroupFilters[key] = key === groupKey; }); + this.filtersTeamGroups.set(newGroupFilters); + const groups = this.teamGroups(); const selectedTeams: TeamName[] = []; - Object.keys(this.filtersTeams).forEach(key => { - this.filtersTeams[key] = this.teamGroups[teamGroup]?.includes(key) || false; - if (this.filtersTeams[key]) selectedTeams.push(key); + const newTeamFilters: Record = {}; + Object.keys(this.filtersTeams()).forEach(key => { + newTeamFilters[key] = groups[groupKey]?.includes(key) || false; + if (newTeamFilters[key]) selectedTeams.push(key); }); + this.filtersTeams.set(newTeamFilters); this.sectorService.setVisibleTeams(selectedTeams); - this.hasTeamsFilter = Object.values(this.filtersTeams).some(v => v === true); this.reColorHeatmap(); } @@ -263,36 +269,48 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { const selectedTeams: string[] = event.value || []; console.log(`${perfNow()}: Heat: Team filter changed: [${selectedTeams.join(', ')}]`); - Object.keys(this.filtersTeams).forEach(key => { - this.filtersTeams[key] = selectedTeams.includes(key); + const newTeamFilters: Record = {}; + Object.keys(this.filtersTeams()).forEach(key => { + newTeamFilters[key] = selectedTeams.includes(key); }); + this.filtersTeams.set(newTeamFilters); - this.hasTeamsFilter = selectedTeams.length > 0; this.sectorService.setVisibleTeams(selectedTeams); - Object.keys(this.teamGroups || {}).forEach(group => { - this.filtersTeamGroups[group] = equalArray(selectedTeams, this.teamGroups[group]); + // Set-based comparison for group highlight (fixes order-sensitive bug) + const selectedSet = new Set(selectedTeams); + const groups = this.teamGroups(); + const newGroupFilters: Record = {}; + Object.keys(groups).forEach(group => { + const groupTeams = groups[group]; + newGroupFilters[group] = + groupTeams.length > 0 && + groupTeams.length === selectedSet.size && + groupTeams.every(t => selectedSet.has(t)); }); - this.filtersTeamGroups = { ...this.filtersTeamGroups }; + this.filtersTeamGroups.set(newGroupFilters); this.reColorHeatmap(); } getTeamProgressState(activityUuid: string, teamName: string): string { - return this.dataStore?.progressStore?.getTeamActivityTitle(activityUuid, teamName) || ''; + return this.dataStore()?.progressStore?.getTeamActivityTitle(activityUuid, teamName) || ''; } getBackedupTeamProgressState(activityUuid: string, teamName: string): string { - return this.dataStore?.progressStore?.getTeamActivityTitle(activityUuid, teamName, true) || ''; + return ( + this.dataStore()?.progressStore?.getTeamActivityTitle(activityUuid, teamName, true) || '' + ); } onProgressChange(activityUuid: Uuid, teamName: TeamName, newProgress: ProgressTitle) { - if (!this.dataStore || !this.dataStore.progressStore || !this.dataStore.activityStore) { + const ds = this.dataStore(); + if (!ds || !ds.progressStore || !ds.activityStore) { throw Error('Data store or progress store is not initialized.'); } - this.dataStore.progressStore.setTeamActivityProgressState(activityUuid, teamName, newProgress); - let activity: Activity = this.dataStore.activityStore.getActivityByUuid(activityUuid); + ds.progressStore.setTeamActivityProgressState(activityUuid, teamName, newProgress); + let activity: Activity = ds.activityStore.getActivityByUuid(activityUuid); let index = this.dimensionLabels.indexOf(activity.dimension) + this.dimensionLabels.length * (activity.level - 1); @@ -312,7 +330,7 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { onDependencyClicked(activityName: string) { console.log(`${perfNow()}: Heat: Dependency clicked: '${activityName}'`); - const activity = this.dataStore?.activityStore?.getActivityByName(activityName); + const activity = this.dataStore()?.activityStore?.getActivityByName(activityName); if (activity?.uuid) { this.navigateToActivityByUuid(activity.uuid); } @@ -377,16 +395,17 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { .on('click', function () { var clickedId = d3.select(this).attr('id'); var index = parseInt(clickedId.replace('index-', '')); - _self.selectedSector = dataset[index]; // Store selected sector for details + const sector = dataset[index]; + _self.selectedSector.set(sector); // Assign showActivityCard to the sector if it has activities, else null - if (_self.selectedSector?.activities?.length) { + if (sector?.activities?.length) { _self.setSectorCursor(svg, '#selected', clickedId); - _self.showActivityCard = _self.selectedSector; - console.log(`${perfNow()}: Heat: Clicked sector: '${_self.selectedSector.dimension}' Level: ${_self.selectedSector.level}`); // eslint-disable-line + _self.showActivityCard.set(sector); + console.log(`${perfNow()}: Heat: Clicked sector: '${sector.dimension}' Level: ${sector.level}`); // eslint-disable-line } else { - _self.showActivityCard = null; + _self.showActivityCard.set(null); _self.setSectorCursor(svg, '#selected', ''); - console.log(`${perfNow()}: Heat: Clicked disabled sector: '${_self?.selectedSector?.dimension}' Level: ${_self?.selectedSector?.level}`); // eslint-disable-line + console.log(`${perfNow()}: Heat: Clicked disabled sector: '${sector?.dimension}' Level: ${sector?.level}`); // eslint-disable-line } }) .on('mouseover', function () { @@ -634,26 +653,28 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { openActivityDetails(uuid: string) { // Find the activity in the selected sector - if (!this.dataStore || !this.dataStore.activityStore) { + const ds = this.dataStore(); + if (!ds || !ds.activityStore) { console.error(`Data store is not initialized. Cannot open activity ${uuid}`); return; } - if (!this.showActivityCard || !this.showActivityCard.activities) { - this.showOverlay = true; + const card = this.showActivityCard(); + if (!card || !card.activities) { + this.showOverlay.set(true); return; } - const activity: Activity = this.dataStore.activityStore.getActivityByUuid(uuid); + const activity: Activity = ds.activityStore.getActivityByUuid(uuid); if (!activity) { - this.showOverlay = true; + this.showOverlay.set(true); return; } // Prepare navigationExtras and details /* eslint-disable */ console.log(`${perfNow()}: Heat: Open Overlay: '${activity.name}'`); - this.showActivityDetails = activity; - this.showOverlay = true; + this.showActivityDetails.set(activity); + this.showOverlay.set(true); // Update URL with activity UUID as fragment if (activity.uuid) { @@ -668,19 +689,20 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { navigateToActivityByUuid(uuid: string) { console.log(`${perfNow()}: Heat: Attempting to open activity with UUID: ${uuid}`); - if (!this.dataStore || !this.dataStore.activityStore) { + const ds = this.dataStore(); + if (!ds || !ds.activityStore) { console.error('Data store is not initialized. Cannot open activity by UUID'); return; } - const activity: Activity = this.dataStore.activityStore.getActivityByUuid(uuid); - const sector = this.allSectors.find(s => s.activities.some(a => a.uuid === uuid)); + const activity: Activity = ds.activityStore.getActivityByUuid(uuid); + const sector = this.allSectors().find(s => s.activities.some(a => a.uuid === uuid)); if (activity && sector) { - this.selectedSector = sector; - this.showActivityCard = sector; + this.selectedSector.set(sector); + this.showActivityCard.set(sector); this.openActivityDetails(activity.uuid); } else { // Only close the overlay, do not update the URL - this.showOverlay = false; + this.showOverlay.set(false); console.warn(`Heat: Activity with UUID ${uuid} not found.`); } } @@ -692,16 +714,17 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { fragment: undefined, queryParamsHandling: 'preserve', }); - this.showOverlay = false; + this.showOverlay.set(false); } toggleFilters() { - this.showFilters = !this.showFilters; + this.showFilters.update(v => !v); } reColorHeatmap() { - console.debug(`${perfNow()}s: Recoloring heatmap of ${this.allSectors.length} sectors`); - for (let index = 0; index < this.allSectors.length; index++) { + const sectors = this.allSectors(); + console.debug(`${perfNow()}s: Recoloring heatmap of ${sectors.length} sectors`); + for (let index = 0; index < sectors.length; index++) { this.recolorSector(index); } } @@ -712,7 +735,7 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { .domain([0, 1]) .range([this.theme_colors['background'], this.theme_colors['filled']]); - let progressValue: number = this.getSectorProgress(this.allSectors[index]); + let progressValue: number = this.getSectorProgress(this.allSectors()[index]); if (progressValue) console.debug(`${perfNow()}s: recolorSector #${index} sector: ${progressValue.toFixed(2)} (${this.theme_colors['filled']})`); // eslint-disable-line d3.select('#index-' + index).attr( @@ -723,33 +746,35 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { exportTeamProgress() { console.log(`${perfNow()}: Exporting teams and groups`); + const ds = this.dataStore(); - let yamlStr: string | null = this.dataStore?.progressStore?.asYamlString() || null; + let yamlStr: string | null = ds?.progressStore?.asYamlString() || null; if (!yamlStr) { this.displayMessage(new DialogInfo('No team progress data available', 'Export Error')); return; } - downloadYamlFile(yamlStr, this.dataStore?.meta?.teamProgressFile || 'team-progress.yaml'); + downloadYamlFile(yamlStr, ds?.meta?.teamProgressFile || 'team-progress.yaml'); } exportTeamEvidences() { console.log(`${perfNow()}: Exporting team evidence`); + const ds = this.dataStore(); - let yamlStr: string | null = this.dataStore?.evidenceStore?.asYamlString() || null; + let yamlStr: string | null = ds?.evidenceStore?.asYamlString() || null; if (!yamlStr) { this.displayMessage(new DialogInfo('No team evidence data available', 'Export Error')); return; } - downloadYamlFile(yamlStr, this.dataStore?.meta?.teamEvidenceFile || 'team-evidence.yaml'); + downloadYamlFile(yamlStr, ds?.meta?.teamEvidenceFile || 'team-evidence.yaml'); } async deleteLocalTeamsProgress() { let buttonClicked: string = await this.displayDeleteLocalFilesDialog('progress'); if (buttonClicked === 'Delete') { - this.dataStore?.progressStore?.deleteBrowserStoredTeamProgress(); + this.dataStore()?.progressStore?.deleteBrowserStoredTeamProgress(); location.reload(); // Make sure all load routines are initialized } } @@ -758,7 +783,7 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { let buttonClicked: string = await this.displayDeleteLocalFilesDialog('evidence'); if (buttonClicked === 'Delete') { - this.dataStore?.evidenceStore?.deleteBrowserStoredEvidence(); + this.dataStore()?.evidenceStore?.deleteBrowserStoredEvidence(); location.reload(); // Make sure all load routines are initialized } } @@ -804,12 +829,13 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { } openAddEvidenceModal(activityUuid: string): void { - const teams = this.dataStore?.meta?.teams || []; + const ds = this.dataStore(); + const teams = ds?.meta?.teams || []; const dialogData: AddEvidenceModalData = { activityUuid, allTeams: teams, - teamGroups: this.teamGroups, + teamGroups: this.teamGroups(), }; const dialogRef = this.dialog.open(AddEvidenceModalComponent, { @@ -818,8 +844,8 @@ export class CircularHeatmapComponent implements OnInit, OnDestroy { }); dialogRef.afterClosed().subscribe(result => { - if (result && result.entry && this.dataStore?.evidenceStore) { - this.dataStore.evidenceStore.addEvidence(result.activityUuid, result.entry); + if (result && result.entry && ds?.evidenceStore) { + ds.evidenceStore.addEvidence(result.activityUuid, result.entry); console.log(`${perfNow()}: Evidence added for activity ${result.activityUuid}`); } }); From d0089a978caed503f143606e718b2c6c5109e418 Mon Sep 17 00:00:00 2001 From: sawankshrma Date: Tue, 28 Jul 2026 03:11:38 +0530 Subject: [PATCH 2/3] refactor: migrate Mapping, Settings, Teams components to Signals - Convert allTeams, dataStore, searchTerms in MappingComponent to signal() - Convert meta, dataStoreMaxLevel, selectedMaxLevel, selectedMaxLevelCaption, editingProgressDefinitions, remoteReleaseCheck, selectedDateFormat in SettingsComponent to signal() - Refactor checkForLatestRelease to build state via local vars + single .set()/.update() instead of mutating remoteReleaseCheck fields directly - Remove unused GithubReleaseInfo import and dead checkingLatest/latestReleaseInfo/etc. fields - Convert dataStore, canEdit, teams, teamGroups, progressTitleImplemented, infoTitle, infoTeams, info, allColumnNames, progressColumnNames in TeamsComponent to signal() - Rework onSelectionChanged/onTeamsChanged to derive new info/currentInfo objects and .set() instead of mutating in place - Update mapping/settings/teams templates and specs to call signals as functions --- src/app/pages/mapping/mapping.component.html | 12 +- src/app/pages/mapping/mapping.component.ts | 32 +++-- .../pages/settings/settings.component.html | 50 ++++---- .../pages/settings/settings.component.spec.ts | 8 +- src/app/pages/settings/settings.component.ts | 120 ++++++++++-------- src/app/pages/teams/teams.component.html | 43 ++++--- src/app/pages/teams/teams.component.spec.ts | 6 +- src/app/pages/teams/teams.component.ts | 104 ++++++++------- 8 files changed, 208 insertions(+), 167 deletions(-) diff --git a/src/app/pages/mapping/mapping.component.html b/src/app/pages/mapping/mapping.component.html index a53e27b4..b82df000 100644 --- a/src/app/pages/mapping/mapping.component.html +++ b/src/app/pages/mapping/mapping.component.html @@ -4,7 +4,7 @@ Search - @for (term of searchTerms; track term) { + @for (term of searchTerms(); track term) { {{ term }} @@ -19,7 +19,7 @@ placeholder="Type and press Enter..." [matChipInputAddOnBlur]="false" /> - @if (searchTerms.length) { + @if (searchTerms().length) { @@ -114,7 +114,7 @@ SAMM ISO 27001:2017 ISO 27001:2022 - @for (team of allTeams; track team) { + @for (team of allTeams(); track team) { {{ team + ' - Status' }} @@ -209,10 +209,10 @@ {{ item.ISO22 | slice: 0 : 32767 }} } - @for (team of allTeams; track team) { + @for (team of allTeams(); track team) { - @if (dataStore.progressStore) { - {{ dataStore.progressStore.getTeamActivityTitle(item.uuid, team) }} + @if (dataStore().progressStore) { + {{ dataStore().progressStore?.getTeamActivityTitle(item.uuid, team) }} } } diff --git a/src/app/pages/mapping/mapping.component.ts b/src/app/pages/mapping/mapping.component.ts index 4d1879aa..06614d76 100644 --- a/src/app/pages/mapping/mapping.component.ts +++ b/src/app/pages/mapping/mapping.component.ts @@ -1,4 +1,12 @@ -import { Component, OnInit, AfterViewInit, ViewChild, ElementRef, inject } from '@angular/core'; +import { + Component, + OnInit, + AfterViewInit, + ViewChild, + ElementRef, + inject, + signal, +} from '@angular/core'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatSort, MatSortModule } from '@angular/material/sort'; import { COMMA, ENTER } from '@angular/cdk/keycodes'; @@ -84,7 +92,7 @@ export class MappingComponent implements OnInit, AfterViewInit { knowledgeLabels: string[] = []; generalLabels: string[] = []; - allTeams: string[] = []; + allTeams = signal([]); displayedColumns: string[] = [ 'dimension', 'subDimension', @@ -98,9 +106,9 @@ export class MappingComponent implements OnInit, AfterViewInit { @ViewChild('chipInput') chipInput!: ElementRef; @ViewChild(MatSort, { static: false }) sort!: MatSort; - dataStore: DataStore = new DataStore(); + dataStore = signal(new DataStore()); - searchTerms: string[] = []; + searchTerms = signal([]); searchCtrl = new FormControl(''); ngOnInit(): void { @@ -138,8 +146,8 @@ export class MappingComponent implements OnInit, AfterViewInit { } setYamlData(dataStore: DataStore) { - this.dataStore = dataStore; - this.allTeams = dataStore.meta?.teams || []; + this.dataStore.set(dataStore); + this.allTeams.set(dataStore.meta?.teams || []); this.allMappings = this.transformDataStore(dataStore); this.dataSource.data = this.allMappings; } @@ -193,31 +201,31 @@ export class MappingComponent implements OnInit, AfterViewInit { const input = event.target as HTMLInputElement; const value = input.value.trim(); if (event.key === 'Enter' && value) { - if (!this.searchTerms.includes(value.toLowerCase())) { - this.searchTerms.push(value.toLowerCase()); + if (!this.searchTerms().includes(value.toLowerCase())) { + this.searchTerms.update(terms => [...terms, value.toLowerCase()]); this.updateFilter(); } input.value = ''; this.searchCtrl.setValue(''); - } else if (!value && this.searchTerms.length === 0) { + } else if (!value && this.searchTerms().length === 0) { this.dataSource.filter = ''; } } removeSearchTerm(term: string) { - this.searchTerms = this.searchTerms.filter(t => t !== term); + this.searchTerms.update(terms => terms.filter(t => t !== term)); this.updateFilter(); } clearFilter() { console.log(`${perfNow()}: Mapping: Clear search filter`); - this.searchTerms = []; + this.searchTerms.set([]); this.dataSource.filter = ''; this.searchCtrl.setValue(''); } updateFilter() { - this.dataSource.filter = this.searchTerms.join(SEPARATOR); + this.dataSource.filter = this.searchTerms().join(SEPARATOR); console.log( `${perfNow()}: Mapping: Search filter: ${this.dataSource.filter?.replace(SEPARATOR, ', ')}` ); diff --git a/src/app/pages/settings/settings.component.html b/src/app/pages/settings/settings.component.html index 05e3bc09..54f0281b 100644 --- a/src/app/pages/settings/settings.component.html +++ b/src/app/pages/settings/settings.component.html @@ -27,13 +27,13 @@ id="maxLevelSlider" showTickMarks min="1" - [max]="dataStoreMaxLevel || 5" + [max]="dataStoreMaxLevel() || 5" step="1" class="max-slider" #ngSlider > - {{ selectedMaxLevelCaption }} + {{ selectedMaxLevelCaption() }}
@@ -50,10 +50,10 @@

Progress Definitions

- @if (editingProgressDefinitions) { + @if (editingProgressDefinitions()) {
Click Accept when finished
} - @if (!editingProgressDefinitions) { + @if (!editingProgressDefinitions()) { } - @if (editingProgressDefinitions) { + @if (editingProgressDefinitions()) {
- @if (editingProgressDefinitions) { + @if (editingProgressDefinitions()) { @for (definition of definitionsFormArray.controls; track definition; let i = $index) { @@ -146,7 +146,7 @@

Progress Definitions

} }
- @if (editingProgressDefinitions) { + @if (editingProgressDefinitions()) {

Define custom progression stages that match your team's workflow. Choose names and @@ -187,7 +187,7 @@

About the DSOMM Model

While the application is the tool for assessing and tracking DevSecOps maturity, i.e. using the model.

- @if (meta.activityMeta?.getDsommVersion()) { + @if (meta()?.activityMeta?.getDsommVersion()) {

The current version of the DSOMM model in use is:

@@ -195,24 +195,24 @@

About the DSOMM Model

Model version
Release date
- {{ meta.activityMeta?.getDsommVersion() || '' }} + {{ meta()?.activityMeta?.getDsommVersion() || '' }}
- {{ dateFormat(meta.activityMeta?.getDsommReleaseDate()) }} + {{ dateFormat(meta()?.activityMeta?.getDsommReleaseDate()) }}
- @if (remoteReleaseCheck.isNewerAvailable === false) { + @if (remoteReleaseCheck().isNewerAvailable === false) {
Up to date
} - @if (remoteReleaseCheck.isNewerAvailable === true) { + @if (remoteReleaseCheck().isNewerAvailable === true) {
- {{ remoteReleaseCheck.latestRelease?.tagName }} + {{ remoteReleaseCheck().latestRelease?.tagName }}
- {{ dateFormat(remoteReleaseCheck.latestRelease?.publishedAt) }} + {{ dateFormat(remoteReleaseCheck().latestRelease?.publishedAt) }}
DownloadAbout the DSOMM Model
ChangelogAbout the DSOMM Model }
- @if (remoteReleaseCheck.latestCheckError) { + @if (remoteReleaseCheck().latestCheckError) {

Error checking updates:

-

{{ remoteReleaseCheck.latestCheckError }}

+

{{ remoteReleaseCheck().latestCheckError }}

}
@@ -240,23 +240,23 @@

About the DSOMM Model

mat-raised-button class="normal-button" (click)="checkForLatestRelease()" - [disabled]="remoteReleaseCheck.isChecking"> + [disabled]="remoteReleaseCheck().isChecking"> Check for newer DSOMM - @if (remoteReleaseCheck.isChecking) { + @if (remoteReleaseCheck().isChecking) { }
- @if (meta.activityFiles.length > 1) { + @if (meta()?.activityFiles && meta()!.activityFiles.length > 1) {
This application uses the official DSOMM Model above, with some local customizations. The following files are used to describe the model in use:
} } - @if (!meta.activityMeta?.getDsommVersion()) { + @if (!meta()?.activityMeta?.getDsommVersion()) {
This application runs on a customized version of the DSOMM model:
    - @for (file of meta.activityFiles; track file) { + @for (file of meta()?.activityFiles || []; track file) {
  • {{ file.replace('assets/YAML/', '') }}
  • diff --git a/src/app/pages/settings/settings.component.spec.ts b/src/app/pages/settings/settings.component.spec.ts index da9ee765..2694cc1d 100644 --- a/src/app/pages/settings/settings.component.spec.ts +++ b/src/app/pages/settings/settings.component.spec.ts @@ -76,12 +76,12 @@ describe('SettingsComponent', () => { beforeEach(fakeAsync(() => { fixture = TestBed.createComponent(SettingsComponent); component = fixture.componentInstance; - component.meta = { + component.meta.set({ activityMeta: null, activityFiles: [], progressDefinition: {}, saveProgressDefinition: jasmine.createSpy('saveProgressDefinition'), - } as any; + } as any); fixture.detectChanges(); tick(); @@ -95,14 +95,14 @@ describe('SettingsComponent', () => { it('should update max level settings correctly', fakeAsync(() => { component.onMaxLevelChange(3); - expect(component.selectedMaxLevel).toBe(3); + expect(component.selectedMaxLevel()).toBe(3); expect(settingsService.setMaxLevel).toHaveBeenCalledWith(3); })); it('should handle max level reset to default', fakeAsync(() => { component.onMaxLevelChange(5); - expect(component.selectedMaxLevel).toBe(5); + expect(component.selectedMaxLevel()).toBe(5); // Remove localStorage when settings' maxLevel is set to activity's maxLevel expect(settingsService.setMaxLevel).toHaveBeenCalledWith(null); diff --git a/src/app/pages/settings/settings.component.ts b/src/app/pages/settings/settings.component.ts index 9080f56c..fdecb04a 100644 --- a/src/app/pages/settings/settings.component.ts +++ b/src/app/pages/settings/settings.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { FormBuilder, FormGroup, @@ -9,7 +9,7 @@ import { ReactiveFormsModule, } from '@angular/forms'; import { SettingsService } from '../../service/settings/settings.service'; -import { GithubService, GithubReleaseInfo } from 'src/app/service/settings/github.service'; +import { GithubService } from 'src/app/service/settings/github.service'; import { LoaderService } from 'src/app/service/loader/data-loader.service'; import { DataStore } from 'src/app/model/data-store'; import { ProgressDefinitions } from 'src/app/model/types'; @@ -82,22 +82,22 @@ export class SettingsComponent implements OnInit { modal = inject(ModalMessageComponent); private githubService = inject(GithubService); - meta!: MetaStore; + meta = signal(null); progressStore!: ProgressStore; - dataStoreMaxLevel!: number; - selectedMaxLevel!: number; - selectedMaxLevelCaption: String = ''; + dataStoreMaxLevel = signal(5); + selectedMaxLevel = signal(5); + selectedMaxLevelCaption = signal(''); progressDefinitionsForm!: FormGroup<{ definitions: FormArray>; }>; tempProgressDefinitions: ProgressDefinitions = {}; - editingProgressDefinitions: boolean = false; - remoteReleaseCheck: RemoteReleaseCheck = { + editingProgressDefinitions = signal(false); + remoteReleaseCheck = signal({ isChecking: false, isNewerAvailable: null, latestRelease: null, latestCheckError: null, - }; + }); private BROWSER_LOCALE = 'BROWSER'; dateFormats = [ @@ -110,15 +110,7 @@ export class SettingsComponent implements OnInit { { value: 'ja' }, { value: 'hu' }, ]; - selectedDateFormat: string = this.BROWSER_LOCALE; - - // GitHub release check state - checkingLatest: boolean = false; - latestReleaseInfo: GithubReleaseInfo | null = null; - latestCheckError: string | null = null; - isNewerAvailable: boolean | null = null; - latestDownloadUrl: string | null = null; - latestReleasePublishedDate: Date | null = null; + selectedDateFormat = signal(this.BROWSER_LOCALE); ngOnInit(): void { this.initialize(); @@ -138,36 +130,47 @@ export class SettingsComponent implements OnInit { } async checkForLatestRelease(): Promise { - this.remoteReleaseCheck.isChecking = true; - this.remoteReleaseCheck.isNewerAvailable = null; - this.remoteReleaseCheck.latestRelease = null; - this.remoteReleaseCheck.latestCheckError = null; - + this.remoteReleaseCheck.update(state => ({ + ...state, + isChecking: true, + isNewerAvailable: null, + latestRelease: null, + latestCheckError: null, + })); + + let latestRelease: RemoteReleaseInfo | null = null; try { - this.remoteReleaseCheck.latestRelease = await this.githubService.getLatestRelease(); + latestRelease = await this.githubService.getLatestRelease(); } catch (err: any) { console.warn('Error checking latest DSOMM release', err); - this.remoteReleaseCheck.latestCheckError = err?.message || 'Failed to check latest release'; + this.remoteReleaseCheck.update(state => ({ + ...state, + isChecking: false, + latestCheckError: err?.message || 'Failed to check latest release', + })); return; - } finally { - this.remoteReleaseCheck.isChecking = false; } - if (!this.remoteReleaseCheck.latestRelease) { - this.remoteReleaseCheck.latestCheckError = - 'Error: No release information received from Github'; + if (!latestRelease) { + this.remoteReleaseCheck.update(state => ({ + ...state, + isChecking: false, + latestCheckError: 'Error: No release information received from Github', + })); } else { - const remote = this.remoteReleaseCheck.latestRelease; + const remote = latestRelease; + const meta = this.meta(); const remoteTag = (remote && remote.tagName?.replace(/^v/, '')) || ''; - const localTag = this.meta?.activityMeta?.getDsommVersion()?.replace(/^v/, '') || ''; + const localTag = meta?.activityMeta?.getDsommVersion()?.replace(/^v/, '') || ''; const remoteDate = remote && remote.publishedAt && new Date(remote.publishedAt.toDateString()); - const localDate = this.meta?.activityMeta?.getDsommReleaseDate(); + const localDate = meta?.activityMeta?.getDsommReleaseDate(); // Prefer version tag comparison, fallback to published date comparison let newer = false; + let checkError: string | null = null; if (remoteTag && localTag && remoteDate && localDate) { newer = remoteTag !== localTag || remoteDate > localDate; } else { @@ -179,15 +182,20 @@ export class SettingsComponent implements OnInit { if (!localTag) tmp.push('local model version'); if (!remoteDate) tmp.push('DSOMM model date'); if (!localDate) tmp.push('local model date'); - this.remoteReleaseCheck.latestCheckError = `Could not determine ${tmp.join(', ')}`; // eslint-disable-line - console.warn('ERROR: ' + this.remoteReleaseCheck.latestCheckError); + checkError = `Could not determine ${tmp.join(', ')}`; // eslint-disable-line + console.warn('ERROR: ' + checkError); } - this.remoteReleaseCheck.isNewerAvailable = newer; + this.remoteReleaseCheck.set({ + isChecking: false, + isNewerAvailable: newer, + latestRelease: remote, + latestCheckError: checkError, + }); } } initialize(): void { - this.selectedDateFormat = this.settings.getDateFormat() || this.BROWSER_LOCALE; + this.selectedDateFormat.set(this.settings.getDateFormat() || this.BROWSER_LOCALE); // Init dates let date: Date = new Date(); @@ -202,8 +210,8 @@ export class SettingsComponent implements OnInit { } setYamlData(dataStore: DataStore): void { - this.dataStoreMaxLevel = dataStore.getMaxLevel(); - this.selectedMaxLevel = this.settings.getMaxLevel() || this.dataStoreMaxLevel; + this.dataStoreMaxLevel.set(dataStore.getMaxLevel()); + this.selectedMaxLevel.set(this.settings.getMaxLevel() || this.dataStoreMaxLevel()); this.updateMaxLevelCaption(); if (dataStore.progressStore) { @@ -212,34 +220,38 @@ export class SettingsComponent implements OnInit { // Load progress definitions if (dataStore.meta) { - this.meta = dataStore.meta; - this.tempProgressDefinitions = deepCopy(this.meta.progressDefinition); + this.meta.set(dataStore.meta); + this.tempProgressDefinitions = deepCopy(dataStore.meta.progressDefinition); } } onDateFormatChange(): void { - let value: any = this.selectedDateFormat == 'null' ? null : this.selectedDateFormat; + const fmt = this.selectedDateFormat(); + let value: any = fmt == 'null' ? null : fmt; this.settings.setDateFormat(value); } onMaxLevelChange(value: number | null): void { - if (value == null) value = this.dataStoreMaxLevel; - if (value == this.dataStoreMaxLevel) { + const maxLevel = this.dataStoreMaxLevel(); + if (value == null) value = maxLevel; + if (value == maxLevel) { this.settings.setMaxLevel(null); } else { this.settings.setMaxLevel(value); } - this.selectedMaxLevel = value; + this.selectedMaxLevel.set(value); this.updateMaxLevelCaption(); } // === Max Level === updateMaxLevelCaption(): void { - if (this.selectedMaxLevel == this.dataStoreMaxLevel) { - this.selectedMaxLevelCaption = 'All maturity levels'; + const level = this.selectedMaxLevel(); + const maxLevel = this.dataStoreMaxLevel(); + if (level == maxLevel) { + this.selectedMaxLevelCaption.set('All maturity levels'); } else { - if (this.selectedMaxLevel == 1) this.selectedMaxLevelCaption = `Maturity level 1 only`; - else this.selectedMaxLevelCaption = `Maturity levels 1-${this.selectedMaxLevel} only`; + if (level == 1) this.selectedMaxLevelCaption.set('Maturity level 1 only'); + else this.selectedMaxLevelCaption.set(`Maturity levels 1-${level} only`); } } @@ -356,7 +368,7 @@ export class SettingsComponent implements OnInit { this.tempProgressDefinitions = this.sortObjectByScore(newProgressDefinitions); // Save the new progress definitions to MetaStore and localStorage - this.meta.saveProgressDefinition(this.tempProgressDefinitions); + this.meta()!.saveProgressDefinition(this.tempProgressDefinitions); // Reinitialize the ProgressStore with the new definitions this.progressStore.init(this.tempProgressDefinitions); @@ -364,18 +376,18 @@ export class SettingsComponent implements OnInit { // Save progress data to localStorage this.progressStore.saveToLocalStorage(); - this.editingProgressDefinitions = false; + this.editingProgressDefinitions.set(false); this.updateProgressDefinitionsForm(); } resetProgressDefinitions(): void { - this.tempProgressDefinitions = deepCopy(this.meta.progressDefinition); - this.editingProgressDefinitions = false; + this.tempProgressDefinitions = deepCopy(this.meta()!.progressDefinition); + this.editingProgressDefinitions.set(false); this.updateProgressDefinitionsForm(); } toggleProgressDefinitionsEdit(): void { - this.editingProgressDefinitions = !this.editingProgressDefinitions; + this.editingProgressDefinitions.update(v => !v); } getFormGroupValue(control: AbstractControl, field: string): any { diff --git a/src/app/pages/teams/teams.component.html b/src/app/pages/teams/teams.component.html index 6cd05277..915c28af 100644 --- a/src/app/pages/teams/teams.component.html +++ b/src/app/pages/teams/teams.component.html @@ -1,13 +1,13 @@
    - @if (dataStore.meta?.hasLocalStorage) { + @if (dataStore().meta?.hasLocalStorage) {
    @@ -15,27 +15,27 @@ }
    -

    {{ infoTitle }}

    - @if (infoTeams.length > 1 && info[infoTitle]) { +

    {{ infoTitle() }}

    + @if (infoTeams().length > 1 && info()[infoTitle()]) {
    - {{ info[infoTitle]?.teams?.join(', ') }} + {{ info()[infoTitle()]?.teams?.join(', ') }}
    } - @if (info[infoTitle]) { + @if (info()[infoTitle()]) {
    + [value]="dateFormat(info()[infoTitle()]?.lastUpdated)" + [suffix]="info()[infoTitle()]?.lastUpdated ? '' : 'No activity registered'">
    } @@ -64,9 +64,9 @@

    Activities in progress

    - @if (dataStore.meta?.getIcon(element?.activity?.category || '')) { + @if (dataStore().meta?.getIcon(element?.activity?.category || '')) { - {{ dataStore.meta?.getIcon(element?.activity?.category || '') }} + {{ dataStore().meta?.getIcon(element?.activity?.category || '') }} } {{ element?.activity?.dimension }} @@ -74,7 +74,7 @@

    Activities in progress

    - @for (progressColumn of progressColumnNames; track progressColumn) { + @for (progressColumn of progressColumnNames(); track progressColumn) { {{ progressColumn }} @@ -91,13 +91,16 @@

    Activities in progress

    - - Currently no activities in progress for {{ info[infoTitle]?.teams?.join(', ') }} + + Currently no activities in progress for + {{ info()[infoTitle()]?.teams?.join(', ') }} - - + +
    diff --git a/src/app/pages/teams/teams.component.spec.ts b/src/app/pages/teams/teams.component.spec.ts index 836b26a6..0ec7757f 100644 --- a/src/app/pages/teams/teams.component.spec.ts +++ b/src/app/pages/teams/teams.component.spec.ts @@ -49,8 +49,8 @@ describe('TeamsComponent', () => { }); it('check loading teams', () => { - expect(component.teams).toContain('Team A'); - expect(component.teams).toContain('Team B'); - expect(component.teamGroups?.['AB']).toBeDefined(); + expect(component.teams()).toContain('Team A'); + expect(component.teams()).toContain('Team B'); + expect(component.teamGroups()?.['AB']).toBeDefined(); }); }); diff --git a/src/app/pages/teams/teams.component.ts b/src/app/pages/teams/teams.component.ts index c439e446..7232160d 100644 --- a/src/app/pages/teams/teams.component.ts +++ b/src/app/pages/teams/teams.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, OnInit, ViewChild, inject } from '@angular/core'; +import { AfterViewInit, Component, OnInit, ViewChild, inject, signal } from '@angular/core'; import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { sum } from 'd3'; @@ -48,20 +48,20 @@ export class TeamsComponent implements OnInit, AfterViewInit { modal = inject(ModalMessageComponent); dateStr = dateStr; - dataStore: DataStore = new DataStore(); - canEdit: boolean = true; - teams: TeamNames = []; - teamGroups: TeamGroups = {}; - progressTitleImplemented: string = 'Implemented'; + dataStore = signal(new DataStore()); + canEdit = signal(true); + teams = signal([]); + teamGroups = signal({}); + progressTitleImplemented = signal('Implemented'); // Info panel showing KPIs for teams and groups - infoTitle: string = ''; - infoTeams: TeamNames = []; - info: Partial> = {}; + infoTitle = signal(''); + infoTeams = signal([]); + info = signal>>({}); dataSource: MatTableDataSource = new MatTableDataSource([]); // eslint-disable-line - allColumnNames: string[] = []; - progressColumnNames: string[] = []; + allColumnNames = signal([]); + progressColumnNames = signal([]); @ViewChild(MatSort, { static: false }) sort!: MatSort; ngOnInit(): void { @@ -98,7 +98,7 @@ export class TeamsComponent implements OnInit, AfterViewInit { return item.activity?.dimension || ''; } // For progress columns, sort by date string or timestamp - if (this.progressColumnNames.includes(property)) { + if (this.progressColumnNames().includes(property)) { // If your progress is a date string, you may want to convert to Date for proper sorting const value = item.progress?.[property]; return value ? new Date(value).getTime() : 0; @@ -109,17 +109,17 @@ export class TeamsComponent implements OnInit, AfterViewInit { } setYamlData(dataStore: DataStore) { - this.dataStore = dataStore; - if (this.dataStore.meta) { - this.canEdit = this.dataStore.meta.allowChangeTeamNameInBrowser; + this.dataStore.set(dataStore); + if (dataStore.meta) { + this.canEdit.set(dataStore.meta.allowChangeTeamNameInBrowser); } - this.teams = dataStore?.meta?.teams || []; - this.teamGroups = dataStore?.meta?.teamGroups || {}; + this.teams.set(dataStore?.meta?.teams || []); + this.teamGroups.set(dataStore?.meta?.teamGroups || {}); - let progressStore: ProgressStore | null = this.dataStore?.progressStore; - this.progressColumnNames = progressStore?.getInProgressTitles() || []; - this.progressTitleImplemented = progressStore?.getCompletedProgressTitle() || 'Implemented'; + let progressStore: ProgressStore | null = dataStore?.progressStore; + this.progressColumnNames.set(progressStore?.getInProgressTitles() || []); + this.progressTitleImplemented.set(progressStore?.getCompletedProgressTitle() || 'Implemented'); this.updateColumnNames(); } @@ -130,47 +130,65 @@ export class TeamsComponent implements OnInit, AfterViewInit { onSelectionChanged(event: SelectionChangedEvent) { console.log(`${perfNow()}: Selection changed: ${JSON.stringify(event)}`); if (event.selectedTeam) { - this.infoTitle = event.selectedTeam; - this.infoTeams = [event.selectedTeam]; + this.infoTitle.set(event.selectedTeam); + this.infoTeams.set([event.selectedTeam]); } else if (event.selectedGroup) { - this.infoTitle = event.selectedGroup; - this.infoTeams = this.teamGroups[event.selectedGroup] || []; + this.infoTitle.set(event.selectedGroup); + this.infoTeams.set(this.teamGroups()[event.selectedGroup] || []); } - if (!this.info[this.infoTitle]) { - this.info[this.infoTitle] = this.makeTeamSummary(this.infoTitle, this.infoTeams); + const title = this.infoTitle(); + const teams = this.infoTeams(); + const currentInfo = this.info(); + if (!currentInfo[title]) { + this.info.set({ ...currentInfo, [title]: this.makeTeamSummary(title, teams) }); } - this.dataSource.data = this?.info[this.infoTitle]?.activitiesInProgress || []; + this.dataSource.data = this.info()[title]?.activitiesInProgress || []; this.updateColumnNames(); } updateColumnNames() { - const baseColumns = this.infoTeams.length > 1 ? ['Team'] : []; - this.allColumnNames = [...baseColumns, 'SubDimension', 'Activity', ...this.progressColumnNames]; + const baseColumns = this.infoTeams().length > 1 ? ['Team'] : []; + this.allColumnNames.set([ + ...baseColumns, + 'SubDimension', + 'Activity', + ...this.progressColumnNames(), + ]); } onTeamsChanged(event: TeamsGroupsChangedEvent) { console.log(`${perfNow()}: Saving teams: ${JSON.stringify(event.teams)}`); console.log(`${perfNow()}: Saving groups: ${JSON.stringify(event.teamGroups)}`); - this.dataStore?.meta?.updateTeamsAndGroups(event.teams, event.teamGroups); + const ds = this.dataStore(); + ds?.meta?.updateTeamsAndGroups(event.teams, event.teamGroups); if (!isEmptyObj(event.teamsRenamed)) { for (let oldName in event.teamsRenamed) { - this.dataStore?.progressStore?.renameTeam(oldName, event.teamsRenamed[oldName]); - delete this.info?.[oldName]; - delete this.info?.[event.teamsRenamed[oldName]]; + ds?.progressStore?.renameTeam(oldName, event.teamsRenamed[oldName]); } - this.dataStore?.progressStore?.saveToLocalStorage(); + ds?.progressStore?.saveToLocalStorage(); } - this.info[this.infoTitle] = this.makeTeamSummary(this.infoTitle, this.infoTeams); - this.dataSource.data = this?.info[this.infoTitle]?.activitiesInProgress || []; - this.setYamlData(this.dataStore); + const title = this.infoTitle(); + const teams = this.infoTeams(); + const currentInfo = { ...this.info() }; + if (!isEmptyObj(event.teamsRenamed)) { + for (let oldName in event.teamsRenamed) { + delete currentInfo[oldName]; + delete currentInfo[event.teamsRenamed[oldName]]; + } + } + currentInfo[title] = this.makeTeamSummary(title, teams); + this.info.set(currentInfo); + this.dataSource.data = currentInfo[title]?.activitiesInProgress || []; + + this.setYamlData(ds); this.updateColumnNames(); } onExportTeamGroups() { console.log(`${perfNow()}: Exporting teams and groups`); - const yamlStr: string | undefined = this?.dataStore?.meta?.asStorableYamlString(); + const yamlStr: string | undefined = this.dataStore()?.meta?.asStorableYamlString(); if (!yamlStr) { this.displayMessage( @@ -186,7 +204,7 @@ export class TeamsComponent implements OnInit, AfterViewInit { let buttonClicked: string = await this.displayDeleteBrowserTeamsDialog(); if (buttonClicked === 'Delete') { - this.dataStore?.meta?.deleteTeamsAndGroups(); + this.dataStore()?.meta?.deleteTeamsAndGroups(); location.reload(); // Make sure all load routines are initialized } } @@ -209,9 +227,9 @@ export class TeamsComponent implements OnInit, AfterViewInit { makeTeamSummary(name: string, teams: TeamNames): TeamSummary { /* eslint-disable */ - let activitiesStarted: progressStoreMapping[] = this.dataStore?.progressStore?.getActivitiesStartedForTeams(teams) || []; - let activitiesInProgress: progressStoreMapping[] = this.dataStore?.progressStore?.getActivitiesInProgressForTeams(teams) || []; - let activitiesCompleted: progressStoreMapping[] = this.dataStore?.progressStore?.getActivitiesCompletedForTeams(teams) || []; + let activitiesStarted: progressStoreMapping[] = this.dataStore()?.progressStore?.getActivitiesStartedForTeams(teams) || []; + let activitiesInProgress: progressStoreMapping[] = this.dataStore()?.progressStore?.getActivitiesInProgressForTeams(teams) || []; + let activitiesCompleted: progressStoreMapping[] = this.dataStore()?.progressStore?.getActivitiesCompletedForTeams(teams) || []; let summary: TeamSummary = { teams, @@ -242,7 +260,7 @@ export class TeamsComponent implements OnInit, AfterViewInit { return { team: input.team, activity: - this.dataStore?.activityStore?.getActivityByUuid(input.activityUuid) || ({} as Activity), + this.dataStore()?.activityStore?.getActivityByUuid(input.activityUuid) || ({} as Activity), progress: input.progress, lastUpdated: Object.values(input.progress).reduce((max, current) => current > max ? current : max From 4ada21e20b74891bcdff6f9248deb2cb89686237 Mon Sep 17 00:00:00 2001 From: sawankshrma Date: Tue, 28 Jul 2026 03:22:12 +0530 Subject: [PATCH 3/3] docs: Add Signal Migration section for CircularHeatmap, Settings, Mapping & Team Component to migration-doc --- docs/migration-doc.md | 49 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/migration-doc.md b/docs/migration-doc.md index 9b40d29c..5f774de0 100644 --- a/docs/migration-doc.md +++ b/docs/migration-doc.md @@ -637,9 +637,54 @@ Each component toggle below documents a single commit. +
    +CircularHeatmapComponent (d7abc966) + +- Full signal migration: `filtersTeams`, `filtersTeamGroups`, `teamGroups`, `allSectors`, `selectedSector`, `showOverlay`, `showFilters`, `showActivityCard`, `showActivityDetails`, and `dataStore` converted from plain properties to `signal()`. +- `hasTeamsFilter` derived as `computed()` from `filtersTeams` (no manual bookkeeping) +- Replaced `ngOnInit` + `requestAnimationFrame` hack with `afterNextRender` (Angular 17+ DOM-ready API). +- Replaced `themeService.theme$` RxJS subscription with an `effect()` tracking the `ThemeService.theme` signal for reactive heatmap repaint. +- Dissolved `destroy$` Subject / `takeUntil` / `OnDestroy`: teardown handled by `DestroyRef` (`destroyRef.onDestroy()` and `takeUntilDestroyed()`). +- Extracted `readThemeColors()` helper to DRY up CSS custom property reads (previously duplicated in init and theme subscriber). +- Group chip filter switched from listbox-level `(change)` to per-chip `(selectionChange)` with `MatChipSelectionChange` — prevents deselection of the active group chip via a three-tier guard (`isUserInput` check → programmatic deselection ignore → already-active check). +- **Bug fix:** Replaced `equalArray()` with set-based comparison for group highlight, fixing order-sensitive mismatch between chip/DOM order and YAML declaration order. +- **Bug fix:** All filter mutations now create new object references via `.set()`, fixing the `keyvalue` pipe caching bug that caused group-selected teams to be silently dropped on subsequent single-chip clicks. +- **Bug fix:** Group chip deselection no longer leaves stale team selections — the per-chip `(selectionChange)` handler prevents visual deselection entirely. +- D3 click/hover handlers updated to use `signal.set()` for `selectedSector`, `showActivityCard`. +- Template updated: all signal properties called with `()` in bindings. +- **Files:** `circular-heatmap.component.ts`, `circular-heatmap.component.html` - +
    + +
    + +MappingComponent, SettingsComponent & TeamsComponent (d0089a97) + +**MappingComponent** +- `allTeams`, `dataStore`, and `searchTerms` converted from plain properties to `signal()`. +- Search-term chip handlers (`removeSearchTerm`, `clearFilter`) and `onSearchKeydown` now use `.update()` / `.set()` instead of direct array mutation. +- `setYamlData()` writes `dataStore` and `allTeams` via `.set()`. +- Template updated: `searchTerms()`, `allTeams()`, `dataStore()` called as functions, including the optional-chained `dataStore().progressStore?.getTeamActivityTitle(...)`. + +**SettingsComponent** +- `meta`, `dataStoreMaxLevel`, `selectedMaxLevel`, `selectedMaxLevelCaption`, `editingProgressDefinitions`, `remoteReleaseCheck`, and `selectedDateFormat` converted to `signal()`. +- Removed unused `GithubReleaseInfo` import and dead `checkingLatest` / `latestReleaseInfo` / `latestCheckError` / `isNewerAvailable` / `latestDownloadUrl` / `latestReleasePublishedDate` fields. These were leftover state never wired to the template, superseded by `remoteReleaseCheck`. +- `checkForLatestRelease()` rewritten to build the release-check result via local variables (`latestRelease`, `checkError`) and commit them in a single `.update()`/`.set()` call per branch, instead of mutating individual `remoteReleaseCheck` fields in place across try/catch/finally. +- `onMaxLevelChange`, `updateMaxLevelCaption`, `toggleProgressDefinitionsEdit`, `saveProgressDefinitions`, `resetProgressDefinitions` updated to read/write via signal `()`/`.set()`/`.update()`. +- Template updated: all signal properties called with `()`, including `meta()?.activityMeta?.getDsommVersion()` and `remoteReleaseCheck().latestRelease?.tagName`-style chains. +- Spec updated: `component.meta.set({...})` and `component.selectedMaxLevel()` assertions. + +**TeamsComponent** +- `dataStore`, `canEdit`, `teams`, `teamGroups`, `progressTitleImplemented`, `infoTitle`, `infoTeams`, `info`, `allColumnNames`, and `progressColumnNames` converted to `signal()`. +- `onSelectionChanged` and `onTeamsChanged` reworked to derive a new `info`/`currentInfo` object and commit it via `.set()`, rather than mutating the existing record in place (avoids the same stale-reference class of bug fixed in the heatmap migration). +- `setYamlData()`, `updateColumnNames()`, `onExportTeamGroups()`, `onResetTeamGroups()`, `makeTeamSummary()` updated to read signals via `()`. +- Template updated: `teams()`, `teamGroups()`, `canEdit()`, `dataStore()`, `infoTitle()`, `infoTeams()`, `info()`, `allColumnNames()`, `progressColumnNames()` called as functions throughout. +- Spec updated: `component.teams()`, `component.teamGroups()` assertions. + +- **Files:** `mapping.component.ts`, `mapping.component.html`, `settings.component.ts`, `settings.component.html`, `settings.component.spec.ts`, `teams.component.ts`, `teams.component.html`, `teams.component.spec.ts` (8 files) + +
    --- @@ -648,7 +693,7 @@ Each component toggle below documents a single commit. | # | Area | Issue | Priority | Notes | |---|-----------|-------|----------|-------| | 1 | `xlsx` (SheetJS) dependency | Prototype pollution + ReDoS vulnerability, no upstream fix available (maintainers stopped publishing security patches) | Medium | Requires code changes wherever `xlsx` is imported for spreadsheet export. Options: replace with `exceljs` or `xlsx-js-style` (community fork), or accept risk if only used for non-sensitive data export. Needs a dedicated PR. | -| 2 | `CircularHeatmapComponent` | • **Order-sensitive group highlight:** `equalArray` compares chip/DOM order against YAML declaration order — mismatched order prevents highlighting. Fix: set-based comparison.
    • **Group selection wipe:** `toggleTeamGroupFilter` mutates `filtersTeams` in place, but `keyvalue` pipe caches on object reference — a subsequent single-chip click drops group-selected teams. Fix: signal-based state.
    • **Stale group deselection:** Deselecting a group chip visually deselects it but doesn't clear the underlying team selection.
    • **Layout shift on scroll:** Heatmap shifts vertically at certain viewport widths. | High | All issues will be fixed in one upcoming PR — migrating `CircularHeatmapComponent` to Signals (as `MatrixComponent` already does). | +| 2 | `CircularHeatmapComponent` | • **Layout shift on scroll:** Heatmap shifts vertically at certain viewport widths. | Low | Remaining issue after signal migration. The three chip/filter bugs (order-sensitive group highlight, group selection wipe, stale group deselection) were resolved in the `CircularHeatmapComponent` signal migration. | | 3 | Logging | Replace `console.log()` and boolean `environment.production` checks with a proper logging library using log-level feature toggles. Preferred Library: [Winston](https://github.com/winstonjs/winston). | Low | Discussed in team meeting. | | 4 | Test Runner | • **Deprecated subdependencies:** Karma pulls in `glob@7.2.3`, `inflight@1.0.6`, and `rimraf@3.0.2` — all deprecated, cluttering `pnpm install` with warnings.
    • **pnpm incompatibility:** Karma's Webpack-based builder (`@angular-devkit/build-angular:karma`) cannot resolve transitive dependencies (e.g. `@babel/runtime`) under pnpm's strict symlinked `node_modules`. Currently requires `node-linker=hoisted` in `.npmrc` as a workaround, defeating pnpm's strictness benefits.
    • **Modern tooling alignment:** Build/serve already use esbuild/Vite via `@angular/build`. Tests are the last piece still on the legacy Webpack pipeline. Migrating would allow dropping `@angular-devkit/build-angular` entirely. | High | Karma is deprecated. Migrate to a Vite-based test runner (e.g. Vitest or `@angular/build` native test support). |