diff --git a/.vscode/launch.json b/.vscode/launch.json index 8f2eb0b275..3ce137f26a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -241,6 +241,25 @@ "ENVIRONMENT": "test", "TZ": "UTC" } + }, + { + "type": "node", + "request": "launch", + "name": "Unit tests - Current test file", + "program": "${workspaceFolder}/sources/packages/backend/node_modules/.bin/jest", + "args": [ + "${fileBasenameNoExtension}", + "--runInBand", + "--config", + "./sources/packages/backend/jest-unit-tests.json", + "--forceExit" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/sources/packages/backend/node_modules/jest/bin/jest" + } } ] } diff --git a/sources/packages/backend/apps/api/src/route-controllers/application/_tests_/application.aest.controller.getApplicationDetails.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/application/_tests_/application.aest.controller.getApplicationDetails.e2e-spec.ts index adf31835d8..ef584bdaef 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/application/_tests_/application.aest.controller.getApplicationDetails.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/application/_tests_/application.aest.controller.getApplicationDetails.e2e-spec.ts @@ -12,6 +12,7 @@ import { saveFakeApplication, } from "@sims/test-utils"; import { + ApplicationData, ApplicationStatus, EducationProgramOffering, OfferingIntensity, @@ -154,6 +155,90 @@ describe("ApplicationAESTController(e2e)-getApplicationDetails", () => { }, ); + it("Should get the student application changes when the application has a previous version and its dynamic data was changed.", async () => { + // Arrange + const previousApplication = await saveFakeApplication( + db.dataSource, + {}, + { + applicationStatus: ApplicationStatus.Overwritten, + applicationData: { + studystartDate: "2000-01-01", + studyendDate: "2000-01-31", + selectedOffering: 1, + studentNumber: "1234567", + courseDetails: [ + { + courseName: "courseName", + courseCode: "courseCode", + }, + ], + } as ApplicationData, + }, + ); + const currentApplication = await saveFakeApplication( + db.dataSource, + {}, + { + applicationStatus: ApplicationStatus.Completed, + applicationNumber: previousApplication.applicationNumber, + applicationData: { + studystartDate: "2000-12-01", + studyendDate: "2000-12-31", + selectedOffering: 4, + courseDetails: [ + { + courseName: "courseName updated", + courseCode: "courseCode", + }, + ], + } as ApplicationData, + }, + ); + const token = await getAESTToken(AESTGroups.BusinessAdministrators); + const endpoint = `/aest/application/${currentApplication.id}`; + + // Act/Assert + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + expect(response.body.changes).toStrictEqual([ + { + changeType: "updated", + key: "studyendDate", + }, + { + changeType: "updated", + changes: [ + { + changeType: "updated", + changes: [ + { + changeType: "updated", + key: "courseName", + }, + ], + index: 0, + }, + ], + key: "courseDetails", + }, + { + changeType: "updated", + key: "studystartDate", + }, + { + changeType: "updated", + key: "selectedOffering", + }, + { + changeType: "updated", + key: "selectedOfferingName", + }, + ]); + }); + afterAll(async () => { await app?.close(); }); diff --git a/sources/packages/backend/apps/api/src/route-controllers/application/application.aest.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/application/application.aest.controller.ts index b788285c27..bcf0660ea8 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/application/application.aest.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/application/application.aest.controller.ts @@ -20,6 +20,7 @@ import { EnrolmentApplicationDetailsAPIOutDTO, InProgressApplicationDetailsAPIOutDTO, ApplicationOverallDetailsAPIOutDTO, + ApplicationFormData, } from "./models/application.dto"; import { AllowAuthorizedParty, @@ -82,16 +83,35 @@ export class ApplicationAESTController extends BaseController { `Application id ${applicationId} was not found.`, ); } - + let currentReadOnlyData: ApplicationFormData; + let previousReadOnlyData: ApplicationFormData; if (loadDynamicData) { - application.data = - await this.applicationControllerService.generateApplicationFormData( + // Check if a previous application exists. + const [previousApplicationVersion] = + await this.applicationService.getPreviousApplicationVersions( + applicationId, + { loadDynamicData: true, limit: 1 }, + ); + const currentReadOnlyDataPromise = + this.applicationControllerService.generateApplicationFormData( application.data, ); + // If there is a previous application, generate its read-only data. + const previousReadOnlyDataPromise = + previousApplicationVersion && + this.applicationControllerService.generateApplicationFormData( + previousApplicationVersion.data, + ); + // Wait for both promises to resolve. + [currentReadOnlyData, previousReadOnlyData] = await Promise.all([ + currentReadOnlyDataPromise, + previousReadOnlyDataPromise, + ]); + application.data = currentReadOnlyData; } - return this.applicationControllerService.transformToApplicationDTO( application, + { previousData: previousReadOnlyData }, ); } diff --git a/sources/packages/backend/apps/api/src/route-controllers/application/application.controller.service.ts b/sources/packages/backend/apps/api/src/route-controllers/application/application.controller.service.ts index cfd4ea7d6f..5f93075d84 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/application/application.controller.service.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/application/application.controller.service.ts @@ -26,6 +26,7 @@ import { ApplicationProgressDetailsAPIOutDTO, CompletedApplicationDetailsAPIOutDTO, InProgressApplicationDetailsAPIOutDTO, + ApplicationDataChangeAPIOutDTO, } from "./models/application.dto"; import { credentialTypeToDisplay, @@ -35,7 +36,11 @@ import { getPIRDeniedReason, getUserFullName, } from "../../utilities"; -import { getDateOnlyFormat } from "@sims/utilities"; +import { + ApplicationDataChange, + getDateOnlyFormat, + compareApplicationData, +} from "@sims/utilities"; import { Application, ApplicationData, @@ -355,7 +360,7 @@ export class ApplicationControllerService { private async processSelectedLocation( data: ApplicationData, additionalFormData: ApplicationFormData, - ) { + ): Promise { if (data.selectedLocation) { const designatedLocation = await this.locationService.getLocation( data.selectedLocation, @@ -378,7 +383,7 @@ export class ApplicationControllerService { private async processSelectedProgram( data: ApplicationData, additionalFormData: ApplicationFormData, - ) { + ): Promise { if (data.selectedProgram) { const selectedProgram = await this.programService.getProgramById( data.selectedProgram, @@ -424,7 +429,7 @@ export class ApplicationControllerService { private async processSelectedOffering( data: ApplicationData, additionalFormData: ApplicationFormData, - ) { + ): Promise { if (data.selectedOffering) { const selectedOffering = await this.offeringService.getOfferingById( data.selectedOffering, @@ -440,12 +445,26 @@ export class ApplicationControllerService { /** * Transformation util for Application. - * @param application - * @returns Application DTO + * @param application application to be converted to the DTO. + * @param options additional options. + * - `previousData` previous application to allow changes detection. + * @returns application DTO. */ async transformToApplicationDTO( application: Application, + options?: { previousData?: unknown }, ): Promise { + let changes: ApplicationDataChangeAPIOutDTO[]; + if (options?.previousData) { + const applicationDataChanges = compareApplicationData( + application.data, + options.previousData, + ); + if (applicationDataChanges.length) { + changes = []; + this.transformToApplicationChangesDTO(applicationDataChanges, changes); + } + } return { data: application.data, id: application.id, @@ -461,9 +480,41 @@ export class ApplicationControllerService { applicationEndDate: application.currentAssessment?.offering?.studyEndDate, applicationInstitutionName: application.location?.institution.legalOperatingName, + changes, }; } + /** + * Recursively converts the {@link ApplicationDataChange} service model to the + * DTO model {@link ApplicationDataChangeAPIOutDTO} which will ensure + * that only required properties will be returned from the API also preventing + * that future changes in the service model will not be directly returned. + * @param applicationDataChanges service model application changes. + * @param applicationDataChangeAPIOutDTO converted API DTO model. + */ + transformToApplicationChangesDTO( + applicationDataChanges: ApplicationDataChange[], + applicationDataChangeAPIOutDTO: ApplicationDataChangeAPIOutDTO[], + ): void { + applicationDataChanges.forEach((dataChange) => { + const dataChangeDTO: ApplicationDataChangeAPIOutDTO = { + key: dataChange.key, + index: dataChange.index, + changeType: dataChange.changeType, + }; + applicationDataChangeAPIOutDTO.push(dataChangeDTO); + // Check if there are nested changes + if (!dataChange.changes.length) { + return; + } + dataChangeDTO.changes = []; + this.transformToApplicationChangesDTO( + dataChange.changes, + dataChangeDTO.changes, + ); + }); + } + /** * Convert disbursements into the enrolment DTO with information * about first and second disbursements. diff --git a/sources/packages/backend/apps/api/src/route-controllers/application/models/application.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/application/models/application.dto.ts index f886c3946d..5f4e3fe569 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/application/models/application.dto.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/application/models/application.dto.ts @@ -19,6 +19,7 @@ import { import { JsonMaxSize } from "../../../utilities/class-validation"; import { JSON_20KB } from "../../../constants"; import { ECertFailedValidation } from "@sims/integrations/services/disbursement-schedule/disbursement-schedule.models"; +import { ChangeTypes } from "@sims/utilities"; export class SaveApplicationAPIInDTO { /** @@ -113,12 +114,20 @@ export class ApplicationDataAPIOutDTO extends ApplicationBaseAPIOutDTO { submittedDate?: Date; } +export class ApplicationDataChangeAPIOutDTO { + key?: string; + index?: number; + changeType: ChangeTypes; + changes?: ApplicationDataChangeAPIOutDTO[]; +} + export class ApplicationSupplementalDataAPIOutDTO extends ApplicationBaseAPIOutDTO { studentFullName: string; applicationOfferingIntensity?: OfferingIntensity; applicationStartDate?: string; applicationEndDate?: string; applicationInstitutionName?: string; + changes?: ApplicationDataChangeAPIOutDTO[]; } export class ApplicationWithProgramYearAPIOutDTO { diff --git a/sources/packages/backend/apps/api/src/services/application/application.service.ts b/sources/packages/backend/apps/api/src/services/application/application.service.ts index 450c3e043d..8acc44f16b 100644 --- a/sources/packages/backend/apps/api/src/services/application/application.service.ts +++ b/sources/packages/backend/apps/api/src/services/application/application.service.ts @@ -1852,17 +1852,25 @@ export class ApplicationService extends RecordDataModelService { /** * Get previous application versions for an application. * @param applicationId application id. + * @param options method options. + * - `limit` number of previous application versions to be returned. + * - `loadDynamicData` optionally loads the dynamic data. * @returns previous application versions. */ async getPreviousApplicationVersions( applicationId: number, + options?: { loadDynamicData?: boolean; limit?: number }, ): Promise { const application = await this.repo.findOne({ select: { applicationNumber: true, submittedDate: true }, where: { id: applicationId }, }); return this.repo.find({ - select: { id: true, submittedDate: true }, + select: { + id: true, + submittedDate: true, + data: !!options?.loadDynamicData as unknown, + }, where: { submittedDate: LessThan(application.submittedDate), applicationNumber: application.applicationNumber, @@ -1870,6 +1878,7 @@ export class ApplicationService extends RecordDataModelService { order: { submittedDate: "DESC", }, + take: options?.limit, }); } diff --git a/sources/packages/backend/jest-unit-tests.json b/sources/packages/backend/jest-unit-tests.json new file mode 100644 index 0000000000..5db4b0e6ba --- /dev/null +++ b/sources/packages/backend/jest-unit-tests.json @@ -0,0 +1,46 @@ +{ + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": ".", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coveragePathIgnorePatterns": [ + "node_modules", + "db-migrations", + "backend/workflow", + "_tests_", + "main.ts", + ".module.ts", + "coverage", + "test-utils", + "testHelpers", + ".e2e-spec.ts", + ".models.ts", + ".model.ts", + ".dto.ts", + ".controller.ts", + ".mock.ts" + ], + "coverageDirectory": "../../tests/coverage/unit-tests", + "testEnvironment": "node", + "roots": [ + "/libs/", + "/apps/" + ], + "moduleNameMapper": { + "^@sims/sims-db(|/.*)$": "/libs/sims-db/src/$1", + "^@sims/services(|/.*)$": "/libs/services/src/$1", + "^@sims/utilities(|/.*)$": "/libs/utilities/src/$1", + "^@sims/test-utils(|/.*)$": "/libs/test-utils/src/$1", + "^@sims/integrations(|/.*)$": "/libs/integrations/src/$1", + "^@sims/auth(|/.*)$": "/libs/auth/src/$1" + } +} \ No newline at end of file diff --git a/sources/packages/backend/libs/test-utils/src/factories/application.ts b/sources/packages/backend/libs/test-utils/src/factories/application.ts index bae344f5d6..2141ab2186 100644 --- a/sources/packages/backend/libs/test-utils/src/factories/application.ts +++ b/sources/packages/backend/libs/test-utils/src/factories/application.ts @@ -240,6 +240,7 @@ export async function saveFakeApplicationDisbursements( * - `programYear` related program year. * @param options additional options: * - `applicationStatus` application status for the application. + * - `applicationNumber` application number for the application. * - `offeringIntensity` if provided sets the offering intensity for the created fakeApplication, otherwise sets it to fulltime by default. * - `applicationData` related application data. * - `pirStatus` program info status. @@ -297,9 +298,9 @@ export async function saveFakeApplication( { initialValue: { data: options?.applicationData, + applicationNumber: options?.applicationNumber, pirStatus: options?.pirStatus, isArchived: options?.isArchived ? options?.isArchived : false, - applicationNumber: options?.applicationNumber, submittedDate: options?.submittedDate, }, }, @@ -343,6 +344,10 @@ export async function saveFakeApplication( fakeOriginalAssessment, ); savedApplication.currentAssessment = savedOriginalAssessment; + if (!savedApplication.submittedDate) { + // Ensures a non-draft application will have a submitted date. + savedApplication.submittedDate = new Date(); + } } else { savedApplication.location = null; } diff --git a/sources/packages/backend/libs/utilities/src/application-data-comparison/_tests_/unit/application-data-comparison.compareApplicationData.spec.ts b/sources/packages/backend/libs/utilities/src/application-data-comparison/_tests_/unit/application-data-comparison.compareApplicationData.spec.ts new file mode 100644 index 0000000000..e3eb50beb7 --- /dev/null +++ b/sources/packages/backend/libs/utilities/src/application-data-comparison/_tests_/unit/application-data-comparison.compareApplicationData.spec.ts @@ -0,0 +1,328 @@ +import { compareApplicationData } from "../../application-data-comparison"; + +describe("compareApplicationData", () => { + it("Should return an empty array when no changes were detected.", () => { + // Arrange + const current = { + bcResident: "yes", + hasDependents: "no", + calculatedTaxYear: 2023, + myStudyPeriodIsntListed: { + offeringnotListed: false, + }, + }; + + // Act + const result = compareApplicationData(current, current); + + // Assert + expect(result).toHaveLength(0); + }); + + it("Should detected changes when the changes happened in primitive values at the first level including null to undefined or vice versa.", () => { + // Arrange + const current = { + bcResident: "yes", + hasDependents: "no", + calculatedTaxYear: 2023, + isFullTimeAllowed: "", + studentGivenNames: null, + programYearEndDate: "2025-07-31", + studentInfoConfirmed: true, + showParentInformation: null, + applicationPDPPDStatus: "no", + myStudyPeriodIsntListed: { + offeringnotListed: false, + }, + }; + const previous = { + bcResident: "changed yes", + hasDependents: "changed no", + calculatedTaxYear: 2000, + // isFullTimeAllowed was removed from the properties and should be detected as changed. + studentGivenNames: undefined, + programYearEndDate: "2025-07-31", + studentInfoConfirmed: true, + showParentInformation: true, + applicationPDPPDStatus: "changed no", + myStudyPeriodIsntListed: { + offeringnotListed: true, + }, + }; + + // Act + const result = compareApplicationData(current, previous); + + // Assert + expect(result).toEqual([ + { + key: "bcResident", + newValue: "yes", + oldValue: "changed yes", + changeType: "updated", + changes: [], + }, + { + key: "hasDependents", + newValue: "no", + oldValue: "changed no", + changeType: "updated", + changes: [], + }, + { + key: "calculatedTaxYear", + newValue: 2023, + oldValue: 2000, + changeType: "updated", + changes: [], + }, + { + key: "isFullTimeAllowed", + newValue: "", + changeType: "updated", + changes: [], + }, + { + key: "studentGivenNames", + newValue: null, + changeType: "updated", + changes: [], + }, + { + key: "showParentInformation", + newValue: null, + oldValue: true, + changeType: "updated", + changes: [], + }, + { + key: "applicationPDPPDStatus", + newValue: "no", + oldValue: "changed no", + changeType: "updated", + changes: [], + }, + { + key: "myStudyPeriodIsntListed", + changeType: "updated", + changes: [ + { + key: "offeringnotListed", + newValue: false, + oldValue: true, + changeType: "updated", + changes: [], + }, + ], + }, + ]); + }); + + it("Should detected items removed in an array when the current data array has fewer items than the previous.", () => { + // Arrange + const current = { + dependants: [ + { + fullName: "My son 1", + dateOfBirth: "2025-01-02", + }, + ], + }; + const previous = { + dependants: [ + { + fullName: "My son 1", + dateOfBirth: "2025-01-02", + }, + { + fullName: "My son 2", + dateOfBirth: "2025-01-02", + }, + ], + }; + + // Act + const result = compareApplicationData(current, previous); + + // Assert + expect(result).toEqual([ + { + key: "dependants", + changeType: "itemsRemoved", + changes: [], + }, + ]); + }); + + it("Should detected items appended to an array when the current data array has more items than the previous.", () => { + // Arrange + const current = { + dependants: [ + { + fullName: "My son 1", + dateOfBirth: "2025-01-02", + }, + { + fullName: "My son 2", + dateOfBirth: "2025-01-02", + }, + ], + }; + const previous = { + dependants: [ + { + fullName: "My son 1", + dateOfBirth: "2025-01-02", + }, + ], + }; + + // Act + const result = compareApplicationData(current, previous); + + // Assert + expect(result).toEqual([ + { + key: "dependants", + changeType: "itemsAppended", + changes: [ + { + newValue: { fullName: "My son 2", dateOfBirth: "2025-01-02" }, + index: 1, + changeType: "updated", + changes: [], + }, + ], + }, + ]); + }); + + it("Should detected items changed in an array at any level when the current data has at least one different property from the previous data.", () => { + // Arrange + const current = { + dependants: [ + { + fullName: "My son 1", + dateOfBirth: "2025-01-02", + someFilesArray: [ + { + url: "student/files/FileNameA-3370396b-8460-41d3-bb47-198ee84cc528.txt", + name: "FileNameA-3370396b-8460-41d3-bb47-198ee84cc528.txt", + originalName: "FileNameA.txt", + }, + { + url: "student/files/FileNameA-3370396b-8460-41d3-bb47-198ee84cc528.txt", + name: "FileNameA-3370396b-8460-41d3-bb47-198ee84cc528_SOMETHING_CHANGED.txt", + originalName: "FileNameA.txt", + }, + ], + }, + ], + }; + const previous = { + dependants: [ + { + fullName: "My son 1", + dateOfBirth: "2025-01-02", + someFilesArray: [ + { + url: "student/files/FileNameA-3370396b-8460-41d3-bb47-198ee84cc528.txt", + name: "FileNameA-3370396b-8460-41d3-bb47-198ee84cc528.txt", + originalName: "FileNameA.txt", + }, + { + url: "student/files/FileNameA-3370396b-8460-41d3-bb47-198ee84cc528.txt", + name: "FileNameA-3370396b-8460-41d3-bb47-198ee84cc528.txt", + originalName: "FileNameA.txt", + }, + ], + }, + ], + }; + + // Act + const result = compareApplicationData(current, previous); + + // Assert + expect(result).toEqual([ + { + key: "dependants", + changeType: "updated", + changes: [ + { + index: 0, + changeType: "updated", + changes: [ + { + key: "someFilesArray", + changeType: "updated", + changes: [ + { + index: 1, + changeType: "updated", + changes: [ + { + key: "name", + newValue: + "FileNameA-3370396b-8460-41d3-bb47-198ee84cc528_SOMETHING_CHANGED.txt", + oldValue: + "FileNameA-3370396b-8460-41d3-bb47-198ee84cc528.txt", + changeType: "updated", + changes: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ]); + }); + + it("Should detected an object with removed properties in the current data when these objects belongs to an array.", () => { + // Arrange + const current = { + dependants: [ + { + fullName: "My son 1", + dateOfBirth: "2025-01-01", + }, + { + fullName: "My son 2", + }, + ], + }; + const previous = { + dependants: [ + { + fullName: "My son 1", + dateOfBirth: "2025-01-01", + }, + { + fullName: "My son 2", + dateOfBirth: "2025-01-02", + }, + ], + }; + + // Act + const result = compareApplicationData(current, previous); + + // Assert + expect(result).toEqual([ + { + key: "dependants", + changeType: "updated", + changes: [ + { + index: 1, + changeType: "propertiesRemoved", + changes: [], + }, + ], + }, + ]); + }); +}); diff --git a/sources/packages/backend/libs/utilities/src/application-data-comparison/application-data-comparison.models.ts b/sources/packages/backend/libs/utilities/src/application-data-comparison/application-data-comparison.models.ts new file mode 100644 index 0000000000..dca6f57e8c --- /dev/null +++ b/sources/packages/backend/libs/utilities/src/application-data-comparison/application-data-comparison.models.ts @@ -0,0 +1,42 @@ +export enum ChangeTypes { + /** + * Indicates that a new value was added or updated. + */ + Updated = "updated", + /** + * An array had at least one property removed. + */ + ItemsRemoved = "itemsRemoved", + /** + * Items were added to the end of the array. + */ + ItemsAppended = "itemsAppended", + /** + * An object had at least one property removed. + */ + PropertiesRemoved = "propertiesRemoved", +} + +export class ApplicationDataChange { + readonly key?: string; + readonly newValue?: unknown; + readonly oldValue?: unknown; + readonly index?: number; + + constructor(options?: { + key?: string; + newValue?: unknown; + oldValue?: unknown; + index?: number; + }) { + this.key = options?.key; + this.newValue = options?.newValue; + this.oldValue = options?.oldValue; + this.index = options?.index; + this.changeType = ChangeTypes.Updated; + this.changes = []; + } + + changeType: ChangeTypes; + changes: ApplicationDataChange[]; +} diff --git a/sources/packages/backend/libs/utilities/src/application-data-comparison/application-data-comparison.ts b/sources/packages/backend/libs/utilities/src/application-data-comparison/application-data-comparison.ts new file mode 100644 index 0000000000..d5a561558f --- /dev/null +++ b/sources/packages/backend/libs/utilities/src/application-data-comparison/application-data-comparison.ts @@ -0,0 +1,166 @@ +import { + ApplicationDataChange, + ChangeTypes, +} from "./application-data-comparison.models"; + +/** + * Compares two sets of student application data and returns an array of changes. + * Identifies differences between the current and previous + * data, such as changes in values, additions, or deletions, and represents + * them as an array of {@link ApplicationDataChange} objects. + * @param currentData current state of the application data. + * @param previousData previous state of the application data. + * @returns array of {@link ApplicationDataChange} objects representing the detected changes. + */ +export function compareApplicationData( + currentData: unknown, + previousData: unknown, +): ApplicationDataChange[] { + // Initial object to allow the recursive function to add changes. + const initialChanges = new ApplicationDataChange(); + compareApplicationDataRecursive(currentData, previousData, initialChanges); + const [root] = initialChanges.changes; + return root?.changes ?? []; +} + +/** + * Recursively compares two sets of student application data and detect the differences. + * @param currentData current state of the student application data. + * @param previousData previous state of the student application data. + * @param parentChange parent change to add the new changes detected. + * @param options options to support the new change creation. + * - `propertyKey`: key of the property that changed. + * - `index`: index of the array item that changed. + */ +function compareApplicationDataRecursive( + currentData: unknown, + previousData: unknown, + parentChange: ApplicationDataChange, + options?: { propertyKey?: string; index?: number }, +): void { + // Same data, no need to compare. + if (isEqual(currentData, previousData)) { + return; + } + // Property has a null or undefined value but had a value before or vice versa. + if ( + (currentData === null || + currentData === undefined || + previousData === null || + previousData === undefined) && + currentData !== previousData + ) { + const newValueChange = new ApplicationDataChange({ + key: options?.propertyKey, + newValue: currentData, + oldValue: previousData, + index: options?.index, + }); + parentChange.changes.push(newValueChange); + return; + } + // Check array items. + // previousData is checked also to enforce the type as an array. In case + // it is null or undefined it will be handled at the previous scenario. + if (Array.isArray(currentData) && Array.isArray(previousData)) { + checkArrayChanges(currentData, previousData, parentChange, options); + return; + } + // Check object properties. + if (typeof currentData === "object") { + checkObjectChanges(currentData, previousData, parentChange, options); + return; + } + // Property was changed and it is a leaf property. + const newChange = new ApplicationDataChange({ + key: options?.propertyKey, + newValue: currentData, + oldValue: previousData, + index: options?.index, + }); + parentChange.changes.push(newChange); +} + +/** + * Checks if an array has changes comparing the currentData and previousData arrays. + * @param currentData current data array. + * @param previousData previous data array. + * @param parentChange parent change that will have the new {@link ApplicationDataChange} added. + * @param options options to support the new change creation. + * - `propertyKey`: key of the property that changed. + * - `index`: index of the array item that changed. + */ +function checkArrayChanges( + currentData: unknown[], + previousData: unknown[], + parentChange: ApplicationDataChange, + options?: { propertyKey?: string; index?: number }, +): void { + const arrayItemChange = new ApplicationDataChange({ + key: options?.propertyKey, + }); + parentChange.changes.push(arrayItemChange); + if (currentData.length < previousData.length) { + arrayItemChange.changeType = ChangeTypes.ItemsRemoved; + } else if (currentData.length > previousData.length) { + arrayItemChange.changeType = ChangeTypes.ItemsAppended; + } + // Property is an array that should have its items checked. + for (let index = 0; index < currentData.length; index++) { + const currentDataArrayItem = currentData[index]; + const previousDataArrayItem = previousData[index]; + if (!isEqual(currentDataArrayItem, previousDataArrayItem)) { + compareApplicationDataRecursive( + currentDataArrayItem, + previousDataArrayItem, + arrayItemChange, + { index }, + ); + } + } +} + +/** + * Checks if an object has changes comparing the currentData and previousData objects. + * @param currentData current data object. + * @param previousData previous data object. + * @param parentChange parent change that will have the new {@link ApplicationDataChange} added. + * @param options options to support the new change creation. + * - `propertyKey`: key of the property that changed. + * - `index`: index of the array item that changed. + */ +function checkObjectChanges( + currentData: unknown, + previousData: unknown, + parentChange: ApplicationDataChange, + options?: { propertyKey?: string; index?: number }, +): void { + // Property is an object that should have its properties checked. + const objectPropertyChange = new ApplicationDataChange({ + key: options?.propertyKey, + index: options?.index, + }); + if (Object.keys(currentData).length < Object.keys(previousData).length) { + objectPropertyChange.changeType = ChangeTypes.PropertiesRemoved; + } + parentChange.changes.push(objectPropertyChange); + for (const propertyKey of Object.keys(currentData)) { + compareApplicationDataRecursive( + currentData[propertyKey], + previousData[propertyKey], + objectPropertyChange, + { propertyKey }, + ); + } +} + +/** + * Define if two objects can be considered the same + * based on their JSON representation. + * @param a first object to be compared. + * @param b second object to be compared. + * @returns true if objects are considered the same. + */ +function isEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/sources/packages/backend/libs/utilities/src/index.ts b/sources/packages/backend/libs/utilities/src/index.ts index 7d1754ebe8..6c55f7e78d 100644 --- a/sources/packages/backend/libs/utilities/src/index.ts +++ b/sources/packages/backend/libs/utilities/src/index.ts @@ -16,3 +16,5 @@ export * from "./specialized-string-builder"; export * from "./string-utils"; export * from "./address-utils"; export * from "./compressed-file-utils"; +export * from "./application-data-comparison/application-data-comparison"; +export * from "./application-data-comparison/application-data-comparison.models"; diff --git a/sources/packages/backend/package.json b/sources/packages/backend/package.json index 13acf35885..d041aae051 100644 --- a/sources/packages/backend/package.json +++ b/sources/packages/backend/package.json @@ -21,10 +21,10 @@ "docker:start:queue-consumers": "node --enable-source-maps --max-old-space-size=550 dist/apps/queue-consumers/main.js", "start:prod:load-test-gateway": "node --enable-source-maps dist/apps/load-test-gateway/main.js", "docker:start:load-test-gateway": "node --enable-source-maps dist/apps/load-test-gateway/main.js", - "test": "cross-env ENVIRONMENT=test jest --verbose --forceExit", - "test:watch": "jest --watch ", - "test:cov": "cross-env ENVIRONMENT=test jest --coverage --forceExit", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test": "cross-env ENVIRONMENT=test jest --verbose --forceExit --config ./jest-unit-tests.json", + "test:watch": "jest --watch --config ./jest-unit-tests.json", + "test:cov": "cross-env ENVIRONMENT=test jest --coverage --forceExit --config ./jest-unit-tests.json", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand --config ./jest-unit-tests.json", "test:e2e:api": "npm run db:seed:test filter=CreateInstitutionsAndAuthenticationUsers,CreateAESTUsers,CreateStudentUsers,CreateRestrictions && cross-env ENVIRONMENT=test jest --collect-coverage --verbose --config ./apps/api/test/jest-e2e.json --forceExit", "test:e2e:api:local": "cross-env ENVIRONMENT=test TZ=UTC jest --config ./apps/api/test/jest-e2e.json --forceExit", "test:e2e:workers": "npm run migration:run && cross-env ENVIRONMENT=test jest --collect-coverage --verbose --config ./apps/workers/test/jest-e2e.json --forceExit", @@ -132,51 +132,5 @@ "ts-node": "^10.9.1", "tsconfig-paths": "^3.9.0", "typescript": "^4.5.5" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": ".", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coveragePathIgnorePatterns": [ - "node_modules", - "db-migrations", - "backend/workflow", - "_tests_", - "main.ts", - ".module.ts", - "coverage", - "test-utils", - "testHelpers", - ".e2e-spec.ts", - ".models.ts", - ".model.ts", - ".dto.ts", - ".controller.ts", - ".mock.ts" - ], - "coverageDirectory": "../../tests/coverage/unit-tests", - "testEnvironment": "node", - "roots": [ - "/libs/", - "/apps/" - ], - "moduleNameMapper": { - "^@sims/sims-db(|/.*)$": "/libs/sims-db/src/$1", - "^@sims/services(|/.*)$": "/libs/services/src/$1", - "^@sims/utilities(|/.*)$": "/libs/utilities/src/$1", - "^@sims/test-utils(|/.*)$": "/libs/test-utils/src/$1", - "^@sims/integrations(|/.*)$": "/libs/integrations/src/$1", - "^@sims/auth(|/.*)$": "/libs/auth/src/$1" - } } -} +} \ No newline at end of file diff --git a/sources/packages/web/src/assets/css/formio-shared.scss b/sources/packages/web/src/assets/css/formio-shared.scss index 821bd78a52..c7e16bbdee 100644 --- a/sources/packages/web/src/assets/css/formio-shared.scss +++ b/sources/packages/web/src/assets/css/formio-shared.scss @@ -750,3 +750,48 @@ td .formio-custom-tooltip:hover .tooltip-text { .bi-question-circle::before { content: "\f504" !important; } + +// Styles related to the application changes +// between the current and previous application versions. +.changed-value-base { + color: $application-changed-value-color; + border-radius: 5px; + border: 1px solid $application-changed-value-color; + padding: 4px; +} + +.changed-value-content-base { + @extend .label-small-bold; + background: $application-changed-value-color; + color: white; + border-radius: 5px; + display: block; + white-space: pre; +} + +.changed-value { + @extend .changed-value-base; +} + +.changed-value::before { + @extend .changed-value-content-base; + content: " Updated"; +} + +.changed-list-item-removed { + @extend .changed-value-base; +} + +.changed-list-item-removed::before { + @extend .changed-value-content-base; + content: " Items were removed from this list"; +} + +.changed-list-item-appended { + @extend .changed-value-base; +} + +.changed-list-item-appended::before { + @extend .changed-value-content-base; + content: " Items were appended to the list"; +} diff --git a/sources/packages/web/src/assets/css/global-style-variables.scss b/sources/packages/web/src/assets/css/global-style-variables.scss index 767ccb2090..8e3b49242e 100644 --- a/sources/packages/web/src/assets/css/global-style-variables.scss +++ b/sources/packages/web/src/assets/css/global-style-variables.scss @@ -27,6 +27,7 @@ $secondary-header-color-lt: #8692a4; $app-content-color: #333a47; $panel-warning-color: #f3b229; $panel-error-color: #dc2525; +$application-changed-value-color: #dc2525; $button-font-color: #ffffff; /** SCSS Font Size variables **/ diff --git a/sources/packages/web/src/components/common/StudentApplication.vue b/sources/packages/web/src/components/common/StudentApplication.vue index c2f6bda2f0..9183b4d6ef 100644 --- a/sources/packages/web/src/components/common/StudentApplication.vue +++ b/sources/packages/web/src/components/common/StudentApplication.vue @@ -6,6 +6,7 @@ @loaded="formLoaded" @changed="formChanged" @submitted="submitted" + @render="formRender" @customEvent="customEvent" > !props.notDraft && !isFirstPage.value && !props.processing, ); - const getSelectedId = (form: any) => { + const getSelectedId = (form: FormIOForm) => { return formioUtils.getComponentValueByKey(form, LOCATIONS_DROPDOWN_KEY); }; @@ -180,7 +182,15 @@ export default defineComponent({ } } }; - const formLoaded = async (form: any) => { + + /** + * The form is done rendering and has completed the attach phase. + */ + const formRender = async (form: FormIOForm) => { + context.emit("render", form); + }; + + const formLoaded = async (form: FormIOForm) => { showNav.value = true; // Emit formLoadedCallback event to the parent, so that parent can // perform the parent specific logic inside parent on @@ -216,10 +226,11 @@ export default defineComponent({ }; formInstance.on("prevPage", prevNextNavigation); formInstance.on("nextPage", prevNextNavigation); + await loadFormDependencies(); }; - const getOfferingDetails = async (form: any, locationId: number) => { + const getOfferingDetails = async (form: FormIOForm, locationId: number) => { const selectedIntensity: OfferingIntensity = formioUtils.getComponentValueByKey(form, OFFERING_INTENSITY_KEY); const educationProgramIdFromForm: number = @@ -336,11 +347,11 @@ export default defineComponent({ formInstance.nextPage(); }; - const submitted = (args: any, form: any) => { + const submitted = (args: any, form: FormIOForm) => { context.emit("submitApplication", args, form); }; - const customEvent = (form: any, event: FormIOCustomEvent) => { + const customEvent = (form: FormIOForm, event: FormIOCustomEvent) => { context.emit("customEventCallback", form, event); }; @@ -355,6 +366,7 @@ export default defineComponent({ wizardGoPrevious, formChanged, formLoaded, + formRender, isFirstPage, isLastPage, submitted, diff --git a/sources/packages/web/src/composables/useFormioUtils.ts b/sources/packages/web/src/composables/useFormioUtils.ts index 1f6ee064e5..fe0a69cbbc 100644 --- a/sources/packages/web/src/composables/useFormioUtils.ts +++ b/sources/packages/web/src/composables/useFormioUtils.ts @@ -7,7 +7,7 @@ import { Utils } from "@formio/js"; */ export function useFormioUtils() { // Get a component in a form definition once it is loaded. - const getComponent = (form: any, componentKey: string): any => { + const getComponent = (form: any, componentKey: string): FormIOComponent => { return Utils.getComponent(form.components, componentKey, true); }; @@ -23,7 +23,7 @@ export function useFormioUtils() { ): FormIOComponent => { const [firstComponentFound] = recursiveSearch( form, - (component) => component.key === componentKey, + (component) => component.component.key === componentKey, { stopOnFirstMatch: true }, ); return firstComponentFound; @@ -117,6 +117,33 @@ export function useFormioUtils() { return matchedComponents; }; + /** + * Search recursively by a component and returns + * all the matches with the same key. + * @param components components to be checked. + * @param componentKey key to be matched. + * @param options search options. + * - `stopOnFirstMatch` indicates if should stop at the first match, default true. + * @returns components found. + */ + const searchByKey = ( + components: FormIOComponent[], + componentKey: string, + options?: { + stopOnFirstMatch: boolean; + }, + ): FormIOComponent[] => { + const defaultOptions = { stopOnFirstMatch: true }; + const matchedComponents: any[] = []; + internalRecursiveSearch( + components, + matchedComponents, + (component) => component.key === componentKey, + options ?? defaultOptions, + ); + return matchedComponents; + }; + // Search for components of a specific type. const getComponentsOfType = (form: any, type: string): any[] => { return recursiveSearch(form, (component) => component.type === type); @@ -229,5 +256,6 @@ export function useFormioUtils() { resetCheckBox, checkFormioValidity, excludeExtraneousValues, + searchByKey, }; } diff --git a/sources/packages/web/src/services/http/dto/Application.dto.ts b/sources/packages/web/src/services/http/dto/Application.dto.ts index 351581ad86..f47eb8c55e 100644 --- a/sources/packages/web/src/services/http/dto/Application.dto.ts +++ b/sources/packages/web/src/services/http/dto/Application.dto.ts @@ -13,6 +13,7 @@ import { ApplicationOfferingChangeRequestStatus, StudentAssessmentStatus, ECertFailedValidation, + ChangeTypes, } from "@/types"; export interface InProgressApplicationDetailsAPIOutDTO { @@ -86,6 +87,13 @@ export interface ApplicationDataAPIOutDTO extends ApplicationBaseAPIOutDTO { submittedDate?: Date; } +export interface ApplicationDataChangeAPIOutDTO { + key?: string; + index?: number; + changeType: ChangeTypes; + changes?: ApplicationDataChangeAPIOutDTO[]; +} + /** * DTO for application data */ @@ -96,6 +104,7 @@ export interface ApplicationSupplementalDataAPIOutDTO applicationStartDate?: string; applicationEndDate?: string; applicationInstitutionName?: string; + changes?: ApplicationDataChangeAPIOutDTO[]; } /** diff --git a/sources/packages/web/src/types/contracts/ApplicationDataComparison.ts b/sources/packages/web/src/types/contracts/ApplicationDataComparison.ts new file mode 100644 index 0000000000..1d491cfb85 --- /dev/null +++ b/sources/packages/web/src/types/contracts/ApplicationDataComparison.ts @@ -0,0 +1,18 @@ +export enum ChangeTypes { + /** + * Indicates that a new value was added or updated. + */ + Updated = "updated", + /** + * An array had at least one property removed. + */ + ItemsRemoved = "itemsRemoved", + /** + * Items were added to the end of the array. + */ + ItemsAppended = "itemsAppended", + /** + * An object had at least one property removed. + */ + PropertiesRemoved = "propertiesRemoved", +} diff --git a/sources/packages/web/src/types/formio.ts b/sources/packages/web/src/types/formio.ts index b09375e6d2..f62794e8fc 100644 --- a/sources/packages/web/src/types/formio.ts +++ b/sources/packages/web/src/types/formio.ts @@ -2,7 +2,7 @@ * FormIO form. Methods available can be checked on * https://help.form.io/developers/form-renderer. */ -export interface FormIOForm { +export interface FormIOForm extends FormIOComponent { data: T; nosubmit: boolean; checkValidity: ( @@ -79,6 +79,30 @@ export enum FormIOCustomEventTypes { ReissueMSFAA = "reissueMSFAA", } +/** + * Form IO component types. + * This does not represent all the types available in Form.IO, + * please expand the list as needed. + */ +export enum FromIOComponentTypes { + Hidden = "hidden", + Datagrid = "datagrid", + Button = "button", + Select = "select", + Panel = "panel", + EditGrid = "editgrid", + Radio = "radio", + Calendar = "calendar", +} + +export interface FormIOComponentInternal { + key: string; + customClass: string; + options: unknown; + values: unknown; + data: { values: unknown[] }; +} + export interface FormIOComponent { id: string; key: string; @@ -86,6 +110,10 @@ export interface FormIOComponent { components: FormIOComponent[]; selectOptions: unknown[]; _visible: boolean; - component: any; + component: FormIOComponentInternal; + customClass: string; redraw: any; + disabled: boolean; + type: FromIOComponentTypes; + setValue: (value: unknown) => void; } diff --git a/sources/packages/web/src/types/index.ts b/sources/packages/web/src/types/index.ts index b33213034d..5d355f2963 100644 --- a/sources/packages/web/src/types/index.ts +++ b/sources/packages/web/src/types/index.ts @@ -46,3 +46,4 @@ export * from "@/types/contracts/StatusChip"; export * from "@/types/contracts/ApplicationOfferingChangeRequest"; export * from "@/types/contracts/ECertFailedValidation"; export * from "@/types/contracts/RestrictionBypassContracts"; +export * from "@/types/contracts/ApplicationDataComparison"; diff --git a/sources/packages/web/src/views/aest/StudentApplicationView.vue b/sources/packages/web/src/views/aest/StudentApplicationView.vue index fd4005df9f..0e981a405e 100644 --- a/sources/packages/web/src/views/aest/StudentApplicationView.vue +++ b/sources/packages/web/src/views/aest/StudentApplicationView.vue @@ -16,6 +16,7 @@ {{ emptyStringFiller(applicationDetail.applicationNumber) }} import { onMounted, ref, defineComponent } from "vue"; import { AESTRoutesConst } from "@/constants/routes/RouteConstants"; -import { ApplicationBaseAPIOutDTO } from "@/services/http/dto"; +import { + ApplicationDataChangeAPIOutDTO, + ApplicationSupplementalDataAPIOutDTO, +} from "@/services/http/dto"; import { ApplicationService } from "@/services/ApplicationService"; import { useFormatters } from "@/composables/useFormatters"; import StudentApplication from "@/components/common/StudentApplication.vue"; +import { useFormioUtils } from "@/composables"; +import { + ChangeTypes, + FormIOComponent, + FormIOForm, + FromIOComponentTypes, +} from "@/types"; export default defineComponent({ components: { @@ -52,15 +63,33 @@ export default defineComponent({ }, setup(props) { const { emptyStringFiller } = useFormatters(); - const applicationDetail = ref({} as ApplicationBaseAPIOutDTO); + const { searchByKey } = useFormioUtils(); + const applicationDetail = ref({} as ApplicationSupplementalDataAPIOutDTO); const initialData = ref({}); const selectedForm = ref(); + let applicationWizard: FormIOForm; + /** + * Happens when all the form components are rendered, including lists. + */ + const formRender = async (form: FormIOForm) => { + applicationWizard = form; + // Highlight changes in the form after all the components are rendered. + // List components are ready only after the form is rendered. + highlightChanges(); + }; + + /** + * Loads the initial application data. + */ onMounted(async () => { // When the application version is present load the given application version instead of the current application version. const applicationId = props.versionApplicationId ?? props.applicationId; + const application = await ApplicationService.shared.getApplicationDetail( + applicationId, + ); applicationDetail.value = - await ApplicationService.shared.getApplicationDetail(applicationId); + application as ApplicationSupplementalDataAPIOutDTO; selectedForm.value = applicationDetail.value.applicationFormName; initialData.value = { ...applicationDetail.value.data, @@ -68,7 +97,115 @@ export default defineComponent({ }; }); + /** + * Check if the application has changes to be highlighted. + * Changes are expected after applications are edited after submitted at least once. + */ + function highlightChanges() { + if (!applicationWizard || !applicationDetail.value.changes?.length) { + return; + } + highlightChangesRecursive( + applicationWizard, + applicationDetail.value.changes, + ); + } + + /** + * Apply the style class to the components that have changes. + * @param parentComponent component to have the changes highlighted. + * @param changes list of changes to be highlighted. + */ + function highlightChangesRecursive( + parentComponent: FormIOComponent, + changes: ApplicationDataChangeAPIOutDTO[], + ) { + for (const change of changes) { + let searchComponent: FormIOComponent | undefined; + if (typeof change.index === "number") { + // The item to be processed is an array item. + searchComponent = processArrayItem( + parentComponent, + change.index, + change, + ); + } else if (change.key) { + // The item to be processed is a component. + searchComponent = parentComponent; + } + if (!change.key || !searchComponent?.components?.length) { + continue; + } + const [component] = searchByKey(searchComponent.components, change.key); + if (component) { + applyChangedValueStyleClass(component, change.changeType); + if (change.changes) { + // Should check further for nested changes. + highlightChangesRecursive(component, change.changes); + } + } + } + } + + /** + * Process an array item component. + * @param parentComponent component that contains the array item. + * @param changeIndex index of the array item that has the change. + * @param change change object that contains the change details. + * @returns component if it exists, otherwise undefined. + */ + function processArrayItem( + parentComponent: FormIOComponent, + changeIndex: number, + change: ApplicationDataChangeAPIOutDTO, + ): FormIOComponent | undefined { + const searchComponent = parentComponent?.components?.length + ? parentComponent.components[changeIndex] + : undefined; + if (searchComponent && change.changes) { + // Should check further for nested changes. + highlightChangesRecursive(searchComponent, change.changes); + } else if (searchComponent) { + // searchComponent has a change, but no nested changes. + // It also does not have a key because it is a child in a list. + applyChangedValueStyleClass(searchComponent, change.changeType); + } + return searchComponent; + } + + /** + * Apply the proper highlight style to a changed component. + * @param component component to received the style. + * @param changeType indicates the operation that has changed the value, + * which may required a different style to be applied. + */ + function applyChangedValueStyleClass( + component: FormIOComponent, + changeType: ChangeTypes, + ) { + if ( + component.type === FromIOComponentTypes.Hidden || + component._visible === false + ) { + return; + } + let cssClass: string; + switch (changeType) { + case ChangeTypes.ItemsAppended: + cssClass = "changed-list-item-appended"; + break; + case ChangeTypes.ItemsRemoved: + cssClass = "changed-list-item-removed"; + break; + default: + cssClass = "changed-value"; + break; + } + document.getElementById(component.id)?.classList.add(cssClass); + } + return { + formRender, applicationDetail, initialData, selectedForm,