diff --git a/package-lock.json b/package-lock.json index 4acebc3d3..43ee426d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@dvsa/mes-config-schema": "1.9.0", "@dvsa/mes-driver-schema": "^0.0.2", "@dvsa/mes-journal-schema": "1.3.1", + "@dvsa/mes-microservice-common": "^1.1.9", "@dvsa/mes-search-schema": "1.3.0", "@dvsa/mes-test-schema": "3.42.5", "@ionic-enterprise/auth": "3.9.5", @@ -53,6 +54,7 @@ "@popperjs/core": "^2.11.8", "@sentry/angular-ivy": "7.93.0", "@sentry/capacitor": "0.17.0", + "apexcharts": "^3.46.0", "assert": "^2.1.0", "bootstrap": "^5.3.2", "browserify-zlib": "^0.2.0", @@ -73,6 +75,7 @@ "jsonschema": "^1.4.1", "lodash-es": "^4.17.21", "moment": "^2.29.4", + "ng-apexcharts": "^1.9.0", "ngrx-store-localstorage": "^17.0.0", "rxjs": "~7.8.1", "rxjs-compat": "^6.6.7", @@ -779,6 +782,40 @@ "rxjs": "^5.5.0 || ^6.5.0 || ^7.3.0" } }, + "node_modules/@aws-lambda-powertools/commons": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@aws-lambda-powertools/commons/-/commons-1.18.0.tgz", + "integrity": "sha512-oSnST8Wr3WZcT/FgCUzZYUFB+qYHWMAKS0GhWbUqHZMr7I5F75jq/JbeUUF16ShOMGgnEzs5oJjizBYVTI6Oww==" + }, + "node_modules/@aws-lambda-powertools/tracer": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@aws-lambda-powertools/tracer/-/tracer-1.18.0.tgz", + "integrity": "sha512-26z3iKaUWUybxFsRsJZXnPaBf0HlMyZHbLrQvMXJ+avjh3i3ZOiHXnnmQNA4gny02lY2XQddEzpw0M62PYhegQ==", + "dependencies": { + "@aws-lambda-powertools/commons": "^1.18.0", + "aws-xray-sdk-core": "^3.5.3" + }, + "peerDependencies": { + "@middy/core": ">=3.x" + }, + "peerDependenciesMeta": { + "@middy/core": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.496.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.496.0.tgz", + "integrity": "sha512-umkGadK4QuNQaMoDICMm7NKRI/mYSXiyPjcn3d53BhsuArYU/52CebGQKdt4At7SwwsiVJZw9RNBHyN5Mm0HVw==", + "dependencies": { + "@smithy/types": "^2.9.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", @@ -2733,6 +2770,16 @@ "integrity": "sha512-68Lynq04VYDCUr8vn3zBjc/0yJjyfZeHSa0qTJ84EhFuHeBV1xl9Br/qdovocG+MU7N6DTu/a7q4cfdRU+A+Dw==", "license": "MIT" }, + "node_modules/@dvsa/mes-microservice-common": { + "version": "1.1.9", + "resolved": "https://npm.pkg.github.com/download/@dvsa/mes-microservice-common/1.1.9/9a20b3170d529c9eace0533a2677e48bfb24b59d", + "integrity": "sha512-KvhgFnZDEYPqBuV09AiH6K5ptwObzhIxJIHIxL8m75A1wFgKB+8sbrMjrihn3P90Cf2m5MyGq+IB05UA6r+Row==", + "license": "MIT", + "dependencies": { + "@aws-lambda-powertools/tracer": "^1.16.0", + "moment": "^2.29.4" + } + }, "node_modules/@dvsa/mes-search-schema": { "version": "1.3.0", "resolved": "https://npm.pkg.github.com/download/@dvsa/mes-search-schema/1.3.0/4196119777d66906ef3bde0b05e133d6d31693cb", @@ -5518,6 +5565,28 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/@smithy/service-error-classification": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-2.1.1.tgz", + "integrity": "sha512-txEdZxPUgM1PwGvDvHzqhXisrc5LlRWYCf2yyHfvITWioAKat7srQvpjMAvgzf0t6t7j8yHrryXU9xt7RZqFpw==", + "dependencies": { + "@smithy/types": "^2.9.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.9.1.tgz", + "integrity": "sha512-vjXlKNXyprDYDuJ7UW5iobdmyDm6g8dDG+BFUncAg/3XJaN45Gy5RWWWUVgrzIK7S4R1KWgIX5LeJcfvSI24bw==", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.1.tgz", @@ -5644,6 +5713,14 @@ "@types/node": "*" } }, + "node_modules/@types/cls-hooked": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@types/cls-hooked/-/cls-hooked-4.3.8.tgz", + "integrity": "sha512-tf/7H883gFA6MPlWI15EQtfNZ+oPL0gLKkOlx9UHFrun1fC/FkuyNBpTKq1B5E3T4fbvjId6WifHUdSGsMMuPg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -5812,7 +5889,6 @@ "version": "20.10.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.8.tgz", "integrity": "sha512-f8nQs3cLxbAFc00vEU59yf9UyGUftkPaLGfvbVOIDdx2i1b8epBqj2aNGyP19fiyXWvlmZ7qC1XLjAzw/OKIeA==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -6450,6 +6526,11 @@ "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", "dev": true }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==" + }, "node_modules/abbrev": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", @@ -6697,6 +6778,20 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/apexcharts": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.46.0.tgz", + "integrity": "sha512-ELAY6vj8JQD7QLktKasTzwm9Wt0qxqfQSo+3QWS7G7I774iK8HCkG1toGsqJH0mkK6PtYBtnSIe66uUcwoCw1w==", + "dependencies": { + "@yr/monotone-cubic-spline": "^1.0.3", + "svg.draggable.js": "^2.2.2", + "svg.easing.js": "^2.0.0", + "svg.filter.js": "^2.0.2", + "svg.pathmorphing.js": "^0.1.3", + "svg.resize.js": "^1.4.3", + "svg.select.js": "^3.0.1" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -6909,6 +7004,17 @@ "integrity": "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==", "dev": true }, + "node_modules/async-hook-jl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz", + "integrity": "sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==", + "dependencies": { + "stack-chain": "^1.3.7" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -6930,6 +7036,11 @@ "node": ">= 4.0.0" } }, + "node_modules/atomic-batcher": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/atomic-batcher/-/atomic-batcher-1.0.2.tgz", + "integrity": "sha512-EFGCRj4kLX1dHv1cDzTk+xbjBFj1GnJDpui52YmEcxxHHEWjYyT6l51U7n6WQ28osZH4S9gSybxe56Vm7vB61Q==" + }, "node_modules/autoprefixer": { "version": "10.4.18", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz", @@ -6981,6 +7092,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-xray-sdk-core": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.5.3.tgz", + "integrity": "sha512-FxDRVvIHqf3bzj76M+LSyh/1V5cYuhn+YLRS+u6Xs6WindPMDn9j03v2PNskPgvUi7pMqU40aVhQphRX/YWTfQ==", + "dependencies": { + "@aws-sdk/types": "^3.4.1", + "@smithy/service-error-classification": "^2.0.4", + "@types/cls-hooked": "^4.3.3", + "atomic-batcher": "^1.0.2", + "cls-hooked": "^4.2.2", + "semver": "^7.5.3" + }, + "engines": { + "node": ">= 14.x" + } + }, "node_modules/b4a": { "version": "1.6.6", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", @@ -7937,6 +8064,27 @@ "node": ">=6" } }, + "node_modules/cls-hooked": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", + "integrity": "sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==", + "dependencies": { + "async-hook-jl": "^1.7.6", + "emitter-listener": "^1.0.1", + "semver": "^5.4.1" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3 || >=8.2.1" + } + }, + "node_modules/cls-hooked/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -9187,6 +9335,14 @@ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, + "node_modules/emitter-listener": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", + "integrity": "sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==", + "dependencies": { + "shimmer": "^1.2.0" + } + }, "node_modules/emoji-regex": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", @@ -14877,6 +15033,20 @@ "node": ">= 0.4.0" } }, + "node_modules/ng-apexcharts": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/ng-apexcharts/-/ng-apexcharts-1.9.0.tgz", + "integrity": "sha512-K6u/VTosww/ksMvNLUi1SigEpic4qwmLyZGtspalifF22xH6UFocp/BlIJ1B0EaTuC9gBP8130gDYJ9NmZZi5g==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/common": "^17.0.2", + "@angular/core": "^17.0.2", + "apexcharts": "^3.45.2", + "rxjs": "^6.5.5 || ^7.4.0" + } + }, "node_modules/ng-mocks": { "version": "14.12.2", "resolved": "https://registry.npmjs.org/ng-mocks/-/ng-mocks-14.12.2.tgz", @@ -17896,6 +18066,11 @@ "node": ">=4" } }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -18421,6 +18596,11 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/stack-chain": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", + "integrity": "sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug==" + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -18761,6 +18941,89 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg.draggable.js": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz", + "integrity": "sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==", + "dependencies": { + "svg.js": "^2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.easing.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/svg.easing.js/-/svg.easing.js-2.0.0.tgz", + "integrity": "sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==", + "dependencies": { + "svg.js": ">=2.3.x" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.filter.js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/svg.filter.js/-/svg.filter.js-2.0.2.tgz", + "integrity": "sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==", + "dependencies": { + "svg.js": "^2.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/svg.js/-/svg.js-2.7.1.tgz", + "integrity": "sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==" + }, + "node_modules/svg.pathmorphing.js": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz", + "integrity": "sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==", + "dependencies": { + "svg.js": "^2.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.resize.js": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/svg.resize.js/-/svg.resize.js-1.4.3.tgz", + "integrity": "sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==", + "dependencies": { + "svg.js": "^2.6.5", + "svg.select.js": "^2.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.resize.js/node_modules/svg.select.js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-2.1.2.tgz", + "integrity": "sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==", + "dependencies": { + "svg.js": "^2.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/svg.select.js": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/svg.select.js/-/svg.select.js-3.0.1.tgz", + "integrity": "sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==", + "dependencies": { + "svg.js": "^2.6.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -19512,8 +19775,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", diff --git a/package.json b/package.json index a201a681c..5f7261567 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@dvsa/mes-config-schema": "1.9.0", "@dvsa/mes-driver-schema": "^0.0.2", "@dvsa/mes-journal-schema": "1.3.1", + "@dvsa/mes-microservice-common": "^1.1.9", "@dvsa/mes-search-schema": "1.3.0", "@dvsa/mes-test-schema": "3.42.5", "@ionic-enterprise/auth": "3.9.5", @@ -95,6 +96,7 @@ "@popperjs/core": "^2.11.8", "@sentry/angular-ivy": "7.93.0", "@sentry/capacitor": "0.17.0", + "apexcharts": "^3.46.0", "assert": "^2.1.0", "bootstrap": "^5.3.2", "browserify-zlib": "^0.2.0", @@ -115,6 +117,7 @@ "jsonschema": "^1.4.1", "lodash-es": "^4.17.21", "moment": "^2.29.4", + "ng-apexcharts": "^1.9.0", "ngrx-store-localstorage": "^17.0.0", "rxjs": "~7.8.1", "rxjs-compat": "^6.6.7", diff --git a/src/app/__tests__/app.component.spec.ts b/src/app/__tests__/app.component.spec.ts index 133df882f..45cde0e34 100644 --- a/src/app/__tests__/app.component.spec.ts +++ b/src/app/__tests__/app.component.spec.ts @@ -38,10 +38,12 @@ import { SideMenuClosed, SideMenuItemSelected, SideMenuOpened } from '@pages/das import { AccessibilityService } from '@providers/accessibility/accessibility.service'; import { AccessibilityServiceMock } from '@providers/accessibility/__mocks__/accessibility-service.mock'; import { LOGIN_PAGE } from '@pages/page-names.constants'; -import { AppComponent } from '../app.component'; +import { AppComponent, Page } from '../app.component'; import { StorageMock } from '@mocks/ionic-mocks/storage.mock'; import { LogHelper } from '@providers/logs/logs-helper'; import { LogHelperMock } from '@providers/logs/__mocks__/logs-helper.mock'; +import { ExaminerRole } from '@dvsa/mes-microservice-common/domain/examiner-role'; +import { AppConfig } from '@providers/app-config/app-config.model'; describe('AppComponent', () => { let component: AppComponent; @@ -247,6 +249,48 @@ describe('AppComponent', () => { }); }); + describe('getFilteredPages', () => { + it('should return all pages if role is not defined', () => { + spyOn(appConfigProvider, 'getAppConfig').and.returnValue({ role: undefined } as AppConfig); + const pages: Page[] = [ + { title: 'Page1', descriptor: 'Page1' }, + { title: 'Page2', descriptor: 'Page2' }, + ]; + const result = component.getFilteredPages(pages); + expect(result).toEqual(pages); + }); + + it('should return filtered pages based on role', () => { + spyOn(appConfigProvider, 'getAppConfig').and.returnValue({ role: ExaminerRole.DLG } as AppConfig); + const pages: Page[] = [ + { title: 'Page1', descriptor: 'Page1', hideWhenRole: [ExaminerRole.DLG] }, + { title: 'Page2', descriptor: 'Page2' }, + ]; + const result = component.getFilteredPages(pages); + expect(result).toEqual([{ title: 'Page2', descriptor: 'Page2' }]); + }); + + it('should return all pages if hideWhenRole is not defined', () => { + spyOn(appConfigProvider, 'getAppConfig').and.returnValue({ role: ExaminerRole.DLG } as AppConfig); + const pages: Page[] = [ + { title: 'Page1', descriptor: 'Page1' }, + { title: 'Page2', descriptor: 'Page2' }, + ]; + const result = component.getFilteredPages(pages); + expect(result).toEqual(pages); + }); + + it('should return empty array if all pages are hidden for the role', () => { + spyOn(appConfigProvider, 'getAppConfig').and.returnValue({ role: ExaminerRole.DLG } as AppConfig); + const pages: Page[] = [ + { title: 'Page1', descriptor: 'Page1', hideWhenRole: [ExaminerRole.DLG] }, + { title: 'Page2', descriptor: 'Page2', hideWhenRole: [ExaminerRole.DLG] }, + ]; + const result = component.getFilteredPages(pages); + expect(result).toEqual([]); + }); + }); + describe('initialisePersistentStorage', () => { it('should call setSecureContainer when in ios', fakeAsync(() => { spyOn(dataStore, 'createContainer') diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index bc30026e5..9eecd9036 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -4,6 +4,7 @@ import { DASHBOARD_PAGE, DELEGATED_REKEY_SEARCH_PAGE, DELEGATED_REKEY_UPLOAD_OUTCOME_PAGE, + EXAMINER_RECORDS, FAKE_JOURNAL_PAGE, JOURNAL_PAGE, LOGIN_PAGE, @@ -38,6 +39,11 @@ const routes: Routes = [ loadChildren: () => import('@pages/pass-certificates/pass-certificates.module') .then((m) => m.PassCertificatesPageModule), }, + { + path: EXAMINER_RECORDS, + loadChildren: () => import('@pages/examiner-records/examiner-records.module') + .then((m) => m.ExaminerRecordsPageModule), + }, { path: LOGIN_PAGE, loadChildren: () => import('./pages/login/login.module').then((m) => m.LoginPageModule), diff --git a/src/app/app.component.html b/src/app/app.component.html index b50d1378e..acc355f62 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -10,7 +10,7 @@ - + {{ page.descriptor }} @@ -19,7 +19,8 @@ slot="end" color="danger" id="un-submitted-test-count-badge" - class="burger-menu-item"> + class="burger-menu-item" + > {{ count }} diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 3258e1c9f..83a81131a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -18,7 +18,7 @@ import { Capacitor } from '@capacitor/core'; import { AppInfoProvider } from '@providers/app-info/app-info'; import { AppConfigProvider } from '@providers/app-config/app-config'; import { SENTRY_ERRORS } from '@app/sentry-error-handler'; -import { DASHBOARD_PAGE, LOGIN_PAGE, UNUPLOADED_TESTS_PAGE } from '@pages/page-names.constants'; +import { DASHBOARD_PAGE, EXAMINER_RECORDS, LOGIN_PAGE, UNUPLOADED_TESTS_PAGE } from '@pages/page-names.constants'; import { SideMenuClosed, SideMenuItemSelected, SideMenuOpened } from '@pages/dashboard/dashboard.actions'; import { SlotProvider } from '@providers/slot/slot'; import { DateTimeProvider } from '@providers/date-time/date-time'; @@ -30,6 +30,7 @@ import { StartSendingCompletedTests, StopSendingCompletedTests } from '@store/te import { SetupPolling, StopPolling } from '@store/journal/journal.actions'; import { getJournalState } from '@store/journal/journal.reducer'; import { getTests } from '@store/tests/tests.reducer'; +import { isAnyOf } from '@shared/helpers/simplifiers'; interface AppComponentPageState { logoutEnabled$: Observable; @@ -60,6 +61,11 @@ export class AppComponent extends LogoutBasePageComponent implements OnInit { showUnSubmittedCount: true, hideWhenRole: [ExaminerRole.DLG], }, + { + title: EXAMINER_RECORDS, + descriptor: 'Examiner records', + hideWhenRole: [ExaminerRole.DLG], + }, // { // title: PASS_CERTIFICATES, // descriptor: 'Missing / spoiled pass certificates', @@ -86,6 +92,16 @@ export class AppComponent extends LogoutBasePageComponent implements OnInit { super(injector); } + getFilteredPages(pages: Page[]): Page[] { + const role = this.appConfigProvider.getAppConfig()?.role; + if (!role) { + return pages; + } + return pages.filter( + (page) => !isAnyOf(role, (page.hideWhenRole || [])), + ); + } + async ngOnInit() { try { await this.platform.ready(); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4568c268d..76e1ffab4 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -70,7 +70,12 @@ import { AppComponent } from './app.component'; import { RemoteDevToolsProxy } from '../../ngrx-devtool-proxy/remote-devtools-proxy'; import { IonicGestureConfig } from '../gestures/ionic-gesture-config'; import { StoreModel } from '@shared/models/store.model'; +import { ExaminerRecordsProvider } from '@providers/examiner-records/examiner-records'; import { CompressionProvider } from '@providers/compression/compression'; +import { LoadingProvider } from '@providers/loader/loader'; +import { ExaminerRecordsStoreModule } from '@store/examiner-records/examiner-records.module'; +import { examinerRecordsReducer } from '@store/examiner-records/examiner-records.reducer'; +import { ExaminerRecordsComponentsModule } from '@pages/examiner-records/components/examiner-records-components.module'; export function createTranslateLoader(http: HttpClient) { return new TranslateHttpLoader(http, 'assets/i18n/', '.json'); @@ -90,6 +95,7 @@ const reducers: ActionReducerMap = { tests: testsReducer, rekeySearch: rekeySearchReducer, delegatedRekeySearch: delegatedSearchReducer, + examinerRecords: examinerRecordsReducer, }; const metaReducers: MetaReducer[] = []; @@ -134,6 +140,7 @@ if (enableRehydrationPlugin) { AppInfoStoreModule, ReferenceDataStoreModule, AppConfigStoreModule, + ExaminerRecordsStoreModule, LogsStoreModule, TestCentreJournalStoreModule, JournalModule, @@ -147,6 +154,7 @@ if (enableRehydrationPlugin) { }, }), HammerModule, + ExaminerRecordsComponentsModule, PipesModule, ], providers: [ @@ -168,8 +176,11 @@ if (enableRehydrationPlugin) { useClass: SentryIonicErrorHandler, }, AppConfigProvider, + ExaminerRecordsProvider, AuthenticationProvider, + CompressionProvider, AppInfoProvider, + LoadingProvider, DateTimeProvider, SecureStorage, IsDebug, diff --git a/src/app/pages/dashboard/dashboard.page.ts b/src/app/pages/dashboard/dashboard.page.ts index a6a29f614..dc8dd4aa7 100644 --- a/src/app/pages/dashboard/dashboard.page.ts +++ b/src/app/pages/dashboard/dashboard.page.ts @@ -49,6 +49,7 @@ import { JournalRehydrationPage, JournalRehydrationType } from '@store/journal/j import { getTests } from '@store/tests/tests.reducer'; import { environment } from '@environments/environment'; import { TestersEnvironmentFile } from '@environments/models/environment.model'; +import { LoadExaminerRecordsPreferences } from '@store/examiner-records/examiner-records.actions'; interface DashboardPageState { appVersion$: Observable; @@ -161,6 +162,7 @@ export class DashboardPage extends BasePageComponent implements OnInit, ViewDidE this.store$.dispatch(ClearCandidateLicenceData()); this.store$.dispatch(ClearVehicleData()); this.store$.dispatch(StoreUnuploadedSlotsInTests()); + this.store$.dispatch(LoadExaminerRecordsPreferences()); //guard against calling journal if the user type is a delegated examiner if (!this.isDelegatedExaminer()) { this.store$.dispatch(journalActions.LoadJournalSilent()); diff --git a/src/app/pages/examiner-records/__tests__/examiner-records.analytics.effects.spec.ts b/src/app/pages/examiner-records/__tests__/examiner-records.analytics.effects.spec.ts new file mode 100644 index 000000000..7f7774f51 --- /dev/null +++ b/src/app/pages/examiner-records/__tests__/examiner-records.analytics.effects.spec.ts @@ -0,0 +1,268 @@ +import { ExaminerRecordsAnalyticsEffects } from '@pages/examiner-records/examiner-records.analytics.effects'; +import { AnalyticsProvider } from '@providers/analytics/analytics'; +import { ReplaySubject } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { + AnalyticsScreenNames, + GoogleAnalyticsEvents, + GoogleAnalyticsEventsTitles, + GoogleAnalyticsEventsValues, +} from '@providers/analytics/analytics.model'; +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { AnalyticsProviderMock } from '@providers/analytics/__mocks__/analytics.mock'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { AnalyticRecorded } from '@providers/analytics/analytics.actions'; +import { + ClickDataCard, + ColourFilterChanged, + DateRangeChanged, DisplayPartialBanner, + ExaminerRecordsViewDidEnter, + HideChartsChanged, + LocationChanged, ReturnToDashboardPressed, + TestCategoryChanged, +} from '@pages/examiner-records/examiner-records.actions'; +import { TestCategory } from '@dvsa/mes-test-schema/category-definitions/common/test-category'; +import { ColourEnum } from '@providers/examiner-records/examiner-records'; +import { DateRange } from '@shared/helpers/date-time'; + +describe('ExaminerStatsAnalyticsEffects', () => { + let effects: ExaminerRecordsAnalyticsEffects; + let analyticsProviderMock: AnalyticsProvider; + let actions$: ReplaySubject; + const screenName = AnalyticsScreenNames.EXAMINER_RECORDS; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + ExaminerRecordsAnalyticsEffects, + { + provide: AnalyticsProvider, + useClass: AnalyticsProviderMock, + }, + provideMockActions(() => actions$), + Store, + ], + }); + + actions$ = new ReplaySubject(1); + effects = TestBed.inject(ExaminerRecordsAnalyticsEffects); + analyticsProviderMock = TestBed.inject(AnalyticsProvider); + spyOn(analyticsProviderMock, 'logGAEvent'); + })); + + describe('examinerStatsViewDidEnter$', () => { + it('should call setCurrentPage', (done) => { + // ACT + actions$.next(ExaminerRecordsViewDidEnter()); + // ASSERT + effects.examinerStatsViewDidEnter$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.setGACurrentPage) + .toHaveBeenCalledWith(screenName); + done(); + }); + }); + }); + describe('dateRangeChanged$', () => { + it('should log an event', (done) => { + // ACT + actions$.next(DateRangeChanged({ + display: '1', + val: DateRange.TODAY, + })); + // ASSERT + effects.dateRangeChanged$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.DATE_RANGE_CHANGED, + 'today', + ); + done(); + }); + }); + }); + describe('locationChanged$', () => { + it('should log an event', (done) => { + // ACT + actions$.next(LocationChanged({ centreId: 1, centreName: '2', costCode: '3' })); + // ASSERT + effects.locationChanged$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.LOCATION_FILTER, + '2', + ); + done(); + }); + }); + }); + describe('testCategoryChanged$', () => { + it('should log an event', (done) => { + // ACT + actions$.next(TestCategoryChanged(TestCategory.ADI2)); + // ASSERT + effects.testCategoryChanged$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.TEST_CATEGORY_FILTER, + TestCategory.ADI2, + ); + done(); + }); + }); + }); + describe('colourFilterChanged$', () => { + it('should log an event with the default colour title if default is selected', (done) => { + // ACT + actions$.next(ColourFilterChanged(ColourEnum.DEFAULT)); + // ASSERT + effects.colourFilterChanged$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.DEFAULT_COLOUR, + GoogleAnalyticsEventsValues.SELECTED, + ); + done(); + }); + }); + it('should log an event with the greyscale colour title if greyscale is selected', (done) => { + // ACT + actions$.next(ColourFilterChanged(ColourEnum.GREYSCALE)); + // ASSERT + effects.colourFilterChanged$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.GREYSCALE_COLOUR, + GoogleAnalyticsEventsValues.SELECTED, + ); + done(); + }); + }); + }); + describe('hideChartsChanged$', () => { + it('should log unselected if called with false', (done) => { + // ACT + actions$.next(HideChartsChanged(false)); + // ASSERT + effects.hideChartsChanged$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.CHART_VISUALISATION, + GoogleAnalyticsEventsValues.UNSELECTED, + ); + done(); + }); + }); + it('should log selected if called with true', (done) => { + // ACT + actions$.next(HideChartsChanged(true)); + // ASSERT + effects.hideChartsChanged$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.CHART_VISUALISATION, + GoogleAnalyticsEventsValues.SELECTED, + ); + done(); + }); + }); + }); + describe('returnToDashboardPressed$', () => { + it('should log an event', (done) => { + // ACT + actions$.next(ReturnToDashboardPressed()); + // ASSERT + effects.returnToDashboardPressed$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.BUTTON_SELECTION, + GoogleAnalyticsEventsValues.RETURN_TO_DASHBOARD, + ); + done(); + }); + }); + }); + describe('onCardClicked$', () => { + it('should log an event with the tap to show title if expanded', (done) => { + // ACT + actions$.next(ClickDataCard({ + isExpanded: true, + title: 'title', + })); + // ASSERT + effects.onCardClicked$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.TAP_TO_SHOW, + 'title', + ); + done(); + }); + }); + it('should log an event with the tap to hide title if not expanded', (done) => { + // ACT + actions$.next(ClickDataCard({ + isExpanded: false, + title: 'title', + })); + // ASSERT + effects.onCardClicked$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.TAP_TO_HIDE, + 'title', + ); + done(); + }); + }); + }); + describe('partialBannerDisplayed$', () => { + it('should log an event', (done) => { + // ACT + actions$.next(DisplayPartialBanner()); + // ASSERT + effects.partialBannerDisplayed$.subscribe((result) => { + expect(result.type) + .toEqual(AnalyticRecorded.type); + expect(analyticsProviderMock.logGAEvent) + .toHaveBeenCalledWith( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.DATA_UNAVAILABLE, + GoogleAnalyticsEventsValues.DATA_BANNER_DISPLAY, + ); + done(); + }); + }); + }); +}); diff --git a/src/app/pages/examiner-records/__tests__/examiner-records.page.spec.ts b/src/app/pages/examiner-records/__tests__/examiner-records.page.spec.ts new file mode 100644 index 000000000..5cb2be72b --- /dev/null +++ b/src/app/pages/examiner-records/__tests__/examiner-records.page.spec.ts @@ -0,0 +1,692 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { IonicModule } from '@ionic/angular'; +import { ExaminerRecordsPage, ExaminerRecordsPageStateData } from '../examiner-records.page'; +import { + ClickDataCard, + ColourFilterChanged, + DateRangeChanged, + ExaminerRecordsViewDidEnter, + GetExaminerRecords, + HideChartsChanged, + LoadingExaminerRecords, + LocationChanged, + TestCategoryChanged, +} from '@pages/examiner-records/examiner-records.actions'; +import { TestCategory } from '@dvsa/mes-test-schema/category-definitions/common/test-category'; +import { of, Subscription } from 'rxjs'; +import { ColourEnum, ExaminerRecordsProvider, SelectableDateRange } from '@providers/examiner-records/examiner-records'; +import { CompressionProvider } from '@providers/compression/compression'; +import { SearchProvider } from '@providers/search/search'; +import { SearchProviderMock } from '@providers/search/__mocks__/search.mock'; +import { ExaminerRecordsProviderMock } from '@providers/examiner-records/__mocks__/examiner-records.mock'; +import { DASHBOARD_PAGE } from '@pages/page-names.constants'; +import { ScreenOrientation } from '@capawesome/capacitor-screen-orientation'; +import { ScrollDetail } from '@ionic/core'; +import moment from 'moment'; +import { selectCachedExaminerRecords, selectLastCachedDate } from '@store/examiner-records/examiner-records.selectors'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; +import { DateRange } from '@shared/helpers/date-time'; + +describe('ExaminerRecordsPage', () => { + let component: ExaminerRecordsPage; + let fixture: ComponentFixture; + let store$: MockStore; + let initialState = { + cachedRecords$: of([]), + isLoadingRecords$: of(false), + routeNumbers$: of([]), + manoeuvres$: of([]), + balanceQuestions$: of([]), + safetyQuestions$: of([]), + independentDriving$: of([]), + showMeQuestions$: of([]), + tellMeQuestions$: of([]), + testCount$: of(0), + circuits$: of([]), + locationList$: of([]), + categoryList$: of([]), + emergencyStops$: of([]) + } + const mockTests: ExaminerRecordModel[] = [ + { + testCategory: TestCategory.B, + testCentre: { + centreId: 3, + centreName: 'Cardiff', + costCode: 'CF1', + }, + routeNumber: 1, + startDate: moment(Date.now()).subtract(1, 'days').format('YYYY-MM-DD'), + }, + { + testCategory: TestCategory.C, + testCentre: { + centreId: 4, + centreName: 'Swansea', + costCode: 'SW1', + }, + routeNumber: 2, + startDate: moment(Date.now()).subtract(10, 'days').format('YYYY-MM-DD'), + }, + { + testCategory: TestCategory.C, + testCentre: { + centreId: 4, + centreName: 'Swansea', + costCode: 'SW1', + }, + routeNumber: 3, + startDate: moment(Date.now()).format('YYYY-MM-DD'), + }, + ] as ExaminerRecordModel[]; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ExaminerRecordsPage], + imports: [IonicModule], + providers: [ + { provide: ExaminerRecordsProvider, useClass: ExaminerRecordsProviderMock }, + CompressionProvider, + { provide: SearchProvider, useClass: SearchProviderMock }, + { provide: Store, useClass: MockStore }, + provideMockStore({ + initialState: { + appInfo: { employeeId: '1' }, + examinerRecords: { + colourScheme: ColourEnum.DEFAULT, + cachedRecords: null, + isLoading: false, + lastUpdatedTime: null, + }, + tests: { + startedTests: { + 1: { + 'appVersion': '4.10.0.0', + 'version': '3.42.5', + 'category': 'B', + 'activityCode': '11', + 'journalData': { + 'examiner': { 'staffNumber': '1234567', 'individualId': 9000001 }, + 'testCentre': { 'centreId': 54322, 'costCode': 'EXTC1', 'centreName': 'Example Test Centre' }, + 'testSlotAttributes': { + 'welshTest': false, + 'slotId': 5137, + 'start': new Date(Date.now()).toString(), + 'specialNeeds': false, + 'specialNeedsCode': 'EXTRA', + 'specialNeedsArray': ['None'], + 'vehicleTypeCode': 'C', + 'extendedTest': false, + 'examinerVisiting': false, + 'previousCancellation': ['Act of nature'], + 'entitlementCheck': false, + 'categoryEntitlementCheck': false, + 'fitMarker': false, + 'slotType': 'Extra Time Needed', + }, + 'candidate': { + 'candidateAddress': { + 'addressLine1': '2345 Station Street', + 'addressLine2': 'Someplace', + 'addressLine3': 'Sometown', + 'postcode': 'AB12 3CD', + }, + 'candidateId': 126, + 'candidateName': { 'firstName': 'test', 'lastName': 'data', 'title': 'Mr' }, + 'driverNumber': 'COOPE015220A99HC', + 'mobileTelephone': '07654 123456', + 'primaryTelephone': '01234 567890', + 'secondaryTelephone': '04321 098765', + 'dateOfBirth': '1974-09-14', + 'ethnicityCode': 'E', + 'gender': 'F', + }, + 'applicationReference': { 'applicationId': 20654332, 'bookingSequence': 3, 'checkDigit': 1 }, + }, + 'preTestDeclarations': { + 'insuranceDeclarationAccepted': true, + 'residencyDeclarationAccepted': true, + 'preTestSignature': '', + 'candidateDeclarationSigned': false, + }, + 'accompaniment': {}, + 'vehicleDetails': { + 'registrationNumber': '1', + 'gearboxCategory': 'Manual', + 'motStatus': 'No details found', + }, + 'instructorDetails': {}, + 'testData': { + 'drivingFaults': { 'moveOffSafety': 1, 'moveOffControl': 1 }, + 'dangerousFaults': {}, + 'seriousFaults': {}, + 'vehicleChecks': { + 'tellMeQuestion': { 'code': 'T6', 'description': 'Antilock braking system', 'outcome': 'P' }, + 'showMeQuestion': { 'outcome': 'P', 'code': 'S1', 'description': 'Rear windscreen' }, + }, + 'controlledStop': { 'selected': false }, + 'eco': { 'completed': true, 'adviceGivenControl': true, 'adviceGivenPlanning': true }, + 'ETA': {}, + 'eyesightTest': { 'complete': true, 'seriousFault': false }, + 'manoeuvres': { 'reverseRight': { 'selected': true } }, + 'testRequirements': { + 'normalStart1': true, + 'normalStart2': true, + 'hillStart': true, + 'angledStart': true, + }, + }, + 'passCompletion': { 'passCertificateNumber': 'A123456X', 'provisionalLicenceProvided': true }, + 'postTestDeclarations': { + 'healthDeclarationAccepted': true, + 'passCertificateNumberReceived': true, + + 'postTestSignature': '', + }, + 'testSummary': { + 'routeNumber': 1, + 'independentDriving': 'Traffic signs', + 'candidateDescription': '1', + 'additionalInformation': null, + 'weatherConditions': ['Snowing'], + 'debriefWitnessed': true, + 'D255': false, + 'identification': 'Licence', + 'trueLikenessToPhoto': true, + }, + 'communicationPreferences': { + 'updatedEmail': '', + 'communicationMethod': 'Post', + 'conductedLanguage': 'English', + }, + 'rekey': false, + 'rekeyDate': null, + 'rekeyReason': { + 'ipadIssue': { + 'selected': false, + 'broken': false, + 'lost': false, + 'technicalFault': false, + 'stolen': false, + }, 'other': { 'selected': false, 'reason': '' }, 'transfer': { 'selected': false }, + }, + 'delegatedTest': false, + 'examinerBooked': 1234567, + 'examinerConducted': 1234567, + 'examinerKeyed': 1234567, + 'changeMarker': false, + }, + }, + }, + }, + }), + ], + }); + + fixture = TestBed.createComponent(ExaminerRecordsPage); + component = fixture.componentInstance; + fixture.detectChanges(); + store$ = TestBed.inject(MockStore); + spyOn(component.store$, 'dispatch'); + + component.pageState = initialState; + + })); + it('should create', () => { + expect(component).toBeTruthy(); + expect(store$).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should call setFilterLists', () => { + spyOn(component, 'setLocationFilter'); + + component.ngOnInit(); + expect(component.setLocationFilter).toHaveBeenCalled(); + }); + }); + + describe('handleScroll', () => { + it('should set displayScrollBanner to true when scrollTop is greater than 203', () => { + component.displayScrollBanner = false; + component.handleScroll({ detail: { scrollTop: 205 } } as CustomEvent); + expect(component.displayScrollBanner).toBe(true); + }); + + it('should set displayScrollBanner to false when scrollTop is less than or equal to 203', () => { + component.displayScrollBanner = true; + component.handleScroll({ detail: { scrollTop: 203 } } as CustomEvent); + expect(component.displayScrollBanner).toBe(false); + }); + }); + + describe('setLocationFilter', () => { + it('should set locationFilterOptions to the item property of each object in locationList$', () => { + spyOn(component, 'ngOnInit'); + component.locationFilterOptions = null; + component.pageState.locationList$ = of([ + { item: { centreName: '1', centreId: 1, costCode: 'X1' }, count: 1 }, + { item: { centreName: '2', centreId: 2, costCode: 'X2' }, count: 2 }, + ]); + + component.setLocationFilter(); + expect(component.locationFilterOptions).toEqual([ + { centreName: '1', centreId: 1, costCode: 'X1' }, + { centreName: '2', centreId: 2, costCode: 'X2' }, + ]); + }); + it('should set locationPlaceholder to the centreName property ' + + 'of the object in the location array with the highest count', () => { + spyOn(component, 'ngOnInit'); + component.locationFilterOptions = null; + component.pageState.locationList$ = of([ + { item: { centreName: '1', centreId: 1, costCode: 'X1' }, count: 1 }, + { item: { centreName: '2', centreId: 2, costCode: 'X2' }, count: 2 }, + ]); + component.setLocationFilter(); + expect(component.locationPlaceholder).toEqual('2'); + }); + it('should call handleLocationFilter with the item of ' + + 'the object in the location array with the highest count', () => { + spyOn(component, 'ngOnInit'); + + spyOn(component, 'handleLocationFilter'); + + component.locationFilterOptions = null; + component.pageState.locationList$ = of([ + { item: { centreName: '1', centreId: 1, costCode: 'X1' }, count: 1 }, + { item: { centreName: '2', centreId: 2, costCode: 'X2' }, count: 2 }, + ]); + component.setLocationFilter(); + expect(component.handleLocationFilter).toHaveBeenCalledWith({ centreName: '2', centreId: 2, costCode: 'X2' }); + }); + }); + + describe('setDefault', () => { + it('should return the value with the highest count within the array', () => { + expect(component.setDefault( + [ + { item: 1, count: 1 }, + { item: 2, count: 2 }, + ])).toEqual({ item: 2, count: 2 }); + }); + }); + + describe('handleDateFilter', () => { + it('should set dateFilter to the display of the value passed', () => { + component.handleDateFilter( + { + detail: { + value: + { + display: '1', + }, + }, + } as CustomEvent, + ); + expect(component.dateFilter).toEqual('1'); + }); + it('should dispatch DateRangeChanged with dateFilter', () => { + component.handleDateFilter( + { + detail: { + value: + { + display: '1', + val: 'today', + }, + }, + } as CustomEvent, + ); + expect(component.store$.dispatch).toHaveBeenCalledWith(DateRangeChanged({ + display: '1', + val: 'today', + } as SelectableDateRange)); + }); + it('should set rangeSubject to the val property of the value passed', () => { + component.handleDateFilter( + { + detail: { + value: + { + val: '1', + }, + }, + } as CustomEvent, + ); + component.rangeSubject$.subscribe(i => { + expect(i).toEqual('1'); + }); + }); + }); + + describe('handleLocationFilter', () => { + it('should set locationFilter to the passed value', () => { + component.locationFilter = { centreId: null, centreName: null, costCode: null }; + component.handleLocationFilter({ centreName: '1', centreId: 1, costCode: '2' }, true); + expect(component.locationFilter).toEqual({ centreName: '1', centreId: 1, costCode: '2' }); + expect(component.locationSelectPristine).toEqual(false); + }); + it('should set locationSubject$ to centreId of the passed value', () => { + component.handleLocationFilter({ centreName: '1', centreId: 1, costCode: '2' }); + component.locationSubject$.subscribe((i) => { + expect(i).toEqual(1); + expect(component.locationSelectPristine).toEqual(true); + }); + }); + it('should dispatch LocationChanged with locationFilter', () => { + + component.handleLocationFilter({ centreName: '1', centreId: 1, costCode: '2' }); + expect(component.store$.dispatch) + .toHaveBeenCalledWith(LocationChanged({ centreName: '1', centreId: 1, costCode: '2' })); + }); + }); + + describe('getOnlineRecords', () => { + // eslint-disable-next-line max-len + it('should dispatch LoadingExaminerRecords and GetExaminerRecords actions when cached records are not available or last cached date is different', () => { + store$.overrideSelector(selectCachedExaminerRecords, null); + store$.overrideSelector(selectLastCachedDate, 'some other date'); + + component.getOnlineRecords(); + + expect(store$.dispatch).toHaveBeenCalledWith(LoadingExaminerRecords()); + expect(store$.dispatch).toHaveBeenCalledWith(GetExaminerRecords('1')); + }); + + it('should not dispatch any actions when cached records are available and last cached date is today', () => { + store$.overrideSelector(selectCachedExaminerRecords, [{} as ExaminerRecordModel]); + store$.overrideSelector(selectLastCachedDate, moment(Date.now()).format('DD/MM/YYYY')); + + component.getOnlineRecords(); + + expect(store$.dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('changeEligibleTests', () => { + it('should update eligTestSubject$ with the result of getEligibleTests', () => { + component.testSubject$.next(mockTests); + component.categorySubject$.next(TestCategory.C); + component.rangeSubject$.next(DateRange.WEEK); + component.locationSubject$.next(4); + + component.changeEligibleTests(); + + expect(component.eligTestSubject$.value).toEqual([mockTests[2]]); + }); + }); + + describe('filterDates', () => { + it('should update testsInRangeSubject$ with the result of getEligibleTests', () => { + component.testSubject$.next(mockTests); + component.rangeSubject$.next(DateRange.WEEK); + + component.filterDates(); + + expect(component.testsInRangeSubject$.value).toEqual([mockTests[0], mockTests[2]]); + }); + }); + + describe('getTestsByParameters', () => { + it('should apply provided function to eligible tests and category', () => { + const mockFn = (tests: ExaminerRecordModel[], category: string) => { + return tests.filter(test => test.testCategory === category); + }; + + component.eligTestSubject$.next(mockTests); + component.categorySubject$.next(TestCategory.B); + + component.getTestsByParameters(mockFn).subscribe(result => { + expect(result).toEqual([mockTests[0]]); + }); + }); + }); + + describe('handleCategoryFilter', () => { + it('should set categoryDisplay to "Test category: B" if the passed value is B', () => { + component.handleCategoryFilter(TestCategory.B, true); + expect(component.categoryDisplay).toEqual('Test category: B'); + expect(component.categorySelectPristine).toEqual(false); + }); + it('should set currentCategory to the passed value', () => { + component.handleCategoryFilter(TestCategory.B); + expect(component['currentCategory']).toEqual(TestCategory.B); + expect(component.categorySelectPristine).toEqual(true); + }); + it('should set categorySubject$ to the passed value', () => { + component.handleCategoryFilter(TestCategory.B); + component.categorySubject$.subscribe((i) => { + expect(i).toEqual(TestCategory.B); + }); + }); + it('should dispatch TestCategoryChanged with passed value', () => { + component.handleCategoryFilter(TestCategory.B); + expect(component.store$.dispatch).toHaveBeenCalledWith(TestCategoryChanged(TestCategory.B)); + }); + }); + + describe('colourFilterChanged', () => { + it('should set colourOption to the value passed', () => { + component.colourOption = component.examinerRecordsProvider.colours.default; + component.colourFilterChanged(ColourEnum.GREYSCALE); + expect(component.colourOption).toEqual(component.examinerRecordsProvider.colours.greyscale); + }); + it('should dispatch ColourFilterChanged with the colour passed', () => { + component.colourFilterChanged(ColourEnum.GREYSCALE); + expect(component.store$.dispatch).toHaveBeenCalledWith(ColourFilterChanged(ColourEnum.GREYSCALE)); + }); + }); + + describe('HideChartsChanged', () => { + it('should flip hideCharts', () => { + component.hideMainContent = true; + component.hideChart(); + expect(component.hideMainContent).toEqual(false); + }); + it('should dispatch the store with HideChartsChanged(true) if hideChart is true after being flipped', () => { + component.hideMainContent = false; + + component.hideChart(); + expect(component.store$.dispatch).toHaveBeenCalledWith(HideChartsChanged(true)); + }); + it('should dispatch the store with HideChartsChanged(false) if hideChart is false after being flipped', () => { + component.hideMainContent = true; + + component.hideChart(); + expect(component.store$.dispatch).toHaveBeenCalledWith(HideChartsChanged(false)); + }); + }); + + describe('showControlledStop', () => { + it('should return true if currentCategory is in the approved list', () => { + component['currentCategory'] = TestCategory.B; + expect(component.showEmergencyStop()).toEqual(true); + }); + it('should return false if currentCategory is not in the approved list', () => { + component['currentCategory'] = TestCategory.ADI3; + expect(component.showEmergencyStop()).toEqual(false); + }); + }); + + describe('goToDashboard', () => { + it('should navigate back to the dashboard page', () => { + spyOn(component.router, 'navigate').and.callThrough(); + component.goToDashboard(); + expect(component.router.navigate).toHaveBeenCalledWith([DASHBOARD_PAGE], { replaceUrl: true }); + }); + }); + + describe('isMod1', () => { + it('should return true if the category is a version of mod 1', () => { + component.currentCategory = TestCategory.EUA1M1; + expect(component.isMod1()).toEqual(true); + }); + it('should return false if the category is not a version of mod 1', () => { + component.currentCategory = TestCategory.B; + expect(component.isMod1()).toEqual(false); + }); + }); + + describe('isBike', () => { + it('should return true if the category is a version of a bike test', () => { + component.currentCategory = TestCategory.EUAM2; + expect(component.isBike()).toEqual(true); + }); + it('should return false if the category is not a version of a bike test', () => { + component.currentCategory = TestCategory.B; + expect(component.isBike()).toEqual(false); + }); + }); + + describe('ionViewDidEnter', () => { + it('should monitor orientation and fire entry analytic', () => { + spyOn(component.orientationProvider, 'monitorOrientation').and.callThrough(); + component.ionViewDidEnter(); + expect(component.orientationProvider.monitorOrientation).toHaveBeenCalled(); + expect(component.store$.dispatch).toHaveBeenCalledWith(ExaminerRecordsViewDidEnter()); + }); + }); + + describe('ionViewWillLeave', () => { + it('should remove all listeners from screen orientation', () => { + spyOn(ScreenOrientation, 'removeAllListeners').and.callThrough(); + component.ionViewWillLeave(); + expect(ScreenOrientation.removeAllListeners).toHaveBeenCalled(); + }); + it('should unsubscribe from the subscription if there is one', () => { + component.subscription = new Subscription(); + spyOn(component.subscription, 'unsubscribe'); + component.ionViewWillLeave(); + expect(component.subscription.unsubscribe) + .toHaveBeenCalled(); + }); + }); + + describe('accordionSelect', () => { + it('should flip accordionOpen to false if it started as true', () => { + component.accordionOpen = true; + component.accordionSelect(); + expect(component.accordionOpen).toEqual(false); + }); + it('should flip accordionOpen to true if it started as false', () => { + component.accordionOpen = false; + component.accordionSelect(); + expect(component.accordionOpen).toEqual(true); + }); + }); + + describe('cardClicked', () => { + it('should dispatch the store', () => { + component.cardClicked({isExpanded: false, title: 'test'}); + expect(component.store$.dispatch).toHaveBeenCalledWith(ClickDataCard({isExpanded: false, title: 'test'})); + }); + }); + + describe('displayNoDataCard', () => { + it('should return true when all data grids are empty', () => { + const emptyData: ExaminerRecordsPageStateData = { + routeGrid: [], + manoeuvresGrid: [], + showMeQuestionsGrid: [], + independentDrivingGrid: [], + tellMeQuestionsGrid: [], + safetyGrid: [], + balanceGrid: [], + testCount: 0, + emergencyStops: [], + circuits: [], + locationList: [], + categoryList: [] + }; + expect(component.displayNoDataCard(emptyData)).toBeTrue(); + }); + + it('should return false when any data grid has items', () => { + const dataWithItems: ExaminerRecordsPageStateData = { + routeGrid: [], + manoeuvresGrid: [], + showMeQuestionsGrid: [], + independentDrivingGrid: [], + tellMeQuestionsGrid: [], + safetyGrid: [], + balanceGrid: [], + testCount: 1, + emergencyStops: [], + circuits: [], + locationList: [{item: {centreName: 'Test Centre 1', centreId: 1, costCode: 'TC1'}, count: 1}], + categoryList: [{item: TestCategory.B, count: 1}] + }; + + spyOn(component, 'getTotal').and.returnValue(1); + expect(component.displayNoDataCard(dataWithItems)).toBeFalse(); + }); + + it('should return true when categoryList and locationList are empty', () => { + const data: ExaminerRecordsPageStateData = { + routeGrid: [{ item: 'Route 1', count: 1, percentage: '10%' }], + manoeuvresGrid: [], + showMeQuestionsGrid: [], + independentDrivingGrid: [], + tellMeQuestionsGrid: [], + safetyGrid: [], + balanceGrid: [], + testCount: 1, + emergencyStops: [], + circuits: [], + locationList: [], + categoryList: [] + }; + expect(component.displayNoDataCard(data)).toBeTrue(); + }); + }); + + describe('getLabelText', () => { + it('should return correct label text for single test', () => { + component.pageState.testCount$ = of(1); + spyOn(component.accessibilityService, 'getTextZoomClass').and.returnValue('text-zoom-large'); + + component.currentCategory = 'B'; + component.startDateFilter = '01/01/2021'; + component.endDateFilter = '31/01/2021'; + component.locationFilter = { centreName: 'Test Centre 1', centreId: 1, costCode: 'TC1' }; + + const expectedText = 'Displaying 1 Category B' + + ' test, from 01/01/2021 to 31/01/2021
' + + '
at Test Centre 1'; + expect(component.getLabelText()).toEqual(expectedText); + }); + + it('should return correct label text for multiple tests', () => { + component.pageState.testCount$ = of(2); + spyOn(component.accessibilityService, 'getTextZoomClass').and.returnValue('text-zoom-large'); + + component.currentCategory = 'C'; + component.startDateFilter = '01/02/2021'; + component.endDateFilter = '28/02/2021'; + component.locationFilter = { centreName: 'Test Centre 2', centreId: 2, costCode: 'TC2' }; + + const expectedText = 'Displaying 2 Category C' + + ' tests, from 01/02/2021 to 28/02/2021' + + '
at Test Centre 2' + expect(component.getLabelText()).toEqual(expectedText); + }); + + it('should not include line break for extra large text', () => { + component.pageState.testCount$ = of(1); + spyOn(component.accessibilityService, 'getTextZoomClass').and.returnValue('text-zoom-x-large'); + + component.currentCategory = 'C'; + component.startDateFilter = '01/02/2021'; + component.endDateFilter = '28/02/2021'; + component.locationFilter = { centreName: 'Test Centre 2', centreId: 2, costCode: 'TC2' }; + + const expectedText = 'Displaying 1 Category C' + + ' test, from 01/02/2021 to 28/02/2021 at Test Centre 2'; + expect(component.getLabelText()).toEqual(expectedText); + }); + }); + +}); diff --git a/src/app/pages/examiner-records/__tests__/examiner-records.selector.spec.ts b/src/app/pages/examiner-records/__tests__/examiner-records.selector.spec.ts new file mode 100644 index 000000000..7351d1ec8 --- /dev/null +++ b/src/app/pages/examiner-records/__tests__/examiner-records.selector.spec.ts @@ -0,0 +1,608 @@ +import { + dateFilter, + getBalanceQuestions, + getCategories, + getCircuits, + getEligibleTests, + getEmergencyStopCount, + getIndependentDrivingStats, + getIndex, + getLocations, getManoeuvresUsed, + getManoeuvreTypeLabels, + getRouteNumbers, + getSafetyQuestions, + getShowMeQuestions, + getStartedTestCount, + getTellMeQuestions, +} from '@pages/examiner-records/examiner-records.selector'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; +import { TestCategory } from '@dvsa/mes-test-schema/category-definitions/common/test-category'; +import moment from 'moment'; +import { ManoeuvreTypes } from '@store/tests/test-data/test-data.constants'; +import { DateRange } from '@shared/helpers/date-time'; + +describe('examiner records selector', () => { + const startedTests: ExaminerRecordModel[] = [ + { + appRef: 1234567, + testCategory: TestCategory.ADI2, + testCentre: { + centreId: 3, + centreName: 'B', + costCode: '000090909', + }, + independentDriving: 'Sat nav', + routeNumber: 3, + startDate: moment(new Date(Date.now())).subtract(5, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.ADI2, + testCentre: { + centreId: 3, + centreName: 'B', + costCode: '000090909', + }, + independentDriving: 'Sat nav', + routeNumber: 2, + startDate: moment(new Date(Date.now())).subtract(5, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.B, + testCentre: { + centreId: 3, + centreName: 'B', + costCode: '000090909', + }, + showMeQuestions: [ + { + outcome: 'P', + description: 'Dipped headlights', + code: 'S3' + } + ], + tellMeQuestions: [ + { + outcome: 'P', + description: 'Headlights & tail lights', + code: 'T5' + } + ], + manoeuvres: [ + {'forwardPark': {'selected': true}} + ], + independentDriving: 'Sat nav', + startDate: moment(new Date(Date.now())).subtract(5, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.B, + testCentre: { + centreId: 1, + centreName: 'B', + costCode: '000090909', + }, + independentDriving: 'Sat nav', + startDate: moment(new Date(Date.now())).subtract(5, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 2345678, + testCategory: TestCategory.B, + testCentre: { + centreId: 2, + centreName: 'A', + costCode: '000090909', + }, + controlledStop: true, + independentDriving: 'Sat nav', + startDate: moment(new Date(Date.now())).subtract(10, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 3456789, + testCategory: TestCategory.B, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + startDate: moment(new Date(Date.now())).subtract(15, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.C, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + controlledStop: true, + startDate: moment(new Date(Date.now())).subtract(5, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.C, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + startDate: moment(new Date(Date.now())).subtract(10, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.C, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + startDate: moment(new Date(Date.now())).subtract(15, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.EUAM2, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Diagram', + safetyQuestions: [{ + code: 'M1', + description: 'Oil level', + outcome: 'P' }], + balanceQuestions: [ + { + code: 'B2', + description: 'Carrying a passenger', + outcome: 'P', + } + ], + startDate: moment(new Date(Date.now())).subtract(15, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.EUAM1, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + circuit: 'Left', + startDate: moment(new Date(Date.now())).subtract(15, 'days').format('YYYY-MM-DD'), + }, + ] as ExaminerRecordModel[]; + + describe('dateFilter', () => { + it('return true if provided examinerRecord is within specified date range', () => { + expect(dateFilter( + startedTests.filter((value) => + moment(new Date(value.startDate)) > moment(new Date(Date.now())).subtract(7, 'days') + )[0], DateRange.WEEK) + ).toEqual(true); + }); + + it('return false if provided examinerRecord is not within specified date range', () => { + expect( + dateFilter( + startedTests.filter((value) => + moment(new Date(value.startDate)) < moment(new Date(Date.now())).subtract(7, 'days') + )[0], DateRange.WEEK) + ).toEqual(false); + }); + }); + + describe('getIndex', () => { + it('should return string without letter prefix', () => { + expect(getIndex('R10 - Route 10')).toEqual(10); + }); + + it('should return string without letter prefix', () => { + expect(getIndex('R - Route')).toEqual(null); + }); + }); + + describe('getEligibleTests', () => { + it('should retrieve 1 eligible test that is cat b within the last 2 weeks', () => { + expect(getEligibleTests(startedTests, TestCategory.B, DateRange.WEEK, 1).length).toBe(1); + expect(getEligibleTests(startedTests, TestCategory.B, DateRange.WEEK, 1)).toEqual([ + { + appRef: 1234567, + testCategory: TestCategory.B, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Sat nav', + startDate: moment(new Date(Date.now())).subtract(5, 'days').format('YYYY-MM-DD'), + }, + ]); + }); + + it('should retrieve 2 eligible tests that is cat c within the last month', () => { + expect(getEligibleTests(startedTests, TestCategory.C, DateRange.FORTNIGHT, 1).length).toBe(2); + expect(getEligibleTests(startedTests, TestCategory.C, DateRange.FORTNIGHT, 1)).toEqual([ + { + appRef: 1234567, + testCategory: TestCategory.C, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + controlledStop: true, + startDate: moment(new Date(Date.now())).subtract(5, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.C, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + startDate: moment(new Date(Date.now())).subtract(10, 'days').format('YYYY-MM-DD'), + }, + ]); + }); + + it('should retrieve 7 eligible tests that are within test centre 1', () => { + expect(getEligibleTests(startedTests, TestCategory.C, DateRange.EIGHTEEN_MONTHS, 1, true, false).length).toBe(7); + expect(getEligibleTests(startedTests, TestCategory.C, DateRange.EIGHTEEN_MONTHS, 1, true, false)).toEqual([ + { + appRef: 1234567, + testCategory: TestCategory.B, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Sat nav', + startDate: moment(new Date(Date.now())).subtract(5, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 3456789, + testCategory: TestCategory.B, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + startDate: moment(new Date(Date.now())).subtract(15, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.C, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + controlledStop: true, + startDate: moment(new Date(Date.now())).subtract(5, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.C, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + startDate: moment(new Date(Date.now())).subtract(10, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.C, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + independentDriving: 'Traffic signs', + startDate: moment(new Date(Date.now())).subtract(15, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.EUAM2, + testCentre: { + centreName: 'B', + centreId: 1, + costCode: '000090909', + }, + safetyQuestions: [ + { + code: 'M1', + description: 'Oil level', + outcome: 'P' + } + ], + balanceQuestions: [ + { + code: 'B2', + description: 'Carrying a passenger', + outcome: 'P', + } + ], + independentDriving: 'Diagram', + startDate: moment(new Date(Date.now())).subtract(15, 'days').format('YYYY-MM-DD'), + }, + { + appRef: 1234567, + testCategory: TestCategory.EUAM1, + testCentre: { centreName: 'B', centreId: 1, costCode: '000090909' }, + circuit: 'Left', + startDate: moment(new Date(Date.now())).subtract(15, 'days').format('YYYY-MM-DD'), + }, + ]); + }); + }); + + describe('getEmergencyStopCount', () => { + it('should return the amount of tests that contain an emergency stop', () => { + expect(getEmergencyStopCount(startedTests)).toEqual(2); + }); + }); + + describe('getLocations', () => { + it('should return an array of all the locations in the passed tests with the amount of times they appear,' + + 'ordered alphabetically', () => { + expect(getLocations(startedTests)).toEqual([ + { item: { centreName: 'A', centreId: 2, costCode: '000090909' }, count: 1 }, + { item: { centreName: 'B', centreId: 1, costCode: '000090909' }, count: 7 }, + { item: { centreId: 3, centreName: 'B', costCode: '000090909' }, count: 3 } + ]); + }); + }); + + describe('getIndependentDrivingStats', () => { + it('should return an array of all the all instances of Traffic Signs and Sat Nav in the passed tests ' + + 'with the amount of times they appear, ordered by Index number if the category is B', () => { + expect( + getIndependentDrivingStats( + startedTests.filter(value => value.testCategory == TestCategory.B), TestCategory.B), + ).toEqual([ + { item: 'I1 - Traffic signs', count: 1, percentage: '25.0%' }, + { item: 'I2 - Sat nav', count: 3, percentage: '75.0%' }, + ]); + }); + it('should return an array of all the all instances of Traffic Signs and Diagram in the passed tests ' + + 'with the amount of times they appear, ordered by Index number if the test is a bike test', () => { + expect( + getIndependentDrivingStats( + startedTests.filter(value => value.testCategory == TestCategory.EUAM2), TestCategory.EUAM2), + ).toEqual([ + { item: 'I1 - Traffic signs', count: 0, percentage: '0.0%' }, + { item: 'I2 - Diagram', count: 1, percentage: '100.0%' }, + ]); + }); + it('should return an empty array if the passed category is F, regardless of the content of startedTests', () => { + expect( + getIndependentDrivingStats( + startedTests.filter(value => value.testCategory == TestCategory.EUAM2), TestCategory.F), + ).toEqual([]); + }); + }); + + describe('getCircuits', () => { + it('should return an array of all the instances of Left and Right in the passed tests ' + + 'with the amount of times they appear, ordered by Index number if the category is Mod 1', () => { + expect( + getCircuits( + startedTests.filter(value => value.testCategory == TestCategory.EUAM1), TestCategory.EUAM1), + ).toEqual([ + { item: 'C1 - Left', count: 1, percentage: '100.0%' }, + { item: 'C2 - Right', count: 0, percentage: '0.0%' }, + ]); + }); + it('should return an empty array if the passed category is not mod 1, ' + + 'regardless of the content of startedTests', () => { + expect( + getCircuits( + startedTests.filter(value => value.testCategory == TestCategory.B), TestCategory.B), + ).toEqual([]); + }); + }); + + describe('getCategories', () => { + it('should return an array of all the categories in the passed tests with the amount of times they appear,' + + 'ordered alphabetically', () => { + expect(getCategories(startedTests, null, null, 1)).toEqual([ + { item: TestCategory.B, count: 2 }, + { item: TestCategory.C, count: 3 }, + { item: TestCategory.EUAM1, count: 1 }, + { item: TestCategory.EUAM2, count: 1 } + ]); + }); + }); + + describe('getStartedTestCount', () => { + it('should return the number of entries in the startedTests array', () => { + expect(getStartedTestCount(startedTests)).toEqual(11); + }); + }); + + describe('getRouteNumbers', () => { + it('should return a list of all route numbers within started tests and the number of times they appear,' + + 'ordered by index', () => { + expect( + getRouteNumbers( + startedTests.filter(value => value.testCategory == TestCategory.ADI2)), + ).toEqual([ + { item: 'R2 - Route 2', count: 1, percentage: '50.0%' }, + { item: 'R3 - Route 3', count: 1, percentage: '50.0%' } + ]); + }); + }); + + describe('getSafetyQuestions', () => { + it('should return a list of all possible safety questions and the amount of times they appear, ' + + 'ordered by index', () => { + expect( + getSafetyQuestions( + startedTests.filter(value => value.testCategory == TestCategory.EUAM2), TestCategory.EUAM2), + ).toEqual([ + { item: 'M1 - Oil level', count: 1, percentage: '100.0%' }, + { item: 'M2 - Horn working', count: 0, percentage: '0.0%' }, + { item: 'M3 - Brake fluid', count: 0, percentage: '0.0%' }, + { item: 'M4 - Lights', count: 0, percentage: '0.0%' }, + { item: 'M5 - Brake lights', count: 0, percentage: '0.0%' }, + { item: 'M6 - Chain', count: 0, percentage: '0.0%' }, + { item: 'M7 - Steering', count: 0, percentage: '0.0%' }, + { item: 'M8 - Tyres', count: 0, percentage: '0.0%' }, + { item: 'M9 - Front brake', count: 0, percentage: '0.0%' }, + { item: 'M10 - Brakes', count: 0, percentage: '0.0%' }, + { item: 'M11 - Engine cut out switch', count: 0, percentage: '0.0%' }, + { item: 'M12 - Fog light', count: 0, percentage: '0.0%' }, + { item: 'M13 - Dipped / main beam', count: 0, percentage: '0.0%' } + ]); + }); + }); + + describe('getBalanceQuestions', () => { + it('should return a list of all possible balance questions and the amount of times they appear, ' + + 'ordered by index', () => { + expect( + getBalanceQuestions( + startedTests.filter(value => value.testCategory == TestCategory.EUAM2), TestCategory.EUAM2), + ).toEqual([ + { item: 'B1 - Pillion passenger problems', count: 0, percentage: '0.0%' }, + { item: 'B2 - Carrying a passenger', count: 1, percentage: '100.0%' }, + { item: 'B3 - Balance with passenger', count: 0, percentage: '0.0%' }, + ]); + }); + }); + + describe('getShowMeQuestions', () => { + it('should return a list of all possible show me questions and the amount of times they appear, ' + + 'ordered by index', () => { + expect( + getShowMeQuestions( + startedTests.filter(value => value.testCategory == TestCategory.B), TestCategory.B), + ).toEqual([ + { item: 'S1 - Rear windscreen', count: 0, percentage: '0.0%' }, + { item: 'S2 - Front windscreen', count: 0, percentage: '0.0%' }, + { item: 'S3 - Dipped headlights', count: 1, percentage: '100.0%' }, + { item: 'S4 - Rear demister', count: 0, percentage: '0.0%' }, + { item: 'S5 - Horn', count: 0, percentage: '0.0%' }, + { item: 'S6 - Demist front windscreen', count: 0, percentage: '0.0%' }, + { item: 'S7 - Side window', count: 0, percentage: '0.0%' } + ]); + }); + }); + + describe('getTellMeQuestions', () => { + it('should return a list of all possible tell me questions and the amount of times they appear, ' + + 'ordered by index', () => { + expect( + getTellMeQuestions( + startedTests.filter(value => value.testCategory == TestCategory.B), TestCategory.B), + ).toEqual([ + { item: 'T1 - Brakes', count: 0, percentage: '0.0%' }, + { item: 'T2 - Tyre pressures', count: 0, percentage: '0.0%' }, + { item: 'T3 - Head restraint', count: 0, percentage: '0.0%' }, + { item: 'T4 - Sufficient tread', count: 0, percentage: '0.0%' }, + { item: 'T5 - Headlights & tail lights', count: 1, percentage: '100.0%' }, + { item: 'T6 - Antilock braking system', count: 0, percentage: '0.0%' }, + { item: 'T7 - Direction indicators', count: 0, percentage: '0.0%' }, + { item: 'T8 - Brake lights', count: 0, percentage: '0.0%' }, + { item: 'T9 - Power assisted steering', count: 0, percentage: '0.0%' }, + { item: 'T10 - Rear fog light(s)', count: 0, percentage: '0.0%' }, + { item: 'T11 - Dipped to main beam', count: 0, percentage: '0.0%' }, + { item: 'T12 - Engine has sufficient oil', count: 0, percentage: '0.0%' }, + { item: 'T13 - Engine coolant', count: 0, percentage: '0.0%' }, + { item: 'T14 - Brake fluid', count: 0, percentage: '0.0%' } + ]); + }); + }); + + describe('getManoeuvreTypeLabels', () => { + it('should return a list of relevant manoeuvre labels for cat B if type is not included', () => { + expect( + getManoeuvreTypeLabels( + TestCategory.B), + ).toEqual({ + reverseRight: 'Reverse right', + reverseParkRoad: 'Reverse park (road)', + reverseParkCarpark: 'Reverse park (car park)', + forwardPark: 'Forward park' + }); + }); + it('should return the string value for the manoeuvre from the list of relevant manoeuvres for the category', () => { + expect( + getManoeuvreTypeLabels( + TestCategory.B, ManoeuvreTypes.reverseRight), + ).toEqual('Reverse right'); + }); + it('should return a list of relevant manoeuvre labels for cat BE if type is not included', () => { + expect( + getManoeuvreTypeLabels( + TestCategory.BE), + ).toEqual({ + reverseLeft: 'Reverse' + }); + }); + it('should return the string value for the manoeuvre from the list of relevant manoeuvres for the category', () => { + expect( + getManoeuvreTypeLabels( + TestCategory.BE, ManoeuvreTypes.reverseLeft), + ).toEqual('Reverse'); + }); + it('should return a list of relevant manoeuvre labels for cat ADI2 if type is not included', () => { + expect( + getManoeuvreTypeLabels( + TestCategory.ADI2), + ).toEqual({ + reverseRight: 'Reverse right', + reverseParkRoad: 'Reverse park (road)', + reverseParkCarpark: 'Reverse park (car park)', + forwardPark: 'Forward park' + }); + }); + it('should return the string value for the manoeuvre from the list of relevant manoeuvres for the category', () => { + expect( + getManoeuvreTypeLabels( + TestCategory.ADI2, ManoeuvreTypes.reverseRight), + ).toEqual('Reverse right'); + }); + it('should return an empty object if an invalid category is passed', () => { + expect( + getManoeuvreTypeLabels( + TestCategory.ADI3), + ).toEqual({}); + }); + }); + + describe('getManoeuvresUsed', () => { + it('should return a list all manoeuvres available for that category and the amount of times they appear, ' + + 'ordered by index', () => { + expect( + getManoeuvresUsed( + startedTests.filter(value => value.testCategory == TestCategory.B), TestCategory.B) + ).toEqual([ + { item: 'E1 - Reverse right', count: 0, percentage: '0.0%' }, + { item: 'E2 - Reverse park (road)', count: 0, percentage: '0.0%' }, + { item: 'E3 - Reverse park (car park)', count: 0, percentage: '0.0%' }, + { item: 'E4 - Forward park', count: 1, percentage: '100.0%' } + ]); + }); + it('should return an empty array if passed a category with no manoeuvres', () => { + expect( + getManoeuvresUsed( + startedTests.filter(value => value.testCategory == TestCategory.ADI3), TestCategory.ADI3) + ).toEqual([ + ]); + }); + }); +}); diff --git a/src/app/pages/examiner-records/components/colour-filter-radio/__tests__/colour-filter-radio.spec.ts b/src/app/pages/examiner-records/components/colour-filter-radio/__tests__/colour-filter-radio.spec.ts new file mode 100644 index 000000000..57b81fd37 --- /dev/null +++ b/src/app/pages/examiner-records/components/colour-filter-radio/__tests__/colour-filter-radio.spec.ts @@ -0,0 +1,41 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { IonicModule } from '@ionic/angular'; +import { provideMockStore } from '@ngrx/store/testing'; +import { UntypedFormGroup } from '@angular/forms'; +import { OutcomeBehaviourMapProvider } from '@providers/outcome-behaviour-map/outcome-behaviour-map'; +import { ColourFilterRadioComponent } from '@pages/examiner-records/components/colour-filter-radio/colour-filter-radio'; +import { ColourEnum } from '@providers/examiner-records/examiner-records'; + +describe('ColourFilterRadioComponent', () => { + let fixture: ComponentFixture; + let component: ColourFilterRadioComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ColourFilterRadioComponent], + imports: [ + IonicModule, + ], + providers: [ + provideMockStore({ ...{} }), + { provide: OutcomeBehaviourMapProvider, useClass: OutcomeBehaviourMapProvider }, + ], + }); + + fixture = TestBed.createComponent(ColourFilterRadioComponent); + component = fixture.componentInstance; + })); + + describe('viewFilterChanged', () => { + it('should emit identification while from control is valid', () => { + spyOn(component.filterChange, 'emit'); + component.formGroup = new UntypedFormGroup({}); + component.ngOnChanges(); + + component.formControl.setValue(1); + + component.viewFilterChanged(ColourEnum.DEFAULT); + expect(component.filterChange.emit).toHaveBeenCalledWith(ColourEnum.DEFAULT); + }); + }); +}); diff --git a/src/app/pages/examiner-records/components/colour-filter-radio/colour-filter-radio.html b/src/app/pages/examiner-records/components/colour-filter-radio/colour-filter-radio.html new file mode 100644 index 000000000..ba367f509 --- /dev/null +++ b/src/app/pages/examiner-records/components/colour-filter-radio/colour-filter-radio.html @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/src/app/pages/examiner-records/components/colour-filter-radio/colour-filter-radio.scss b/src/app/pages/examiner-records/components/colour-filter-radio/colour-filter-radio.scss new file mode 100644 index 000000000..6f612d08c --- /dev/null +++ b/src/app/pages/examiner-records/components/colour-filter-radio/colour-filter-radio.scss @@ -0,0 +1,8 @@ +.white-background { + & + label:before { + box-shadow: inset 0 0 0 9px var(--mes-white), inset 0 0 0 30px var(--mes-white); + } + &:checked + label:before { + box-shadow: inset 0 0 0 9px var(--mes-white); + } +} diff --git a/src/app/pages/examiner-records/components/colour-filter-radio/colour-filter-radio.ts b/src/app/pages/examiner-records/components/colour-filter-radio/colour-filter-radio.ts new file mode 100644 index 000000000..189ba512a --- /dev/null +++ b/src/app/pages/examiner-records/components/colour-filter-radio/colour-filter-radio.ts @@ -0,0 +1,39 @@ +import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; +import { AccessibilityService } from '@providers/accessibility/accessibility.service'; +import { ColourEnum } from '@providers/examiner-records/examiner-records'; + +@Component({ + selector: 'colour-filter-radio', + templateUrl: 'colour-filter-radio.html', + styleUrls: ['colour-filter-radio.scss'], +}) +export class ColourFilterRadioComponent implements OnChanges { + + @Input() + formGroup: UntypedFormGroup; + + @Input() + colourScheme: ColourEnum = ColourEnum.DEFAULT; + + @Output() + filterChange = new EventEmitter(); + + public formControl: UntypedFormControl; + constructor(public accessibilityService: AccessibilityService) { + } + + ngOnChanges(): void { + if (!this.formControl) { + this.formControl = new UntypedFormControl(this.colourScheme); + this.formGroup.addControl('colourFilter', this.formControl); + } + this.formControl.patchValue(this.colourScheme); + } + + viewFilterChanged(viewFilter: ColourEnum): void { + if (this.formControl.valid) { + this.filterChange.emit(viewFilter); + } + } +} diff --git a/src/app/pages/examiner-records/components/examiner-records-components.module.ts b/src/app/pages/examiner-records/components/examiner-records-components.module.ts new file mode 100644 index 000000000..856c91151 --- /dev/null +++ b/src/app/pages/examiner-records/components/examiner-records-components.module.ts @@ -0,0 +1,29 @@ +import { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { CommonModule } from '@angular/common'; +import { ComponentsModule } from '@components/common/common-components.module'; +import { ColourFilterRadioComponent } from '@pages/examiner-records/components/colour-filter-radio/colour-filter-radio'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ExaminerReportsCard } from '@pages/examiner-records/components/examiner-reports-card/examiner-reports-card'; +import { CompressionProvider } from '@providers/compression/compression'; + +@NgModule({ + declarations: [ + ColourFilterRadioComponent, + ExaminerReportsCard, + ], + imports: [ + IonicModule, + CommonModule, + ComponentsModule, + ReactiveFormsModule, + ], + providers: [ + CompressionProvider, + ], + exports: [ + ColourFilterRadioComponent, + ExaminerReportsCard, + ], +}) +export class ExaminerRecordsComponentsModule { } diff --git a/src/app/pages/examiner-records/components/examiner-reports-card/__tests__/examiner-reports-card.spec.ts b/src/app/pages/examiner-records/components/examiner-reports-card/__tests__/examiner-reports-card.spec.ts new file mode 100644 index 000000000..37a348be3 --- /dev/null +++ b/src/app/pages/examiner-records/components/examiner-reports-card/__tests__/examiner-reports-card.spec.ts @@ -0,0 +1,155 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { IonicModule } from '@ionic/angular'; +import { provideMockStore } from '@ngrx/store/testing'; +import { OutcomeBehaviourMapProvider } from '@providers/outcome-behaviour-map/outcome-behaviour-map'; +import { ExaminerReportsCard } from '@pages/examiner-records/components/examiner-reports-card/examiner-reports-card'; + +describe('ExaminerReportsCard', () => { + let fixture: ComponentFixture; + let component: ExaminerReportsCard; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ExaminerReportsCard], + imports: [ + IonicModule, + ], + providers: [ + provideMockStore({ ...{} }), + { provide: OutcomeBehaviourMapProvider, useClass: OutcomeBehaviourMapProvider }, + ], + }); + + fixture = TestBed.createComponent(ExaminerReportsCard); + component = fixture.componentInstance; + })); + describe('getTapText', () => { + it('should return the trueCondition text if showExpandedData is true', () => { + component.showExpandedData = true; + expect(component.getTapText('true', 'false')).toEqual('true'); + }); + it('should return the falseCondition text if showExpandedData is false', () => { + component.showExpandedData = false; + expect(component.getTapText('true', 'false')).toEqual('false'); + }); + }); + + describe('handleCardClick', () => { + it('should toggle showExpandedData if canExpand is true', () => { + component.canExpand = true; + component.showExpandedData = false; + component.handleCardClick(); + expect(component.showExpandedData).toEqual(true); + }); + it('should not toggle showExpandedData if canExpand is false', () => { + component.canExpand = false; + component.showExpandedData = false; + component.handleCardClick(); + expect(component.showExpandedData).toEqual(false); + }); + }); + + describe('getTotal', () => { + it('should return 10 if the total number of data points is equal to 10', () => { + expect(component.getTotal([ + { + item: '1', + count: 1, + percentage: 'test', + }, + { + item: '2', + count: 2, + percentage: 'test', + }, + { + item: '3', + count: 7, + percentage: 'test', + }, + ])).toEqual(10); + }); + }); + + describe('setMinWidth', () => { + it('should return the portrait width if the card has a chart, ' + + 'it is not a bar chart and isPortrait is true', () => { + component.chartTransform = { portrait: { width: 740, height: 300 }, landscape: { width: 1020, height: 300 } }; + component.hasChart = true; + component.isPortrait = true; + component.chartType = 'pie'; + + expect(component.setMinWidth()).toEqual('740.0px'); + }); + it('should return the landscape width if the card has a chart, ' + + 'it is not a bar chart and isPortrait is false', () => { + component.chartTransform = { portrait: { width: 740, height: 300 }, landscape: { width: 1020, height: 300 } }; + component.hasChart = true; + component.isPortrait = false; + component.chartType = 'pie'; + + expect(component.setMinWidth()).toEqual('1020.0px'); + }); + it('should return the portrait width multiplied by 1.07 if the card has a chart, ' + + 'it is a bar chart and isPortrait is true', () => { + component.chartTransform = { portrait: { width: 740, height: 300 }, landscape: { width: 1020, height: 300 } }; + component.hasChart = true; + component.isPortrait = true; + component.chartType = 'bar'; + + expect(component.setMinWidth()).toEqual('791.8px'); + }); + it('should return the landscape width multiplied by 1.07 if the card has a chart, ' + + 'it is a bar chart and isPortrait is true', () => { + component.chartTransform = { portrait: { width: 740, height: 300 }, landscape: { width: 1020, height: 300 } }; + component.hasChart = true; + component.isPortrait = false; + component.chartType = 'bar'; + + expect(component.setMinWidth()).toEqual('1060.8px'); + }); + it('should return null if hasChart if false and chartType is not bar', () => { + component.chartTransform = { portrait: { width: 740, height: 300 }, landscape: { width: 1020, height: 300 } }; + component.hasChart = false; + component.chartType = null; + + expect(component.setMinWidth()).toEqual(null); + }); + }); + + describe('filterDataForGrid', () => { + it('should return the passed data as an array', () => { + expect(component.filterDataForGrid([ + { + item: '1', + count: 1, + percentage: 'test', + }, + { + item: '2', + count: 2, + percentage: 'test', + }, + { + item: '3', + count: 7, + percentage: 'test', + }, + ])).toEqual([ + [ + '1', 1, 'test', + ], + [ + '2', 2, 'test', + ], + [ + '3', 7, 'test', + ], + ] as any[][]); + }); + it('should return an empty array if the passed data is empty', () => { + expect(component.filterDataForGrid(null)).toEqual([[]]); + }); + }); +}) +; diff --git a/src/app/pages/examiner-records/components/examiner-reports-card/examiner-reports-card.html b/src/app/pages/examiner-records/components/examiner-reports-card/examiner-reports-card.html new file mode 100644 index 000000000..263e06b84 --- /dev/null +++ b/src/app/pages/examiner-records/components/examiner-reports-card/examiner-reports-card.html @@ -0,0 +1,95 @@ + + + + + + {{ cardTitle }} + + + + + +
+ + + + + + + + + + + +
+
+ + Average + +
+ + + + + + + + + +
+ + + + {{ getTotal(passedData) }} + + +
+
+
+ + + +
+ + + {{ getTapText('Tap to hide data', 'Tap to show data') }} + + + +
+
+ + No data to display + +
+
diff --git a/src/app/pages/examiner-records/components/examiner-reports-card/examiner-reports-card.scss b/src/app/pages/examiner-records/components/examiner-reports-card/examiner-reports-card.scss new file mode 100644 index 000000000..12b920d53 --- /dev/null +++ b/src/app/pages/examiner-records/components/examiner-reports-card/examiner-reports-card.scss @@ -0,0 +1,23 @@ +.dashed-line { + border: none; + border-top: 5px dashed; + color: #fff; + background-color: transparent; + height: 1px; + width: 50px; +} + +.ion-activated { + background-color: #ececec; +} + +.direction-column { + flex-direction: column; +} + +.white-text { + color: white !important; + & ion-text, label, span, p { + color: white !important; + } +} diff --git a/src/app/pages/examiner-records/components/examiner-reports-card/examiner-reports-card.ts b/src/app/pages/examiner-records/components/examiner-reports-card/examiner-reports-card.ts new file mode 100644 index 000000000..8206d9222 --- /dev/null +++ b/src/app/pages/examiner-records/components/examiner-reports-card/examiner-reports-card.ts @@ -0,0 +1,160 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { AccessibilityService } from '@providers/accessibility/accessibility.service'; +import { ExaminerRecordData } from '@pages/examiner-records/examiner-records.selector'; +import { ChartType } from 'ng-apexcharts'; + +export interface ExaminerReportsCardClick { + isExpanded: boolean; + title: string; +} + +@Component({ + selector: 'examiner-reports-card', + templateUrl: 'examiner-reports-card.html', + styleUrls: ['examiner-reports-card.scss'], +}) + +export class ExaminerReportsCard { + + @Output() + onCardClick: EventEmitter = new EventEmitter(); + + @Input() + passedData: ExaminerRecordData[] = null; + @Input() + chartID: string = null; + @Input() + averageColour: string = '#000000'; + @Input() + cardTitle: string = 'Card title'; + @Input() + gridHeaders: string[] = ['Item', 'Amount', 'Percentage of total']; + @Input() + strokeColour: string = '#FFFFFF'; + @Input() + labelColour: string = '#000000'; + @Input() + chartSubtitle: boolean = false; + @Input() + isPortrait: boolean = false; + @Input() + useGrid: boolean = true; + @Input() + hasCustomMainContent: boolean = false; + @Input() + hasCustomExpandedContent: boolean = false; + @Input() + displayColoursOnDataGrid: boolean = false; + @Input() + showExpandedData: boolean = false; + @Input() + canExpand: boolean = true; + @Input() + showMainContent: boolean = true; + @Input() + hasChart: boolean = true; + @Input() + showTotal: boolean = true; + @Input() + splitChartLabel: boolean = false; + @Input() + darkColours: boolean = false; + @Input() + chartType: ChartType = 'bar'; + @Input() + colourScheme: string[] = [ + '#008FFB', + '#ED6926', + '#FF526F', + '#007C42', + '#a05195', + ]; + @Input() public chartTransform: { portrait: { + width: number, height: number, + }, + landscape: { + width: number, height: number, + } + } = { portrait: { width: 740, height: 300 }, landscape: { width: 1020, height: 300 } }; + + constructor(public accessibilityService: AccessibilityService) { + } + + /** + * Calculate the total number of individual instances of data. + * + * This method takes an array of `ExaminerRecordData` objects and sums up the `count` property of each object. + * The `count` property is converted to a number before summing. + * + * @template T - The type of the data contained in the `ExaminerRecordData` objects. + * @param {ExaminerRecordData[]} value - The array of `ExaminerRecordData` objects to be totaled. + * @returns {number} The total count of all `ExaminerRecordData` objects. + */ + getTotal = ( + value: ExaminerRecordData[], + ): number => value.reduce((total, val) => total + Number(val.count), 0); + + /** + * Format data for use in a data grid. + * + * This method takes an array of `ExaminerRecordData` objects and converts each object + * into an array of its values. If the input array is empty or null, it returns an array + * containing an empty array. + * + * @template T - The type of the data contained in the `ExaminerRecordData` objects. + * @param {ExaminerRecordData[]} examinerRecordData - The array of `ExaminerRecordData` objects to be formatted. + * @returns {T[][]} A two-dimensional array where each inner array contains the values of an `ExaminerRecordData` + * object. + */ + filterDataForGrid(examinerRecordData: ExaminerRecordData[]): T[][] { + if (!!examinerRecordData && examinerRecordData.length > 0) { + return examinerRecordData.map((obj) => Object.values(obj) as T[]); + } + return [[]]; + } + + /** + * Return the relevant text string based on whether the card is expanded. + * + * This method returns the `trueCondition` string if `showExpandedData` is true, + * otherwise it returns the `falseCondition` string. + * + * @param {string} trueCondition - The text to return if the card is expanded. + * @param {string} falseCondition - The text to return if the card is not expanded. + * @returns {string} The relevant text string based on the card's expanded state. + */ + getTapText(trueCondition: string, falseCondition: string): string { + return this.showExpandedData ? trueCondition : falseCondition; + } + + /** + * Toggle the expandable data if this card is allowed to expand. + * + * This method toggles the `showExpandedData` property if the card can expand, + * and emits an event with the new expanded state and the card title. + */ + handleCardClick() { + if (this.canExpand) { + this.showExpandedData = !this.showExpandedData; + this.onCardClick.emit({ isExpanded: this.showExpandedData, title: this.cardTitle }) + } + } + + /** + * Set the minimum width of the card, so it appears as the same size when graphs are disabled. + * If the type is bar, add additional padding to make the card look right. + * (1.07 and 1.04 are not determined in any mathematical way, they are values that appear to work consistently) + * + * @returns {string} The minimum width of the card in pixels, or null if no minimum width is set. + */ + setMinWidth(): string { + if (!this.hasChart) return null; + + let minWidth = this.isPortrait ? this.chartTransform.portrait.width : this.chartTransform.landscape.width; + if (this.chartType === 'bar') { + minWidth *= this.isPortrait ? 1.07 : 1.04; + } + + return minWidth.toFixed(1) + 'px'; + } +} diff --git a/src/app/pages/examiner-records/examiner-records-routing.module.ts b/src/app/pages/examiner-records/examiner-records-routing.module.ts new file mode 100644 index 000000000..b057225db --- /dev/null +++ b/src/app/pages/examiner-records/examiner-records-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; + +import { ExaminerRecordsPage } from './examiner-records.page'; + +const routes: Routes = [ + { + path: '', + component: ExaminerRecordsPage, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ExaminerRecordsRoutingModule {} diff --git a/src/app/pages/examiner-records/examiner-records.actions.ts b/src/app/pages/examiner-records/examiner-records.actions.ts new file mode 100644 index 000000000..7fb8f65b0 --- /dev/null +++ b/src/app/pages/examiner-records/examiner-records.actions.ts @@ -0,0 +1,71 @@ +import { createAction } from '@ngrx/store'; +import { TestCategory } from '@dvsa/mes-test-schema/category-definitions/common/test-category'; +import { TestCentre } from '@dvsa/mes-test-schema/categories/common'; +import { ColourEnum, SelectableDateRange } from '@providers/examiner-records/examiner-records'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; +import { + ExaminerReportsCardClick +} from '@pages/examiner-records/components/examiner-reports-card/examiner-reports-card'; + +export const ExaminerRecordsViewDidEnter = createAction( + '[ExaminerRecordsPage] Page Entered', +); + +export const DateRangeChanged = createAction( + '[ExaminerRecordsPage] Date range changed', + (selectedDate: SelectableDateRange) => ({ selectedDate }), +); + +export const LocationChanged = createAction( + '[ExaminerRecordsPage] Location changed', + (location: TestCentre) => ({ location }), +); + +export const TestCategoryChanged = createAction( + '[ExaminerRecordsPage] Test category changed', + (testCategory: TestCategory) => ({ testCategory }), +); + +export const GetExaminerRecords = createAction( + '[ExaminerRecordsPage] Call backend tests', + (staffNumber: string) => ({ staffNumber }), +); + +export const ClickDataCard = createAction( + '[ExaminerRecordsPage] Card has been clicked', + (onClickData: ExaminerReportsCardClick) => ({ onClickData }), +); +export const CacheExaminerRecords = createAction( + '[ExaminerRecordsPage] Cache backend tests', + (tests: ExaminerRecordModel[]) => ({ tests }), +); +export const UpdateLastCached = createAction( + '[ExaminerRecordsPage] Updated examiner records last cached time', + (time: string) => ({ time }), +); +export const LoadingExaminerRecords = createAction( + '[ExaminerRecordsPage] Examiner records begins loading', +); + +export const ReturnToDashboardPressed = createAction( + '[ExaminerRecordsPage] Return to Dashboard button pressed', +); + +export const DisplayPartialBanner = createAction( + '[ExaminerRecordsPage] Examiner records partial banner displayed', +); + +export const ColourFilterChanged = createAction( + '[ExaminerRecordsPage] Colour filter changed', + (colour: ColourEnum) => ({ colour }), +); + +export const HideChartsChanged = createAction( + '[ExaminerRecordsPage] Hide charts grids changed', + (hideChart: boolean) => ({ hideChart: hideChart }), +); + +export const NoExaminerRecordSetting = createAction( + '[ExaminerRecordsPage] No Examiner record setting was found', + (setting: string) => ({ setting }), +); diff --git a/src/app/pages/examiner-records/examiner-records.analytics.effects.ts b/src/app/pages/examiner-records/examiner-records.analytics.effects.ts new file mode 100644 index 000000000..f158da65b --- /dev/null +++ b/src/app/pages/examiner-records/examiner-records.analytics.effects.ts @@ -0,0 +1,139 @@ +import { Injectable } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { AnalyticsProvider } from '@providers/analytics/analytics'; +import { switchMap } from 'rxjs/operators'; +import { + AnalyticsScreenNames, + GoogleAnalyticsEvents, + GoogleAnalyticsEventsTitles, + GoogleAnalyticsEventsValues, +} from '@providers/analytics/analytics.model'; +import { of } from 'rxjs'; +import { AnalyticRecorded } from '@providers/analytics/analytics.actions'; +import { + ClickDataCard, + ColourFilterChanged, + DateRangeChanged, DisplayPartialBanner, + ExaminerRecordsViewDidEnter, + HideChartsChanged, + LocationChanged, ReturnToDashboardPressed, + TestCategoryChanged, +} from '@pages/examiner-records/examiner-records.actions'; +import { ColourEnum } from '@providers/examiner-records/examiner-records'; + +@Injectable() +export class ExaminerRecordsAnalyticsEffects { + + constructor( + private analytics: AnalyticsProvider, + private actions$: Actions, + ) { + } + + examinerStatsViewDidEnter$ = createEffect(() => this.actions$.pipe( + ofType(ExaminerRecordsViewDidEnter), + switchMap(() => { + this.analytics.setGACurrentPage(AnalyticsScreenNames.EXAMINER_RECORDS); + return of(AnalyticRecorded()); + }), + )); + + dateRangeChanged$ = createEffect(() => this.actions$.pipe( + ofType(DateRangeChanged), + switchMap(({ selectedDate }) => { + this.analytics.logGAEvent( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.DATE_RANGE_CHANGED, + selectedDate.val, + ); + return of(AnalyticRecorded()); + }), + )); + + locationChanged$ = createEffect(() => this.actions$.pipe( + ofType(LocationChanged), + switchMap(({ location }) => { + this.analytics.logGAEvent( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.LOCATION_FILTER, + location.centreName, + ); + return of(AnalyticRecorded()); + }), + )); + + testCategoryChanged$ = createEffect(() => this.actions$.pipe( + ofType(TestCategoryChanged), + switchMap(({ testCategory }) => { + this.analytics.logGAEvent( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.TEST_CATEGORY_FILTER, + testCategory, + ); + return of(AnalyticRecorded()); + }), + )); + + colourFilterChanged$ = createEffect(() => this.actions$.pipe( + ofType(ColourFilterChanged), + switchMap(({ colour }) => { + + this.analytics.logGAEvent( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + colour == ColourEnum.DEFAULT ? + GoogleAnalyticsEventsTitles.DEFAULT_COLOUR : GoogleAnalyticsEventsTitles.GREYSCALE_COLOUR, + GoogleAnalyticsEventsValues.SELECTED, + ); + return of(AnalyticRecorded()); + }), + )); + + returnToDashboardPressed$ = createEffect(() => this.actions$.pipe( + ofType(ReturnToDashboardPressed), + switchMap(() => { + this.analytics.logGAEvent( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.BUTTON_SELECTION, + GoogleAnalyticsEventsValues.RETURN_TO_DASHBOARD, + ); + return of(AnalyticRecorded()); + }), + )); + + hideChartsChanged$ = createEffect(() => this.actions$.pipe( + ofType(HideChartsChanged), + switchMap(({ hideChart }) => { + this.analytics.logGAEvent( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.CHART_VISUALISATION, + hideChart ? GoogleAnalyticsEventsValues.SELECTED : GoogleAnalyticsEventsValues.UNSELECTED, + ); + return of(AnalyticRecorded()); + }), + )); + + partialBannerDisplayed$ = createEffect(() => this.actions$.pipe( + ofType(DisplayPartialBanner), + switchMap(() => { + this.analytics.logGAEvent( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + GoogleAnalyticsEventsTitles.DATA_UNAVAILABLE, + GoogleAnalyticsEventsValues.DATA_BANNER_DISPLAY, + ); + return of(AnalyticRecorded()); + }), + )); + + onCardClicked$ = createEffect(() => this.actions$.pipe( + ofType(ClickDataCard), + switchMap(({ onClickData }) => { + this.analytics.logGAEvent( + GoogleAnalyticsEvents.EXAMINER_RECORDS, + onClickData.isExpanded == true ? + GoogleAnalyticsEventsTitles.TAP_TO_SHOW : GoogleAnalyticsEventsTitles.TAP_TO_HIDE, + onClickData.title, + ); + return of(AnalyticRecorded()); + }), + )); +} diff --git a/src/app/pages/examiner-records/examiner-records.effects.ts b/src/app/pages/examiner-records/examiner-records.effects.ts new file mode 100644 index 000000000..9c3db8c6e --- /dev/null +++ b/src/app/pages/examiner-records/examiner-records.effects.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { + CacheExaminerRecords, + GetExaminerRecords, + UpdateLastCached, +} from '@pages/examiner-records/examiner-records.actions'; +import { SearchProvider } from '@providers/search/search'; +import { Store } from '@ngrx/store'; +import { StoreModel } from '@shared/models/store.model'; +import { Router } from '@angular/router'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; +import { CompressionProvider } from '@providers/compression/compression'; +import { SaveLog } from '@store/logs/logs.actions'; +import { LogType } from '@shared/models/log.model'; +import { LogHelper } from '@providers/logs/logs-helper'; +import { DateTime } from '@shared/helpers/date-time'; +import { of } from 'rxjs'; + +@Injectable() +export class ExaminerRecordsEffects { + + constructor( + private searchProvider: SearchProvider, + private actions$: Actions, + public store$: Store, + public router: Router, + public compressionProvider: CompressionProvider, + private logHelper: LogHelper, + ) { + } + + onlineExaminerRecordsCalled$ = createEffect(() => this.actions$.pipe( + ofType(GetExaminerRecords), + switchMap(({ staffNumber }) => { + //Get backend tests in the examiner records format + return this.searchProvider.examinerRecordsSearch( + staffNumber, + ).pipe( + catchError((err) => { + this.store$.dispatch(SaveLog({ + payload: this.logHelper.createLog(LogType.ERROR, 'Error retrieving examiner records', err.error), + })); + return of(null); + }), + ); + }), + //Remove blank properties from returned records + map((examinerHash: string) => + (examinerHash ? this.compressionProvider.extract(examinerHash) : null) as ExaminerRecordModel[]), + map((examinerRecords): ExaminerRecordModel[] => { + return examinerRecords ? examinerRecords.map((examinerRecord) => { + let newRecord = Object.fromEntries(Object.entries(examinerRecord).filter(([, v]) => v != null)); + if (newRecord.controlledStop) { + newRecord.controlledStop = JSON.parse(newRecord.controlledStop) + } + if (newRecord.extendedTest) { + newRecord.extendedTest = JSON.parse(newRecord.extendedTest) + } + return newRecord as ExaminerRecordModel + }) : null; + }), + //cache results + map((examinerRecords: ExaminerRecordModel[]) => { + this.store$.dispatch(CacheExaminerRecords(examinerRecords)); + if (!!examinerRecords) this.store$.dispatch(UpdateLastCached(new DateTime().format('DD/MM/YYYY'))); + }), + ), { dispatch: false }); +} diff --git a/src/app/pages/examiner-records/examiner-records.module.ts b/src/app/pages/examiner-records/examiner-records.module.ts new file mode 100644 index 000000000..0c58b179a --- /dev/null +++ b/src/app/pages/examiner-records/examiner-records.module.ts @@ -0,0 +1,43 @@ +import { CUSTOM_ELEMENTS_SCHEMA, NgModule, NO_ERRORS_SCHEMA } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { IonicModule } from '@ionic/angular'; + +import { EffectsModule } from '@ngrx/effects'; + +import { ExaminerRecordsPage } from './examiner-records.page'; +import { ExaminerRecordsRoutingModule } from '@pages/examiner-records/examiner-records-routing.module'; +import { ExaminerRecordsAnalyticsEffects } from '@pages/examiner-records/examiner-records.analytics.effects'; +import { TranslateModule } from '@ngx-translate/core'; +import { ComponentsModule } from '@components/common/common-components.module'; +import { ExaminerRecordsComponentsModule } from '@pages/examiner-records/components/examiner-records-components.module'; +import { ExaminerRecordsEffects } from '@pages/examiner-records/examiner-records.effects'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + IonicModule, + NgbModule, + ReactiveFormsModule, + ExaminerRecordsComponentsModule, + ExaminerRecordsRoutingModule, + EffectsModule.forFeature([ + ExaminerRecordsAnalyticsEffects, + ExaminerRecordsEffects, + ]), + TranslateModule, + ComponentsModule, + ], + declarations: [ + ExaminerRecordsPage, + ], + schemas: [ + NO_ERRORS_SCHEMA, + CUSTOM_ELEMENTS_SCHEMA, + ], +}) +export class ExaminerRecordsPageModule { +} diff --git a/src/app/pages/examiner-records/examiner-records.page.html b/src/app/pages/examiner-records/examiner-records.page.html new file mode 100644 index 000000000..64fe210f5 --- /dev/null +++ b/src/app/pages/examiner-records/examiner-records.page.html @@ -0,0 +1,459 @@ + + + + + + Examiner records + +
+ +
+ + + +
+ + + + + + + + + + + {{ option.centreName }} + + + No locations available + + + + + + + + + + + {{ option }} + + + No categories available + + + + + + + + + + + {{ option.display }} + +
+ + {{ option.display }} + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + {{ data.independentDrivingGrid[0].percentage }} + + + + + {{ (data.independentDrivingGrid[0].item).split("- ")[1] }} + + + + + + + {{ data.independentDrivingGrid[1].percentage }} + + + + + {{ (data.independentDrivingGrid[1].item).split("- ")[1] }} + + + + +
+
+ + + + + + + + +
+
+
+ + +
+ + + + + {{ data.emergencyStops[0].percentage }} + + + + Emergency stop + + + +
+
+ + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + No data available + + + + + + + No driving test data available for this period.

Select another date range. +
+
+
+
+ + + + + + Return to dashboard + + + + diff --git a/src/app/pages/examiner-records/examiner-records.page.scss b/src/app/pages/examiner-records/examiner-records.page.scss new file mode 100644 index 000000000..8e41e7ffd --- /dev/null +++ b/src/app/pages/examiner-records/examiner-records.page.scss @@ -0,0 +1,86 @@ +#unauthenticated-mode-indicator { + text-align: center; + padding: 16px 0; + background: var(--gds-yellow); + #unauth-text { + font-size: 20px; + letter-spacing: 0.34px; + } +} + +.width-1500 { + width: 1500px; + } + +.background-colour { + background: var(--mes-background); +} + +.dot { + height: 25px; + width: 25px; + background-color: #ff0000; + border-radius: 50%; + display: inline-block; +} + +.target-padding { + padding-top: 40px; +} + +.regular-weight { + font-weight: 400; +} + +.additional-data-row { + display: flex; + flex-direction: column; + justify-content: center; +} + +ion-menu-button { + color: white; +} + +.footer-styling { + background-color: white; +} + +.width-80-percent { + width: 80%; +} + +.width-100-percent { + width: 100%; +} + +.text-align-center { + text-align: center; +} + +.direction-column { + flex-direction: column; +} + +.height-65-percent { + height: 65%; +} + +.padding-top { + padding-top: 40px; +} + +.input-padding { + padding-left: 24px; +} + +.ion-padding-20 { + padding-top: 20px; +} + +.white-text { + color: white !important; + & ion-text, label, span, p { + color: white !important; + } +} diff --git a/src/app/pages/examiner-records/examiner-records.page.ts b/src/app/pages/examiner-records/examiner-records.page.ts new file mode 100644 index 000000000..69a0dbf66 --- /dev/null +++ b/src/app/pages/examiner-records/examiner-records.page.ts @@ -0,0 +1,883 @@ +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { UntypedFormGroup } from '@angular/forms'; +import { BehaviorSubject, combineLatest, merge, Observable, of, Subscription } from 'rxjs'; +import { map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; +import { StoreModel } from '@shared/models/store.model'; +import { + ClickDataCard, + ColourFilterChanged, + DateRangeChanged, DisplayPartialBanner, + ExaminerRecordsViewDidEnter, + GetExaminerRecords, + HideChartsChanged, + LoadingExaminerRecords, + LocationChanged, ReturnToDashboardPressed, + TestCategoryChanged, +} from '@pages/examiner-records/examiner-records.actions'; +import { + ExaminerRecordData, + getBalanceQuestions, + getCategories, + getCircuits, + getEligibleTests, + getEmergencyStopCount, + getIndependentDrivingStats, + getLocations, + getManoeuvresUsed, + getRouteNumbers, + getSafetyQuestions, + getShowMeQuestions, + getStartedTestCount, + getTellMeQuestions, +} from '@pages/examiner-records/examiner-records.selector'; +import { DateRange, DateTime } from '@shared/helpers/date-time'; +import { TestCentre } from '@dvsa/mes-test-schema/categories/common'; +import { TestCategory } from '@dvsa/mes-test-schema/category-definitions/common/test-category'; +import { isAnyOf } from '@shared/helpers/simplifiers'; +import { DASHBOARD_PAGE } from '@pages/page-names.constants'; +import { Router } from '@angular/router'; +import { + getIsLoadingRecords, + selectCachedExaminerRecords, + selectColourScheme, + selectLastCachedDate, +} from '@store/examiner-records/examiner-records.selectors'; +import { OrientationMonitorProvider } from '@providers/orientation-monitor/orientation-monitor.provider'; +import { AccessibilityService } from '@providers/accessibility/accessibility.service'; +import { CompressionProvider } from '@providers/compression/compression'; +import { SearchProvider } from '@providers/search/search'; +import { getTests } from '@store/tests/tests.reducer'; +import { getStartedTests } from '@store/tests/tests.selector'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; +import { + ColourEnum, + ExaminerRecordsProvider, + SelectableDateRange, + ColourScheme, +} from '@providers/examiner-records/examiner-records'; +import { ScreenOrientation } from '@capawesome/capacitor-screen-orientation'; +import { ScrollDetail } from '@ionic/core'; +import { + ExaminerReportsCardClick +} from '@pages/examiner-records/components/examiner-reports-card/examiner-reports-card'; +import { selectEmployeeId } from '@store/app-info/app-info.selectors'; + +export interface ExaminerRecordsPageStateData { + routeGrid: ExaminerRecordData[], + manoeuvresGrid: ExaminerRecordData[], + showMeQuestionsGrid: ExaminerRecordData[], + independentDrivingGrid: ExaminerRecordData[], + tellMeQuestionsGrid: ExaminerRecordData[], + safetyGrid: ExaminerRecordData[], + balanceGrid: ExaminerRecordData[], + testCount: number, + emergencyStops: ExaminerRecordData[], + circuits: ExaminerRecordData[], + locationList: { item: TestCentre, count: number }[], + categoryList: { item: TestCategory, count: number }[] +} + +interface ExaminerRecordsState { + cachedRecords$: Observable; + isLoadingRecords$: Observable; + routeNumbers$: Observable[]>; + manoeuvres$: Observable[]>; + showMeQuestions$: Observable[]>; + tellMeQuestions$: Observable[]>; + safetyQuestions$: Observable[]>; + balanceQuestions$: Observable[]>; + independentDriving$: Observable[]>; + testCount$: Observable; + locationList$: Observable<{ item: TestCentre, count: number }[]>; + categoryList$: Observable<{ item: TestCategory, count: number }[]>; + emergencyStops$: Observable[]>; + circuits$: Observable[]>; +} + +@Component({ + selector: 'examiner-records', + templateUrl: './examiner-records.page.html', + styleUrls: ['./examiner-records.page.scss'], +}) +export class ExaminerRecordsPage implements OnInit { + + merged$: Observable; + form: UntypedFormGroup = new UntypedFormGroup({}); + testSubject$ = new BehaviorSubject(null); + testsInRangeSubject$ = new BehaviorSubject(null); + eligTestSubject$ = new BehaviorSubject(null); + rangeSubject$ = new BehaviorSubject(null); + locationSubject$ = new BehaviorSubject(null); + categorySubject$ = new BehaviorSubject(null); + pageState: ExaminerRecordsState; + hideMainContent = false; + colourOption: ColourScheme = this.getColour(this.store$.selectSignal(selectColourScheme)()); + categoryPlaceholder: string; + locationPlaceholder: string; + locationFilterOptions: TestCentre[] = null; + categoryFilterOptions: TestCategory[] = null; + cachedExaminerRecords: ExaminerRecordModel[] = null; + startDateFilter: string; + endDateFilter: string = new DateTime().format('DD/MM/YYYY'); + scrollValue: number = 0; + isLoading: boolean = false; + + public defaultDate: SelectableDateRange = this.examinerRecordsProvider.localFilterOptions[2]; + public dateFilter: string = this.defaultDate.display; + public locationFilter: TestCentre = { centreName: null, centreId: null, costCode: null }; + public categoryDisplay: string; + public currentCategory: string; + accordionOpen: boolean = false; + displayScrollBanner: boolean = false; + categorySelectPristine: boolean = true; + currentTestCentre: TestCentre; + locationSelectPristine: boolean = true; + showExpandedData: boolean = false; + public testResults: ExaminerRecordModel[]; + subscription: Subscription; + + constructor( + public store$: Store, + public router: Router, + public compressionProvider: CompressionProvider, + public orientationProvider: OrientationMonitorProvider, + public accessibilityService: AccessibilityService, + public examinerRecordsProvider: ExaminerRecordsProvider, + public searchProvider: SearchProvider, + private cdr: ChangeDetectorRef, + ) { + } + + /** + * Handles the scroll event and updates the displayScrollBanner property. + * + * This method is triggered when a scroll event occurs. It checks the scroll position + * and sets the displayScrollBanner property to true if the scroll position is greater + * than 203 pixels, otherwise sets it to false. + * + * @param {CustomEvent} ev - The scroll event containing the scroll details. + */ + handleScroll(ev: CustomEvent) { + this.displayScrollBanner = (ev.detail.scrollTop > 203) + } + + /** + * Fetches online examiner records if they have not been cached today. + * + * This method checks if the examiner records have already been cached for today. + * If not, it dispatches actions to load the examiner records from the online source. + * + * @returns {Promise} A promise that resolves when the records have been checked and potentially fetched. + **/ + async getOnlineRecords(): Promise { + if ( + !this.store$.selectSignal(selectCachedExaminerRecords)() || + this.store$.selectSignal(selectLastCachedDate)() !== new DateTime().format('DD/MM/YYYY') + ) { + let staffNumber: string = this.store$.selectSignal(selectEmployeeId)(); + this.store$.dispatch(LoadingExaminerRecords()); + this.store$.dispatch(GetExaminerRecords(staffNumber)); + } + } + + /** + * Updates the eligible tests based on the current filter criteria. + * + * This method retrieves the eligible tests using the current values of + * `testSubject$`, `categorySubject$`, `rangeSubject$`, and `locationSubject$`, + * and updates the `eligTestSubject$` with the filtered results. + */ + changeEligibleTests() { + this.eligTestSubject$.next( + getEligibleTests( + this.testSubject$.value, + this.categorySubject$.value, + this.rangeSubject$.value, + this.locationSubject$.value, + )); + } + + /** + * Filters and updates the tests that are within the selected date range. + * + * This method retrieves the eligible tests using the current values of + * `testSubject$` and `rangeSubject$`, and updates the `testsInRangeSubject$` + * with the filtered results. + */ + filterDates() { + this.testsInRangeSubject$.next( + getEligibleTests( + this.testSubject$.value, + null, + this.rangeSubject$.value, + null, + )); + } + + /** + * Retrieves and processes eligible tests based on the provided function and current category. + * + * This method combines the latest values from `eligTestSubject$` and applies the provided function `fn` + * to the eligible tests and current category, returning the result as an observable. + * + * @template T The type of the result returned by the provided function `fn`. + * @param {function(ExaminerRecordModel[], string): T} fn - The function to apply to the eligible tests + * and current category. + * @returns {Observable} An observable that emits the result of applying the function `fn` to the + * eligible tests and current category. + */ + getTestsByParameters = (fn: ( + tests: ExaminerRecordModel[], + category: string, + ) => T, + ): Observable => combineLatest( + [ + this.eligTestSubject$.asObservable(), + ]) + .pipe( + // return an observable using the generic `fn` + switchMap(() => of(fn( + this.eligTestSubject$.value, + this.categorySubject$.value, + ))), + ); + + /** + * Wrapper used to reduce/centralize code. + * Takes in a dynamic type and a function with the signature `fn(tests, date, location, category)`. + * + * This method combines the latest values from `locationSubject$` and `testsInRangeSubject$`, + * and applies the provided function `fn` to the eligible tests, date range, category, and location, + * returning the result as an observable. + * + * @template T The type of the result returned by the provided function `fn`. + * @param {function(ExaminerRecordModel[], DateRange, string, number): T} fn - The function to apply to the eligible + * tests, date range, category, and location. + * @returns {Observable} An observable that emits the result of applying the function `fn` to the eligible tests, + * date range, category, and location. + */ + private getCategoriesByParameters = (fn: ( + tests: ExaminerRecordModel[], + range: DateRange, + category: string, + location: number, + ) => T, + ): Observable => combineLatest( + [ + this.locationSubject$.asObservable(), + this.testsInRangeSubject$.asObservable(), + + ]) + .pipe( + // return an observable using the generic `fn` + switchMap(() => { + return of(fn( + this.testsInRangeSubject$.value, + this.rangeSubject$.value, + this.categorySubject$.value, + this.locationSubject$.value, + )); + }), + ); + + /** + * Wrapper used to reduce/centralize code. + * Takes in a dynamic type and a function with the signature `fn(tests, date, location, category)`. + * + * This method combines the latest values from `testsInRangeSubject$`, + * and applies the provided function `fn` to the eligible tests, date range, category, and location, + * returning the result as an observable. + * + * @template T The type of the result returned by the provided function `fn`. + * @param {function(ExaminerRecordModel[], DateRange, string, number): T} fn - The function to apply to the eligible + * tests, date range, category, and location. + * @returns {Observable} An observable that emits the result of applying the function `fn` to the eligible tests, + * date range, category, and location. + */ + private getLocationsByParameters = (fn: ( + tests: ExaminerRecordModel[], + range: DateRange, + category: string, + location: number, + ) => T, + ): Observable => combineLatest( + [ + this.testsInRangeSubject$.asObservable(), + ]) + .pipe( + // return an observable using the generic `fn` + switchMap(() => { + return of(fn( + this.testsInRangeSubject$.value, + this.rangeSubject$.value, + this.categorySubject$.value, + this.locationSubject$.value, + )); + }), + ); + + /** + * Merges local records with cached online records. + * + * This method combines the local records with the cached online records. + * If there are no cached online records, it dispatches an action to display a partial banner. + * + * @param {ExaminerRecordModel[]} localRecords - The local records to be merged. + * @param {ExaminerRecordModel[]} cachedExaminerRecords - The cached online records to be merged. + * @returns {ExaminerRecordModel[]} The merged array of local and cached online records. + */ + mergeWithOnlineResults(localRecords: ExaminerRecordModel[], cachedExaminerRecords: ExaminerRecordModel[]) { + this.cachedExaminerRecords = cachedExaminerRecords; + if (!this.cachedExaminerRecords) { + this.store$.dispatch(DisplayPartialBanner()) + } + + return [ + ...localRecords, + ...cachedExaminerRecords === null ? [] : cachedExaminerRecords, + ]; + } + + /** + * Removes duplicate entries from an array and sorts its content by the most recent date. + * + * This method filters out duplicate entries in the provided array based on the `appRef` property + * and then sorts the remaining entries in descending order by the `startDate` property. + * + * @param {ExaminerRecordModel[]} result - The array of examiner records to be processed. + * @returns {ExaminerRecordModel[]} The filtered and sorted array of examiner records. + */ + removeDuplicatesAndSort(result: ExaminerRecordModel[]): ExaminerRecordModel[] { + //remove duplicates from array + return result.filter((item, index, self) => { + return self.findIndex(item2 => item2.appRef === item.appRef) === index; + }) + //put final array in date order by most recent + .sort((a, b) => { + return Date.parse(b.startDate) - Date.parse(a.startDate); + }); + } + + /** + * Pulls local tests from the store and performs the following functions: + * 1. Filters them so that only tests conducted by the examiner are used, + * excluding tests they rekeyed on other examiner's behalf. + * 2. Formats the tests into the ExaminerRecordModel format. + * + * @returns {ExaminerRecordModel[]} The array of formatted examiner records. + */ + getLocalResults(): ExaminerRecordModel[] { + + let result: ExaminerRecordModel[] = []; + this.store$.pipe( + select(getTests), + map(getStartedTests), + take(1), + map((value) => Object.values(value)), + map((value) => { + //Filter out rekeyd tests for other users + return value.filter((test) => ( + ([ + test.examinerBooked, + test.examinerKeyed, + test.examinerConducted, + ].every((val, i, arr) => val === arr[0])))); + }), + map(value => { + const recordArray: ExaminerRecordModel[] = []; + //format tests into ExaminerRecordModel + value.forEach((test) => { + recordArray.push(this.examinerRecordsProvider.formatForExaminerRecords(test)); + }); + return recordArray; + }), + ).subscribe((value) => { + result = value; + }).unsubscribe(); + return result; + } + + /** + * Initializes the component and sets up the necessary data and subscriptions. + * + * This method performs the following actions: + * 1. Retrieves and sorts local test results. + * 2. Sets the default date filter. + * 3. Initializes the page state with various observables. + * 4. Sets up subscriptions to handle changes in test results and loading state. + * 5. Sets the location filter and fetches online records if necessary. + * + * @returns {Promise} A promise that resolves when the initialization is complete. + */ + async ngOnInit(): Promise { + this.testResults = this.removeDuplicatesAndSort(this.getLocalResults()); + if (this.testResults.length > 0) { + this.testSubject$.next(this.testResults); + } + //Set default date + this.handleDateFilter({ detail: { value: this.defaultDate } } as CustomEvent); + if (!!this.categorySubject$.value) { + this.categorySelectPristine = false; + } + if (!!this.locationSubject$.value) { + this.locationSelectPristine = false; + } + + this.pageState = { + cachedRecords$: this.store$.select(selectCachedExaminerRecords), + isLoadingRecords$: this.store$.select(getIsLoadingRecords), + routeNumbers$: this.getTestsByParameters(getRouteNumbers), + manoeuvres$: this.getTestsByParameters(getManoeuvresUsed), + balanceQuestions$: this.getTestsByParameters(getBalanceQuestions), + safetyQuestions$: this.getTestsByParameters(getSafetyQuestions), + independentDriving$: this.getTestsByParameters(getIndependentDrivingStats), + showMeQuestions$: this.getTestsByParameters(getShowMeQuestions), + tellMeQuestions$: this.getTestsByParameters(getTellMeQuestions), + testCount$: this.getTestsByParameters(getStartedTestCount), + circuits$: this.getTestsByParameters(getCircuits), + locationList$: this.getLocationsByParameters(getLocations) + .pipe( + tap((value) => { + this.locationFilterOptions = []; + + //add every visited location to location array + value.forEach((val) => { + if (!(val.item.centreName)) { + // Should there be no centre name available, display cost code or centre id, + // depending on whether cost code is available + val.item.centreName = `Limited details - ${ + !!val.item.costCode ? val.item.costCode : val.item.centreId.toString() + }` + } + this.locationFilterOptions.push(val.item); + }); + + + if (!this.locationFilterOptions.map(({ centreId }) => centreId) + .includes(this.locationSubject$.value)) { + //find most common location and set it as the default + const mostUsed = this.setDefault(value); + if (!!mostUsed) { + this.locationPlaceholder = mostUsed.item.centreName; + this.handleLocationFilter(mostUsed.item); + this.locationSelectPristine = true; + } else if (value.length === 0) { + this.locationPlaceholder = ''; + this.handleLocationFilter({ centreId: null, centreName: '', costCode: '' }); + this.locationSelectPristine = true; + } + } + + }), + ), + categoryList$: this.getCategoriesByParameters(getCategories) + .pipe( + tap((value: Omit, 'percentage'>[]) => { + this.categoryFilterOptions = []; + + //add every completed category to category array + value.forEach((val) => { + this.categoryFilterOptions.push(val.item); + }); + + if (!this.categoryFilterOptions.includes(this.categorySubject$.value)) { + //find most common category and set it as the default + const mostUsed = this.setDefault(value); + + if (!!mostUsed) { + this.categoryPlaceholder = mostUsed.item; + this.handleCategoryFilter(mostUsed.item); + this.categorySelectPristine = true; + } + } else { + this.changeEligibleTests(); + } + }), + ), + emergencyStops$: this.getTestsByParameters(getStartedTestCount) + .pipe( + withLatestFrom(this.getTestsByParameters(getEmergencyStopCount)), + //Turn emergency stop count into two objects containing tests with stops and tests without + map(([testCount, emergencyStopCount]) => ([ + { + item: 'Stop', + count: emergencyStopCount, + percentage: `${((emergencyStopCount / testCount) * 100).toFixed(1)}%`, + }, + { + item: 'No stop', + count: testCount - emergencyStopCount, + percentage: `${(((testCount - emergencyStopCount) / testCount) * 100).toFixed(1)}%`, + }, + ])), + ), + }; + + const { + cachedRecords$, + isLoadingRecords$, + } = this.pageState; + + this.merged$ = merge( + //listen for changes to test result and send the result to the behaviour subject + cachedRecords$.pipe(tap((value) => { + this.testResults = this.removeDuplicatesAndSort(this.mergeWithOnlineResults(this.testResults, value)); + if (this.testResults.length > 0) { + this.testSubject$.next(this.testResults); + } + })), + //deactivate loading ui when no longer loading + isLoadingRecords$.pipe(map((value) => { + this.examinerRecordsProvider.handleLoadingUI(value); + })), + ); + if (this.merged$) { + this.subscription = this.merged$.subscribe(); + } + + this.setLocationFilter(); + await this.getOnlineRecords(); + } + + /** + * Deactivates subscriptions and listeners when the view is about to leave. + * + * This method performs the following actions: + * 1. Unsubscribes from the current subscription if it exists. + * 2. Removes all listeners from the ScreenOrientation. + * + * @returns {Promise} A promise that resolves when the cleanup is complete. + */ + async ionViewWillLeave(): Promise { + if (this.subscription) { + this.subscription.unsubscribe(); + } + await ScreenOrientation.removeAllListeners(); + } + + /** + * Dispatches the ExaminerRecordsViewDidEnter action and monitors screen orientation. + * + * This method performs the following actions: + * 1. Dispatches the ExaminerRecordsViewDidEnter action to the store. + * 2. Calls the monitorOrientation method of the orientationProvider to start monitoring screen orientation changes. + * + * @returns {Promise} A promise that resolves when the orientation monitoring is complete. + */ + async ionViewDidEnter(): Promise { + this.store$.dispatch(ExaminerRecordsViewDidEnter()); + await this.orientationProvider.monitorOrientation(); + } + + /** + * Pulls the list of the current locations where tests have been conducted, then sets the most common location + * as the default. + * + * This method performs the following actions: + * 1. Initializes the location filter options if they are not already set. + * 2. Subscribes to the location list observable and populates the location filter options. + * 3. Determines the most used location and sets it as the default. + */ + setLocationFilter() { + if (!this.locationFilterOptions) { + this.locationFilterOptions = []; + let mostUsed = null; + + this.pageState.locationList$.subscribe(value => { + value.forEach((val) => { + this.locationFilterOptions.push(val.item); + }); + mostUsed = this.setDefault(value); + }) + .unsubscribe(); + + if (!!mostUsed) { + this.locationPlaceholder = mostUsed.item.centreName; + this.handleLocationFilter(mostUsed.item); + } + } + } + + /** + * Find the most used element in an array and return it. + * + * This method performs the following actions: + * 1. Checks if the input data array is null or empty. + * 2. Uses the reduce function to find the element with the highest count. + * + * @template T The type of the elements in the data array. + * @param {Omit, 'percentage'>[]} data - The array of data elements to search. + * @returns {Omit, 'percentage'> | null} The element with the highest count, or null if the + * input data is null or empty. + */ + setDefault(data: Omit, 'percentage'>[]): Omit, 'percentage'> { + if (!data || data?.length === 0) { + return null; + } + return data.reduce((max, obj) => (obj.count > max.count) ? obj : max); + } + + /** + * Sets the date range filter to the event value and sends that value to the behavior subject. + * + * This method performs the following actions: + * 1. Updates the `dateFilter` with the display value from the event. + * 2. Updates the `rangeSubject$` with the value from the event. + * 3. Formats and sets the `startDateFilter` using the range value from the event. + * 4. Filters the tests based on the updated date range. + * 5. Dispatches the `DateRangeChanged` action to the store with the event value. + * + * @param {CustomEvent} event - The event containing the new date range value. + */ + handleDateFilter(event: CustomEvent) { + this.dateFilter = event.detail?.value.display ?? null; + this.rangeSubject$.next(event.detail?.value.val ?? null); + this.startDateFilter = this.examinerRecordsProvider.getRangeDate(event.detail?.value.val).format('DD/MM/YYYY'); + this.filterDates(); + + this.store$.dispatch(DateRangeChanged(event.detail?.value)); + } + + /** + * Sets the current location to the selected value, updates relevant variables for + * displaying the new value, and sends that value to the behavior subject. + * + * This method performs the following actions: + * 1. Sets `locationSelectPristine` to false if the selection was triggered by the user. + * 2. Updates the `locationFilter` and `currentTestCentre` with the selected location if it is different + * from the current one. + * 3. Sends the selected location's ID to the `locationSubject$` behavior subject. + * 4. Dispatches the `LocationChanged` action to the store with the selected location. + * + * @param {TestCentre} event - The selected location. + * @param {boolean} [ionSelectTriggered=false] - Indicates if the selection was triggered by the user. + */ + handleLocationFilter(event: TestCentre, ionSelectTriggered: boolean = false) { + if (ionSelectTriggered) { + this.locationSelectPristine = false; + } + + if (event && (event.centreId !== this.locationFilter.centreId)) { + this.locationFilter = event; + this.locationSubject$.next(event.centreId ?? null); + this.currentTestCentre = event; + + this.store$.dispatch(LocationChanged(event)); + } + } + + /** + * Sets the current category to the selected value, updates relevant variables for + * displaying the new value, and sends that value to the behavior subject. + * + * This method performs the following actions: + * 1. Sets `categorySelectPristine` to false if the selection was triggered by the user. + * 2. Updates the `categoryDisplay` and `currentCategory` with the selected category if + * it is different from the current one. + * 3. Sends the selected category to the `categorySubject$` behavior subject. + * 4. Calls `changeEligibleTests` to update the eligible tests based on the new category. + * 5. Dispatches the `TestCategoryChanged` action to the store with the selected category. + * + * @param {TestCategory} event - The selected category. + * @param {boolean} [ionSelectTriggered=false] - Indicates if the selection was triggered by the user. + */ + handleCategoryFilter(event: TestCategory, ionSelectTriggered: boolean = false): void { + if (ionSelectTriggered) { + this.categorySelectPristine = false; + } + if (event && this.categorySubject$.value !== event) { + this.categoryDisplay = `Test category: ${event}`; + this.currentCategory = event; + this.categorySubject$.next(event ?? null); + this.changeEligibleTests(); + + + this.store$.dispatch(TestCategoryChanged(event)); + } + } + + /** + * Updates the colour filter to the selected value and triggers change detection. + * + * This method performs the following actions: + * 1. Dispatches the `ColourFilterChanged` action to the store with the selected colour. + * 2. Updates the `colourOption` with the new colour scheme. + * 3. Triggers change detection to update the view. + * + * @param {ColourEnum} colour - The selected colour scheme. + */ + colourFilterChanged(colour: ColourEnum) { + this.store$.dispatch(ColourFilterChanged(colour)); + this.colourOption = this.getColour(colour); + this.cdr.detectChanges(); + } + + /** + * Toggles the visibility of the main content and expanded data, and dispatches the `HideChartsChanged` action. + * + * This method performs the following actions: + * 1. Toggles the `hideMainContent` and `showExpandedData` properties. + * 2. Dispatches the `HideChartsChanged` action to the store with the updated `hideMainContent` value. + */ + hideChart(): void { + this.hideMainContent = !this.hideMainContent; + this.showExpandedData = !this.showExpandedData; + this.store$.dispatch(HideChartsChanged(this.hideMainContent)); + } + + /** + * Returns the colour scheme associated with the given colour option. + * + * This method performs the following actions: + * 1. Checks if the provided colour option is `GREYSCALE`. + * 2. If it is, returns the greyscale colour scheme from the `examinerRecordsProvider`. + * 3. If it is not, returns the default colour scheme from the `examinerRecordsProvider`. + * + * @param {ColourEnum} colourOption - The colour option to get the colour scheme for. + * @returns {ColourScheme} The colour scheme associated with the given colour option. + */ + getColour(colourOption: ColourEnum): ColourScheme { + switch (colourOption) { + case ColourEnum.GREYSCALE: + return this.examinerRecordsProvider.colours.greyscale; + default: + return this.examinerRecordsProvider.colours.default; + } + } + + /** + * Returns whether the current category uses an optional emergency stop. + * + * This method checks if the `currentCategory` is one of the categories that use an optional emergency stop. + * + * @returns {boolean} `true` if the current category uses an optional emergency stop, otherwise `false`. + */ + showEmergencyStop(): boolean { + return isAnyOf(this.currentCategory, [ + TestCategory.B, + TestCategory.F, + TestCategory.G, + TestCategory.H, + TestCategory.K, + ]); + } + + /** + * Toggles the state of the accordion between open and closed. + * + * This method performs the following actions: + * 1. Toggles the `accordionOpen` property. + */ + accordionSelect() { + this.accordionOpen = !this.accordionOpen; + } + + /** + * Navigate back to the dashboard. + * + * This method performs the following actions: + * 1. Dispatches the `ReturnToDashboardPressed` action to the store. + * 2. Navigates to the dashboard page and replaces the current URL. + * + * @returns {Promise} A promise that resolves when the navigation is complete. + */ + async goToDashboard(): Promise { + this.store$.dispatch(ReturnToDashboardPressed()); + await this.router.navigate([DASHBOARD_PAGE], { replaceUrl: true }); + } + + /** + * Returns whether the current category is a mod1 test. + * + * This method checks if the `currentCategory` is one of the mod1 test categories. + * + * @returns {boolean} `true` if the current category is a mod1 test, otherwise `false`. + */ + public isMod1 = (): boolean => isAnyOf(this.currentCategory, [ + // Cat Mod1 + TestCategory.EUA1M1, TestCategory.EUA2M1, TestCategory.EUAM1, TestCategory.EUAMM1, + ]); + + /** + * Returns whether the current category is a bike test (mod1/mod2). + * + * This method checks if the `currentCategory` is one of the bike test categories. + * + * @returns {boolean} `true` if the current category is a bike test, otherwise `false`. + */ + public isBike = (): boolean => isAnyOf(this.currentCategory, [ + // Cat Mod1 + TestCategory.EUA1M1, TestCategory.EUA2M1, TestCategory.EUAM1, TestCategory.EUAMM1, + // Cat Mod2 + TestCategory.EUA1M2, TestCategory.EUA2M2, TestCategory.EUAM2, TestCategory.EUAMM2, + ]); + + /** + * Get the total number of individual instances of data. + * + * This method takes an array of `ExaminerRecordData` objects and calculates the total count + * by summing up the `count` property of each object in the array. + * + * @template T The type of the data in the `ExaminerRecordData` objects. + * @param {ExaminerRecordData[]} value - The array of `ExaminerRecordData` objects. + * @returns {number} The total count of individual instances of data. + */ + getTotal = ( + value: ExaminerRecordData[], + ): number => value.reduce((total, val) => total + Number(val.count), 0); + + /** + * Determines if the "No Data" card should be displayed. + * + * This method checks if there is no data available in the provided `ExaminerRecordsPageStateData` object. + * It iterates over the keys of the data object and checks if the total count of each key is greater than 0. + * If any key has a total count greater than 0, it sets `noData` to false. + * Additionally, it checks if the `categoryList` or `locationList` arrays are empty. + * + * @param {ExaminerRecordsPageStateData} data - The data object containing various examiner records. + * @returns {boolean} `true` if the "No Data" card should be displayed, otherwise `false`. + */ + displayNoDataCard(data: ExaminerRecordsPageStateData): boolean { + let noData = true; + + Object.keys(data).forEach((key) => { + if (!isAnyOf(key, ['testCount', 'locationList', 'categoryList'])) { + if (this.getTotal(data[key]) > 0) { + noData = false; + } + } + }); + return noData || (data.categoryList?.length === 0 || data.locationList?.length === 0) + } + + /** + * Returns the text used on the sticky label. + * + * This method retrieves the test count from the `testCount$` observable and constructs a string + * that displays the number of tests, the current category, the date range, and the location. + * + * @returns {string} The formatted label text. + */ + getLabelText(): string { + let testCount: number = 0; + this.pageState.testCount$.subscribe(value => testCount = value).unsubscribe(); + + return `Displaying ${testCount} Category ${this.currentCategory} test` + + (testCount > 1 ? 's' : '') + + `, from ${this.startDateFilter} to ${this.endDateFilter}` + + (this.accessibilityService.getTextZoomClass() !== 'text-zoom-x-large' ? '
' : '') + + ` at ${this.locationFilter.centreName}`; + } + + /** + * Handles the click event on an examiner reports card. + * + * This method dispatches the `ClickDataCard` action to the store with the provided event. + * + * @param {ExaminerReportsCardClick} event - The event object containing details of the card click. + */ + cardClicked(event: ExaminerReportsCardClick) { + this.store$.dispatch(ClickDataCard(event)); + } +} diff --git a/src/app/pages/examiner-records/examiner-records.selector.ts b/src/app/pages/examiner-records/examiner-records.selector.ts new file mode 100644 index 000000000..21a36b6f6 --- /dev/null +++ b/src/app/pages/examiner-records/examiner-records.selector.ts @@ -0,0 +1,462 @@ +import { forOwn, get, transform, uniqBy } from 'lodash-es'; +import { TestCategory } from '@dvsa/mes-test-schema/category-definitions/common/test-category'; +import { Manoeuvre, SafetyQuestionResult, TestCentre } from '@dvsa/mes-test-schema/categories/common'; +import { ManoeuvreTypes } from '@store/tests/test-data/test-data.constants'; +import { DateRange, DateTime } from '@shared/helpers/date-time'; +import { QuestionResult } from '@dvsa/mes-test-schema/categories/C/partial'; +import { QuestionProvider } from '@providers/question/question'; +import { manoeuvreTypeLabels as manoeuvreTypeLabelsCatB } from '@shared/constants/competencies/catb-manoeuvres'; +import { manoeuvreTypeLabels as manoeuvreTypeLabelsCatBE } from '@shared/constants/competencies/catbe-manoeuvres'; +import { manoeuvreTypeLabels as manoeuvreTypeLabelsCatADI2 } from '@shared/constants/competencies/catadi2-manoeuvres'; +import { isAnyOf } from '@shared/helpers/simplifiers'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; +import { ExaminerRecordsRange } from '@providers/examiner-records/examiner-records'; + +// Generic `T` is the configurable type of the item +export interface ExaminerRecordData { + item: T; + count: number; + percentage: string; +} + +let unwantedCategories: TestCategory[] = [ + TestCategory.ADI3, + TestCategory.SC, + TestCategory.CCPC, + TestCategory.DCPC, + TestCategory.CM, + TestCategory.DM, + TestCategory.D1M, + TestCategory.D1EM, + TestCategory.DEM, + TestCategory.CM, + TestCategory.C1M, + TestCategory.C1EM, + TestCategory.CEM, +]; + +// add date range filter +export const dateFilter = (test: ExaminerRecordModel, range: DateRange = null): boolean => (range) + // use range when provided + ? new DateTime(get(test, 'startDate')).isDuring(range) + // return true to get all tests when no range provided + : true; + +/** + * strip out the prefix letter to get the numerical value in the code of the string + */ +export const getIndex = (item: string) => { + const regex = /[A-Za-z]*(\d+)/; + const match = item.match(regex); + return match && match[1] ? Number(match[1]) : null; +}; + +/** + * returns all the tests that fall within the filters given + */ +export const getEligibleTests = ( + startedTests: ExaminerRecordModel[], + category: TestCategory = null, + range: ExaminerRecordsRange = null, + centreId: number = null, + filterByLocation: boolean = true, + filterByCategory: boolean = true, + allowExtendedTests: boolean = false, +): ExaminerRecordModel[] => { + if (startedTests) { + return startedTests.filter((value: ExaminerRecordModel) => { + return (!!range ? dateFilter(value, range as DateRange) : true) && + (filterByCategory ? (!!category ? (get(value, 'testCategory') === category) : true) : true) && + (filterByLocation ? (!!centreId ? (get(value, 'testCentre.centreId') === centreId) : true) : true) && + (allowExtendedTests ? true : !(get(value, 'extendedTest') === true)); + }); + } else { + return []; + } +}; + +/** + * Return the total amount of tests with an emergency stop from the eligible tests + */ +export const getEmergencyStopCount = ( + startedTests: ExaminerRecordModel[], +): number => { + if (startedTests) { + return (startedTests) + .filter((controlledStop) => (get(controlledStop, 'controlledStop', null) === true)) + .length; + } else { + return 0; + } +}; + +/** + * Returns an array of locations the examiner has conducted a test in within the date range and + * the amount of tests they have conducted there + */ +export const getLocations = ( + startedTests: ExaminerRecordModel[], + range: ExaminerRecordsRange = null, + // Omit is a TS type, to remove a property from an interface +): Omit, 'percentage'>[] => { + if (startedTests) { + const data: ExaminerRecordModel[] = getEligibleTests(startedTests, null, range, null) + .filter((record) => !!get(record, 'testCentre', null).centreId); + + return uniqBy(data.map(({ testCentre }) => { + return { + item: testCentre, + count: data.filter((val) => val.testCentre.centreId === testCentre.centreId).length, + }; + }), 'item.centreId') + .sort((item1, item2) => + (item1.item.centreName) > (item2.item.centreName) ? 1 : -1); + } else { + return []; + } +}; + +/** + * Returns an array containing the counts of both independent driving options within the tests for the given category + */ +export const getIndependentDrivingStats = ( + startedTests: ExaminerRecordModel[], + category: TestCategory, +): ExaminerRecordData[] => { + //IndependentDriving is not applicable to the following categories, and so we can avoid the entire function + if (!startedTests || !category || isAnyOf(category, [ + TestCategory.ADI3, TestCategory.SC, + TestCategory.F, TestCategory.G, TestCategory.H, TestCategory.K, + TestCategory.CCPC, TestCategory.DCPC, + TestCategory.EUA1M1, TestCategory.EUA2M1, TestCategory.EUAM1, TestCategory.EUAMM1, + TestCategory.CM, TestCategory.C1M, TestCategory.CEM, TestCategory.C1EM, + TestCategory.DM, TestCategory.D1M, TestCategory.DEM, TestCategory.D1EM, + ])) { + return []; + } + let indDrivingOptions: string[]; + if (isAnyOf(category, [ + TestCategory.C, TestCategory.C1, TestCategory.CE, TestCategory.C1E, // C + TestCategory.EUAMM2, TestCategory.EUA1M2, TestCategory.EUA2M2, TestCategory.EUAM2, // Mod 2 + ])) { + indDrivingOptions = ['Traffic signs', 'Diagram']; + } else { + indDrivingOptions = ['Traffic signs', 'Sat nav']; + } + + const data: ExaminerRecordModel[] = (startedTests) + // extract cost codes + .filter((record) => !!get(record, 'independentDriving', null)); + + return indDrivingOptions.map((item: string, index) => { + const count = data.filter((val) => val.independentDriving === item).length; + return { + item: `I${index + 1} - ${item}`, + count, + percentage: `${(count / data.length * 100).toFixed(1)}%`, + }; + }) + .sort((item1, item2) => + getIndex(item1.item as string) - getIndex(item2.item as string)); +}; + +/** + * Returns an array containing the counts of both circuit options within the given tests + */ +export const getCircuits = ( + startedTests: ExaminerRecordModel[], + category: TestCategory, +): ExaminerRecordData[] => { + //getCircuits is only applicable to the following categories, and so we can avoid the entire function + if (!startedTests || !category || !isAnyOf(category, [ + TestCategory.EUA1M1, TestCategory.EUA2M1, TestCategory.EUAM1, TestCategory.EUAMM1, + ])) { + return []; + } + const circuitOptions = ['Left', 'Right']; + + const data: ExaminerRecordModel[] = (startedTests) + // extract circuits + .filter((record) => !!get(record, 'circuit', null)); + + + return circuitOptions.map((item: string, index) => { + const count = data.filter((val) => val.circuit === item).length; + return { + item: `C${index + 1} - ${item}`, + count, + percentage: `${(count / data.length * 100).toFixed(1)}%`, + }; + }) + .sort((item1, item2) => + getIndex(item1.item as string) - getIndex(item2.item as string)); +}; + +/** + * Returns an array of categories the examiner has conducted a test in within the date range + * at the selected location and the amount of tests of that type they have conducted + */ +export const getCategories = ( + startedTests: ExaminerRecordModel[], + range: ExaminerRecordsRange, + category: TestCategory, + centreId: number, +): { + item: TestCategory; + count: number +}[] => { + if (startedTests) { + const data: ExaminerRecordModel[] = startedTests + .filter((record: ExaminerRecordModel) => + (get(record, 'testCentre.centreId', null) === centreId) + && !isAnyOf(get(record, 'testCategory', null), unwantedCategories)); + + return uniqBy(data.map(({ testCategory }) => { + return { + item: testCategory, + count: data.filter((val) => val.testCategory === testCategory).length, + }; + }), 'item') + .sort((item1, item2) => + (item1.item as string) > (item2.item as string) ? 1 : -1); + } else { + return []; + } +}; + +/** + * Returns the total number of conducted tests of the selected category at the selected location within + * the selected time frame + */ +export const getStartedTestCount = ( + startedTests: ExaminerRecordModel[], +): number => + !!startedTests ? startedTests.length : 0; + + +/** + * Returns an array containing the conducted test routes within the given tests and their frequency of appearance + */ +export const getRouteNumbers = ( + startedTests: ExaminerRecordModel[], +): ExaminerRecordData[] => { + if (startedTests) { + const data = (startedTests) + .filter((record: ExaminerRecordModel) => get(record, 'routeNumber', null) !== null); + + return uniqBy(data.map(({ routeNumber }) => { + const count = data.filter((val) => val.routeNumber === routeNumber).length; + return { + item: `R${routeNumber} - Route ${routeNumber}`, + count, + percentage: `${((count) / data.length * 100).toFixed(1)}%`, + }; + }), 'item') + .sort((item1, item2) => + getIndex(item1.item as string) - getIndex(item2.item as string)); + } else { + return []; + } +}; + +/** + * Returns an array containing the selected safety questions within the given tests and their frequency of appearance + */ +export const getSafetyQuestions = ( + startedTests: ExaminerRecordModel[], + category: TestCategory = null, +): ExaminerRecordData[] => { + const qp = new QuestionProvider(); + + if (startedTests) { + const questions = [ + ...qp.getSafetyQuestions(category) + .map((q) => ({ + code: q.code, + description: q.shortName, + })), + ]; + + const data = (startedTests) + .flatMap((record: ExaminerRecordModel) => [ + ...get(record, 'safetyQuestions', []) as QuestionResult[], + ]) + // filter for any empty string/null values + .filter((question: SafetyQuestionResult | QuestionResult) => + ('code' in question) ? !!question?.code : !!question?.description); + + return questions.map((q) => { + const count = data.filter((val) => val.code === q.code).length; + return { + item: `${q.code} - ${q.description}`, + count, + percentage: `${((count / data.length) * 100).toFixed(1)}%`, + }; + }) + .sort((item1, item2) => + getIndex(item1.item as string) - getIndex(item2.item as string)); + } else { + return []; + } +}; + +/** + * Returns an array containing the selected balance questions within the given tests and their frequency of appearance + */ +export const getBalanceQuestions = ( + startedTests: ExaminerRecordModel[], + category: TestCategory = null, +): ExaminerRecordData[] => { + const qp = new QuestionProvider(); + + if (startedTests) { + const questions = [ + ...qp.getBalanceQuestions(category) + .map((q) => ({ + code: q.code, + description: q.shortName, + })), + ]; + + const data = (startedTests) + .flatMap((record: ExaminerRecordModel) => get(record, 'balanceQuestions', []) as QuestionResult[]) + // filter for any empty string/null values + .filter((question: QuestionResult) => + ('code' in question) ? !!question?.code : !!question?.description); + + return questions.map((q) => { + const count = data.filter((val) => val.code === q.code).length; + return { + item: `${q.code} - ${q.description}`, + count, + percentage: `${((count / data.length) * 100).toFixed(1)}%`, + }; + }) + .sort((item1, item2) => + getIndex(item1.item as string) - getIndex(item2.item as string)); + } else { + return []; + } +}; + +/** + * Returns an array containing the selected show me questions within the given tests and their frequency of appearance + */ +export const getShowMeQuestions = ( + startedTests: ExaminerRecordModel[], + category: TestCategory = null, +): ExaminerRecordData[] => { + const qp = new QuestionProvider(); + + if (startedTests) { + const questions = qp.getShowMeQuestions(category).filter((q) => q.code !== 'N/A'); + + const data = (startedTests) + .flatMap((record: ExaminerRecordModel) => get(record, 'showMeQuestions', []) as QuestionResult[]) + // filter for any empty string/null values + .filter((question: QuestionResult) => !!question?.code); + + return questions.map((q) => { + const count = data.filter((val) => val.code === q.code).length; + return { + item: `${q.code} - ${q.shortName}`, + count, + percentage: `${((count / data.length) * 100).toFixed(1)}%`, + }; + }) + .sort((item1, item2) => + getIndex(item1.item as string) - getIndex(item2.item as string)); + } else { + return []; + } +}; + +/** + * Returns an array containing the selected tell me questions within the given tests and their frequency of appearance + */ +export const getTellMeQuestions = ( + startedTests: ExaminerRecordModel[], + category: TestCategory = null, +): ExaminerRecordData[] => { + const qp = new QuestionProvider(); + + if (startedTests) { + const questions = qp.getTellMeQuestions(category); + const data = (startedTests) + .flatMap((record: ExaminerRecordModel) => get(record, 'tellMeQuestions', []) as QuestionResult[]) + // filter for any empty string/null values + .filter((question: QuestionResult) => !!question?.code); + + return questions.map((q) => { + const count = data.filter((val) => val.code === q.code).length; + return { + item: `${q.code} - ${q.shortName}`, + count, + percentage: `${((count / data.length) * 100).toFixed(1)}%`, + }; + }) + .sort((item1, item2) => + getIndex(item1.item as string) - getIndex(item2.item as string)); + } else { + return []; + } +}; + +/** + * Returns the correct labels for manoeuvres in a specified category + */ +export const getManoeuvreTypeLabels = (category: TestCategory, type?: ManoeuvreTypes) => { + if ([TestCategory.B].includes(category)) { + return type ? manoeuvreTypeLabelsCatB[type] : manoeuvreTypeLabelsCatB; + } else if ([TestCategory.BE].includes(category)) { + return type ? manoeuvreTypeLabelsCatBE[type] : manoeuvreTypeLabelsCatBE; + } else if ([TestCategory.ADI2].includes(category)) { + return type ? manoeuvreTypeLabelsCatADI2[type] : manoeuvreTypeLabelsCatADI2; + } else { + return {}; + } +}; + +/** + * Returns an array containing the selected manoeuvres within the given tests and their frequency of appearance + */ +export const getManoeuvresUsed = ( + startedTests: ExaminerRecordModel[], + category: TestCategory = null, +): ExaminerRecordData[] => { + let faultsEncountered: string[] = []; + let manoeuvreTypeLabels: string[] = []; + if (category) { + manoeuvreTypeLabels = Object.values(getManoeuvreTypeLabels(category)); + } + if (manoeuvreTypeLabels.length == 0 || !startedTests) { + return []; + } + + (startedTests) + .forEach((record: ExaminerRecordModel) => { + const manoeuvres = get(record, 'manoeuvres'); + if (!manoeuvres) return; + const mans = Array.isArray(manoeuvres) ? manoeuvres : [manoeuvres]; + mans.forEach((manoeuvre) => { + forOwn(manoeuvre, (man: Manoeuvre, type: ManoeuvreTypes) => { + const faults = !man.selected ? [] : transform(man, (result) => { + result.push(getManoeuvreTypeLabels(category, type)); + }, []); + faultsEncountered.push(...faults); + }); + }); + faultsEncountered = faultsEncountered.filter((fault) => !!fault); + }); + + return manoeuvreTypeLabels.map((q, index) => { + const count = faultsEncountered.filter((val) => val === q).length; + return { + item: `E${index + 1} - ${q}`, + count, + percentage: `${((count / faultsEncountered.length) * 100).toFixed(1)}%`, + }; + }) + .sort((item1, item2) => + getIndex(item1.item as string) - getIndex(item2.item as string)); +}; diff --git a/src/app/pages/page-names.constants.ts b/src/app/pages/page-names.constants.ts index 30c30f3e0..dc9b6a83f 100644 --- a/src/app/pages/page-names.constants.ts +++ b/src/app/pages/page-names.constants.ts @@ -10,6 +10,7 @@ export const JOURNAL_FORCE_CHECK_MODAL = 'JournalForceCheckModal'; export const JOURNAL_EARLY_START_MODAL = 'JournalEarlyStartModal'; export const ERROR_PAGE = 'ErrorPage'; export const DASHBOARD_PAGE = 'DashboardPage'; +export const EXAMINER_RECORDS = 'ExaminerRecordsPage'; export const UNUPLOADED_TESTS_PAGE = 'UnuploadedTestsPage'; export const REKEY_SEARCH_PAGE = 'RekeySearchPage'; export const TEST_RESULTS_SEARCH_PAGE = 'TestResultsSearchPage'; diff --git a/src/app/pages/test-results-search/components/advanced-search/advanced-search.ts b/src/app/pages/test-results-search/components/advanced-search/advanced-search.ts index 0ca2bc8e1..355525064 100644 --- a/src/app/pages/test-results-search/components/advanced-search/advanced-search.ts +++ b/src/app/pages/test-results-search/components/advanced-search/advanced-search.ts @@ -11,11 +11,50 @@ import { TestCentre } from '@dvsa/mes-journal-schema'; import { AccessibilityService } from '@providers/accessibility/accessibility.service'; import { DateTime, Duration } from '@shared/helpers/date-time'; +export const TestCategories: TestCategory[] = [ + TestCategory.ADI2, + TestCategory.ADI3, + TestCategory.B, + TestCategory.BE, + TestCategory.C, + TestCategory.C1, + TestCategory.C1E, + TestCategory.C1EM, + TestCategory.C1M, + TestCategory.CCPC, + TestCategory.CE, + TestCategory.CEM, + TestCategory.CM, + TestCategory.D, + TestCategory.D1, + TestCategory.D1E, + TestCategory.D1EM, + TestCategory.D1M, + TestCategory.DCPC, + TestCategory.DE, + TestCategory.DEM, + TestCategory.DM, + TestCategory.EUA1M1, + TestCategory.EUA1M2, + TestCategory.EUA2M1, + TestCategory.EUA2M2, + TestCategory.EUAM1, + TestCategory.EUAM2, + TestCategory.EUAMM1, + TestCategory.EUAMM2, + TestCategory.F, + TestCategory.G, + TestCategory.H, + TestCategory.K, + TestCategory.SC, +]; + @Component({ selector: 'advanced-search', templateUrl: 'advanced-search.html', styleUrls: ['advanced-search.scss'], }) + export class AdvancedSearchComponent { @Input() @@ -41,44 +80,7 @@ export class AdvancedSearchComponent { ...activityCodeModelList, ]; - testCategories: string[] = [ - 'All', - TestCategory.ADI2, - TestCategory.ADI3, - TestCategory.B, - TestCategory.BE, - TestCategory.C, - TestCategory.C1, - TestCategory.C1E, - TestCategory.C1EM, - TestCategory.C1M, - TestCategory.CCPC, - TestCategory.CE, - TestCategory.CEM, - TestCategory.CM, - TestCategory.D, - TestCategory.D1, - TestCategory.D1E, - TestCategory.D1EM, - TestCategory.D1M, - TestCategory.DCPC, - TestCategory.DE, - TestCategory.DEM, - TestCategory.DM, - TestCategory.EUA1M1, - TestCategory.EUA1M2, - TestCategory.EUA2M1, - TestCategory.EUA2M2, - TestCategory.EUAM1, - TestCategory.EUAM2, - TestCategory.EUAMM1, - TestCategory.EUAMM2, - TestCategory.F, - TestCategory.G, - TestCategory.H, - TestCategory.K, - TestCategory.SC, - ]; + testCategories: string[] = ['All'].concat(TestCategories); selectedActivity: { activityCode: string; description: string; } = this.activityCodes[0]; selectedCategory: string = this.testCategories[0]; diff --git a/src/app/providers/analytics/analytics.model.ts b/src/app/providers/analytics/analytics.model.ts index 6222a6b72..2e30fb312 100644 --- a/src/app/providers/analytics/analytics.model.ts +++ b/src/app/providers/analytics/analytics.model.ts @@ -50,6 +50,7 @@ export enum AnalyticsScreenNames { FAKE_JOURNAL = 'practice journal screen', UN_UPLOADED = 'incomplete tests screen', PASS_CERTIFICATES = 'pass certificates screen', + EXAMINER_RECORDS = 'examiner records screen', } export enum AnalyticsEventCategories { @@ -364,6 +365,7 @@ export enum GoogleAnalyticsEvents { AVOIDANCE_MANOEUVRE = 'avoidance_manoeuvre', EMERGENCY_STOP = 'emergency_stop', STUDENT_EXPERIENCE = 'student_experience', + EXAMINER_RECORDS = 'examiner_records', } export enum GoogleAnalyticsEventsTitles { @@ -426,6 +428,15 @@ export enum GoogleAnalyticsEventsTitles { LEVEL = 'level', ADDED = 'added', REMOVED = 'removed', + LOCATION_FILTER = 'location_filter', + TEST_CATEGORY_FILTER = 'test_category_filter', + DATE_RANGE_CHANGED = 'date_range_filter', + GREYSCALE_COLOUR = 'greyscale_scheme', + DEFAULT_COLOUR = 'default_scheme', + CHART_VISUALISATION = 'chart_visualisation', + TAP_TO_SHOW = 'tap_to_show_data', + TAP_TO_HIDE = 'tap_to_hide_data', + DATA_UNAVAILABLE = 'data_unavailable', } export enum GoogleAnalyticsEventsValues { @@ -496,4 +507,8 @@ export enum GoogleAnalyticsEventsValues { REVERSE_MANOEUVRE = 'reverse_manoeuvre', OTHER_REASON = 'other_reason', TRAINER_ID_ENTERED = 'trainer_id_entered', + SELECTED = 'selected', + UNSELECTED = 'unselected', + RETURN_TO_DASHBOARD = 'return_to_dashboard', + DATA_BANNER_DISPLAY = 'data_banner_display', } diff --git a/src/app/providers/app-config/__mocks__/app-config.mock.ts b/src/app/providers/app-config/__mocks__/app-config.mock.ts index c5899dde6..e5bc02dff 100644 --- a/src/app/providers/app-config/__mocks__/app-config.mock.ts +++ b/src/app/providers/app-config/__mocks__/app-config.mock.ts @@ -46,6 +46,7 @@ export class AppConfigProviderMock { testSubmissionUrl: localEnvironmentMock.tests.testSubmissionUrl, multipleTestResultsUrl: localEnvironmentMock.tests.multipleTestResultsUrl, autoSendInterval: localEnvironmentMock.tests.autoSendInterval, + examinerRecordsUrl: localEnvironmentMock.tests.examinerRecordsUrl, }, user: { findUserUrl: localEnvironmentMock.user.findUserUrl, diff --git a/src/app/providers/app-config/app-config.model.ts b/src/app/providers/app-config/app-config.model.ts index aadf63989..8aa00af89 100644 --- a/src/app/providers/app-config/app-config.model.ts +++ b/src/app/providers/app-config/app-config.model.ts @@ -46,6 +46,7 @@ export type AppConfig = { tests: { testSubmissionUrl: string, multipleTestResultsUrl: string, + examinerRecordsUrl: string, autoSendInterval: number, }, user: { diff --git a/src/app/providers/app-config/app-config.ts b/src/app/providers/app-config/app-config.ts index 7f6651b14..be3e35f87 100644 --- a/src/app/providers/app-config/app-config.ts +++ b/src/app/providers/app-config/app-config.ts @@ -337,6 +337,7 @@ export class AppConfigProvider { testSubmissionUrl: data.tests.testSubmissionUrl, multipleTestResultsUrl: data.tests.multipleTestResultsUrl, autoSendInterval: data.tests.autoSendInterval, + examinerRecordsUrl: data.tests.examinerRecordsUrl }, user: { findUserUrl: data.user.findUserUrl, diff --git a/src/app/providers/colour-contrast/__tests__/colour-contrast.service.spec.ts b/src/app/providers/colour-contrast/__tests__/colour-contrast.service.spec.ts new file mode 100644 index 000000000..9421a3af1 --- /dev/null +++ b/src/app/providers/colour-contrast/__tests__/colour-contrast.service.spec.ts @@ -0,0 +1,53 @@ +import { TestBed, waitForAsync } from '@angular/core/testing'; +import { ColourContrastService } from '@providers/colour-contrast/colour-contrast.service'; + +describe('ColourContrastService', () => { + let colourContrastService: ColourContrastService; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + providers: [ + ColourContrastService, + ], + }); + + colourContrastService = TestBed.inject(ColourContrastService); + })); + + describe('luminance', () => { + it('should return properly calculated variables', () => { + spyOn(colourContrastService, 'relativeLuminance').and.returnValue(1); + expect(colourContrastService.luminance([1, 2, 3])).toEqual(1); + }); + }); + describe('hexToRgb', () => { + it('should return 3 values between 0 and 255 equal to the hexadecimal values within the string sent', () => { + expect(colourContrastService.hexToRgb('#FFFFFF')).toEqual([255, 255, 255]); + }); + }); + describe('getContrastRatio', () => { + it('should convert the hex codes to rgb if strings are passed in', () => { + spyOn(colourContrastService, 'hexToRgb').and.callThrough(); + colourContrastService.getContrastRatio('#FFFFFF', '#000000'); + expect(colourContrastService.hexToRgb).toHaveBeenCalledWith('#FFFFFF'); + expect(colourContrastService.hexToRgb).toHaveBeenCalledWith('#000000'); + }); + it('should return l1 + 0.05 / l2 + 0.05 if l1 is bigger than l2', () => { + expect(colourContrastService.getContrastRatio([255, 255, 255], [1, 1, 1])).toEqual(20.87); + }); + it('should return l2 + 0.05 / l1 + 0.05 if l1 not bigger than l2', () => { + expect(colourContrastService.getContrastRatio([1, 1, 1])).toEqual(20.87); + }); + }); + describe('relativeLuminance', () => { + it('should return Math.pow((input/255 + 0.055) / 1.055, 2.4) ' + + 'if input/255 is more than 0.04045', () => { + expect(colourContrastService.relativeLuminance(255)).toEqual(1); + }); + it('should return Math.pow((input/255)/12.92) ' + + 'if input/255 is less than or equal to 0.04045', () => { + expect(colourContrastService.relativeLuminance(1)).toEqual(0.0003035269835488375); + }); + }); + +}); diff --git a/src/app/providers/colour-contrast/colour-contrast.service.ts b/src/app/providers/colour-contrast/colour-contrast.service.ts new file mode 100644 index 000000000..6d4809144 --- /dev/null +++ b/src/app/providers/colour-contrast/colour-contrast.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +//Many of the values used appear arbitrary, but they are not, +// a full explanation can be found here https://mallonbacka.com/blog/2023/03/wcag-contrast-formula/ +export class ColourContrastService { + relativeLuminance(value: number): number { + //convert value from between 0 and 255 to between 0 and 1 + const ratio = value / 255; + //return the relative luminance of the colour passed + return ratio <= 0.04045 ? ratio / 12.92 : Math.pow((ratio + 0.055) / 1.055, 2.4); + } + luminance([colour1, colour2, colour3]: [number, number, number]): number { + //return luminance value for the passed colour + return 0.2126 * this.relativeLuminance(colour1) + + 0.7152 * this.relativeLuminance(colour2) + + 0.0722 * this.relativeLuminance(colour3); + } + hexToRgb(hex: string): [number, number, number] { + let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? [parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16), + ] : null; + } + + getContrastRatio( + colour1: [number, number, number] | string, + colour2: [number, number, number] | string = [255, 255, 255], + ): number { + if (typeof colour1 === 'string') { + colour1 = this.hexToRgb(colour1); + } + if (typeof colour2 === 'string') { + colour2 = this.hexToRgb(colour2); + } + const luminance1 = this.luminance(colour1); + const luminance2 = this.luminance(colour2); + + // calculate contrast using (L1 + 0.05) / (L2 + 0.05), where l1 is the largest of the 2 l values, return as a number + // with zero decimal places, but without rounding. + return Number( + (luminance1 > luminance2 ? + (luminance1 + 0.05) / (luminance2 + 0.05) : + (luminance2 + 0.05) / (luminance1 + 0.05) + ).toString().match(/^-?\d+(?:\.\d{0,2})?/)[0]); + } +} diff --git a/src/app/providers/data-store/data-store.ts b/src/app/providers/data-store/data-store.ts index 10483571e..639782578 100644 --- a/src/app/providers/data-store/data-store.ts +++ b/src/app/providers/data-store/data-store.ts @@ -18,6 +18,7 @@ export enum LocalStorageKey { LOGS = 'LOGS', STORAGE_MIGRATED = 'STORAGE_MIGRATED', TESTS = 'TESTS', + EXAMINER_STATS_KEY = 'EXAMINER_STAT_PREFERENCES', } export type StorageKey = LocalStorageKey | Token; diff --git a/src/app/providers/examiner-records/__mocks__/examiner-records.mock.ts b/src/app/providers/examiner-records/__mocks__/examiner-records.mock.ts new file mode 100644 index 000000000..121c8c440 --- /dev/null +++ b/src/app/providers/examiner-records/__mocks__/examiner-records.mock.ts @@ -0,0 +1,111 @@ +import { TestCategory } from '@dvsa/mes-test-schema/category-definitions/common/test-category'; +import { ColourEnum, ColourScheme, SelectableDateRange } from '@providers/examiner-records/examiner-records'; +import moment from 'moment/moment'; +import { DateRange } from '@shared/helpers/date-time'; + +export class ExaminerRecordsProviderMock { + + public colours: { + default: ColourScheme, + greyscale: ColourScheme, + } = { + default: { + name: ColourEnum.DEFAULT, + pie: [ + '#008FFB', + '#ED6926', + '#FF526F', + '#007C42', + '#a05195', + ], + bar: ['#008FFB'], + emergencyStop: [ + '#ED6926', + '#777777' + ], + average: '#000000', + }, + greyscale: { + name: ColourEnum.GREYSCALE, + pie: ['#474747', + '#5A5A5A', + '#6E6E6E', + '#818181', + '#949494', + ], + bar: ['#777777'], + average: '#000000', + }, + }; + + public localFilterOptions: SelectableDateRange[] = [ + { + display: 'Today', + val: DateRange.TODAY, + }, + { + display: 'Last 7 days', + val: DateRange.WEEK, + }, + { + display: 'Last 14 days', + val: DateRange.FORTNIGHT, + }, + ]; + public onlineFilterOptions: SelectableDateRange[] = [ + { + display: 'Last 90 days', + val: DateRange.NINETY_DAYS, + }, + { + display: 'Last 1 year', + val: DateRange.ONE_YEAR, + }, + { + display: 'Last 18 months', + val: DateRange.EIGHTEEN_MONTHS, + }, + ] + + getRangeDate = jasmine.createSpy('getRangeDate').and.returnValue(moment(new Date())) + + handleLoadingUI = jasmine.createSpy('handleLoadingUI') + + formatForExaminerRecords = jasmine.createSpy('formatForExaminerRecords') + .and + .returnValue({ + appRef: 1, + testCategory: TestCategory.B, + testCentre: { + centreId: 1, + costCode: 'testCode', + centreName: 'testName' + }, + startDate: '01-01-01', + routeNumber: 1, + controlledStop: true, + independentDriving: 'Sat nav', + circuit: 'Left', + safetyQuestions: [{ + code: 'code', + description: 'description', + outcome: 'P', + }], + balanceQuestions: [{ + code: 'code', + description: 'description', + outcome: 'P', + }], + manoeuvres: null, + showMeQuestions: [{ + code: 'code', + description: 'description', + outcome: 'P', + }], + tellMeQuestions: [{ + code: 'code', + description: 'description', + outcome: 'P', + }], + }); +} diff --git a/src/app/providers/examiner-records/__tests__/examiner-records.spec.ts b/src/app/providers/examiner-records/__tests__/examiner-records.spec.ts new file mode 100644 index 000000000..9180b05b8 --- /dev/null +++ b/src/app/providers/examiner-records/__tests__/examiner-records.spec.ts @@ -0,0 +1,359 @@ +import { ExaminerRecordsProvider } from '../examiner-records'; +import { TestBed } from '@angular/core/testing'; +import moment from 'moment'; +import { SearchProvider } from '@providers/search/search'; +import { SearchProviderMock } from '@providers/search/__mocks__/search.mock'; +import { CompressionProvider } from '@providers/compression/compression'; +import { CompressionProviderMock } from '@providers/compression/__mocks__/compression.mock'; +import { Store } from '@ngrx/store'; +import { TestCategory } from '@dvsa/mes-test-schema/category-definitions/common/test-category'; +import { JournalData, TestData, TestSlotAttributes } from '@dvsa/mes-test-schema/categories/common'; +import { TestResultSchemasUnion } from '@dvsa/mes-test-schema/categories'; +import { DateRange } from '@shared/helpers/date-time'; + +describe('ExaminerRecordsProvider', () => { + let provider: ExaminerRecordsProvider; + + class StoreMock { + } + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ExaminerRecordsProvider, + { + provide: SearchProvider, + useClass: SearchProviderMock, + }, + { + provide: CompressionProvider, + useClass: CompressionProviderMock, + }, + { provide: Store, useClass: StoreMock }, + ], + }); + + provider = TestBed.inject(ExaminerRecordsProvider); + }); + + + describe('getRangeDate', () => { + it('should return the current date if the range is "today"', () => { + expect(provider.getRangeDate(DateRange.TODAY).format('DD/MM/YYYY')) + .toEqual(moment(new Date()).format('DD/MM/YYYY')); + }); + it('should return the date last week if the range is "week"', () => { + expect(provider.getRangeDate(DateRange.WEEK).format('DD/MM/YYYY')) + .toEqual(moment(new Date()).subtract(1, 'week').format('DD/MM/YYYY')); + }); + it('should return the date 2 weeks ago if the range is "fortnight"', () => { + expect(provider.getRangeDate(DateRange.FORTNIGHT).format('DD/MM/YYYY')) + .toEqual(moment(new Date()).subtract(2, 'week').format('DD/MM/YYYY')); + }); + it('should return the date 90 days ago if the range is "90 days"', () => { + expect(provider.getRangeDate(DateRange.NINETY_DAYS).format('DD/MM/YYYY')) + .toEqual(moment(new Date()).subtract(90, 'days').format('DD/MM/YYYY')); + }); + it('should return the date 1 year ago if the range is "1 year"', () => { + expect(provider.getRangeDate(DateRange.ONE_YEAR).format('DD/MM/YYYY')) + .toEqual(moment(new Date()).subtract(1, 'year').format('DD/MM/YYYY')); + }); + it('should return the date 18 months ago if the range is "18 months"', () => { + expect(provider.getRangeDate(DateRange.EIGHTEEN_MONTHS).format('DD/MM/YYYY')) + .toEqual(moment(new Date()).subtract(18, 'months').format('DD/MM/YYYY')); + }); + }); + + describe('formatForExaminerRecords', () => { + it('should an object containing the mandatory fields', () => { + expect(provider.formatForExaminerRecords({ + journalData: { + testSlotAttributes: { + start: 'Text Date', + } as TestSlotAttributes, + applicationReference: { + applicationId: 1, + bookingSequence: 2, + checkDigit: 3, + }, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + } as JournalData, + category: TestCategory.B, + } as TestResultSchemasUnion)).toEqual({ + appRef: 1023, + testCategory: TestCategory.B, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + startDate: 'Text Date', + }); + }); + it('should an object containing an optional field contained within the for loop', () => { + expect(provider.formatForExaminerRecords({ + testData: { controlledStop: { selected: true } }, + journalData: { + testSlotAttributes: { + start: 'Text Date', + } as TestSlotAttributes, + applicationReference: { + applicationId: 1, + bookingSequence: 2, + checkDigit: 3, + }, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + } as JournalData, + category: TestCategory.B, + } as TestResultSchemasUnion)).toEqual({ + controlledStop: true, + appRef: 1023, + testCategory: TestCategory.B, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + startDate: 'Text Date', + }); + }); + it('should an object containing an optional route number', () => { + expect(provider.formatForExaminerRecords({ + testSummary: { routeNumber: 1 }, + journalData: { + testSlotAttributes: { + start: 'Text Date', + } as TestSlotAttributes, + applicationReference: { + applicationId: 1, + bookingSequence: 2, + checkDigit: 3, + }, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + } as JournalData, + category: TestCategory.B, + } as TestResultSchemasUnion)).toEqual({ + routeNumber: 1, + appRef: 1023, + testCategory: TestCategory.B, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + startDate: 'Text Date', + }); + }); + it('should an object containing an optional show me question', () => { + expect(provider.formatForExaminerRecords({ + testData: { + vehicleChecks: { + showMeQuestion: { + code: 'code', + description: 'description', + outcome: 'P', + }, + }, + } as TestData, + journalData: { + testSlotAttributes: { + start: 'Text Date', + } as TestSlotAttributes, + applicationReference: { + applicationId: 1, + bookingSequence: 2, + checkDigit: 3, + }, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + } as JournalData, + category: TestCategory.B, + } as TestResultSchemasUnion)).toEqual({ + showMeQuestions: [{ code: 'code', description: 'description', outcome: 'P' }], + appRef: 1023, + testCategory: TestCategory.B, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + startDate: 'Text Date', + }); + }); + it('should an object containing an optional show me questions array', () => { + expect(provider.formatForExaminerRecords({ + testData: { + vehicleChecks: { + showMeQuestions: [{ + code: 'code', + description: 'description', + outcome: 'P', + }], + }, + } as TestData, + journalData: { + testSlotAttributes: { + start: 'Text Date', + } as TestSlotAttributes, + applicationReference: { + applicationId: 1, + bookingSequence: 2, + checkDigit: 3, + }, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + } as JournalData, + category: TestCategory.B, + } as TestResultSchemasUnion)).toEqual({ + showMeQuestions: [{ code: 'code', description: 'description', outcome: 'P' }], + appRef: 1023, + testCategory: TestCategory.B, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + startDate: 'Text Date', + }); + }); + it('should an object containing an optional tell me question', () => { + expect(provider.formatForExaminerRecords({ + testData: { + vehicleChecks: { + tellMeQuestion: { + code: 'code', + description: 'description', + outcome: 'P', + }, + }, + } as TestData, + journalData: { + testSlotAttributes: { + start: 'Text Date', + } as TestSlotAttributes, + applicationReference: { + applicationId: 1, + bookingSequence: 2, + checkDigit: 3, + }, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + } as JournalData, + category: TestCategory.B, + } as TestResultSchemasUnion)).toEqual({ + tellMeQuestions: [{ code: 'code', description: 'description', outcome: 'P' }], + appRef: 1023, + testCategory: TestCategory.B, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + startDate: 'Text Date', + }); + }); + it('should an object containing an optional tell me questions array', () => { + expect(provider.formatForExaminerRecords({ + testData: { + vehicleChecks: { + tellMeQuestions: [{ + code: 'code', + description: 'description', + outcome: 'P', + }], + }, + } as TestData, + journalData: { + testSlotAttributes: { + start: 'Text Date', + } as TestSlotAttributes, + applicationReference: { + applicationId: 1, + bookingSequence: 2, + checkDigit: 3, + }, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + } as JournalData, + category: TestCategory.B, + } as TestResultSchemasUnion)).toEqual({ + tellMeQuestions: [{ code: 'code', description: 'description', outcome: 'P' }], + appRef: 1023, + testCategory: TestCategory.B, + testCentre: { + centreId: 1, + costCode: 'EXPLE', + centreName: 'Example', + }, + startDate: 'Text Date', + }); + }); + }); + + describe('handleLoadingUI', () => { + it('should call handleUILoading if should load while not currently loading', () => { + spyOn(provider.loadingProvider, 'handleUILoading').and.callThrough(); + provider.currentlyLoading = false; + provider.handleLoadingUI(true); + expect(provider.loadingProvider.handleUILoading) + .toHaveBeenCalledWith(true, { + id: 'examinerRecord_loading_spinner', + spinner: 'circles', + backdropDismiss: false, + translucent: false, + message: 'Loading...', + }); + }); + it('should call handleUILoading if should not load while currently loading', () => { + spyOn(provider.loadingProvider, 'handleUILoading').and.callThrough(); + provider.currentlyLoading = true; + provider.handleLoadingUI(false); + expect(provider.loadingProvider.handleUILoading) + .toHaveBeenCalledWith(false, { + id: 'examinerRecord_loading_spinner', + spinner: 'circles', + backdropDismiss: false, + translucent: false, + message: 'Loading...', + }); + }); + it('should not call handleUILoading if should load while currently loading', () => { + spyOn(provider.loadingProvider, 'handleUILoading').and.callThrough(); + provider.currentlyLoading = true; + provider.handleLoadingUI(true); + expect(provider.loadingProvider.handleUILoading) + .not.toHaveBeenCalled(); + }); + it('should not call handleUILoading if should not load while not currently loading', () => { + spyOn(provider.loadingProvider, 'handleUILoading').and.callThrough(); + provider.currentlyLoading = true; + provider.handleLoadingUI(true); + expect(provider.loadingProvider.handleUILoading) + .not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/providers/examiner-records/examiner-records.ts b/src/app/providers/examiner-records/examiner-records.ts new file mode 100644 index 000000000..c9872624b --- /dev/null +++ b/src/app/providers/examiner-records/examiner-records.ts @@ -0,0 +1,255 @@ +import { Injectable } from '@angular/core'; +import { SearchProvider } from '@providers/search/search'; +import { CompressionProvider } from '@providers/compression/compression'; +import { Store } from '@ngrx/store'; +import { StoreModel } from '@shared/models/store.model'; +import { TestResultSchemasUnion } from '@dvsa/mes-test-schema/categories'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; +import { formatApplicationReference } from '@shared/helpers/formatters'; +import { TestCategory } from '@dvsa/mes-test-schema/category-definitions/common/test-category'; +import { get } from 'lodash-es'; +import { QuestionResult } from '@dvsa/mes-test-schema/categories/common'; +import { DateRange } from '@shared/helpers/date-time'; +import { ChartType } from 'ng-apexcharts'; +import { Router } from '@angular/router'; +import { LoadingProvider } from '@providers/loader/loader'; +import moment from 'moment'; + +export interface ColourScheme { + name: ColourEnum, + bar: string[], + pie: string[], + emergencyStop?: string[], + average: string +} + +export const enum ColourEnum { + DEFAULT = 'Default', + GREYSCALE = 'Greyscale', +} + +export type ExaminerRecordsRange = DateRange; + +export interface SelectableDateRange { + display: string; + val: ExaminerRecordsRange; +} +export type DESChartTypes = Extract; +@Injectable() +export class ExaminerRecordsProvider { + + public colours: { + default: ColourScheme, + greyscale: ColourScheme, + } = { + default: { + name: ColourEnum.DEFAULT, + pie: [ + '#008FFB', + '#ED6926', + '#FF526F', + '#007C42', + '#a05195', + ], + bar: ['#008FFB'], + emergencyStop: [ + '#ED6926', + '#777777' + ], + average: '#000000', + }, + greyscale: { + name: ColourEnum.GREYSCALE, + pie: [ + '#474747', + '#6E6E6E', + '#222222', + '#606060', + '#949494', + ], + bar: ['#777777'], + average: '#000000', + }, + }; + + public localFilterOptions: SelectableDateRange[] = [ + { + display: 'Today', + val: DateRange.TODAY, + }, + { + display: 'Last 7 days', + val: DateRange.WEEK, + }, + { + display: 'Last 14 days', + val: DateRange.FORTNIGHT, + }, + ]; + public onlineFilterOptions: SelectableDateRange[] = [ + { + display: 'Last 90 days', + val: DateRange.NINETY_DAYS, + }, + { + display: 'Last 1 year', + val: DateRange.ONE_YEAR, + }, + { + display: 'Last 18 months', + val: DateRange.EIGHTEEN_MONTHS, + }, + ]; + + currentlyLoading: boolean = false; + + + constructor( + public searchProvider: SearchProvider, + public compressionProvider: CompressionProvider, + public store$: Store, + public router: Router, + public loadingProvider: LoadingProvider, + ) { + } + + /** + * Handler for loading spinner while pulling backend data. + * + * This method manages the UI state for a loading spinner based on the `isLoading` parameter. + * It updates the `currentlyLoading` state and invokes the `handleUILoading` method of the `loadingProvider` + * to show or hide the loading spinner with specific options. + * + * @param {boolean} isLoading - Indicates whether the loading spinner should be displayed + * (`true`) or hidden (`false`). + * @returns {Promise} A promise that resolves to `null` after the loading state is handled. + */ + handleLoadingUI = async (isLoading: boolean): Promise => { + if ((isLoading && !this.currentlyLoading) || (!isLoading && this.currentlyLoading)) { + this.currentlyLoading = isLoading; + await this.loadingProvider.handleUILoading(isLoading, { + id: 'examinerRecord_loading_spinner', + spinner: 'circles', + backdropDismiss: false, + translucent: false, + message: 'Loading...' + }); + + } + return null; + }; + + /** + * Get the date from a set date range ago in order to display on screen. + * + * This method calculates the date based on the provided date range. + * It returns the current date for 'today', one week ago for 'week', + * two weeks ago for 'fortnight', 90 days ago for '90 days', + * one year ago for '1 year', and 18 months ago for '18 months'. + * + * @param {DateRange} range - The date range to calculate the date from. + * @returns {moment.Moment} The calculated date based on the provided range. + */ + getRangeDate(range: DateRange): moment.Moment { + switch (range) { + case DateRange.TODAY: + return moment(new Date()); + case DateRange.WEEK: + return moment(new Date()) + .subtract(1, 'week'); + case DateRange.FORTNIGHT: + return moment(new Date()) + .subtract(2, 'week'); + case DateRange.NINETY_DAYS: + return moment(new Date()) + .subtract(90, 'days'); + case DateRange.ONE_YEAR: + return moment(new Date()) + .subtract(1, 'year'); + case DateRange.EIGHTEEN_MONTHS: + return moment(new Date()) + .subtract(18, 'months'); + default: + return null + } + } + + /** + * Converts a test result to the ExaminerRecordModel format. + * + * This method transforms the provided test result into the format required for examiner records. + * It extracts various fields from the test result and assigns them to the corresponding properties + * of the ExaminerRecordModel. The method handles optional fields and ensures they are only added + * if they exist in the test result. + * + * @param {TestResultSchemasUnion} testResult - The test result to be formatted. + * @returns {ExaminerRecordModel} The formatted examiner record. + */ + formatForExaminerRecords = (testResult: TestResultSchemasUnion): ExaminerRecordModel => { + let result: ExaminerRecordModel = { + appRef: Number(formatApplicationReference(testResult.journalData.applicationReference)), + testCategory: testResult.category as TestCategory, + testCentre: testResult.journalData.testCentre, + startDate: testResult.journalData.testSlotAttributes.start, + }; + + [ + { field: 'controlledStop', value: get(testResult, 'testData.controlledStop.selected') }, + { field: 'extendedTest', value: get(testResult, 'journalData.testSlotAttributes.extendedTest') }, + { field: 'independentDriving', value: get(testResult, 'testSummary.independentDriving') }, + { field: 'circuit', value: get(testResult, 'testSummary.circuit') }, + { field: 'safetyQuestions', value: get(testResult, 'testData.safetyAndBalanceQuestions.safetyQuestions') }, + { field: 'balanceQuestions', value: get(testResult, 'testData.safetyAndBalanceQuestions.balanceQuestions') }, + { field: 'manoeuvres', value: get(testResult, 'testData.manoeuvres') }, + ].forEach(item => { + if (item.value) { + result = { ...result, [item.field]: item.value, }; + } + }); + + let routeNumber = get(testResult, 'testSummary.routeNumber'); + if (routeNumber) { + result = { + ...result, + routeNumber: Number(routeNumber), + }; + } + + let showQuestion = get(testResult, 'testData.vehicleChecks.showMeQuestion'); + let showQuestions = get(testResult, 'testData.vehicleChecks.showMeQuestions'); + if (showQuestion) { + result = { + ...result, + showMeQuestions: [showQuestion], + }; + } else if ( + showQuestions && + (showQuestions as QuestionResult[]).filter((question) => Object.keys(question).length > 0).length !== 0) { + + result = { + ...result, + showMeQuestions: (showQuestions as QuestionResult[]).filter( + (question) => Object.keys(question).length > 0), + }; + } + let tellQuestion = get(testResult, 'testData.vehicleChecks.tellMeQuestion'); + let tellQuestions = get(testResult, 'testData.vehicleChecks.tellMeQuestions'); + if (tellQuestion) { + result = { + ...result, + tellMeQuestions: [tellQuestion], + }; + } else if ( + tellQuestions && + (tellQuestions as QuestionResult[]).filter((question) => Object.keys(question).length > 0).length !== 0) { + + result = { + ...result, + tellMeQuestions: (tellQuestions as QuestionResult[]).filter( + (question) => Object.keys(question).length > 0), + }; + } + return result; + }; + +} diff --git a/src/app/providers/orientation-monitor/orientation-monitor.provider.ts b/src/app/providers/orientation-monitor/orientation-monitor.provider.ts index 56af798aa..1e1148591 100644 --- a/src/app/providers/orientation-monitor/orientation-monitor.provider.ts +++ b/src/app/providers/orientation-monitor/orientation-monitor.provider.ts @@ -3,7 +3,9 @@ import { BehaviorSubject } from 'rxjs'; import { ScreenOrientation } from '@capawesome/capacitor-screen-orientation'; import { isPortrait } from '@shared/helpers/is-portrait-mode'; -@Injectable() +@Injectable( { + providedIn: 'root', +} ) export class OrientationMonitorProvider { isPortraitMode$: BehaviorSubject = new BehaviorSubject(false); diff --git a/src/app/providers/search/__mocks__/search.mock.ts b/src/app/providers/search/__mocks__/search.mock.ts index 899b5d2fc..a1c1e109d 100644 --- a/src/app/providers/search/__mocks__/search.mock.ts +++ b/src/app/providers/search/__mocks__/search.mock.ts @@ -4,6 +4,10 @@ import { searchResultsMock } from './search-results.mock'; export class SearchProviderMock { + examinerRecordsSearch = jasmine.createSpy('examinerRecordsSearch') + .and + .returnValue(of(searchResultsMock)); + driverNumberSearch = jasmine.createSpy('driverNumberSearch') .and .returnValue(of(searchResultsMock)); diff --git a/src/app/providers/search/search.ts b/src/app/providers/search/search.ts index 20af0b454..3f1628a18 100644 --- a/src/app/providers/search/search.ts +++ b/src/app/providers/search/search.ts @@ -18,6 +18,25 @@ export class SearchProvider { ) { } + examinerRecordsSearch(staffNumber: string, startDate?: string, endDate?: string): Observable { + let parameters = {}; + + [ + { name: 'staffNumber', val: staffNumber }, + { name: 'startDate', val: startDate }, + { name: 'endDate', val: endDate }, + ].forEach(param => { + if (param.val) parameters[param.name] = param.val + }) + + return this.http.get( + this.urlProvider.getExaminerRecordsUrl(), + { + params: parameters, + }, + ).pipe(timeout(this.appConfig.getAppConfig().requestTimeout)); + } + driverNumberSearch(driverNumber: string): Observable { return this.http.get( this.urlProvider.getTestResultServiceUrl(), diff --git a/src/app/providers/url/url.ts b/src/app/providers/url/url.ts index 97edeb097..3c7731f4c 100644 --- a/src/app/providers/url/url.ts +++ b/src/app/providers/url/url.ts @@ -26,6 +26,9 @@ export class UrlProvider { getTestResultServiceUrl(): string { return this.appConfigProvider.getAppConfig()?.tests.testSubmissionUrl; } + getExaminerRecordsUrl(): string { + return this.appConfigProvider.getAppConfig()?.tests.examinerRecordsUrl; + } getMultipleTestResultsUrl(): string { return this.appConfigProvider.getAppConfig()?.tests.multipleTestResultsUrl; diff --git a/src/app/shared/constants/competencies/catb-manoeuvres.ts b/src/app/shared/constants/competencies/catb-manoeuvres.ts index e04894d9b..4369c1301 100644 --- a/src/app/shared/constants/competencies/catb-manoeuvres.ts +++ b/src/app/shared/constants/competencies/catb-manoeuvres.ts @@ -1,5 +1,4 @@ export enum manoeuvreTypeLabels { - reverseLeft = 'Reverse left', reverseRight = 'Reverse right', reverseParkRoad = 'Reverse park (road)', reverseParkCarpark = 'Reverse park (car park)', diff --git a/src/app/shared/helpers/date-time.ts b/src/app/shared/helpers/date-time.ts index 155883657..d6c85ae01 100644 --- a/src/app/shared/helpers/date-time.ts +++ b/src/app/shared/helpers/date-time.ts @@ -9,6 +9,14 @@ export enum Duration { SECOND = 'second', } +export enum DateRange { + TODAY = 'today', + WEEK = '7 days', + FORTNIGHT = '14 days', + NINETY_DAYS = '90 days', + ONE_YEAR = '1 year', + EIGHTEEN_MONTHS = '18 months', +} export class DateTime { moment: moment.Moment; @@ -84,6 +92,30 @@ export class DateTime { return date.moment.diff(this.moment, Duration.SECOND) > 0; } + isDuring(range: DateRange) { + const today = new Date(); + const dateRange = (() => { + switch (range) { + case DateRange.TODAY: + return moment(today).subtract(1, 'day'); + case DateRange.WEEK: + return moment(today).subtract(1, 'week'); + case DateRange.FORTNIGHT: + return moment(today).subtract(2, 'weeks'); + case DateRange.NINETY_DAYS: + return moment(today).subtract(90, 'days'); + case DateRange.ONE_YEAR: + return moment(today).subtract(1, 'year'); + case DateRange.EIGHTEEN_MONTHS: + return moment(today).subtract(18, 'months'); + default: + return null; + } + })(); + + return this.moment.isSameOrAfter(dateRange); + } + static today(): Date { return moment() .toDate(); diff --git a/src/app/shared/models/store.model.ts b/src/app/shared/models/store.model.ts index 1767047ef..792c6650f 100644 --- a/src/app/shared/models/store.model.ts +++ b/src/app/shared/models/store.model.ts @@ -9,6 +9,8 @@ import { testCentreJournalFeatureKey } from '@store/test-centre-journal/test-cen import { TestsModel } from '@store/tests/tests.model'; import { testsFeatureKey } from '@store/tests/tests.reducer'; import { refDataFeatureKey, RefDataStateModel } from '@store/reference-data/reference-data.reducer'; +import { examinerRecordsFeatureKey } from '@store/examiner-records/examiner-records.reducer'; +import { ExaminerRecordStateModel } from '@store/examiner-records/examiner-records.model'; export interface StoreModel { [appInfoFeatureKey]: AppInfoStateModel, @@ -17,4 +19,5 @@ export interface StoreModel { [testCentreJournalFeatureKey]: TestCentreJournalModel, [testsFeatureKey]: TestsModel, [refDataFeatureKey]: RefDataStateModel, + [examinerRecordsFeatureKey]: ExaminerRecordStateModel, } diff --git a/src/app/shared/pipes/pipes.module.ts b/src/app/shared/pipes/pipes.module.ts index 578a32683..a68dbc09b 100644 --- a/src/app/shared/pipes/pipes.module.ts +++ b/src/app/shared/pipes/pipes.module.ts @@ -4,7 +4,6 @@ import { EllipsisPipe } from '@shared/pipes/ellipsis.pipe'; import { CustomKeyValuePipe } from '@shared/pipes/customKeyValue.pipe'; import { ContainsPipe } from '@shared/pipes/contains.pipe'; -import { RoleFilterPipe } from '@shared/pipes/roleFilter.pipe'; import { ModifyCompetencyLabel } from './modifyCompetencyLabel'; @NgModule({ @@ -14,7 +13,6 @@ import { ModifyCompetencyLabel } from './modifyCompetencyLabel'; EllipsisPipe, CustomKeyValuePipe, ContainsPipe, - RoleFilterPipe, ], exports: [ ModifyCompetencyLabel, @@ -22,7 +20,6 @@ import { ModifyCompetencyLabel } from './modifyCompetencyLabel'; EllipsisPipe, CustomKeyValuePipe, ContainsPipe, - RoleFilterPipe, ], imports: [], }) diff --git a/src/app/shared/pipes/roleFilter.pipe.ts b/src/app/shared/pipes/roleFilter.pipe.ts deleted file mode 100644 index 2405803a5..000000000 --- a/src/app/shared/pipes/roleFilter.pipe.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import { Page } from '@app/app.component'; -import { isAnyOf } from '@shared/helpers/simplifiers'; -import { AppConfigProvider } from '@providers/app-config/app-config'; - -@Pipe({ - name: 'roleFilter', -}) -export class RoleFilterPipe implements PipeTransform { - - constructor(private appConfigProvider: AppConfigProvider) { - } - - transform(pages: Page[]): Page[] { - const role = this.appConfigProvider.getAppConfig()?.role; - if (!role) { - return pages; - } - - return pages.filter( - (page) => !isAnyOf(role, (page.hideWhenRole || [])), - ); - } -} diff --git a/src/components/common/chart/__tests__/chart.spec.ts b/src/components/common/chart/__tests__/chart.spec.ts new file mode 100644 index 000000000..c2f36a8ff --- /dev/null +++ b/src/components/common/chart/__tests__/chart.spec.ts @@ -0,0 +1,269 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { IonicModule } from '@ionic/angular'; +import { ChartComponent } from '@components/common/chart/chart'; +import { SimpleChange } from '@angular/core'; +import { ApexOptions } from 'ng-apexcharts'; + +describe('ChartComponent', () => { + let component: ChartComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ChartComponent], + imports: [IonicModule], + providers: [ + ], + }); + fixture = TestBed.createComponent(ChartComponent); + component = fixture.componentInstance; + component.passedData = [ + { item: 'Index2 - Label2', count: 2, percentage: '45' }, + { item: 'Index3 - Label3', count: 3, percentage: '50' }, + ]; + component.chartId = 'test'; + + fixture.detectChanges(); + })); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('filterData', () => { + it('should set labels to an array of the item property within passed data', () => { + component.filterData(); + expect(component.labels).toEqual(['Index2 - Label2', 'Index3 - Label3']); + }); + it('should set values to an array of the count property within passed data', () => { + component.filterData(); + expect(component.dataValues).toEqual([2, 3]); + }); + it('should leave values as an array if getChartType returns 1Axis', () => { + spyOn(component, 'getChartType').and.returnValue('1Axis'); + + component.filterData(); + expect(component.dataValues).toEqual([2, 3]); + }); + it('should move values into an object if getChartType returns 2Axis', () => { + spyOn(component, 'getChartType').and.returnValue('2Axis'); + + component.filterData(); + expect(component.dataValues).toEqual([{ data: [2, 3] }]); + }); + }); + + describe('getChartType', () => { + it('should return 1Axis if chartType is pie', () => { + component.chartType = 'pie'; + expect(component.getChartType()).toEqual('1Axis'); + }); + it('should return 2Axis if chartType is bar', () => { + component.chartType = 'bar'; + expect(component.getChartType()).toEqual('2Axis'); + }); + }); + + describe('options', () => { + it('should set yaxis.labels.offsetY to 7 if horizontal is true', () => { + component.chartType = 'bar'; + component.horizontal = true; + if ('labels' in component.options.yaxis) { + expect(component.options.yaxis.labels.offsetY).toEqual(7); + } + }); + it('should set yaxis.labels.offsetY to 6 if horizontal is false', () => { + component.chartType = 'bar'; + component.horizontal = false; + if ('labels' in component.options.yaxis) { + expect(component.options.yaxis.labels.offsetY).toEqual(6); + } + }); + + it('should set xaxis.labels.offsetY to 10 if horizontal is true', () => { + component.chartType = 'bar'; + component.horizontal = true; + expect(component.options.xaxis.labels.offsetY).toEqual(10); + }); + it('should set xaxis.labels.offsetY to 0 if horizontal is false', () => { + component.chartType = 'bar'; + component.horizontal = false; + expect(component.options.xaxis.labels.offsetY).toEqual(0); + }); + + it('should return unformatted value if the type is bar', () => { + component.chartType = 'bar'; + expect(component.options.dataLabels.formatter('1')).toEqual('1'); + }); + it('should return split value with calculated percentage if the type is not bar ' + + 'and both splitLabel and calculatePercentages are true', () => { + component.chartType = 'pie'; + component.splitLabel = true; + component.calculatePercentages = true; + expect(component.options.dataLabels.formatter(2, + { + seriesIndex: 1, + w: { + globals: { + labels: [ 'Index - Label', 'Index2 - Label2'], + }, + }, + })).toEqual('Index2: 2.0%'); + }); + it('should return split value without calculated percentage if the type is not bar ' + + 'and both splitLabel is true and calculatePercentages is false', () => { + component.chartType = 'pie'; + component.splitLabel = true; + component.calculatePercentages = false; + expect(component.options.dataLabels.formatter(2, + { + seriesIndex: 1, + w: { + globals: { + labels: [ 'Index - Label', 'Index2 - Label2'], + }, + }, + })).toEqual('Index2: 50'); + }); + it('should return split value with calculated percentage if the type is not bar ' + + 'and calculatePercentages is true and splitLabel is false', () => { + component.chartType = 'pie'; + component.splitLabel = false; + component.calculatePercentages = true; + expect(component.options.dataLabels.formatter(2, + { + seriesIndex: 1, + w: { + globals: { + labels: [ 'Index - Label', 'Index2 - Label2'], + }, + }, + })).toEqual('Index2 - Label2: 2.0%'); + }); + it('should return split value without calculated percentage if the type is not bar ' + + 'and splitLabel and calculatePercentages are false', () => { + component.chartType = 'pie'; + component.splitLabel = false; + component.calculatePercentages = false; + expect(component.options.dataLabels.formatter(2, + { + seriesIndex: 1, + w: { + globals: { + labels: [ 'Index - Label', 'Index2 - Label2'], + }, + }, + })).toEqual('Index2 - Label2: 50'); + }); + + it('should return xaxis split value if horizontal is false and splitlabel is true', () => { + component.horizontal = false; + component.splitLabel = true; + + expect(component.options.xaxis.labels.formatter('test label')).toEqual('test'); + }); + it('should return xaxis split value if horizontal is false and splitlabel is false', () => { + component.horizontal = false; + component.splitLabel = false; + expect(component.options.xaxis.labels.formatter('test label')).toEqual('test label'); + }); + it('should return xaxis value if horizontal is true', () => { + component.horizontal = true; + expect(component.options.xaxis.labels.formatter('2')).toEqual('2'); + }); + + it('should return yaxis split value if horizontal is true and splitlabel is true', () => { + component.chartType = 'bar'; + + component.horizontal = true; + component.splitLabel = true; + + if ('labels' in component.options.yaxis) { + expect(component.options.yaxis.labels.formatter(1)).toEqual('1'); + } + }); + it('should return yaxis split value if horizontal is true and splitlabel is false', () => { + component.chartType = 'bar'; + + component.horizontal = true; + component.splitLabel = false; + if ('labels' in component.options.yaxis) { + expect(component.options.yaxis.labels.formatter(2)).toEqual('2'); + } + }); + it('should return yaxis value if horizontal is false', () => { + component.chartType = 'bar'; + + component.horizontal = false; + if ('labels' in component.options.yaxis) { + expect(component.options.yaxis.labels.formatter(3)).toEqual('3'); + } + }); + }); + + describe('ngOnChanges', () => { + it('should run filterData and updateOptions with options if dataChanged is true and chart is present', () => { + component.chart = { + updateOptions(options:any): void { + return options; + }, + } as ApexCharts; + + spyOn(component, 'filterData'); + spyOnProperty(component, 'options').and.returnValue({} as ApexOptions); + spyOn(component.chart, 'updateOptions'); + + component.ngOnChanges({ data: { previousValue: '1', currentValue: '2' } as SimpleChange }); + + expect(component.filterData).toHaveBeenCalled(); + expect(component.chart.updateOptions).toHaveBeenCalledWith(component.options); + }); + it('should reassign chart if dataChanged is true, chart is present and the changes include chartType', () => { + component.chart = { + updateOptions(options:any): void { + return options; + }, + render(): void { + return; + }, + } as ApexCharts; + + + component.ngOnChanges({ chartType: { previousValue: '1', currentValue: '2' } as SimpleChange }); + + expect(component.chart).not.toEqual({ + updateOptions(options:any): void { + return options; + }, + render(): void { + return; + }, + } as ApexCharts); + }); + }); + + describe('getTickCount', () => { + it('should return the largest number in the passed array if it is equal to or less than 5', () => { + expect(component.getTickCount([1,2,3,4])).toBe(4); + }); + it('should return null if the largest number in the passed array if it is more than 5', () => { + expect(component.getTickCount([1,2,3,6])).toBe(null); + }); + }) + + describe('getFontSize', () => { + it('should return 16px if the font size is set to text-zoom-regular', () => { + component.zoomSize = 'text-zoom-regular' + expect(component.getFontSize()).toBe('16px'); + }); + it('should return 18px if the font size is set to text-zoom-large', () => { + component.zoomSize = 'text-zoom-large' + expect(component.getFontSize()).toBe('18px'); + }); + it('should return 20px if the font size is set to text-zoom-x-large', () => { + component.zoomSize = 'text-zoom-x-large' + expect(component.getFontSize()).toBe('20px'); + }); + }) + +}); diff --git a/src/components/common/chart/chart.html b/src/components/common/chart/chart.html new file mode 100644 index 000000000..98ce68814 --- /dev/null +++ b/src/components/common/chart/chart.html @@ -0,0 +1 @@ +
diff --git a/src/components/common/chart/chart.ts b/src/components/common/chart/chart.ts new file mode 100644 index 000000000..96e9101b1 --- /dev/null +++ b/src/components/common/chart/chart.ts @@ -0,0 +1,406 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import ApexCharts from 'apexcharts'; +import { ApexAxisChartSeries, ApexNonAxisChartSeries, ApexOptions, ChartType } from 'ng-apexcharts'; +import { isEqual } from 'lodash-es'; +import { ExaminerRecordData } from '@pages/examiner-records/examiner-records.selector'; + +@Component({ + selector: 'chart', + templateUrl: 'chart.html', +}) +export class ChartComponent implements OnInit, OnChanges { + @Input() + public zoomSize: string = '16px'; + + @Input() + public chartId: string = ''; + + @Input() + public chartType: ChartType = 'pie'; + + @Input() + public passedData: ExaminerRecordData[] = null; + + @Input() + public showLegend: boolean = false; + + @Input() + public isPortrait: boolean = false; + + @Input() + public horizontal: boolean = false; + + @Input() + public splitLabel: boolean = true; + + @Input() + public calculatePercentages: boolean = false; + + @Input() + public transformOptions: { + portrait: { + width: number | string, height: number | string, + }, + landscape: { + width: number | string, height: number | string, + } + } = { portrait: { width: 740, height: 300 }, landscape: { width: 1020, height: 300 } }; + + @Input() + public colors: string[] = ['#008FFB', '#00E396', '#FEB019', '#FF4560', '#775DD0']; + + @Input() + public labelColour: string = '#000000'; + + @Input() + public strokeColour: string = '#FFFFFF'; + + @Input() + public averageColour: string = '#FF0000'; + + public dataValues: ApexAxisChartSeries | ApexNonAxisChartSeries = []; + public labels: string[] = []; + public average: number = 0; + public tickCount: number = null; + public chart: ApexCharts = null; + public chartOptions: ApexOptions; + + /** + * Get the chart type as a string. + * + * This method returns a string representing the type of chart based on the `chartType` property. + * If the `chartType` is 'pie', it returns '1Axis'. If the `chartType` is 'bar', it returns '2Axis'. + * + * @returns {string} The chart type as a string. + */ + getChartType(): string { + switch (this.chartType) { + case 'pie': + return '1Axis'; + case 'bar': + return '2Axis'; + } + } + + /** + * Initialize the component. + * + * This lifecycle hook is called after Angular has initialized all data-bound properties. + * It filters the data and sets the chart options. + */ + ngOnInit() { + this.filterData(); + this.chartOptions = this.options; + } + + /** + * Lifecycle hook that is called after Angular has fully initialized a component's view. + * + * This method initializes the ApexCharts chart by selecting the chart element using the `chartId` + * and rendering the chart with the specified options. + * + * @returns {Promise} A promise that resolves when the chart has been rendered. + */ + async ngAfterViewInit(): Promise { + let chartElement: HTMLElement = document.getElementById(this.chartId) + if (chartElement) { + this.chart = new ApexCharts(chartElement, this.options); + await this.chart.render(); + } + } + + /** + * Lifecycle hook that is called when any data-bound property of a directive changes. + * + * This method checks if any of the input properties have changed. If there are changes and the chart exists, + * it re-filters the data and updates the chart options. If the `chartType` has changed, it renders a new chart. + * + * @param {SimpleChanges} changes - An object of key/value pairs for the set of changed properties. + * @returns {Promise} A promise that resolves when the chart has been updated. + */ + async ngOnChanges(changes: SimpleChanges): Promise + { + //check if there are any changed elements + const dataChanged = Object.keys(changes) + .some((key) => !isEqual(changes[key]?.currentValue, changes[key]?.previousValue)); + + if (!!this.chart && dataChanged) { + //if data has changed, re-filter data + this.filterData(); + + //if we want to change chart type, render an entirely new chart + if (Object.keys(changes).includes('chartType')) { + this.chart = new ApexCharts(document.getElementById(this.chartId), this.options); + await this.chart.render(); + } else { + } + //update chart with new options + await this.chart.updateOptions(this.options); + } + } + + /** + * Get the chart options. + * + * This getter method returns the configuration options for the ApexCharts chart. + * It includes settings for states, annotations, fill, chart properties, data labels, stroke, x-axis, + * y-axis, colors, series, labels, legend, tooltip, and plot options. + * + * @returns {ApexOptions} The configuration options for the chart. + */ + get options(): ApexOptions { + return { + states: { + //disable chart section darkening on click + active: { + filter: { + type: 'none', + }, + }, + }, + annotations: { + //draws a line across the y-axis depicting the average value + yaxis: [ + { + y: this.average, + borderColor: this.averageColour, + borderWidth: 2, + strokeDashArray: 8, + }, + ], + }, + fill: { + //opacity for the graph's elements + opacity: 1, + }, + chart: { + //disable toolbar that would provide export options + toolbar: { + show: false, + }, + //disable animations + animations: { + enabled: false, + }, + //set font options + fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, Roboto', + fontSize: '24px', + //Sets the colour for the text used ON the chart + foreColor: this.labelColour, + //set width and height of chart based on whether the device is in portrait mode + width: this.isPortrait ? this.transformOptions.portrait.width : this.transformOptions.landscape.width, + height: this.isPortrait ? this.transformOptions.portrait.height : this.transformOptions.landscape.height, + //type of chart (pie, bar, etc.) + type: this.chartType, + }, + dataLabels: { + //enables an external display of the value of the chart element on any chart that isn't a bar + enabled: true, + //styling for this label + style: { + fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, Roboto', + fontSize: this.getFontSize(), + fontWeight: 'bold', + colors: [this.labelColour], + }, + //gives the label a background color + background: { + enabled: true, + }, + //disables drop shadow on the label + dropShadow: { + enabled: false, + }, + //Applies an offset to the label for better positioning + offsetY: 5, + + formatter: (val, opts) => { + //apply no styling if it's a bar chart + if (this.chartType === 'bar') { + return val; + } + /* + If the label itself contains the full name of the value, trim that section off and only take the code, + then display either the percentage of the total or the value. + Example, where the label is split and the total is 10: + name: "M1 - Test Question" value: 2 + returns "M1: 20%" + */ + if (this.splitLabel) { + return this.calculatePercentages ? + opts.w.globals.labels[opts.seriesIndex].split(/[ ,]+/)[0] + ': ' + + Number(val).toFixed(1) + '%' : + opts.w.globals.labels[opts.seriesIndex].split(/[ ,]+/)[0] + ': ' + + this.passedData[opts.seriesIndex].percentage; + } + return this.calculatePercentages ? + opts.w.globals.labels[opts.seriesIndex] + ': ' + + Number(val).toFixed(1) + '%' : + opts.w.globals.labels[opts.seriesIndex] + ': ' + + this.passedData[opts.seriesIndex].percentage; + }, + }, + //Applies a border to the chart elements + stroke: { show: true, colors: [this.strokeColour] }, + xaxis: { + //disable the x-axis from darkening when the user clicks on it + crosshairs: { + show: false, + }, + //render the labels that appear on the bottom of the graphs + labels: { + //apply an offset + offsetY: this.horizontal ? 10 : 0, + //style the font + style: { + fontSize: '24px', + colors: this.labelColour, + }, + /**due to the chart having rotate functionality, we should account for both possibilities + * if horizontal is set to true (the values on the side and the amount at the bottom), return the number with + * no decimal point. + * + * otherwise, split the value sent in by spaces and return the first element, + * which will be the code we use to refer to the full value. (M1 - test question = M1) + */ + formatter: (val) => { + if (this.horizontal) { + return Number(val).toFixed(0); + } + return this.splitLabel ? val.toString().split(/[ ,]+/)[0] : val; + }, + }, + }, + yaxis: { + //disable the y-axis from darkening when the user clicks on it + crosshairs: { + show: false, + }, + //value used to determine how many "steps" are allowed to appear for the values + tickAmount: this.tickCount, + //render the labels that appear on the side of the graphs + labels: { + //apply an offset + offsetY: this.horizontal ? 7 : 6, + //style the font + style: { + fontSize: '24px', + colors: this.labelColour, + }, + /**due to the chart having rotate functionality, we should account for both possibilities + * if horizontal is set to false (the values on the side and the amount at the bottom), return the number with + * no decimal point. + * + * otherwise, split the value sent in by spaces and return the first element, + * which will be the code we use to refer to the full value. (M1 - test question = M1) + */ + formatter: (val) => { + if (this.horizontal) { + return this.splitLabel ? val.toString().split(/[ ,]+/)[0] : val.toString(); + } + return val.toFixed(0).toString(); + + }, + }, + }, + //define the colours that should be used by the chart's elements + colors: this.colors, + //data to be used by the chart + series: this.dataValues, + //labels for the graph to describe the elements + labels: this.labels, + //value determining whether the graph should render a box which explains the elements on the graph + legend: { + show: this.showLegend, + }, + //options for the "tooltip", a popup that appears whenever the graph is clicked + tooltip: { + //value that determines whether the tooltip appears where the mouse clicked + // or on a set point on the graph element + followCursor: false, + //whether the tooltip appears at all or not + enabled: false, + //formatting for the tooltip + custom: function({ series, seriesIndex, dataPointIndex, w }) { + return '
' + + '' + + '' + w.globals.labels[dataPointIndex] + ': ' + series[seriesIndex][dataPointIndex] + '' + + '' + + '
'; + }, + }, + //options for specific graph types + plotOptions: { + bar: { + //determines whether the graph should render horizontally + horizontal: this.horizontal, + dataLabels: { + orientation: 'horizontal', + position: 'bottom' + } + }, + pie: { + dataLabels: { + //offset the data labels + offset: -15, + }, + //set whether the graph elements should expand on click + expandOnClick: false, + }, + }, + } as ApexOptions; + } + + /** + * Morphs the passed data into a format that can be used by the graph, and calculates the average and tick count + * for the graph. + * + * This method processes the `passedData` to extract labels and values, calculates the average value, determines + * the tick count, + * and sets the `dataValues` property based on the chart type. + */ + filterData() { + this.labels = this.passedData.map((val) => val.item); + const values: number[] = this.passedData.map((val) => val.count); + this.average = ((values.reduce((a, b) => a + b, 0)) / values.length) || 0; + this.tickCount = this.getTickCount(values); + + this.dataValues = (this.getChartType() === '1Axis') + ? values as ApexNonAxisChartSeries + : [{ data: values }] as ApexAxisChartSeries; + } + + /** + * Get the tick count for the y-axis. + * + * This method returns the largest value in the provided array if that value is less than or equal to 5. + * The purpose of this is to prevent the chart's y-axis from attempting to draw numbers with decimal points, + * as they are not allowed. + * + * @param {number[]} numbers - An array of numbers to evaluate. + * @returns {number | null} The largest value if it is less than or equal to 5, otherwise null. + */ + getTickCount(numbers: number[]): number | null { + const max = Math.max(...numbers) + return max <= 5 ? max : null; + } + + /** + * Get the font size based on the zoom size. + * + * This method returns a string representing the font size in pixels based on the `zoomSize` property. + * It supports three zoom sizes: 'text-zoom-regular', 'text-zoom-large', and 'text-zoom-x-large'. + * + * @returns {string} The font size in pixels. + */ + getFontSize(): string { + switch (this.zoomSize) { + case 'text-zoom-regular': + return '16px'; + case 'text-zoom-large': + return '18px'; + case 'text-zoom-x-large': + return '20px'; + } + } +} diff --git a/src/components/common/common-components.module.ts b/src/components/common/common-components.module.ts index e8773b4f8..c0b4195d4 100644 --- a/src/components/common/common-components.module.ts +++ b/src/components/common/common-components.module.ts @@ -47,6 +47,9 @@ import { DrivingFaultsBadgeComponent } from './driving-faults-badge/driving-faul import { EndTestLinkComponent } from './end-test-link/end-test-link'; import { VRNCaptureModalModule } from './vrn-capture-modal/vrn-capture-modal.module'; import { DirectivesModule } from '@directives/directives.module'; +import { DataGridComponent } from '@components/common/data-grid/data-grid'; +import { ChartComponent } from '@components/common/chart/chart'; +import { NgApexchartsModule } from 'ng-apexcharts'; import { TestRecoveredBannerComponent } from '@components/common/test-recovered-banner/test-recovered-banner'; @NgModule({ @@ -85,6 +88,9 @@ import { TestRecoveredBannerComponent } from '@components/common/test-recovered- Adi3DebriefCardBox, SearchablePicklistComponentWrapper, SearchablePicklistModal, + DataGridComponent, + ChartComponent, + DataRowComponent, ], imports: [ AngularSignaturePadModule, @@ -98,6 +104,7 @@ import { TestRecoveredBannerComponent } from '@components/common/test-recovered- TerminateTestModalModule, NgOptimizedImage, DirectivesModule, + NgApexchartsModule, ], exports: [ DateTimeInputComponent, @@ -135,6 +142,9 @@ import { TestRecoveredBannerComponent } from '@components/common/test-recovered- Adi3DebriefCardBox, SearchablePicklistComponentWrapper, SearchablePicklistModal, + DataGridComponent, + ChartComponent, + DataRowComponent, ], }) export class ComponentsModule { diff --git a/src/components/common/data-grid/__tests__/data-grid.spec.ts b/src/components/common/data-grid/__tests__/data-grid.spec.ts new file mode 100644 index 000000000..f86ebb0ff --- /dev/null +++ b/src/components/common/data-grid/__tests__/data-grid.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { IonicModule } from '@ionic/angular'; +import { AppModule } from '@app/app.module'; +import { DataGridComponent } from '@components/common/data-grid/data-grid'; +import { SimpleChange } from '@angular/core'; + +describe('DataGridComponent', () => { + let fixture: ComponentFixture; + let component: DataGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + DataGridComponent, + ], + imports: [ + IonicModule, + AppModule, + ], + providers: [], + }); + + fixture = TestBed.createComponent(DataGridComponent); + component = fixture.componentInstance; + })); + + describe('ngOnChanges', () => { + it('should set finalColourArray to loopColours if the changes include colourScheme', () => { + spyOn(component, 'loopColours').and.returnValue(['1', '2']); + + component.finalColourArray = null; + component.ngOnChanges({ colourScheme: null }); + + expect(component.finalColourArray).toEqual(['1', '2']); + }); + it('should set finalColourArray to loopColours if dataChanged is true', () => { + + spyOn(component, 'loopColours').and.returnValue(['1', '2']); + + component.finalColourArray = null; + component.ngOnChanges({ data: { previousValue: '1', currentValue: '2' } as SimpleChange }); + + expect(component.finalColourArray).toEqual(['1', '2']); + }); + }); + describe('loopColours', () => { + it('should loop the colour array once if there ' + + 'is more rows in the data grid than items in the colour array', () => { + component.passedData = [[1], [2], [3], [4], [5], [6]]; + component.colourScheme = ['1', '2', '3', '4']; + + expect(component.loopColours()).toEqual(['1', '2', '3', '4', '1', '2', '3', '4']); + }); + }); + describe('trackByIndex', () => { + it('should return passed indes', () => { + expect(component.trackByIndex(1)).toEqual(1); + }); + }); + +}); diff --git a/src/components/common/data-grid/data-grid.html b/src/components/common/data-grid/data-grid.html new file mode 100644 index 000000000..4ddd0861c --- /dev/null +++ b/src/components/common/data-grid/data-grid.html @@ -0,0 +1,32 @@ + + + + + + + + + +
+ +
+ + + + + + + {{ passedItem[y] }} + + diff --git a/src/components/common/data-grid/data-grid.scss b/src/components/common/data-grid/data-grid.scss new file mode 100644 index 000000000..cb4c13966 --- /dev/null +++ b/src/components/common/data-grid/data-grid.scss @@ -0,0 +1,26 @@ +.text-align-center { + text-align: center; +} +.text-align-left { + text-align: left; +} +span, label { + color: inherit !important; +} +.colour-col { + display: flex; + align-items: start; +} +.data-row-padding { + padding-bottom: 10px; +} +.dot { + height: 25px; + width: 25px; + background-color: #ff0000; + border-radius: 50%; + display: inline-block; + &.placeholder { + background-color: rgba(0, 0, 0, 0); + } +} diff --git a/src/components/common/data-grid/data-grid.ts b/src/components/common/data-grid/data-grid.ts new file mode 100644 index 000000000..f57d14542 --- /dev/null +++ b/src/components/common/data-grid/data-grid.ts @@ -0,0 +1,88 @@ +import { Component, Input, OnInit, SimpleChanges } from '@angular/core'; +import { isEqual } from 'lodash-es'; +import { AccessibilityService } from '@providers/accessibility/accessibility.service'; + +export type PassedData = [string, number, string]; + +@Component({ + selector: 'data-grid', + templateUrl: 'data-grid.html', + styleUrls: ['data-grid.scss'], +}) +export class DataGridComponent implements OnInit { + + @Input() headers: string[] = null; + @Input() passedData: any[][] = null; + @Input() colourScheme: string[] = null; + @Input() displayColour: boolean = false; + @Input() showSeparator: boolean = true; + @Input() showHeaders: boolean = true; + @Input() padDataTable: boolean = false; + + public finalColourArray: string[] = null; + + constructor(public accessibilityService: AccessibilityService) { + } + + /** + * Lifecycle hook that is called after Angular has initialized all data-bound properties of a directive. + * + * It checks if `colourScheme` is set and `finalColourArray` is not initialized, + * then it calls the `loopColours` method to set the final colour array. + */ + ngOnInit() { + if (this.colourScheme && !this.finalColourArray) { + this.finalColourArray = this.loopColours(); + } + } + + /** + * Lifecycle hook that is called when any data-bound property of a directive changes. + * + * This method checks if any of the properties in `changes` have different current and previous values. + * If the `colourScheme` property is included in the changes or any data-bound property has changed, + * it updates the `finalColourArray` by calling the `loopColours` method. + * If any data-bound property has changed and both `rowCropCount` and `passedData` are set, + * it calls the `cropData` method to update the cropped data. + * + * @param {SimpleChanges} changes - An object of key/value pairs for the set of changed properties. + */ + ngOnChanges(changes: SimpleChanges) { + const dataChanged = Object.keys(changes) + .some((key) => !isEqual(changes[key]?.currentValue, changes[key]?.previousValue)); + + if (Object.keys(changes).includes('colourScheme') || dataChanged) { + this.finalColourArray = this.loopColours(); + } + } + + /** + * Generates an array of colours by repeating the `colourScheme` + * array enough times to cover the length of `passedData`. + * + * This method calculates how many times the `colourScheme` array needs to be repeated to match + * or exceed the length of `passedData`. + * It then creates a new array by repeating the `colourScheme` array the required number of times and + * flattens the result into a single array. + * + * @returns {string[]} An array of colours repeated to match the length of `passedData`. + */ + loopColours(): string[] { + const loopCount = Math.ceil((this.passedData.length) / this.colourScheme.length); + + return Array(loopCount) + .fill(() => null) + .map(() => this.colourScheme) + .flat(); + } + + /** + * TrackBy function for ngFor to improve performance by tracking items by their index. + * + * This method returns the index of the item, which Angular uses to track the identity of items in the list. + * + * @param {number} index - The index of the item in the list. + * @returns {number} The index of the item. + */ + trackByIndex = (index: number): number => index; +} diff --git a/src/components/common/data-row/data-row.html b/src/components/common/data-row/data-row.html index 4e7e0efaa..20caa7895 100644 --- a/src/components/common/data-row/data-row.html +++ b/src/components/common/data-row/data-row.html @@ -1,6 +1,9 @@ - - - + + + - + - - {{ value }} + + {{ value }} diff --git a/src/components/common/data-row/data-row.scss b/src/components/common/data-row/data-row.scss new file mode 100644 index 000000000..d54f519eb --- /dev/null +++ b/src/components/common/data-row/data-row.scss @@ -0,0 +1,3 @@ +.no-padding { + padding: 0 !important; +} diff --git a/src/components/common/data-row/data-row.ts b/src/components/common/data-row/data-row.ts index 380307013..79fa3b204 100644 --- a/src/components/common/data-row/data-row.ts +++ b/src/components/common/data-row/data-row.ts @@ -3,6 +3,7 @@ import { Component, Input } from '@angular/core'; @Component({ selector: 'data-row', templateUrl: 'data-row.html', + styleUrls: ['data-row.scss'] }) export class DataRowComponent { @@ -10,7 +11,13 @@ export class DataRowComponent { label: string; @Input() - dataStyling?: string; + dataStyling?: {[p: string]: any}= null; + + @Input() + rowStyling?: {[p: string]: any}= null; + + @Input() + labelStyling?: {[p: string]: any} = null; @Input() imgSrc: string; @@ -26,4 +33,11 @@ export class DataRowComponent { @Input() idPrefix?: string; + + @Input() + centreData: boolean = false; + + @Input() + customLabelWidth: number = null; + } diff --git a/src/environments/environment.local.ts b/src/environments/environment.local.ts index 997def4c1..c4eb95abd 100644 --- a/src/environments/environment.local.ts +++ b/src/environments/environment.local.ts @@ -234,8 +234,8 @@ export const environment: LocalEnvironmentFile = { tests: { testSubmissionUrl: 'https://dev.mes.dev-dvsacloud.uk/v1/test-results', multipleTestResultsUrl: 'https://dev.mes.dev-dvsacloud.uk/v1/test-results/multiple-results', + examinerRecordsUrl: 'https://dev.mes.dev-dvsacloud.uk/v1/test-results/search-examiner-records', autoSendInterval: 900000, - examinerRecordsUrl: '' }, user: { findUserUrl: 'https://dev.mes.dev-dvsacloud.uk/v1/users/{staffNumber}', diff --git a/src/global.scss b/src/global.scss index 8c319caa4..045480863 100644 --- a/src/global.scss +++ b/src/global.scss @@ -71,6 +71,10 @@ input, textarea { padding-top: 0; } +.mes-padding-right { + padding-right: 16px; +} + .select-cancel-button-display { .alert-button:nth-child(2) { display: none; diff --git a/src/store/app-config/app-config.reducer.ts b/src/store/app-config/app-config.reducer.ts index 9e9bcc90c..d8a932deb 100644 --- a/src/store/app-config/app-config.reducer.ts +++ b/src/store/app-config/app-config.reducer.ts @@ -48,6 +48,7 @@ export const initialState: AppConfig = { testSubmissionUrl: null, multipleTestResultsUrl: null, autoSendInterval: null, + examinerRecordsUrl: null, }, user: { findUserUrl: null, diff --git a/src/store/app-info/__tests__/app-info.effects.spec.ts b/src/store/app-info/__tests__/app-info.effects.spec.ts index dafc44a92..30c877395 100644 --- a/src/store/app-info/__tests__/app-info.effects.spec.ts +++ b/src/store/app-info/__tests__/app-info.effects.spec.ts @@ -28,6 +28,10 @@ import { DateTimeProviderMock } from '@providers/date-time/__mocks__/date-time.m import { LOGIN_PAGE } from '@pages/page-names.constants'; import { DateTime } from '@shared/helpers/date-time'; import { DetectDeviceTheme } from '@pages/dashboard/dashboard.actions'; +import { DataStoreProvider } from '@providers/data-store/data-store'; +import { LogHelper } from '@providers/logs/logs-helper'; +import { NetworkStateProvider } from '@providers/network-state/network-state'; +import { Network } from '@awesome-cordova-plugins/network/ngx'; describe('AppInfoEffects', () => { let effects: AppInfoEffects; @@ -46,6 +50,10 @@ describe('AppInfoEffects', () => { ], providers: [ AppInfoEffects, + DataStoreProvider, + LogHelper, + NetworkStateProvider, + Network, provideMockActions(() => actions$), { provide: Router, diff --git a/src/store/app-info/app-info.actions.ts b/src/store/app-info/app-info.actions.ts index 87949b2b3..235b2f691 100644 --- a/src/store/app-info/app-info.actions.ts +++ b/src/store/app-info/app-info.actions.ts @@ -4,7 +4,6 @@ import { UpdateAvailable } from '@pages/dashboard/components/update-available-mo export const LoadAppVersion = createAction( '[AppComponent] Load App Version', ); - export const LoadAppVersionSuccess = createAction( '[AppInfoEffects] Load App Version Success', props<{ versionNumber: string }>(), diff --git a/src/store/examiner-records/__tests__/examiner-records.effects.spec.ts b/src/store/examiner-records/__tests__/examiner-records.effects.spec.ts new file mode 100644 index 000000000..f6191d831 --- /dev/null +++ b/src/store/examiner-records/__tests__/examiner-records.effects.spec.ts @@ -0,0 +1,126 @@ +import { TestBed } from '@angular/core/testing'; +import { of, ReplaySubject } from 'rxjs'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { DataStoreProvider } from '@providers/data-store/data-store'; +import { DataStoreProviderMock } from '@providers/data-store/__mocks__/data-store.mock'; +import { ExaminerRecordsEffects } from '@store/examiner-records/examiner-records.effects'; +import { StoreModel } from '@shared/models/store.model'; +import { + CacheExaminerRecords, + ColourFilterChanged, + UpdateLastCached, +} from '@pages/examiner-records/examiner-records.actions'; +import { ColourEnum } from '@providers/examiner-records/examiner-records'; +import { + LoadExaminerRecordsFailure, + LoadExaminerRecordsPreferences, +} from '@store/examiner-records/examiner-records.actions'; + +describe('ExaminerRecordsStoreEffects', () => { + let actions$: ReplaySubject; + let effects: ExaminerRecordsEffects; + let store$: Store; + let dataStore: DataStoreProvider; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + ], + providers: [ + ExaminerRecordsEffects, + provideMockActions(() => actions$), + { provide: DataStoreProvider, useClass: DataStoreProviderMock }, + Store, + ], + }); + + // ARRANGE + actions$ = new ReplaySubject(1); + effects = TestBed.inject(ExaminerRecordsEffects); + store$ = TestBed.inject(Store); + dataStore = TestBed.inject(DataStoreProvider); + }); + + describe('persistExaminerRecordsPreferences$', () => { + it('should persist examiner records preferences on ColourFilterChanged action', (done) => { + const action = ColourFilterChanged(ColourEnum.GREYSCALE); + const examinerRecordsPreferences = { colourScheme: ColourEnum.GREYSCALE }; + spyOn(store$, 'select').and.returnValue(of(examinerRecordsPreferences)); + actions$.next(action) + + effects.persistExaminerRecordsPreferences$.subscribe(() => { + expect(dataStore.setItem).toHaveBeenCalledWith( + ExaminerRecordsEffects['EXAMINER_RECORDS_KEY'], + JSON.stringify(examinerRecordsPreferences) + ); + done(); + }); + }); + + it('should persist examiner records preferences on CacheExaminerRecords action', (done) => { + const action = CacheExaminerRecords([]); + const examinerRecordsPreferences = { cachedRecords: [] }; + spyOn(store$, 'select').and.returnValue(of(examinerRecordsPreferences)); + actions$.next(action) + + effects.persistExaminerRecordsPreferences$.subscribe(() => { + expect(dataStore.setItem).toHaveBeenCalledWith( + ExaminerRecordsEffects['EXAMINER_RECORDS_KEY'], + JSON.stringify(examinerRecordsPreferences) + ); + done(); + }); + }); + + it('should persist examiner records preferences on UpdateLastCached action', (done) => { + const action = UpdateLastCached('2023-10-01T00:00:00Z'); + const examinerRecordsPreferences = { lastUpdatedTime: '2023-10-01T00:00:00Z' }; + spyOn(store$, 'select').and.returnValue(of(examinerRecordsPreferences)); + actions$.next(action) + + effects.persistExaminerRecordsPreferences$.subscribe(() => { + expect(dataStore.setItem).toHaveBeenCalledWith( + ExaminerRecordsEffects['EXAMINER_RECORDS_KEY'], + JSON.stringify(examinerRecordsPreferences) + ); + done(); + }); + }); + }); + + describe('loadExaminerRecordsPreferences$', () => { + it('should dispatch LoadExaminerRecordsFailure when no preferences are found', (done) => { + const action = LoadExaminerRecordsPreferences(); + spyOn(dataStore, 'getItem').and.returnValue(Promise.resolve(null)); + actions$.next(action); + + effects.loadExaminerRecordsPreferences$.subscribe((result) => { + expect(result).toEqual(LoadExaminerRecordsFailure('Examiner stats preferences not found')); + done(); + }); + }); + + it('should dispatch appropriate actions when preferences are found', () => { + const action = LoadExaminerRecordsPreferences(); + const examinerRecords = JSON.stringify({ + colourScheme: ColourEnum.GREYSCALE, + cachedRecords: [], + lastUpdatedTime: '2023-10-01T00:00:00Z', + }); + // actions$ = of(action); + spyOn(dataStore, 'getItem').and.returnValue(Promise.resolve(examinerRecords)); + actions$.next(action); + + effects.loadExaminerRecordsPreferences$.subscribe((result) => { + if (result.type === ColourFilterChanged.type) { + expect(result).toEqual(ColourFilterChanged(ColourEnum.GREYSCALE)); + } + if (result.type === UpdateLastCached.type) { + expect(result).toEqual(UpdateLastCached('2023-10-01T00:00:00Z')); + } + }); + }); + }); +}); diff --git a/src/store/examiner-records/__tests__/examiner-records.reducer.spec.ts b/src/store/examiner-records/__tests__/examiner-records.reducer.spec.ts new file mode 100644 index 000000000..5a6856b32 --- /dev/null +++ b/src/store/examiner-records/__tests__/examiner-records.reducer.spec.ts @@ -0,0 +1,53 @@ +import { + CacheExaminerRecords, + ColourFilterChanged, + LoadingExaminerRecords, + UpdateLastCached, +} from '@pages/examiner-records/examiner-records.actions'; +import { ColourEnum } from '@providers/examiner-records/examiner-records'; +import { examinerRecordsReducer } from '@store/examiner-records/examiner-records.reducer'; +import { ExaminerRecordStateModel } from '@store/examiner-records/examiner-records.model'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; + +describe('ExaminerRecordsReducer', () => { + let initialState: ExaminerRecordStateModel = { + cachedRecords: null, + colourScheme: ColourEnum.DEFAULT, + isLoading: false, + lastUpdatedTime: null, + }; + beforeEach(() => { + initialState = { + cachedRecords: null, + colourScheme: ColourEnum.DEFAULT, + isLoading: false, + lastUpdatedTime: null, + } + }) + + describe('examinerRecordsReducer', () => { + it('should update lastUpdatedTime on UpdateLastCached action', () => { + const action = UpdateLastCached('2023-10-01T00:00:00Z'); + const state = examinerRecordsReducer(initialState, action); + expect(state.lastUpdatedTime).toEqual('2023-10-01T00:00:00Z'); + }); + + it('should cache examiner records and set isLoading to false on CacheExaminerRecords action', () => { + const action = CacheExaminerRecords([{startDate: '2023-10-01'} as ExaminerRecordModel]); + const state = examinerRecordsReducer(initialState, action); + expect(state.cachedRecords).toEqual([{startDate: '2023-10-01'} as ExaminerRecordModel]); + expect(state.isLoading).toBe(false); + }); + + it('should set isLoading to true on LoadingExaminerRecords action', () => { + const action = LoadingExaminerRecords(); + const state = examinerRecordsReducer(initialState, action); + expect(state.isLoading).toBe(true); + }); + + it('should update colourScheme on ColourFilterChanged action', () => { + const action = ColourFilterChanged(ColourEnum.GREYSCALE); + const state = examinerRecordsReducer(initialState, action); + expect(state.colourScheme).toEqual(ColourEnum.GREYSCALE); + }); + });}); diff --git a/src/store/examiner-records/examiner-records.actions.ts b/src/store/examiner-records/examiner-records.actions.ts new file mode 100644 index 000000000..299d2b4df --- /dev/null +++ b/src/store/examiner-records/examiner-records.actions.ts @@ -0,0 +1,11 @@ +import { createAction } from '@ngrx/store'; + +export const LoadExaminerRecordsPreferences = createAction( + '[AppComponent] Load examiner records preferences', +); + +export const LoadExaminerRecordsFailure = createAction( + '[AppComponent] Load examiner records failed', + (reason: string) => ({ reason }), +); + diff --git a/src/store/examiner-records/examiner-records.effects.ts b/src/store/examiner-records/examiner-records.effects.ts new file mode 100644 index 000000000..2b2ed4ea4 --- /dev/null +++ b/src/store/examiner-records/examiner-records.effects.ts @@ -0,0 +1,71 @@ +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { Injectable } from '@angular/core'; +import { StoreModel } from '@shared/models/store.model'; +import { + CacheExaminerRecords, + ColourFilterChanged, + NoExaminerRecordSetting, + UpdateLastCached, +} from '@pages/examiner-records/examiner-records.actions'; +import { concatMap, switchMap, withLatestFrom } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { DataStoreProvider, LocalStorageKey, StorageKey } from '@providers/data-store/data-store'; +import { + LoadExaminerRecordsFailure, + LoadExaminerRecordsPreferences, +} from '@store/examiner-records/examiner-records.actions'; +import { selectExaminerRecords } from '@store/examiner-records/examiner-records.selectors'; +import { ExaminerRecordStateModel } from '@store/examiner-records/examiner-records.model'; + +@Injectable() +export class ExaminerRecordsEffects { + private static readonly EXAMINER_RECORDS_KEY: StorageKey = LocalStorageKey.EXAMINER_STATS_KEY; + + constructor( + private actions$: Actions, + private store$: Store, + private dataStore: DataStoreProvider + ) {} + + persistExaminerRecordsPreferences$ = createEffect(() => this.actions$.pipe( + ofType( + ColourFilterChanged, + CacheExaminerRecords, + UpdateLastCached, + ), + concatMap((action) => of(action) + .pipe( + withLatestFrom( + this.store$.select(selectExaminerRecords), + ), + )), + concatMap(async ( + [, examinerRecordsPreferences], + ) => this.dataStore.setItem( + ExaminerRecordsEffects.EXAMINER_RECORDS_KEY, JSON.stringify(examinerRecordsPreferences)) + ), + ), { dispatch: false }); + + loadExaminerRecordsPreferences$ = createEffect(() => this.actions$.pipe( + ofType(LoadExaminerRecordsPreferences), + concatMap(() => this.dataStore.getItem(ExaminerRecordsEffects.EXAMINER_RECORDS_KEY)), + switchMap((examinerRecords) => { + if (!examinerRecords) { + return [LoadExaminerRecordsFailure('Examiner stats preferences not found')]; + } + const { + colourScheme, + cachedRecords, + lastUpdatedTime, + } = JSON.parse(examinerRecords) as ExaminerRecordStateModel; + + return [ + (!!colourScheme) ? ColourFilterChanged(colourScheme) : NoExaminerRecordSetting('colour scheme'), + (!!cachedRecords && cachedRecords.length) ? + CacheExaminerRecords(cachedRecords) : NoExaminerRecordSetting('cached records'), + (!!lastUpdatedTime) ? (UpdateLastCached(lastUpdatedTime)) : NoExaminerRecordSetting('last updated time'), + ]; + }), + )); +} diff --git a/src/store/examiner-records/examiner-records.model.ts b/src/store/examiner-records/examiner-records.model.ts new file mode 100644 index 000000000..3fcb7646f --- /dev/null +++ b/src/store/examiner-records/examiner-records.model.ts @@ -0,0 +1,9 @@ +import { ColourEnum } from '@providers/examiner-records/examiner-records'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; + +export type ExaminerRecordStateModel = { + cachedRecords: ExaminerRecordModel[]; + colourScheme: ColourEnum; + isLoading?: boolean; + lastUpdatedTime: string; +}; diff --git a/src/store/examiner-records/examiner-records.module.ts b/src/store/examiner-records/examiner-records.module.ts new file mode 100644 index 000000000..bf31ea8c2 --- /dev/null +++ b/src/store/examiner-records/examiner-records.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; +import { ExaminerRecordsEffects } from './examiner-records.effects'; +import * as examinerRecordsReducer from './examiner-records.reducer'; + +@NgModule({ + imports: [ + StoreModule.forFeature( + examinerRecordsReducer.examinerRecordsFeatureKey, examinerRecordsReducer.examinerRecordsReducer, + ), + EffectsModule.forFeature([ExaminerRecordsEffects]), + ], +}) +export class ExaminerRecordsStoreModule { } diff --git a/src/store/examiner-records/examiner-records.reducer.ts b/src/store/examiner-records/examiner-records.reducer.ts new file mode 100644 index 000000000..f03b1f470 --- /dev/null +++ b/src/store/examiner-records/examiner-records.reducer.ts @@ -0,0 +1,42 @@ +import { createFeatureSelector, createReducer, on } from '@ngrx/store'; + +import { ExaminerRecordStateModel } from '@store/examiner-records/examiner-records.model'; +import { ColourEnum } from '@providers/examiner-records/examiner-records'; +import { + CacheExaminerRecords, + ColourFilterChanged, + LoadingExaminerRecords, + UpdateLastCached, +} from '@pages/examiner-records/examiner-records.actions'; + +export const examinerRecordsFeatureKey = 'examinerRecords'; + +export const initialState: ExaminerRecordStateModel = { + cachedRecords: null, + colourScheme: ColourEnum.DEFAULT, + isLoading: false, + lastUpdatedTime: null +}; + +export const examinerRecordsReducer = createReducer( + initialState, + on(UpdateLastCached, (state: ExaminerRecordStateModel, { time }) => ({ + ...state, + lastUpdatedTime: time, + })), + on(CacheExaminerRecords, (state: ExaminerRecordStateModel, { tests }) => ({ + ...state, + cachedRecords: tests, + isLoading: false, + })), + on(LoadingExaminerRecords, (state: ExaminerRecordStateModel, { }) => ({ + ...state, + isLoading: true, + })), + on(ColourFilterChanged, (state: ExaminerRecordStateModel, { colour }) => ({ + ...state, + colourScheme: colour, + })), +); + +export const getExaminerRecordsState = createFeatureSelector('examinerRecords'); diff --git a/src/store/examiner-records/examiner-records.selectors.ts b/src/store/examiner-records/examiner-records.selectors.ts new file mode 100644 index 000000000..f922c179f --- /dev/null +++ b/src/store/examiner-records/examiner-records.selectors.ts @@ -0,0 +1,25 @@ +import { createSelector } from '@ngrx/store'; +import { StoreModel } from '@shared/models/store.model'; +import { ColourEnum } from '@providers/examiner-records/examiner-records'; +import { ExaminerRecordStateModel } from '@store/examiner-records/examiner-records.model'; +import { ExaminerRecordModel } from '@dvsa/mes-microservice-common/domain/examiner-records'; + +export const selectExaminerRecords = (state: StoreModel): ExaminerRecordStateModel => state.examinerRecords; + +export const selectCachedExaminerRecords = createSelector( + selectExaminerRecords, + (examinerRecords): ExaminerRecordModel[] => examinerRecords.cachedRecords, +); +export const selectLastCachedDate = createSelector( + selectExaminerRecords, + (examinerRecords): string => examinerRecords.lastUpdatedTime, +); +export const getIsLoadingRecords = createSelector( + selectExaminerRecords, + (examinerRecords): boolean => examinerRecords.isLoading, +); +export const selectColourScheme = createSelector( + selectExaminerRecords, + (examinerRecords): ColourEnum => examinerRecords.colourScheme, +); + diff --git a/src/store/tests/tests.selector.ts b/src/store/tests/tests.selector.ts index d4be8e9a1..d54220024 100644 --- a/src/store/tests/tests.selector.ts +++ b/src/store/tests/tests.selector.ts @@ -26,6 +26,10 @@ export const getCurrentTest = (tests: TestsModel): TestResultSchemasUnion => { return tests.startedTests[currentTestSlotId]; }; +export const getStartedTests = (tests: TestsModel): StartedTests => { + return tests.startedTests; +}; + export const isPassed = (test: TestResultSchemasUnion): boolean => { return test.activityCode === ActivityCodes.PASS; }; diff --git a/src/theme/sass-partials/_box.scss b/src/theme/sass-partials/_box.scss index 63d27ac9c..90918a214 100644 --- a/src/theme/sass-partials/_box.scss +++ b/src/theme/sass-partials/_box.scss @@ -14,6 +14,13 @@ padding: 32px; } +.mes-full-width-card-half-padding { + &.box-shadow { + box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5); + } + padding: 16px; +} + .mes-full-width-card-small-top { &.box-shadow { box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5); @@ -54,7 +61,7 @@ signature-pad { &.ng-invalid { canvas { border: 4px solid var(--mes-validation-error) !important; - box-shadow: inset 0 0px 0px 0 #a5a2a2; + box-shadow: inset 0 0 0 0 #a5a2a2; } } }