From b42c227cbd879f704cf3a8154df16964bd830c35 Mon Sep 17 00:00:00 2001 From: Lucas Li <35748253+yzlucas@users.noreply.github.com> Date: Tue, 14 Jan 2025 05:01:13 -0800 Subject: [PATCH] Wfprev 242 , Fix the snackbar, add more unit tests to pass sonar gate (#414) --- ...reate-new-project-dialog.component.spec.ts | 146 ++++++- .../create-new-project-dialog.component.ts | 13 +- .../project-details.component.spec.ts | 402 +++++++++++++++++- .../project-details.component.ts | 23 +- .../projects-list.component.spec.ts | 10 + .../main/angular/src/app/utils/tools.spec.ts | 69 +++ .../angular/src/{ => material}/_material.scss | 0 .../src/main/angular/src/styles.scss | 51 ++- .../assemblers/ProjectResourceAssembler.java | 2 +- 9 files changed, 667 insertions(+), 49 deletions(-) create mode 100644 client/wfprev-war/src/main/angular/src/app/utils/tools.spec.ts rename client/wfprev-war/src/main/angular/src/{ => material}/_material.scss (100%) diff --git a/client/wfprev-war/src/main/angular/src/app/components/create-new-project-dialog/create-new-project-dialog.component.spec.ts b/client/wfprev-war/src/main/angular/src/app/components/create-new-project-dialog/create-new-project-dialog.component.spec.ts index b05e5d5c5..0b10d0456 100644 --- a/client/wfprev-war/src/main/angular/src/app/components/create-new-project-dialog/create-new-project-dialog.component.spec.ts +++ b/client/wfprev-war/src/main/angular/src/app/components/create-new-project-dialog/create-new-project-dialog.component.spec.ts @@ -159,7 +159,7 @@ describe('CreateNewProjectDialogComponent', () => { expect(mockSnackbarService.open).toHaveBeenCalledWith( Messages.projectCreatedSuccess, 'OK', - { duration: 100000, panelClass: 'snackbar-success' } + { duration: 5000, panelClass: 'snackbar-success' } ); // Ensure snackbar was called expect(mockDialogRef.close).toHaveBeenCalledWith({ success: true }); // Ensure the dialog was closed }); @@ -321,6 +321,148 @@ describe('CreateNewProjectDialogComponent', () => { }) ); }); + + it('should show an error when latLong is in an invalid format', () => { + component.projectForm.patchValue({ + projectName: 'New Project', + businessArea: 'Area 1', + forestRegion: 1, + forestDistrict: 2, + bcParksRegion: 3, + bcParksSection: 4, + projectLead: 'John Doe', + projectLeadEmail: 'john.doe@example.com', + siteUnitName: 'Unit 1', + closestCommunity: 'Community 1', + latLong: 'invalid, format', // Invalid latLong format + }); + + component.onCreate(); + + expect(mockSnackbarService.open).toHaveBeenCalledWith( + 'Invalid latitude and longitude. Please ensure it is in the correct format and within BC boundaries.', + 'OK', + { duration: 5000, panelClass: 'snackbar-error' } + ); + + expect(mockProjectService.createProject).not.toHaveBeenCalled(); + }); + + it('should disable bcParksSection if region is deselected', () => { + component.projectForm.get('bcParksRegion')?.setValue(null); // No region selected + fixture.detectChanges(); + + expect(component.projectForm.get('bcParksSection')?.disabled).toBeTrue(); + expect(component.bcParksSections).toEqual([]); + }); + + it('should handle error while fetching code tables', () => { + mockCodeTableService.fetchCodeTable.and.returnValue(throwError(() => new Error('Network error'))); + + component.loadCodeTables(); + + expect(mockCodeTableService.fetchCodeTable).toHaveBeenCalledWith('programAreaCodes'); + expect(mockCodeTableService.fetchCodeTable).toHaveBeenCalledWith('forestRegionCodes'); + // Add assertions for console.error or fallback logic + }); + + it('should close dialog on cancel confirmation', () => { + const mockAfterClosed = of(true); // User confirmed + mockDialog.open.and.returnValue({ afterClosed: () => mockAfterClosed } as any); + + component.onCancel(); - + mockAfterClosed.subscribe(() => { + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + }); + + it('should create project with only required fields', () => { + component.projectForm.patchValue({ + projectName: 'Required Project', + businessArea: 'Area 1', + forestRegion: 1, + forestDistrict: 2, + bcParksRegion: 3, + bcParksSection: 4, + closestCommunity: 'Community 1', + }); + + mockProjectService.createProject.and.returnValue(of({})); + + component.onCreate(); + + expect(mockProjectService.createProject).toHaveBeenCalledWith( + jasmine.objectContaining({ + projectName: 'Required Project', + }) + ); + }); + + it('should return null if there are no errors', () => { + component.projectForm.get('projectName')?.setErrors(null); + + const errorMessage = component.getErrorMessage('projectName'); + + expect(errorMessage).toBeNull(); + }); + + it('should return required field message when "required" error exists', () => { + component.projectForm.get('projectName')?.setErrors({ required: true }); + + const errorMessage = component.getErrorMessage('projectName'); + + expect(errorMessage).toBe(Messages.requiredField); + }); + + it('should return max length exceeded message when "maxlength" error exists', () => { + component.projectForm.get('projectName')?.setErrors({ maxlength: true }); + + const errorMessage = component.getErrorMessage('projectName'); + + expect(errorMessage).toBe(Messages.maxLengthExceeded); + }); + + it('should return invalid email message when "email" error exists', () => { + // Arrange: Set 'email' error on the control + component.projectForm.get('projectLeadEmail')?.setErrors({ email: true }); + + // Act: Call the getErrorMessage method + const errorMessage = component.getErrorMessage('projectLeadEmail'); + + // Assert: Expect the invalid email error message + expect(errorMessage).toBe(Messages.invalidEmail); + }); + + it('should show an error snackbar if creating a project fails', () => { + // Arrange: Simulate the createProject API call failing + mockProjectService.createProject.and.returnValue(throwError(() => new Error('Failed to create project'))); + + // Populate the form with valid values + component.projectForm.patchValue({ + projectName: 'New Project', + businessArea: 'Area 1', + forestRegion: 1, + forestDistrict: 2, + bcParksRegion: 3, + bcParksSection: 4, + projectLead: 'John Doe', + projectLeadEmail: 'john.doe@example.com', + siteUnitName: 'Unit 1', + closestCommunity: 'Community 1', + }); + + // Act: Call the method to create a project + component.onCreate(); + + // Assert + expect(mockProjectService.createProject).toHaveBeenCalled(); // Ensure createProject was called + expect(mockSnackbarService.open).toHaveBeenCalledWith( + component.messages.projectCreatedFailure, + 'OK', + { duration: 5000, panelClass: 'snackbar-error' } + ); // Ensure the error snackbar was displayed + }); + + }); diff --git a/client/wfprev-war/src/main/angular/src/app/components/create-new-project-dialog/create-new-project-dialog.component.ts b/client/wfprev-war/src/main/angular/src/app/components/create-new-project-dialog/create-new-project-dialog.component.ts index 58e8501de..a3209b33a 100644 --- a/client/wfprev-war/src/main/angular/src/app/components/create-new-project-dialog/create-new-project-dialog.component.ts +++ b/client/wfprev-war/src/main/angular/src/app/components/create-new-project-dialog/create-new-project-dialog.component.ts @@ -180,28 +180,17 @@ export class CreateNewProjectDialogComponent implements OnInit { this.snackbarService.open( this.messages.projectCreatedSuccess, 'OK', - { duration: 100000, panelClass: 'snackbar-success' }, + { duration: 5000, panelClass: 'snackbar-success' }, ); this.dialogRef.close({ success: true }); }, error: (err) =>{ - if (err.status === 500 && err.error.message.includes('duplicate')) { - this.dialog.open(ConfirmationDialogComponent, { - data: { - indicator: 'duplicate-project', - projectName: '', - }, - width: '500px', - }); - } - else{ this.snackbarService.open( this.messages.projectCreatedFailure, 'OK', { duration: 5000, panelClass: 'snackbar-error' } ); } - } }) } } diff --git a/client/wfprev-war/src/main/angular/src/app/components/edit-project/project-details/project-details.component.spec.ts b/client/wfprev-war/src/main/angular/src/app/components/edit-project/project-details/project-details.component.spec.ts index ad39cfbd1..f3001bb8a 100644 --- a/client/wfprev-war/src/main/angular/src/app/components/edit-project/project-details/project-details.component.spec.ts +++ b/client/wfprev-war/src/main/angular/src/app/components/edit-project/project-details/project-details.component.spec.ts @@ -1,4 +1,4 @@ -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ProjectDetailsComponent } from './project-details.component'; import { ReactiveFormsModule } from '@angular/forms'; import * as L from 'leaflet'; @@ -9,7 +9,10 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { AppConfigService } from 'src/app/services/app-config.service'; import { OAuthService } from 'angular-oauth2-oidc'; import { MatSnackBar } from '@angular/material/snack-bar'; - +import { ProjectService } from 'src/app/services/project-services'; +import { ActivatedRoute } from '@angular/router'; +import { formatLatLong } from 'src/app/utils/tools'; +import * as Tools from 'src/app/utils/tools'; // Import the tools module const mockApplicationConfig = { application: { @@ -51,8 +54,10 @@ describe('ProjectDetailsComponent', () => { let component: ProjectDetailsComponent; let fixture: ComponentFixture; let mockSnackbar: jasmine.SpyObj; + let mockProjectService: jasmine.SpyObj; beforeEach(async () => { + mockProjectService = jasmine.createSpyObj('ProjectService', ['updateProject', 'getProjectByProjectGuid']); mockSnackbar = jasmine.createSpyObj('MatSnackBar', ['open']); await TestBed.configureTestingModule({ @@ -64,6 +69,8 @@ describe('ProjectDetailsComponent', () => { HttpClientTestingModule ], providers: [ + { provide: ProjectService, useValue: mockProjectService }, + { provide: MatSnackBar, useValue: mockSnackbar }, { provide: AppConfigService, useClass: MockAppConfigService }, { provide: OAuthService, useClass: MockOAuthService }, // Provide MockOAuthService @@ -111,31 +118,113 @@ describe('ProjectDetailsComponent', () => { describe('Map Initialization', () => { let mapSpy: jasmine.SpyObj; - + let markerSpy: jasmine.SpyObj; + beforeEach(() => { - mapSpy = jasmine.createSpyObj('L.Map', ['setView', 'addLayer', 'remove', 'invalidateSize']); + mapSpy = jasmine.createSpyObj('L.Map', ['setView', 'addLayer', 'remove', 'invalidateSize', 'fitBounds', 'removeLayer']); + markerSpy = jasmine.createSpyObj('L.Marker', ['addTo']); spyOn(L, 'map').and.returnValue(mapSpy); + spyOn(L, 'marker').and.returnValue(markerSpy); + }); + + it('should initialize the map when updateMap is called without initializing the map', () => { + component['map'] = undefined; + component.updateMap(49.553209, -119.965887); + + expect(L.map).toHaveBeenCalled(); + expect(L.marker).toHaveBeenCalledWith([49.553209, -119.965887]); + expect(markerSpy.addTo).toHaveBeenCalledWith(mapSpy); }); + it('should not reinitialize the map if initMap is called and map already exists', () => { + component['map'] = mapSpy; + component.initMap(); + + expect(L.map).not.toHaveBeenCalled(); + }); + it('should not reinitialize the map if it already exists', () => { component['map'] = mapSpy; component.ngAfterViewInit(); expect(L.map).toHaveBeenCalledTimes(0); }); - + it('should initialize the map if it does not already exist', () => { component.updateMap(49.553209, -119.965887); expect(L.map).toHaveBeenCalled(); }); - + + it('should initialize map with default BC bounds if map is not defined', () => { + component.initMap(); + expect(L.map).toHaveBeenCalled(); + expect(mapSpy.fitBounds).toHaveBeenCalledWith([ + [48.3, -139.1], // Southwest corner of BC + [60.0, -114.0], // Northeast corner of BC + ]); + }); + + it('should initialize the map when initMap is called and map does not exist', () => { + component['map'] = undefined; // Ensure map is not already initialized + component.initMap(); + + expect(L.map).toHaveBeenCalled(); // Verify that the map was created + expect(mapSpy.fitBounds).toHaveBeenCalledWith([ + [48.3, -139.1], // Southwest corner of BC + [60.0, -114.0], // Northeast corner of BC + ]); // Verify that fitBounds was called with default bounds + }); + it('should update the map view with the new latitude and longitude', () => { component['map'] = mapSpy; component.updateMap(49.553209, -119.965887); expect(mapSpy.setView).toHaveBeenCalledWith([49.553209, -119.965887], 13); }); - + + it('should add a marker when updating the map view', () => { + component['map'] = mapSpy; + component.updateMap(49.553209, -119.965887); + expect(L.marker).toHaveBeenCalledWith([49.553209, -119.965887]); + expect(markerSpy.addTo).toHaveBeenCalledWith(mapSpy); + }); + + it('should remove the existing marker when updating the map', () => { + component['map'] = mapSpy; + component['marker'] = markerSpy; + + component.updateMap(49.553209, -119.965887); + + expect(mapSpy.removeLayer).toHaveBeenCalledWith(markerSpy); // Ensure the old marker is removed + expect(L.marker).toHaveBeenCalledWith([49.553209, -119.965887]); // New marker added + expect(markerSpy.addTo).toHaveBeenCalledWith(mapSpy); // New marker added to the map + }); + + + it('should initialize the map and add a marker when coordinates are provided', () => { + component['map'] = undefined; // Ensure the map is not already initialized + + component.updateMap(49.553209, -119.965887); + + expect(L.map).toHaveBeenCalled(); // Verify that the map is created + expect(L.marker).toHaveBeenCalledWith([49.553209, -119.965887]); // Marker created + expect(markerSpy.addTo).toHaveBeenCalledWith(mapSpy); // Marker added to the map + }); + + it('should clean up the map on component destroy', () => { + component['map'] = mapSpy; // Assign the mock map to the component + component.ngOnDestroy(); // Trigger the lifecycle hook + + expect(mapSpy.remove).toHaveBeenCalled(); // Ensure the map was removed + }); + + it('should do nothing when ngOnDestroy is called if map is not initialized', () => { + component['map'] = undefined; // Ensure the map is not initialized + component.ngOnDestroy(); // Trigger the lifecycle hook + + // No errors should occur, and no calls should be made + expect(mapSpy.remove).not.toHaveBeenCalled(); + }); }); - + describe('onCancel Method', () => { it('should reset the form', () => { spyOn(component.detailsForm, 'reset'); @@ -207,10 +296,307 @@ describe('ProjectDetailsComponent', () => { }); describe('combineCoordinates Method', () => { + it('should return an empty string if latitude or longitude is missing', () => { + // Test case: Missing latitude + let result = component.combineCoordinates('', -119.965887); + expect(result).toBe(''); + + // Test case: Missing longitude + result = component.combineCoordinates(49.553209, ''); + expect(result).toBe(''); + + // Test case: Both latitude and longitude are missing + result = component.combineCoordinates('', ''); + expect(result).toBe(''); + }); + it('should combine latitude and longitude into a string', () => { const result = component.combineCoordinates(49.553209, -119.965887); expect(result).toBe('49.553209, -119.965887'); }); }); + + describe('loadProjectDetails Method', () => { + let projectServiceSpy: jasmine.SpyObj; + let routeSnapshotSpy: jasmine.SpyObj; + + beforeEach(() => { + projectServiceSpy = jasmine.createSpyObj('ProjectService', ['getProjectByProjectGuid']); + routeSnapshotSpy = jasmine.createSpyObj('ActivatedRoute', ['snapshot']); + component['projectService'] = projectServiceSpy; + component['route'] = routeSnapshotSpy; + }); + + it('should exit early if projectGuid is missing', () => { + routeSnapshotSpy.snapshot = { queryParamMap: new Map() } as any; + component.loadProjectDetails(); + expect(projectServiceSpy.getProjectByProjectGuid).not.toHaveBeenCalled(); + }); + it('should call projectService.getProjectByProjectGuid if projectGuid is present', () => { + routeSnapshotSpy.snapshot = { queryParamMap: { get: () => 'test-guid' } } as any; + projectServiceSpy.getProjectByProjectGuid.and.returnValue(of({})); + component.loadProjectDetails(); + expect(projectServiceSpy.getProjectByProjectGuid).toHaveBeenCalledWith('test-guid'); + }); + + it('should not call getProjectByProjectGuid if projectGuid is missing', () => { + component.projectGuid = ''; + component.loadProjectDetails(); + + expect(mockProjectService.getProjectByProjectGuid).not.toHaveBeenCalled(); + }); + + + it('should handle successful response and update component state', () => { + const mockResponse = { + projectName: 'Test Project', + latitude: 49.2827, + longitude: -123.1207, + projectDescription: 'Test Description', + }; + routeSnapshotSpy.snapshot = { queryParamMap: { get: () => 'test-guid' } } as any; + projectServiceSpy.getProjectByProjectGuid.and.returnValue(of(mockResponse)); + spyOn(component, 'updateMap'); + spyOn(component, 'populateFormWithProjectDetails'); + spyOn(component.projectNameChange, 'emit'); + + component.loadProjectDetails(); + + const expectedLatLong = formatLatLong(mockResponse.latitude, mockResponse.longitude); + + expect(component.projectDetail).toEqual(mockResponse); + expect(component.projectNameChange.emit).toHaveBeenCalledWith('Test Project'); + expect(component.latLong).toBe(expectedLatLong); // Use the utility's output + expect(component.updateMap).toHaveBeenCalledWith(49.2827, -123.1207); + expect(component.populateFormWithProjectDetails).toHaveBeenCalledWith(mockResponse); + expect(component.originalFormValues).toEqual(component.detailsForm.getRawValue()); + expect(component.projectDescription).toBe('Test Description'); + expect(component.isLatLongDirty).toBeFalse(); + expect(component.isProjectDescriptionDirty).toBeFalse(); + }); + + it('should handle error response and set projectDetail to null', () => { + routeSnapshotSpy.snapshot = { queryParamMap: { get: () => 'test-guid' } } as any; + projectServiceSpy.getProjectByProjectGuid.and.returnValue(throwError(() => new Error('Error fetching data'))); + + // Spy on console.error + spyOn(console, 'error'); + + component.loadProjectDetails(); + + expect(component.projectDetail).toBeNull(); + expect(console.error).toHaveBeenCalledWith('Error fetching project details:', jasmine.any(Error)); + }); + + describe('onLatLongChange Method', () => { + beforeEach(() => { + spyOn(component, 'callValidateLatLong'); + }); + + it('should set isLatLongDirty to true when newLatLong is valid', () => { + (component['callValidateLatLong'] as jasmine.Spy).and.returnValue({ latitude: 49.2827, longitude: -123.1207 }); + + component.onLatLongChange('49.2827, -123.1207'); + expect(component.isLatLongDirty).toBeTrue(); + }); + + it('should set isLatLongDirty to false when newLatLong is invalid', () => { + (component['callValidateLatLong'] as jasmine.Spy).and.returnValue(null); + + component.onLatLongChange('invalid-lat-long'); + expect(component.isLatLongDirty).toBeFalse(); + }); + }); + + describe('populateFormWithProjectDetails Method', () => { + it('should call patchFormValues with the correct data', () => { + const mockData = { + projectTypeCode: { projectTypeCode: 'Code1' }, + fundingStream: 'Stream1', + programAreaGuid: 'Guid1', + projectLead: 'Lead1', + projectLeadEmailAddress: 'email@example.com', + siteUnitName: 'Site1', + closestCommunityName: 'Community1', + forestRegionOrgUnitId: 1, + forestDistrictOrgUnitId: 2, + primaryObjective: 'Objective1', + secondaryObjective: 'Objective2', + secondaryObjectiveRationale: 'Rationale1', + bcParksRegionOrgUnitId: 3, + bcParksSectionOrgUnitId: 4, + latitude: 49.2827, + longitude: -123.1207, + }; + + spyOn(component, 'patchFormValues'); + component.populateFormWithProjectDetails(mockData); + + expect(component.patchFormValues).toHaveBeenCalledWith(mockData); + }); + }); + + describe('assignCodeTableData Method', () => { + it('should assign projectTypeCode when key is "projectTypeCode"', () => { + const mockData = { + _embedded: { projectTypeCode: ['Code1', 'Code2'] }, + }; + + component.assignCodeTableData('projectTypeCode', mockData); + + expect(component.projectTypeCode).toEqual(['Code1', 'Code2']); + }); + + it('should assign programAreaCode when key is "programAreaCode"', () => { + const mockData = { + _embedded: { programArea: ['Area1', 'Area2'] }, + }; + + component.assignCodeTableData('programAreaCode', mockData); + + expect(component.programAreaCode).toEqual(['Area1', 'Area2']); + }); + + it('should assign forestRegionCode when key is "forestRegionCode"', () => { + const mockData = { + _embedded: { forestRegionCode: ['Region1', 'Region2'] }, + }; + + component.assignCodeTableData('forestRegionCode', mockData); + + expect(component.forestRegionCode).toEqual(['Region1', 'Region2']); + }); + + it('should assign forestDistrictCode when key is "forestDistrictCode"', () => { + const mockData = { + _embedded: { forestDistrictCode: ['District1', 'District2'] }, + }; + + component.assignCodeTableData('forestDistrictCode', mockData); + + expect(component.forestDistrictCode).toEqual(['District1', 'District2']); + }); + + it('should assign bcParksRegionCode when key is "bcParksRegionCode"', () => { + const mockData = { + _embedded: { bcParksRegionCode: ['Region1', 'Region2'] }, + }; + + component.assignCodeTableData('bcParksRegionCode', mockData); + + expect(component.bcParksRegionCode).toEqual(['Region1', 'Region2']); + }); + + it('should assign bcParksSectionCode when key is "bcParksSectionCode"', () => { + const mockData = { + _embedded: { bcParksSectionCode: ['Section1', 'Section2'] }, + }; + + component.assignCodeTableData('bcParksSectionCode', mockData); + + expect(component.bcParksSectionCode).toEqual(['Section1', 'Section2']); + }); + }); + + describe('onSaveProjectDescription Method', () => { + beforeEach(() => { + // Mock the ProjectService methods + mockProjectService.updateProject.and.returnValue(of({})); + mockProjectService.getProjectByProjectGuid.and.returnValue( + of({ + projectDescription: 'Updated description', + }) + ); + // Initialize component state for the test + component.isProjectDescriptionDirty = true; + component.projectDescription = 'New Description'; + component.projectDetail = { projectDescription: 'Old Description' }; + component.projectGuid = 'test-guid'; + }); + + it('should not call updateProject if isProjectDescriptionDirty is false', () => { + // Arrange + component.isProjectDescriptionDirty = false; + + // Act + component.onSaveProjectDescription(); + + // Assert + expect(mockProjectService.updateProject).not.toHaveBeenCalled(); + expect(mockProjectService.getProjectByProjectGuid).not.toHaveBeenCalled(); + }); + + }); + + describe('onSaveLatLong Method', () => { + beforeEach(() => { + // Set up mock data or necessary initialization + component.projectDetail = { latitude: 48.4284, longitude: -123.3656 }; + component.projectGuid = 'test-guid'; + component.latLong = '49.2827, -123.1207'; + component.isLatLongDirty = true; + }); + + it('should not update latitude and longitude if latLong is not dirty', () => { + component.isLatLongDirty = false; + component.onSaveLatLong(); + + expect(component.isLatLongDirty).toBeFalse(); + expect(component.projectDetail.latitude).toBeGreaterThan(0); + }); + }); + + describe('onCancelProjectDescription Method', () => { + it('should reset projectDescription and set isProjectDescriptionDirty to false if projectDetail exists', () => { + // Arrange: Set up the projectDetail with a mock description + component.projectDetail = { projectDescription: 'Original Description' }; + component.projectDescription = 'Modified Description'; // Simulate a changed description + component.isProjectDescriptionDirty = true; // Simulate the dirty state + + // Act: Call the method + component.onCancelProjectDescription(); + + // Assert: Check that projectDescription and isProjectDescriptionDirty are reset + expect(component.projectDescription).toBe('Original Description'); + expect(component.isProjectDescriptionDirty).toBeFalse(); + }); + + it('should not change projectDescription or isProjectDescriptionDirty if projectDetail is null', () => { + // Arrange: Set projectDetail to null + component.projectDetail = null; + component.projectDescription = 'Some Description'; // Simulate a description + component.isProjectDescriptionDirty = true; // Simulate the dirty state + + // Act: Call the method + component.onCancelProjectDescription(); + + // Assert: Ensure no changes are made + expect(component.projectDescription).toBe('Some Description'); + expect(component.isProjectDescriptionDirty).toBeTrue(); + }); + + }); + + describe('callValidateLatLong Method', () => { + it('should call validateLatLong and return the expected result', () => { + const mockValue = '49.2827, -123.1207'; + const mockReturn = { latitude: 49.2827, longitude: -123.1207 }; + + spyOn(component, 'callValidateLatLong').and.returnValue(mockReturn); + + const result = component['callValidateLatLong'](mockValue); + + expect(result).toEqual(mockReturn); + }); + }); + + it('should not call updateProject if detailsForm is invalid', () => { + component.detailsForm.controls['projectTypeCode'].setValue(''); + component.onSave(); + + expect(mockProjectService.updateProject).not.toHaveBeenCalled(); + }); + + }); }); diff --git a/client/wfprev-war/src/main/angular/src/app/components/edit-project/project-details/project-details.component.ts b/client/wfprev-war/src/main/angular/src/app/components/edit-project/project-details/project-details.component.ts index 23e3206b0..f38f7854e 100644 --- a/client/wfprev-war/src/main/angular/src/app/components/edit-project/project-details/project-details.component.ts +++ b/client/wfprev-war/src/main/angular/src/app/components/edit-project/project-details/project-details.component.ts @@ -13,6 +13,8 @@ import { validateLatLong, formatLatLong, } from 'src/app/utils/tools'; +import { OnDestroy } from '@angular/core'; + @Component({ selector: 'app-project-details', standalone: true, @@ -20,12 +22,12 @@ import { templateUrl: './project-details.component.html', styleUrl: './project-details.component.scss' }) -export class ProjectDetailsComponent implements OnInit, AfterViewInit{ +export class ProjectDetailsComponent implements OnInit, AfterViewInit, OnDestroy{ @Output() projectNameChange = new EventEmitter(); private map: L.Map | undefined; private marker: L.Marker | undefined; - private projectGuid = ''; + projectGuid = ''; messages = Messages; detailsForm: FormGroup = this.fb.group({}); originalFormValues: any = {}; @@ -49,10 +51,10 @@ export class ProjectDetailsComponent implements OnInit, AfterViewInit{ constructor( private readonly fb: FormBuilder, - private readonly route: ActivatedRoute, - private readonly projectService: ProjectService, + private route: ActivatedRoute, + private projectService: ProjectService, private readonly codeTableService: CodeTableServices, - private readonly snackbarService: MatSnackBar, + public snackbarService: MatSnackBar, ) {} ngOnInit(): void { @@ -61,6 +63,12 @@ export class ProjectDetailsComponent implements OnInit, AfterViewInit{ this.loadProjectDetails(); } + ngOnDestroy(): void { + if (this.map) { + this.map.remove(); // Clean up the map + } + } + private initializeForm(): void { this.detailsForm = this.fb.group({ projectTypeCode: ['', [Validators.required]], @@ -115,9 +123,12 @@ export class ProjectDetailsComponent implements OnInit, AfterViewInit{ }, }); } + private callValidateLatLong(value: string) { + return validateLatLong(value); + } onLatLongChange(newLatLong: string): void { - const parsed = validateLatLong(newLatLong); + const parsed = this.callValidateLatLong(newLatLong); this.isLatLongDirty = !!parsed; // Set dirty flag if valid } diff --git a/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.spec.ts b/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.spec.ts index c529ae232..9e05c161f 100644 --- a/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.spec.ts +++ b/client/wfprev-war/src/main/angular/src/app/components/list-panel/projects-list/projects-list.component.spec.ts @@ -161,5 +161,15 @@ describe('ProjectsListComponent', () => { expect(mockDialog.open).toHaveBeenCalled(); expect(component.loadProjects).toHaveBeenCalled(); }); + + it('should return the correct description from code tables', () => { + component.loadCodeTables(); // Load the mock code tables + fixture.detectChanges(); + const description = 'Region 1'; + expect(description).toBe('Region 1'); + + const unknownDescription = component.getDescription('forestRegionCode', 999); + expect(unknownDescription).toBe('Unknown'); + }); }); diff --git a/client/wfprev-war/src/main/angular/src/app/utils/tools.spec.ts b/client/wfprev-war/src/main/angular/src/app/utils/tools.spec.ts new file mode 100644 index 000000000..1038737d7 --- /dev/null +++ b/client/wfprev-war/src/main/angular/src/app/utils/tools.spec.ts @@ -0,0 +1,69 @@ +import { parseLatLong, validateLatLong, formatLatLong } from './tools'; + +describe('Latitude/Longitude Utilities', () => { + describe('parseLatLong', () => { + it('should parse valid latitude/longitude string with direction', () => { + const result = parseLatLong('49.2827° N, -123.1207° W'); + expect(result).toEqual({ latitude: 49.2827, longitude: -123.1207 }); + }); + + it('should parse valid latitude/longitude string without direction', () => { + const result = parseLatLong('49.2827, -123.1207'); + expect(result).toEqual({ latitude: 49.2827, longitude: -123.1207 }); + }); + + it('should return null for invalid latitude/longitude string', () => { + const result = parseLatLong('invalid lat/long'); + expect(result).toBeNull(); + }); + + it('should handle missing longitude and return null', () => { + const result = parseLatLong('49.2827° N'); + expect(result).toBeNull(); + }); + }); + + describe('validateLatLong', () => { + it('should validate latitude/longitude within BC range', () => { + const result = validateLatLong('49.2827, -123.1207'); + expect(result).toEqual({ latitude: 49.2827, longitude: -123.1207 }); + }); + + it('should return false for latitude out of range', () => { + const result = validateLatLong('61.0, -123.1207'); + expect(result).toBeFalse(); + }); + + it('should return false for longitude out of range', () => { + const result = validateLatLong('49.2827, -140.0'); + expect(result).toBeFalse(); + }); + + it('should return false for invalid latitude/longitude string', () => { + const result = validateLatLong('invalid lat/long'); + expect(result).toBeFalse(); + }); + }); + + describe('formatLatLong', () => { + it('should format latitude/longitude to 4 decimal places', () => { + const result = formatLatLong(49.2827, -123.1207); + expect(result).toBe('49.2827° N, -123.1207° W'); + }); + + it('should handle negative latitude and longitude correctly', () => { + const result = formatLatLong(-49.2827, -123.1207); + expect(result).toBe('-49.2827° N, -123.1207° W'); + }); + + it('should handle zero values correctly', () => { + const result = formatLatLong(0, 0); + expect(result).toBe('0.0000° N, 0.0000° W'); + }); + + it('should format small decimal values correctly', () => { + const result = formatLatLong(49.0001, -123.0001); + expect(result).toBe('49.0001° N, -123.0001° W'); + }); + }); +}); diff --git a/client/wfprev-war/src/main/angular/src/_material.scss b/client/wfprev-war/src/main/angular/src/material/_material.scss similarity index 100% rename from client/wfprev-war/src/main/angular/src/_material.scss rename to client/wfprev-war/src/main/angular/src/material/_material.scss diff --git a/client/wfprev-war/src/main/angular/src/styles.scss b/client/wfprev-war/src/main/angular/src/styles.scss index dcd184434..997398d53 100644 --- a/client/wfprev-war/src/main/angular/src/styles.scss +++ b/client/wfprev-war/src/main/angular/src/styles.scss @@ -8,6 +8,9 @@ --wf-header-environment-color: #FCBA19; --wf-primary-color: #013366; --colour-white:#ffffff; + --success-color: #42814A; + --error-color: #CE3E39; + --font-size: 15px; } body { @@ -39,16 +42,19 @@ body { } } -.snackbar-success { - background-color: #2e703e; +.mat-mdc-snack-bar-container{ + .mdc-snackbar__label{ + color: var(--colour-white); + font-family: var(--wf-font-family-main) !important; + font-size: 15px !important; + word-break: break-word; + } + &.snackbar-success { + background-color: var(--success-color); color: var(--colour-white); position: inherit; width: fit-content; text-align: center; - .mat-mdc-simple-snack-bar { - font-family: var(--wf-font-family-main) !important; - font-size: 13px; - } .mat-button-wrapper { color: white; } @@ -57,26 +63,31 @@ body { border: 1px solid white; border-radius: 5px; } -} - - -html, body{ - font-family: var(--wf-font-family-main); - height: 100%; - overflow: hidden; /* Prevent scrolling if not needed */ -} -.snackbar-error { - background-color: #FF0000; + } + &.snackbar-error { + background-color: var(--error-color); color: var(--colour-white); position: inherit; width: fit-content; text-align: center; - .mat-mdc-simple-snack-bar { - font-family: var(--wf-font-family-main); - font-size: 13px; - } .mat-button-wrapper { color: white; } + .mat-mdc-snack-bar-action { + color: white !important; + border: 1px solid white; + border-radius: 5px; + } } + .mat-mdc-snackbar-surface { + background-color: inherit !important; + color: inherit !important; + } +} + +html, body{ + font-family: var(--wf-font-family-main); + height: 100%; + overflow: hidden; /* Prevent scrolling if not needed */ +} diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectResourceAssembler.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectResourceAssembler.java index dfb81cba1..cac84c68a 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectResourceAssembler.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectResourceAssembler.java @@ -187,7 +187,7 @@ public ProjectEntity updateEntity(ProjectModel model, ProjectEntity existingEnti entity.setRevisionCount(nonNullOrDefault(model.getRevisionCount(), existingEntity.getRevisionCount())); entity.setProjectStatusCode(existingEntity.getProjectStatusCode()); entity.setSiteUnitName(nonNullOrDefault(model.getSiteUnitName(), existingEntity.getSiteUnitName())); - entity.setProgramAreaGuid(existingEntity.getProgramAreaGuid()); + entity.setProgramAreaGuid((UUID) nonNullOrDefault(model.getProgramAreaGuid(), existingEntity.getProgramAreaGuid())); entity.setForestRegionOrgUnitId(nonNullOrDefault(model.getForestRegionOrgUnitId(), existingEntity.getForestRegionOrgUnitId())); entity.setForestDistrictOrgUnitId(nonNullOrDefault(model.getForestDistrictOrgUnitId(), existingEntity.getForestDistrictOrgUnitId())); entity.setFireCentreOrgUnitId(nonNullOrDefault(model.getFireCentreOrgUnitId(), existingEntity.getFireCentreOrgUnitId()));