From f0d5b5729dd6fcf1b193163828e3fde9bcaa9b9b Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Wed, 12 Jun 2019 16:45:43 -0700 Subject: [PATCH] ACRFD-14: project list page + tests, update search component + tests. --- .gitignore | 7 +- angular.json | 6 +- package.json | 10 +- src/app/app-routing.module.ts | 5 + src/app/app.component.scss | 7 +- src/app/app.module.ts | 4 +- .../application-add-edit.component.html | 36 +- .../application-add-edit.component.ts | 162 +-- .../application-aside.component.html | 4 +- .../application-detail.component.html | 60 +- .../application-detail.component.spec.ts | 6 +- .../application-detail.component.ts | 64 +- .../application-list.component.html | 125 -- .../application-list.component.scss | 198 --- .../application-list.component.spec.ts | 69 -- .../application-list.component.ts | 115 -- .../application-resolver.service.ts | 8 +- .../applications-routing.module.ts | 5 - src/app/applications/applications.module.ts | 7 +- .../review-comments.component.html | 6 +- .../review-comments.component.scss | 22 + .../review-comments.component.spec.ts | 8 +- .../review-comments.component.ts | 14 +- src/app/header/header.component.html | 7 +- src/app/list/list.component.html | 175 +++ src/app/list/list.component.scss | 154 +++ src/app/list/list.component.spec.ts | 1092 +++++++++++++++++ src/app/list/list.component.ts | 735 +++++++++++ src/app/models/application.ts | 118 +- src/app/models/commentperiod.ts | 27 +- src/app/models/decision.ts | 37 +- src/app/models/document.ts | 23 +- src/app/models/search.ts | 89 -- src/app/pipes/published.pipe.ts | 2 +- src/app/search/search.component.html | 43 +- src/app/search/search.component.spec.ts | 397 +++--- src/app/search/search.component.ts | 90 +- src/app/services/api.spec.ts | 112 ++ src/app/services/api.ts | 422 +++++-- src/app/services/application.service.spec.ts | 500 ++------ src/app/services/application.service.ts | 212 +++- src/app/services/comment.service.ts | 10 +- .../services/commentperiod.service.spec.ts | 57 +- src/app/services/commentperiod.service.ts | 60 +- src/app/services/decision.service.ts | 16 +- src/app/services/document.service.ts | 2 +- src/app/services/excel.service.spec.ts | 15 - src/app/services/excel.service.ts | 23 - src/app/services/export.service.spec.ts | 15 + src/app/services/export.service.ts | 45 + src/app/services/keycloak.service.ts | 7 +- src/app/services/search.service.spec.ts | 178 +-- src/app/services/search.service.ts | 74 +- src/app/shared.module.ts | 12 +- src/app/spec/helpers.ts | 41 + src/app/utils/constants/application.spec.ts | 6 +- src/app/utils/constants/application.ts | 72 +- src/app/utils/constants/constantUtils.spec.ts | 2 +- src/app/utils/ng-var.directive.spec.ts | 28 - src/app/utils/ng-var.directive.ts | 26 - src/assets/styles/components/pagination.scss | 19 - src/assets/styles/components/table-cards.scss | 132 ++ src/assets/styles/themes/default.scss | 24 + src/styles.scss | 2 +- src/tsconfig.spec.json | 4 +- tsconfig.json | 5 +- tslint.json | 10 +- 67 files changed, 4072 insertions(+), 1996 deletions(-) delete mode 100644 src/app/applications/application-list/application-list.component.html delete mode 100644 src/app/applications/application-list/application-list.component.scss delete mode 100644 src/app/applications/application-list/application-list.component.spec.ts delete mode 100644 src/app/applications/application-list/application-list.component.ts create mode 100644 src/app/list/list.component.html create mode 100644 src/app/list/list.component.scss create mode 100644 src/app/list/list.component.spec.ts create mode 100644 src/app/list/list.component.ts create mode 100644 src/app/services/api.spec.ts delete mode 100644 src/app/services/excel.service.spec.ts delete mode 100644 src/app/services/excel.service.ts create mode 100644 src/app/services/export.service.spec.ts create mode 100644 src/app/services/export.service.ts delete mode 100644 src/app/utils/ng-var.directive.spec.ts delete mode 100644 src/app/utils/ng-var.directive.ts delete mode 100644 src/assets/styles/components/pagination.scss create mode 100644 src/assets/styles/components/table-cards.scss diff --git a/.gitignore b/.gitignore index a0b9d6da..0feeeb75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. +# See https://git-scm.com/docs/gitignore for more about ignoring files. # compiled output /dist @@ -6,7 +6,7 @@ /out-tsc # dependencies -/node_modules +node_modules # IDEs and editors /.idea @@ -19,10 +19,7 @@ # IDE - VSCode .vscode/* -!.vscode/settings.json -!.vscode/tasks.json !.vscode/launch.json -!.vscode/extensions.json # misc /.sass-cache diff --git a/angular.json b/angular.json index 26abd35c..643ac4c5 100644 --- a/angular.json +++ b/angular.json @@ -7,7 +7,7 @@ "root": "", "sourceRoot": "src", "projectType": "application", - "targets": { + "architect": { "build": { "builder": "@angular-devkit/build-angular:browser", "options": { @@ -107,7 +107,7 @@ "root": "e2e", "sourceRoot": "e2e", "projectType": "application", - "targets": { + "architect": { "e2e": { "builder": "@angular-devkit/build-angular:protractor", "options": { @@ -137,4 +137,4 @@ "prefix": "app" } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 5552b9d0..df848c8d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "start": "ng serve -o --base-href /admin/ --deploy-url /admin/", "build": "npm run lint && ng build --prod --deploy-url /admin/ --base-href /admin/", "postinstall": "ng build --prod --deploy-url /admin/ --base-href /admin/", - "test": "echo Test suite should be run with `npm run tests` or npm `run tests-ci`", + "test": "echo Test suite should be run with `npm run tests` or `npm run tests-ci`", "tests": "ng test", "tests-ci": "ng test --watch=false", "e2e": "ng e2e", @@ -38,7 +38,9 @@ "@types/leaflet": "1.4.4", "bootstrap": "4.3.1", "core-js": "2.6.8", + "file-saver": "2.0.2", "hammerjs": "2.0.8", + "json2csv": "4.5.1", "keycloak-angular": "3.0.3", "leaflet": "1.5.1", "linkify-it": "2.1.0", @@ -59,8 +61,10 @@ "@angular/cli": "6.2.9", "@angular/compiler": "6.1.10", "@angular/compiler-cli": "6.1.10", - "@types/arcgis-js-api": "4.12.0", + "@types/arcgis-js-api": "4.11.0", + "@types/file-saver": "2.0.1", "@types/jasmine": "3.3.13", + "@types/json2csv": "4.5.0", "@types/linkify-it": "2.1.0", "@types/lodash": "4.14.136", "@types/node": "10.14.7", @@ -84,5 +88,5 @@ "tslint": "5.18.0", "tslint-config-prettier": "1.18.0", "typescript": "2.9.2" - } + } } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 979462e2..9830b268 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -8,6 +8,7 @@ import { AdministrationComponent } from './administration/administration.compone import { UsersComponent } from './administration/users/users.component'; import { CanDeactivateGuard } from 'app/services/can-deactivate-guard.service'; +import { ListComponent } from './list/list.component'; const routes: Routes = [ { @@ -26,6 +27,10 @@ const routes: Routes = [ path: 'not-authorized', component: NotAuthorizedComponent }, + { + path: 'list', + component: ListComponent + }, { path: 'search', component: SearchComponent diff --git a/src/app/app.component.scss b/src/app/app.component.scss index cbfca029..b04acfa5 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -24,6 +24,11 @@ app-header { @include flex-direction(column); position: relative; - margin-top: 68px; overflow-y: auto; } + +@media (min-width: 768px) { + .app-body { + margin-top: 68px; // pad the top of the page so content isn't covered by the floating header bar. + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5ed5f123..bbf2f1ae 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -16,6 +16,7 @@ import { AppRoutingModule } from 'app/app-routing.module'; import { AppComponent } from 'app/app.component'; import { HomeComponent } from 'app/home/home.component'; import { SearchComponent } from 'app/search/search.component'; +import { ListComponent } from 'app/list/list.component'; import { LoginComponent } from 'app/login/login.component'; import { ConfirmComponent } from 'app/confirm/confirm.component'; import { HeaderComponent } from 'app/header/header.component'; @@ -57,7 +58,8 @@ export function kcFactory(keycloakService: KeycloakService) { AdministrationComponent, UsersComponent, AddEditUserComponent, - NotAuthorizedComponent + NotAuthorizedComponent, + ListComponent ], imports: [ BrowserModule, diff --git a/src/app/applications/application-add-edit/application-add-edit.component.html b/src/app/applications/application-add-edit/application-add-edit.component.html index 2c8e071b..a1a04c02 100644 --- a/src/app/applications/application-add-edit/application-add-edit.component.html +++ b/src/app/applications/application-add-edit/application-add-edit.component.html @@ -2,7 +2,7 @@
-

Crown land File: {{application.clFile}}  ›  +

Crown land File: {{application.meta.clFile}}  ›  {{!application._id ? 'Create' : 'Edit'}} Application

Disposition Transaction: {{application.tantalisID}} @@ -15,7 +15,7 @@

Crown land File: {{application.clFile}}  &rsaq @@ -23,9 +23,9 @@

Crown land File: {{application.clFile}}  &rsaq

@@ -33,9 +33,9 @@

Crown land File: {{application.clFile}}  &rsaq

@@ -98,7 +98,7 @@

Crown land File: {{application.clFile}}  &rsaq
- {{application.applicants || 'No Applicant on this File'}} + {{application.meta.applicants || 'No Applicant on this File'}}
@@ -111,8 +111,8 @@

Crown land File: {{application.clFile}}  &rsaq

-
    -
  • +
      +
    • insert_drive_file @@ -120,24 +120,24 @@

      Crown land File: {{application.clFile}}  &rsaq {{doc.documentFileName}} -

    + (filesChange)="addDocuments($event, application.meta.documents); applicationFiles = []">
Application Decision Documents -
+
-
    -
  • +
      +
    • insert_drive_file @@ -147,24 +147,24 @@

      Crown land File: {{application.clFile}}  &rsaq -

    + (filesChange)="addDocuments($event, application.meta.decision.meta.documents); decisionFiles = []">
@@ -26,7 +26,7 @@

Crown land File: {{application.clFile}}

-
  • +
  • No application documents
  • @@ -171,16 +171,16 @@

    Documents

    -
    +

    Legal Description

    -
    -

    Geographic Shape Information ({{application.features.length}})

    +
    +

    Geographic Shape Information ({{application.meta.features.length}})

      -
    • +
    • Shape ID: {{shape.properties.INTRID_SID}}
      • @@ -220,32 +220,32 @@

        Application Status

        Comment Period Details

        -
        +
        Not Open For Commenting
        -
        +
        • Status: - {{application.cpStatus}} + {{application.meta.cpStatusStringLong}}
        • Start Date: - {{application.currentPeriod.startDate | date:'longDate'}} + {{application.meta.currentPeriod.startDate | date:'longDate'}}
        • End Date: - {{application.currentPeriod.endDate | date:'longDate'}} + {{application.meta.currentPeriod.endDate | date:'longDate'}}
        • -
        • +
        • Days Remaining: - {{application.currentPeriod['daysRemaining']}} + {{application.meta.currentPeriod.meta.daysRemaining}}
        @@ -253,12 +253,12 @@

        Comment Period Details

        Application Decision Documents

        -
        +

        No decision documents have been added to this application.

        -
        +
          -
        • +
        • insert_drive_file @@ -275,7 +275,7 @@

          Application Decision Documents

        • -
        • +
        • No decision documents
        @@ -283,7 +283,7 @@

        Application Decision Documents

        diff --git a/src/app/applications/application-detail/application-detail.component.spec.ts b/src/app/applications/application-detail/application-detail.component.spec.ts index 57b0e699..3f27fa45 100644 --- a/src/app/applications/application-detail/application-detail.component.spec.ts +++ b/src/app/applications/application-detail/application-detail.component.spec.ts @@ -16,6 +16,8 @@ import { FeatureService } from 'app/services/feature.service'; import { Application } from 'app/models/application'; import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRouteStub } from 'app/spec/helpers'; +import { InlineSVGModule } from 'ng-inline-svg'; +import { LinkifyPipe } from 'app/pipes/linkify.pipe'; describe('ApplicationDetailComponent', () => { let component: ApplicationDetailComponent; @@ -40,8 +42,8 @@ describe('ApplicationDetailComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ApplicationDetailComponent, NewlinesPipe, ApplicationAsideComponent], - imports: [RouterTestingModule, NgbModule], + declarations: [ApplicationDetailComponent, NewlinesPipe, LinkifyPipe, ApplicationAsideComponent], + imports: [RouterTestingModule, NgbModule, InlineSVGModule], providers: [ { provide: MatSnackBar }, { provide: ApiService }, diff --git a/src/app/applications/application-detail/application-detail.component.ts b/src/app/applications/application-detail/application-detail.component.ts index 5a3871a5..4f5ebe80 100644 --- a/src/app/applications/application-detail/application-detail.component.ts +++ b/src/app/applications/application-detail/application-detail.component.ts @@ -65,7 +65,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { } public deleteApplication() { - if (this.application['numComments'] > 0) { + if (this.application.meta.numComments > 0) { this.dialogService .addDialog( ConfirmComponent, @@ -82,7 +82,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { return; } - if (this.application.isPublished) { + if (this.application.meta.isPublished) { this.dialogService .addDialog( ConfirmComponent, @@ -124,25 +124,25 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { let observables = of(null); // delete comment period - if (this.application.currentPeriod) { - observables = observables.pipe(concat(this.commentPeriodService.delete(this.application.currentPeriod))); + if (this.application.meta.currentPeriod) { + observables = observables.pipe(concat(this.commentPeriodService.delete(this.application.meta.currentPeriod))); } // delete decision documents - if (this.application.decision && this.application.decision.documents) { - for (const doc of this.application.decision.documents) { + if (this.application.meta.decision && this.application.meta.decision.meta.documents) { + for (const doc of this.application.meta.decision.meta.documents) { observables = observables.pipe(concat(this.documentService.delete(doc))); } } // delete decision - if (this.application.decision) { - observables = observables.pipe(concat(this.decisionService.delete(this.application.decision))); + if (this.application.meta.decision) { + observables = observables.pipe(concat(this.decisionService.delete(this.application.meta.decision))); } // delete application documents - if (this.application.documents) { - for (const doc of this.application.documents) { + if (this.application.meta.documents) { + for (const doc of this.application.meta.documents) { observables = observables.pipe(concat(this.documentService.delete(doc))); } } @@ -263,28 +263,28 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { let observables = of(null); // publish comment period - if (this.application.currentPeriod && !this.application.currentPeriod.isPublished) { - observables = observables.pipe(concat(this.commentPeriodService.publish(this.application.currentPeriod))); + if (this.application.meta.currentPeriod && !this.application.meta.currentPeriod.meta.isPublished) { + observables = observables.pipe(concat(this.commentPeriodService.publish(this.application.meta.currentPeriod))); } // publish decision documents - if (this.application.decision && this.application.decision.documents) { - for (const doc of this.application.decision.documents) { - if (!doc.isPublished) { + if (this.application.meta.decision && this.application.meta.decision.meta.documents) { + for (const doc of this.application.meta.decision.meta.documents) { + if (!doc.meta.isPublished) { observables = observables.pipe(concat(this.documentService.publish(doc))); } } } // publish decision - if (this.application.decision && !this.application.decision.isPublished) { - observables = observables.pipe(concat(this.decisionService.publish(this.application.decision))); + if (this.application.meta.decision && !this.application.meta.decision.meta.isPublished) { + observables = observables.pipe(concat(this.decisionService.publish(this.application.meta.decision))); } // publish application documents - if (this.application.documents) { - for (const doc of this.application.documents) { - if (!doc.isPublished) { + if (this.application.meta.documents) { + for (const doc of this.application.meta.documents) { + if (!doc.meta.isPublished) { observables = observables.pipe(concat(this.documentService.publish(doc))); } } @@ -292,7 +292,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { // publish application // do this last in case of prior failures - if (!this.application.isPublished) { + if (!this.application.meta.isPublished) { observables = observables.pipe(concat(this.applicationService.publish(this.application))); } @@ -346,28 +346,28 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { let observables = of(null); // unpublish comment period - if (this.application.currentPeriod && this.application.currentPeriod.isPublished) { - observables = observables.pipe(concat(this.commentPeriodService.unPublish(this.application.currentPeriod))); + if (this.application.meta.currentPeriod && this.application.meta.currentPeriod.meta.isPublished) { + observables = observables.pipe(concat(this.commentPeriodService.unPublish(this.application.meta.currentPeriod))); } // unpublish decision documents - if (this.application.decision && this.application.decision.documents) { - for (const doc of this.application.decision.documents) { - if (doc.isPublished) { + if (this.application.meta.decision && this.application.meta.decision.meta.documents) { + for (const doc of this.application.meta.decision.meta.documents) { + if (doc.meta.isPublished) { observables = observables.pipe(concat(this.documentService.unPublish(doc))); } } } // unpublish decision - if (this.application.decision && this.application.decision.isPublished) { - observables = observables.pipe(concat(this.decisionService.unPublish(this.application.decision))); + if (this.application.meta.decision && this.application.meta.decision.meta.isPublished) { + observables = observables.pipe(concat(this.decisionService.unPublish(this.application.meta.decision))); } // unpublish application documents - if (this.application.documents) { - for (const doc of this.application.documents) { - if (doc.isPublished) { + if (this.application.meta.documents) { + for (const doc of this.application.meta.documents) { + if (doc.meta.isPublished) { observables = observables.pipe(concat(this.documentService.unPublish(doc))); } } @@ -375,7 +375,7 @@ export class ApplicationDetailComponent implements OnInit, OnDestroy { // unpublish application // do this last in case of prior failures - if (this.application.isPublished) { + if (this.application.meta.isPublished) { observables = observables.pipe(concat(this.applicationService.unPublish(this.application))); } diff --git a/src/app/applications/application-list/application-list.component.html b/src/app/applications/application-list/application-list.component.html deleted file mode 100644 index e329c693..00000000 --- a/src/app/applications/application-list/application-list.component.html +++ /dev/null @@ -1,125 +0,0 @@ -
        -
        -
        -
        - -
        -
        -

        Crown Lands Applications in British Columbia

        -

        Use the list below to navigate to individual applications. Click on any application to go directly to its details page - or click arrow_drop_down to expand the list item to see basic - information (e.g., Location, Description, etc.) about that application.

        - - - -
        - - Only show applications with an open or future comment period - -
        - - - - - - - - - - - - - - - - - - - - -
        Applicant(s) - - CL File - - Purpose / Subpurpose - - Region - - Status - - PRC Status - -
        - - -
        -
        -
        -
          - -
        • - Comment Period Status: - {{app.cpStatus}} -
        • -
        • - Comment Period Dates: - {{app.currentPeriod.startDate | date:'mediumDate'}} to {{app.currentPeriod.endDate | date:'mediumDate'}} - -  ({{app.currentPeriod['daysRemaining'] + (app.currentPeriod['daysRemaining'] === 1 ? ' day ' : ' days ') + 'remaining'}}) - - -
        • -
        • - Comments Received: - {{app['numComments']}} -
        • -
        • - Location: - {{app.location || '-'}} -
        • -
        • - Description: - -
        • -
        - - -
        -
        -
        -
        -
        -
        -
        diff --git a/src/app/applications/application-list/application-list.component.scss b/src/app/applications/application-list/application-list.component.scss deleted file mode 100644 index 119ee01d..00000000 --- a/src/app/applications/application-list/application-list.component.scss +++ /dev/null @@ -1,198 +0,0 @@ -@import "assets/styles/base/base.scss"; - -.application-list { - &__options { - @include flex(0 0 auto); - - padding: 1rem 0; - - mat-slide-toggle { - white-space: normal; - } - } -} - -.application-table { - tbody { - tr { - background: transparent; - - .accordion__collapse-header { - background: $table-row-bg; - } - - &:nth-child(even) { - .accordion__collapse-header { - background: $table-alt-row-bg; - } - } - } - } - - .accordion__collapse-header { - background: transparent; - } - - &__application-details { - font-size: 0.85rem; - - &--links { - padding-top: 1.5rem; - } - - &--description { - margin-bottom: 1.25rem; - - &::ng-deep { - p { - margin: 0; - line-height: calc(0.85rem * 1.5); - font-size: 0.85rem; - } - } - } - - &--list { - li { - @include align-items(start); - - + li { - border-top: 1px solid $white; - } - } - - .name, - .value { - margin: 0; - padding: 0.25rem 0.75rem; - width: 50%; - } - - .name { - font-weight: bold; - } - } - } -} - -@media (max-width: 767px) { - .application-table { - table-layout: auto; - - thead { - display: block; - width: 100%; - - tr { - @include flex-box(); - } - - th { - border-top: none; - padding-left: 0.75rem; - padding-right: 0.75rem; - - &.application-table__name-col { - @include flex(1 1 auto); - } - } - } - - &__name-col { - // width: 45%; - } - - &__cl_file-col { - width: 25%; - } - - &__purpose-col { - display: none; - } - - &__region-col { - display: none; - } - - &__status-col { - width: 30%; - } - - &__commenting-col { - display: none; - } - - &__application-details { - &--links { - .btn { - width: 100%; - - + .btn { - margin-top: 0.5rem; - } - } - } - } - } -} - -@media (min-width: 768px) { - .application-table { - &__name-col { - // width: 20%; - } - - &__cl_file-col { - width: 12.5%; - } - - &__purpose-col { - width: 25%; - } - - &__region-col { - width: 12.5%; - } - - &__status-col { - width: 14%; - } - - &__commenting-col { - width: 16%; - } - - &__application-details { - font-size: 0.9375rem; - - &--description { - &::ng-deep { - p { - line-height: calc(0.9375rem * 1.5); - font-size: 0.9375rem; - } - } - } - - &--list { - list-style-type: none; - - .name { - width: 15rem; - } - - .value { - width: auto; - } - } - - &--links { - .btn { - + .btn { - margin-left: 0.5rem; - } - } - } - } - } -} diff --git a/src/app/applications/application-list/application-list.component.spec.ts b/src/app/applications/application-list/application-list.component.spec.ts deleted file mode 100644 index 3a556206..00000000 --- a/src/app/applications/application-list/application-list.component.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -import { ApplicationListComponent } from './application-list.component'; -import { RouterTestingModule } from '@angular/router/testing'; -import { MatSlideToggleModule } from '@angular/material'; -import { OrderByPipe } from 'app/pipes/order-by.pipe'; -import { NewlinesPipe } from 'app/pipes/newlines.pipe'; -import { ApplicationService } from 'app/services/application.service'; -import { CommentPeriodService } from 'app/services/commentperiod.service'; -import { Application } from 'app/models/application'; -import { of, throwError } from 'rxjs'; - -describe('ApplicationListComponent', () => { - let component: ApplicationListComponent; - let fixture: ComponentFixture; - - const applicationServiceStub = { - getAll() { - return of([]); - } - }; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ApplicationListComponent, OrderByPipe, NewlinesPipe], - imports: [RouterTestingModule, MatSlideToggleModule], - providers: [{ provide: ApplicationService, useValue: applicationServiceStub }, { provide: CommentPeriodService }] - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(ApplicationListComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should be created', () => { - expect(component).toBeTruthy(); - }); - - describe('when applications are returned from the service', () => { - const existingApplications = [new Application(), new Application()]; - - beforeEach(() => { - const applicationService = TestBed.get(ApplicationService); - spyOn(applicationService, 'getAll').and.returnValue(of(existingApplications)); - }); - - it('sets the component application to the one from the route', () => { - component.ngOnInit(); - expect(component.applications).toEqual(existingApplications); - }); - }); - - describe('when the application service throws an error', () => { - beforeEach(() => { - const applicationService = TestBed.get(ApplicationService); - spyOn(applicationService, 'getAll').and.returnValue(throwError('Beep boop server error')); - }); - - it('redirects to root', () => { - const navigateSpy = spyOn((component as any).router, 'navigate'); - - component.ngOnInit(); - - expect(navigateSpy).toHaveBeenCalledWith(['/']); - }); - }); -}); diff --git a/src/app/applications/application-list/application-list.component.ts b/src/app/applications/application-list/application-list.component.ts deleted file mode 100644 index 28054cba..00000000 --- a/src/app/applications/application-list/application-list.component.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * This component is no longer supported (but it may still mostly work). - * There is no route to this component but it can be navigated to directly. - * Some developers might find this component useful for looking at data. - * Known issues: - * - the accordion collapse is broken because it needs jQuery (which has since been removed) - */ - -import { Component, OnInit, OnDestroy } from '@angular/core'; -import { Router, ActivatedRoute, ParamMap, Params } from '@angular/router'; -import { Location } from '@angular/common'; -import { Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; - -import { Application } from 'app/models/application'; -import { ApplicationService } from 'app/services/application.service'; -import { CommentPeriodService } from 'app/services/commentperiod.service'; - -@Component({ - selector: 'app-application-list', - templateUrl: './application-list.component.html', - styleUrls: ['./application-list.component.scss'] -}) -export class ApplicationListComponent implements OnInit, OnDestroy { - public loading = true; - private paramMap: ParamMap = null; - public showOnlyOpenApps: boolean; - public applications: Application[] = []; - public column: string = null; - public direction = 0; - private ngUnsubscribe: Subject = new Subject(); - - constructor( - private location: Location, - private router: Router, - private route: ActivatedRoute, - private applicationService: ApplicationService, - public commentPeriodService: CommentPeriodService - ) {} - - ngOnInit() { - // get optional query parameters - this.route.queryParamMap.pipe(takeUntil(this.ngUnsubscribe)).subscribe(paramMap => { - this.paramMap = paramMap; - - // set initial filters - this.resetFilters(); - }); - - // get data - this.applicationService - .getAll({ getCurrentPeriod: true }) - .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe( - applications => { - this.loading = false; - this.applications = applications; - }, - error => { - this.loading = false; - console.log(error); - alert("Uh-oh, couldn't load applications"); - // applications not found --> navigate back to home - this.router.navigate(['/']); - } - ); - } - - public showOnlyOpenAppsChange(checked: boolean) { - this.showOnlyOpenApps = checked; - this.saveFilters(); - } - - private saveFilters() { - const params: Params = {}; - - if (this.showOnlyOpenApps) { - params['showOnlyOpenApps'] = true; - } - - if (this.column && this.direction) { - params['col'] = this.column; - params['dir'] = this.direction; - } - - // change browser URL without reloading page (so any query params are saved in history) - this.location.go(this.router.createUrlTree([], { relativeTo: this.route, queryParams: params }).toString()); - } - - private resetFilters() { - this.showOnlyOpenApps = this.paramMap.get('showOnlyOpenApps') === 'true'; - this.column = this.paramMap.get('col'); // == null if col isn't present - this.direction = +this.paramMap.get('dir'); // == 0 if dir isn't present - } - - ngOnDestroy() { - this.ngUnsubscribe.next(); - this.ngUnsubscribe.complete(); - } - - public sort(property: string) { - this.column = property; - this.direction = this.direction > 0 ? -1 : 1; - this.saveFilters(); - } - - public showThisApp(item: Application) { - const statusCode = item && this.commentPeriodService.getCode(item.currentPeriod); - return ( - !this.showOnlyOpenApps || - this.commentPeriodService.isOpen(statusCode) || - this.commentPeriodService.isNotStarted(statusCode) - ); - } -} diff --git a/src/app/applications/application-resolver.service.ts b/src/app/applications/application-resolver.service.ts index 4c93717d..dfd8fc63 100644 --- a/src/app/applications/application-resolver.service.ts +++ b/src/app/applications/application-resolver.service.ts @@ -37,13 +37,13 @@ export class ApplicationDetailResolver implements Resolve { // 7-digit CL File number for display if (application.cl_file) { - application.clFile = application.cl_file.toString().padStart(7, '0'); + application.meta.clFile = application.cl_file.toString().padStart(7, '0'); } // derive unique applicants if (application.client) { const clients = application.client.split(', '); - application.applicants = _.uniq(clients).join(', '); + application.meta.applicants = _.uniq(clients).join(', '); } // derive retire date @@ -55,12 +55,12 @@ export class ApplicationDetailResolver implements Resolve { StatusCodes.ABANDONED.code ].includes(ConstantUtils.getCode(CodeType.STATUS, application.status)) ) { - application.retireDate = moment(application.statusHistoryEffectiveDate) + application.meta.retireDate = moment(application.statusHistoryEffectiveDate) .endOf('day') .add(6, 'months') .toDate(); // set flag if retire date is in the past - application.isRetired = moment(application.retireDate).isBefore(); + application.meta.isRetired = moment(application.meta.retireDate).isBefore(); } return of(application); diff --git a/src/app/applications/applications-routing.module.ts b/src/app/applications/applications-routing.module.ts index 62a73606..bb812c20 100644 --- a/src/app/applications/applications-routing.module.ts +++ b/src/app/applications/applications-routing.module.ts @@ -1,7 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; -import { ApplicationListComponent } from './application-list/application-list.component'; import { ApplicationDetailComponent } from './application-detail/application-detail.component'; import { ApplicationAddEditComponent } from './application-add-edit/application-add-edit.component'; import { ApplicationDetailResolver } from './application-resolver.service'; @@ -10,10 +9,6 @@ import { ReviewCommentsComponent } from './review-comments/review-comments.compo import { CanDeactivateGuard } from 'app/services/can-deactivate-guard.service'; const routes: Routes = [ - { - path: 'applications', - component: ApplicationListComponent - }, { path: 'a/:appId', component: ApplicationDetailComponent, diff --git a/src/app/applications/applications.module.ts b/src/app/applications/applications.module.ts index 3de17db5..758177ea 100644 --- a/src/app/applications/applications.module.ts +++ b/src/app/applications/applications.module.ts @@ -9,7 +9,6 @@ import { ApplicationsRoutingModule } from './applications-routing.module'; import { InlineSVGModule } from 'ng-inline-svg'; // components -import { ApplicationListComponent } from './application-list/application-list.component'; import { ApplicationDetailComponent } from './application-detail/application-detail.component'; import { ApplicationAsideComponent } from './application-aside/application-aside.component'; import { ApplicationAddEditComponent } from './application-add-edit/application-add-edit.component'; @@ -19,7 +18,7 @@ import { CommentDetailComponent } from './review-comments/comment-detail/comment // services import { ApiService } from 'app/services/api'; import { ApplicationService } from 'app/services/application.service'; -import { ExcelService } from 'app/services/excel.service'; +import { ExportService } from 'app/services/export.service'; @NgModule({ imports: [ @@ -32,7 +31,6 @@ import { ExcelService } from 'app/services/excel.service'; ApplicationsRoutingModule ], declarations: [ - ApplicationListComponent, ApplicationDetailComponent, ApplicationAsideComponent, ApplicationAddEditComponent, @@ -40,13 +38,12 @@ import { ExcelService } from 'app/services/excel.service'; CommentDetailComponent ], exports: [ - ApplicationListComponent, ApplicationDetailComponent, ApplicationAsideComponent, ApplicationAddEditComponent, ReviewCommentsComponent, CommentDetailComponent ], - providers: [ApiService, ApplicationService, ExcelService] + providers: [ApiService, ApplicationService, ExportService] }) export class ApplicationsModule {} diff --git a/src/app/applications/review-comments/review-comments.component.html b/src/app/applications/review-comments/review-comments.component.html index e412cd97..ebb64103 100644 --- a/src/app/applications/review-comments/review-comments.component.html +++ b/src/app/applications/review-comments/review-comments.component.html @@ -8,7 +8,7 @@
        -

        Review Comments for Crown Land File: {{application.clFile}}

        +

        Review Comments for Crown Land File: {{application.meta.clFile}}

      • - + - diff --git a/src/app/list/list.component.html b/src/app/list/list.component.html new file mode 100644 index 00000000..f13eaf7e --- /dev/null +++ b/src/app/list/list.component.html @@ -0,0 +1,175 @@ +
        + +
        +
        +

        List Crown Land Applications

        + +
        +
        + + + +
        + +
        + + + +
        + +
        + + + +
        + +
        +
        + {{this.pagination.message}} +
        + + +
        + +
        +
        +
        + +
        +
        +
        +
        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        Applicant(s) + + CL File + + Region + + Created + + Purpose / Subpurpose + + StatusRetiredCommentingComments
        + {{(app.meta.applicants | titlecase) || '-'}} + + + {{app.meta.clFile.padStart(7, '0') || '-'}} + + + {{app.meta.region || '-'}} + + {{getFormattedDate(app.createdDate) || '-'}} + + {{(app.purpose | titlecase) || '-'}} / {{(app.subpurpose | titlecase) || '-'}} + + {{getStatusStringLong(app) || '-'}} + + {{isApplicationRetired(app) && 'Yes' || '-'}} + + {{app.meta.cpStatusStringLong}} + + {{app.meta.numComments}} +
        +
        +
        + +
        diff --git a/src/app/list/list.component.scss b/src/app/list/list.component.scss new file mode 100644 index 00000000..0cf66a24 --- /dev/null +++ b/src/app/list/list.component.scss @@ -0,0 +1,154 @@ +@import "assets/styles/base/base.scss"; + +.grid-container { + display: grid; + grid-auto-rows: minmax(0, auto); +} + +.search-container { + padding-top: 2.5rem; + padding-bottom: 1.5rem; + background: #f7f8fa; +} + +.filter-grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-column-gap: 2rem; + margin-top: 2rem; + + .filter-select, + .filter-input { + margin-bottom: 1rem; + } + + .filter-input { + @include placeholder { + color: $gray7; + } + } + + .filter-buttons { + .filter-btn-clear { + margin-right: 1rem; + padding: 0.75rem; + font-size: 0.875rem; + font-weight: 700; + } + + .filter-btn-search { + margin-right: 1rem; + padding: 0.75rem; + font-size: 0.875rem; + font-weight: 700; + width: 7rem; + } + + .filter-btn-export { + padding: 0.75rem; + font-size: 0.875rem; + font-weight: 700; + + i { + &.material-icons { + font-size: 1.1rem; + font-weight: 700; + } + } + } + } + + .filter-count { + display: flex; + flex-direction: column-reverse; + align-items: flex-end; + } + + .spinner-border { + font-size: 0.7rem; + height: 0.95rem; + width: 0.95rem; + } +} + +.loading-container { + position: relative; + padding: 3rem 0; +} + +@media (max-width: 768px) { + .filter-grid { + grid-template-columns: 1fr; + + .filter-count { + margin-top: 1rem; + align-items: center; + } + + .filter-buttons { + display: flex; + justify-content: center; + } + + .pagination-nav { + justify-content: center; + } + } +} + +select { + option:first-child { + color: $gray6; + } +} + +.table-cards { + td { + padding: 0.75rem; + } + + th { + padding: 0.75rem; + white-space: nowrap; + } +} + +@media (min-width: 1025px) { + .table-cards { + td { + &.text-align { + // center text only when in table view (not in card view) + text-align: center; + } + } + } +} + +// pagination +.pagination-nav { + display: flex; + flex-direction: row-reverse; + margin-top: 0.5rem; + grid-column: 1 / -1; + + .pagination { + margin-bottom: 0; + text-align: center; + font-size: 1.25rem; + font-weight: 400; + font-family: 'myriad-pro', 'Calibri', 'Arial', sans-serif; + cursor: pointer; + + .current-page { + font-weight: 550; + text-decoration: underline; + } + } + + .disabled { + > * { + opacity: 0.5; + pointer-events: none; + } + } +} diff --git a/src/app/list/list.component.spec.ts b/src/app/list/list.component.spec.ts new file mode 100644 index 00000000..d9bbb38d --- /dev/null +++ b/src/app/list/list.component.spec.ts @@ -0,0 +1,1092 @@ +import { Location } from '@angular/common'; +import { async, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ApplicationService } from 'app/services/application.service'; +import { ListComponent } from './list.component'; +import { of, throwError } from 'rxjs'; +import { OrderByPipe } from 'app/pipes/order-by.pipe'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Application } from 'app/models/application'; +// import { CommentCodes } from 'app/utils/constants/comment'; +import ActivatedRouteStub from 'app/spec/helpers'; +import { ExportService } from 'app/services/export.service'; +import { QueryParamModifier } from 'app/services/api'; + +describe('ListComponent', () => { + // component constructor mocks + const mockLocation = jasmine.createSpyObj('Location', ['go']); + const mockRouter = jasmine.createSpyObj('Router', ['navigate', 'createUrlTree', 'events']); + const mockActivatedRoute = new ActivatedRouteStub(); + const mockApplicationService = jasmine.createSpyObj('ApplicationService', ['getAll', 'getCount']); + const mockExportService = jasmine.createSpyObj('ExportService', ['exportAsCSV']); + + /** + * Initialize the test bed. + */ + beforeEach(async(() => { + setDefaultMockBehaviour(); + + TestBed.configureTestingModule({ + declarations: [ListComponent, OrderByPipe], + providers: [ + { provide: Location, useValue: mockLocation }, + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: ApplicationService, useValue: mockApplicationService }, + { provide: ExportService, useValue: mockExportService } + ], + imports: [RouterTestingModule] + }).compileComponents(); + })); + + /** + * Return the mocks to their default stubbed state after each test so the tests don't interfere with one another. + */ + afterEach(() => { + setDefaultMockBehaviour(); + }); + + /** + * Sets the default stubbed behaviour of all mocks used by the component. + */ + function setDefaultMockBehaviour() { + mockLocation.go.and.stub(); + mockRouter.createUrlTree.and.returnValue(''); + mockActivatedRoute.clear(); + mockApplicationService.getAll.and.returnValue(of([])); + mockApplicationService.getCount.and.returnValue(of(0)); + mockExportService.exportAsCSV.and.stub(); + } + + /** + * Initializes the component and fixture. + * + * - In most cases, this will be called in the beforeEach. + * - In tests that require custom mock behaviour, set up the mock behaviour before calling this. + * + * @param {boolean} [detectChanges=true] set to false if you want to manually call fixture.detectChanges(), etc. + * Usually you want to control this when the timing of ngOnInit, and similar auto-exec functions, matters. + * @returns {{component, fixture}} Object containing the component and test fixture. + */ + function createComponent(detectChanges: boolean = true) { + const fixture = TestBed.createComponent(ListComponent); + const component = fixture.componentInstance; + + if (detectChanges) { + fixture.detectChanges(); + } + + return { component, fixture }; + } + + it('should create', () => { + const { component } = createComponent(); + expect(component).toBeTruthy(); + }); + + describe('getApplicationQueryParamSets', () => { + let component; + beforeEach(() => { + ({ component } = createComponent()); + }); + + it('returns application query parameters', () => { + component.pagination.currentPage = 7; + component.pagination.itemsPerPage = 17; + component.purposeCodeFilters = ['AGRICULTURE']; + component.statusCodeFilters = ['APPLICATION REVIEW COMPLETE']; + component.regionCodeFilter = 'SK - LAND MGMNT - SKEENA FIELD OFFICE'; + + const queryParameters = component.getApplicationQueryParamSets()[0]; + + expect(queryParameters.isDeleted).toEqual(false); + expect(queryParameters.pageNum).toEqual(6); + expect(queryParameters.pageSize).toEqual(17); + expect(queryParameters.purpose).toEqual({ value: ['AGRICULTURE'], modifier: QueryParamModifier.Equal }); + expect(queryParameters.status).toEqual({ + value: ['OFFER ACCEPTED', 'OFFERED'], + modifier: QueryParamModifier.Equal + }); + expect(queryParameters.businessUnit).toEqual({ + value: 'SK - LAND MGMNT - SKEENA FIELD OFFICE', + modifier: QueryParamModifier.Equal + }); + }); + }); + + describe('getApplications', () => { + describe('happy path', () => { + let component; + let applicationServiceMock; + + const applications = [new Application({ _id: 1 }), new Application({ _id: 2 })]; + + beforeEach(async(() => { + ({ component } = createComponent()); + + component.applications = []; + component.pagination.totalItems = 0; + component.searching = true; + component.loading = true; + + applicationServiceMock = TestBed.get(ApplicationService); + applicationServiceMock.getAll.calls.reset(); + applicationServiceMock.getAll.and.returnValue(of(applications)); + applicationServiceMock.getCount.calls.reset(); + applicationServiceMock.getCount.and.returnValue(of(2)); + + component.getApplications(); + })); + + it('calls ApplicationService.getAll', () => { + expect(applicationServiceMock.getAll).toHaveBeenCalledWith( + { getCurrentPeriod: true }, + component.getApplicationQueryParamSets() + ); + }); + + it('calls ApplicationService.getCount', () => { + expect(applicationServiceMock.getCount).toHaveBeenCalledWith(component.getApplicationQueryParamSets()); + }); + + it('updates applications', () => { + expect(component.applications).toEqual(applications); + }); + + it('updates pagination', () => { + expect(component.pagination.totalItems).toEqual(2); + }); + + it('updates searching/loading flags', () => { + expect(component.searching).toEqual(false); + expect(component.loading).toEqual(false); + }); + }); + + describe('on error', () => { + let component; + beforeEach(async(() => { + ({ component } = createComponent()); + })); + + it('re-navigates to the list page on error', async(() => { + const applicationServiceMock = TestBed.get(ApplicationService); + applicationServiceMock.getAll.and.returnValue(throwError('some error')); + + const routerMock = TestBed.get(Router); + routerMock.navigate.calls.reset(); + + component.getApplications(); + + expect(routerMock.navigate).toHaveBeenCalledWith(['/list']); + })); + }); + }); + + describe('export', () => { + describe('happy path', () => { + let component; + let applicationServiceMock; + let exportServiceMock; + + const applications = [new Application({ _id: 3 })]; + + beforeEach(async(() => { + ({ component } = createComponent()); + + component.pagination.currentPage = 8; + component.pagination.itemsPerPage = 18; + component.purposeCodeFilters = ['purposeFilterF']; + component.statusCodeFilters = ['UNKNOWN']; + component.regionCodeFilter = 'regionFilterF'; + component.exporting = true; + + applicationServiceMock = TestBed.get(ApplicationService); + applicationServiceMock.getAll.calls.reset(); + applicationServiceMock.getAll.and.returnValue(of(applications)); + + exportServiceMock = TestBed.get(ExportService); + exportServiceMock.exportAsCSV.calls.reset(); + + component.export(); + })); + + it('calls ExportService.exportAsCSV', () => { + expect(exportServiceMock.exportAsCSV).toHaveBeenCalledTimes(1); + expect(exportServiceMock.exportAsCSV.calls.all()[0].args[0]).toEqual(applications); + }); + + it('updates exporting flag', () => { + expect(component.exporting).toEqual(false); + }); + }); + + describe('on error', () => { + let component; + let exportServiceMock; + + const applications = [new Application({ _id: 4 })]; + + beforeEach(async(() => { + ({ component } = createComponent()); + + component.exporting = true; + + const applicationServiceMock = TestBed.get(ApplicationService); + applicationServiceMock.getAll.calls.reset(); + applicationServiceMock.getAll.and.returnValue(of(applications)); + + exportServiceMock = TestBed.get(ExportService); + exportServiceMock.exportAsCSV.calls.reset(); + exportServiceMock.exportAsCSV.and.returnValue(throwError('some error 2')); + + component.export(); + })); + + it('calls ExportService.exportAsCSV', () => { + expect(exportServiceMock.exportAsCSV).toHaveBeenCalledTimes(1); + expect(exportServiceMock.exportAsCSV.calls.all()[0].args[0]).toEqual(applications); + }); + + it('updates exporting flag', () => { + expect(component.exporting).toEqual(false); + }); + }); + }); + + describe('setInitialQueryParameters', () => { + it('sets default query parameters when parameters are not saved to the url', () => { + const { component } = createComponent(); + + component.pagination.currentPage = 7; + component.sorting.column = 'columnC'; + component.sorting.direction = 2; + component.purposeCodeFilters = ['purposeFilterC']; + component.statusCodeFilters = ['statusFilterC']; + component.regionCodeFilter = 'regionFilterC'; + // component.commentCodeFilters = ['commentFilterC']; + + component.setInitialQueryParameters(); + + expect(component.pagination.currentPage).toEqual(1); + expect(component.sorting.column).toEqual(null); + expect(component.sorting.direction).toEqual(0); + expect(component.purposeCodeFilters).toEqual([]); + expect(component.statusCodeFilters).toEqual([]); + expect(component.regionCodeFilter).toEqual(''); + // expect(component.commentCodeFilters).toEqual([]); + }); + + it('sets default query parameters when parameters are saved to the url', () => { + const activatedRouteStub: ActivatedRouteStub = TestBed.get(ActivatedRoute); + activatedRouteStub.setQueryParamMap({ + page: 3, + sortBy: '+columnD', + purpose: 'purpose1|purpose2', + status: 'status1|status2|status3', + region: 'region1', + comment: 'comment1' + }); + + const { component } = createComponent(); + + component.pagination.currentPage = 77; + component.sorting.column = 'columnCC'; + component.sorting.direction = 22; + component.purposeCodeFilters = ['purposeFilterCC']; + component.statusCodeFilters = ['statusFilterCC']; + component.regionCodeFilter = 'regionFilterCC'; + // component.commentCodeFilters = ['commentFilterCC']; + + component.setInitialQueryParameters(); + + expect(component.pagination.currentPage).toEqual(3); + expect(component.sorting.column).toEqual('columnD'); + expect(component.sorting.direction).toEqual(1); + expect(component.purposeCodeFilters).toEqual(['purpose1', 'purpose2']); + expect(component.statusCodeFilters).toEqual(['status1', 'status2', 'status3']); + expect(component.regionCodeFilter).toEqual('region1'); + // expect(component.commentCodeFilters).toEqual(['comment1']); + }); + }); + + describe('saveQueryParameters', () => { + let component; + let spyRouter; + let spyLocation; + + beforeEach(() => { + spyRouter = TestBed.get(Router); + spyRouter.createUrlTree.and.callFake((...args) => { + expect(args[1].queryParams).toEqual({ + page: 4, + sortBy: '-columnA', + purpose: 'purposeFilterA', + region: 'regionFilterA', + status: 'statusFilterA' + // comment: 'commentFilterA' + }); + return 'I was called 1'; + }); + + spyLocation = TestBed.get(Location); + spyLocation.go.calls.reset(); + + ({ component } = createComponent()); + }); + + it('saves all query parameters to the url', () => { + component.pagination.currentPage = 4; + component.sorting.column = 'columnA'; + component.sorting.direction = -1; + component.purposeCodeFilters = ['purposeFilterA']; + component.statusCodeFilters = ['statusFilterA']; + component.regionCodeFilter = 'regionFilterA'; + // component.commentCodeFilters = ['commentFilterA']; + + component.saveQueryParameters(); + + expect(spyLocation.go).toHaveBeenCalledWith('I was called 1'); + }); + }); + + describe('clearQueryParameters', () => { + let component; + let spyRouter; + let spyLocation; + + beforeEach(() => { + spyRouter = TestBed.get(Router); + spyRouter.createUrlTree.and.callFake((...args) => { + expect(args[1]['queryParams']).toBeUndefined(); + return 'I was called 2'; + }); + + spyLocation = TestBed.get(Location); + spyLocation.go.calls.reset(); + + ({ component } = createComponent()); + }); + + it('sets all query parameters to their default values', () => { + component.pagination.currentPage = 5; + component.pagination.totalItems = 11; + component.sorting.column = 'columnB'; + component.sorting.direction = -1; + component.purposeCodeFilters = ['purposeFilterB']; + component.statusCodeFilters = ['statusFilterB']; + component.regionCodeFilter = 'regionFilterB'; + // component.commentCodeFilters = ['commentFilterB']; + + component.clearQueryParameters(); + + expect(component.pagination.currentPage).toEqual(1); + expect(component.pagination.totalItems).toEqual(0); + expect(component.sorting.column).toEqual(null); + expect(component.sorting.direction).toEqual(0); + expect(component.purposeCodeFilters).toEqual([]); + expect(component.statusCodeFilters).toEqual([]); + expect(component.regionCodeFilter).toEqual(''); + // expect(component.commentCodeFilters).toEqual([]); + + expect(spyLocation.go).toHaveBeenCalledWith('I was called 2'); + }); + }); + + describe('setPurposeFilter', () => { + let component; + beforeEach(() => { + ({ component } = createComponent()); + }); + + describe('purposeCode is undefined', () => { + beforeEach(() => { + component.purposeCodeFilters = ['oldFilter']; + component.filterChanged = false; + + component.setPurposeFilter(undefined); + }); + + it('sets purposeCodeFilters to empty array', () => { + expect(component.purposeCodeFilters).toEqual([]); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + + describe('purposeCode is null', () => { + beforeEach(() => { + component.purposeCodeFilters = ['oldFilter']; + component.filterChanged = false; + + component.setPurposeFilter(null); + }); + + it('sets purposeCodeFilters to empty array', () => { + expect(component.purposeCodeFilters).toEqual([]); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + + describe('purposeCode is empty string', () => { + beforeEach(() => { + component.purposeCodeFilters = ['oldFilter']; + component.filterChanged = false; + + component.setPurposeFilter(''); + }); + + it('sets purposeCodeFilters to empty array', () => { + expect(component.purposeCodeFilters).toEqual([]); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + + describe('purposeCode is valid', () => { + beforeEach(() => { + component.purposeCodeFilters = ['oldFilter']; + component.filterChanged = false; + + component.setPurposeFilter('newFilter'); + }); + + it('sets purposeCodeFilters to array containing new filter', () => { + expect(component.purposeCodeFilters).toEqual(['newFilter']); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + }); + + describe('setStatusFilter', () => { + let component; + beforeEach(() => { + ({ component } = createComponent()); + }); + + describe('statusCode is undefined', () => { + beforeEach(() => { + component.statusCodeFilters = ['oldFilter']; + component.filterChanged = false; + + component.setStatusFilter(undefined); + }); + + it('sets statusCodeFilters to empty array', () => { + expect(component.statusCodeFilters).toEqual([]); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + + describe('statusCode is null', () => { + beforeEach(() => { + component.statusCodeFilters = ['oldFilter']; + component.filterChanged = false; + + component.setStatusFilter(null); + }); + + it('sets statusCodeFilters to empty array', () => { + expect(component.statusCodeFilters).toEqual([]); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + + describe('statusCode is empty string', () => { + beforeEach(() => { + component.statusCodeFilters = ['oldFilter']; + component.filterChanged = false; + + component.setStatusFilter(''); + }); + + it('sets statusCodeFilters to empty array', () => { + expect(component.statusCodeFilters).toEqual([]); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + + describe('statusCode is valid', () => { + beforeEach(() => { + component.statusCodeFilters = ['oldFilter']; + component.filterChanged = false; + + component.setStatusFilter('newFilter'); + }); + + it('sets statusCodeFilters to array containing new filter', () => { + expect(component.statusCodeFilters).toEqual(['newFilter']); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + }); + + describe('setRegionFilter', () => { + let component; + beforeEach(() => { + ({ component } = createComponent()); + }); + + describe('regionCode is undefined', () => { + beforeEach(() => { + component.regionCodeFilter = 'oldFilter'; + component.filterChanged = false; + + component.setRegionFilter(undefined); + }); + + it('sets regionCodeFilter to empty array', () => { + expect(component.regionCodeFilter).toEqual(''); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + + describe('regionCode is null', () => { + beforeEach(() => { + component.regionCodeFilter = 'oldFilter'; + component.filterChanged = false; + + component.setRegionFilter(null); + }); + + it('sets regionCodeFilter to empty string', () => { + expect(component.regionCodeFilter).toEqual(''); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + + describe('regionCode is empty string', () => { + beforeEach(() => { + component.regionCodeFilter = 'oldFilter'; + component.filterChanged = false; + + component.setRegionFilter(''); + }); + + it('sets regionCodeFilter to empty string', () => { + expect(component.regionCodeFilter).toEqual(''); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + + describe('regionCode is valid', () => { + beforeEach(() => { + component.regionCodeFilter = 'oldFilter'; + component.filterChanged = false; + + component.setRegionFilter('newFilter'); + }); + + it('sets regionCodeFilter to new filter', () => { + expect(component.regionCodeFilter).toEqual('newFilter'); + }); + + it('sets filterChanged to true', () => { + expect(component.filterChanged).toEqual(true); + }); + }); + }); + + // describe('setCommentFilter', () => { + // let component; + // beforeEach(() => { + // ({ component } = createComponent()); + // }); + + // describe('commentCode is undefined', () => { + // beforeEach(() => { + // component.commentCodeFilters = ['oldFilter']; + // component.filterChanged = false; + + // component.setCommentFilter(undefined); + // }); + + // it('sets commentCodeFilters to empty array', () => { + // expect(component.commentCodeFilters).toEqual([]); + // }); + + // it('sets filterChanged to true', () => { + // expect(component.filterChanged).toEqual(true); + // }); + // }); + + // describe('commentCode is null', () => { + // beforeEach(() => { + // component.commentCodeFilters = ['oldFilter']; + // component.filterChanged = false; + + // component.setCommentFilter(null); + // }); + + // it('sets commentCodeFilters to empty array', () => { + // expect(component.commentCodeFilters).toEqual([]); + // }); + + // it('sets filterChanged to true', () => { + // expect(component.filterChanged).toEqual(true); + // }); + // }); + + // describe('commentCode is empty string', () => { + // beforeEach(() => { + // component.commentCodeFilters = ['oldFilter']; + // component.filterChanged = false; + + // component.setCommentFilter(''); + // }); + + // it('sets commentCodeFilters to empty array', () => { + // expect(component.commentCodeFilters).toEqual([]); + // }); + + // it('sets filterChanged to true', () => { + // expect(component.filterChanged).toEqual(true); + // }); + // }); + + // describe('commentCode is valid', () => { + // beforeEach(() => { + // component.commentCodeFilters = ['oldFilter']; + // component.filterChanged = false; + + // component.setCommentFilter('newFilter'); + // }); + + // it('sets commentCodeFilters to array containing new filter', () => { + // expect(component.commentCodeFilters).toEqual(['newFilter']); + // }); + + // it('sets filterChanged to true', () => { + // expect(component.filterChanged).toEqual(true); + // }); + // }); + // }); + + // describe('applyCommentPeriodFilter', () => { + // let component; + // beforeEach(() => { + // ({ component } = createComponent()); + // }); + + // const applications: Application[] = [ + // new Application({ _id: 1, meta: { cpStatusStringLong: CommentCodes.CLOSED.code } }), + // new Application({ _id: 2, meta: { cpStatusStringLong: CommentCodes.NOT_STARTED.code } }), + // new Application({ _id: 3, meta: { cpStatusStringLong: CommentCodes.NOT_OPEN.code } }), + // new Application({ _id: 4, meta: { cpStatusStringLong: CommentCodes.OPEN.code } }) + // ]; + + // it('returns original applications array if array is undefined', () => { + // component.commentCodeFilters = [CommentCodes.CLOSED.code]; + + // const filteredApplications = component.applyCommentPeriodFilter(undefined); + // expect(filteredApplications).toEqual(undefined); + // }); + + // it('returns original applications array if array is null', () => { + // component.commentCodeFilters = [CommentCodes.CLOSED.code]; + + // const filteredApplications = component.applyCommentPeriodFilter(null); + // expect(filteredApplications).toEqual(null); + // }); + + // it('returns original applications array if commentCodeFilters is undefined', () => { + // component.commentCodeFilters = undefined; + + // const filteredApplications = component.applyCommentPeriodFilter(applications); + // expect(filteredApplications).toEqual(applications); + // }); + + // it('returns original applications array if commentCodeFilters is null', () => { + // component.commentCodeFilters = null; + + // const filteredApplications = component.applyCommentPeriodFilter(applications); + // expect(filteredApplications).toEqual(applications); + // }); + + // it('returns original applications array if commentCodeFilters is empty', () => { + // component.commentCodeFilters = []; + + // const filteredApplications = component.applyCommentPeriodFilter(applications); + // expect(filteredApplications).toEqual(applications); + // }); + + // it('filters out applications that are missing the cpStatusStringLong field', () => { + // component.commentCodeFilters = [CommentCodes.NOT_STARTED.code]; + + // const applicationsMissingCPStatus = [ + // new Application({ _id: 1 }), + // new Application({ _id: 2, meta: { cpStatusStringLong: CommentCodes.NOT_STARTED.code } }), + // new Application({ _id: 3 }) + // ]; + + // const filteredApplications = component.applyCommentPeriodFilter(applicationsMissingCPStatus); + // expect(filteredApplications).toEqual([ + // new Application({ _id: 2, meta: { cpStatusStringLong: CommentCodes.NOT_STARTED.code } }) + // ]); + // }); + + // it('filters out applications whose cpStatusStringLong does not match the commentCodeFilters', () => { + // component.commentCodeFilters = [CommentCodes.CLOSED.code]; + + // const filteredApplications = component.applyCommentPeriodFilter(applications); + // expect(filteredApplications).toEqual([ + // new Application({ _id: 1, meta: { cpStatusStringLong: CommentCodes.CLOSED.code } }) + // ]); + // }); + + // it('filters out applications whose cpStatusStringLong does not match the commentCodeFilters', () => { + // component.commentCodeFilters = [CommentCodes.CLOSED.code, CommentCodes.OPEN.code]; + + // const filteredApplications = component.applyCommentPeriodFilter(applications); + // expect(filteredApplications).toEqual([ + // new Application({ _id: 1, meta: { cpStatusStringLong: CommentCodes.CLOSED.code } }), + // new Application({ _id: 4, meta: { cpStatusStringLong: CommentCodes.OPEN.code } }) + // ]); + // }); + // }); + + describe('sort', () => { + let component; + beforeEach(() => { + ({ component } = createComponent()); + }); + + it('does nothing if sortBy is undefined', () => { + component.sorting.column = 'columnE'; + component.sorting.direction = 1; + + component.sort(undefined); + expect(component.sorting.column).toEqual('columnE'); + expect(component.sorting.direction).toEqual(1); + }); + + it('does nothing if sortBy is null', () => { + component.sorting.column = 'columnE'; + component.sorting.direction = 1; + + component.sort(null); + expect(component.sorting.column).toEqual('columnE'); + expect(component.sorting.direction).toEqual(1); + }); + + it('sets column and toggles direction', () => { + component.sorting.column = ''; + component.sorting.direction = null; + + component.sort('columnA'); + expect(component.sorting.column).toEqual('columnA'); + expect(component.sorting.direction).toEqual(1); + + component.sort('columnA'); + expect(component.sorting.column).toEqual('columnA'); + expect(component.sorting.direction).toEqual(-1); + + component.sort('columnA'); + expect(component.sorting.column).toEqual('columnA'); + expect(component.sorting.direction).toEqual(1); + + component.sort('columnD'); + expect(component.sorting.column).toEqual('columnD'); + expect(component.sorting.direction).toEqual(1); + }); + }); + + describe('updatePagination', () => { + const initialPaginationValues = { + currentPage: 3, + itemsPerPage: 2, + totalItems: 9, + pageCount: 5, + message: 'Displaying 5 - 6 of 9 applications' + }; + + let component; + beforeEach(() => { + ({ component } = createComponent()); + component.pagination = { ...initialPaginationValues }; + }); + + it('does nothing if paginationParams is undefined', () => { + component.updatePagination(undefined); + expect(component.pagination).toEqual(initialPaginationValues); + }); + + it('does nothing if paginationParams is null', () => { + component.updatePagination(null); + expect(component.pagination).toEqual(initialPaginationValues); + }); + + it('does nothing if totalItems is negative', () => { + component.updatePagination({ totalItems: -1 }); + expect(component.pagination).toEqual({ ...initialPaginationValues }); + }); + + it('does nothing if currentPage is negative', () => { + component.updatePagination({ currentPage: -1 }); + expect(component.pagination).toEqual({ ...initialPaginationValues }); + }); + + it('sets canned message when totalItems is 0', () => { + component.updatePagination({ totalItems: 0 }); + expect(component.pagination).toEqual({ + ...initialPaginationValues, + totalItems: 0, + pageCount: 1, + message: 'No applications found' + }); + }); + + it('updates pagination values for small sets', () => { + component.updatePagination({ currentPage: 1, totalItems: 1 }); + expect(component.pagination).toEqual({ + ...initialPaginationValues, + currentPage: 1, + totalItems: 1, + pageCount: 1, + message: 'Displaying 1 - 1 of 1 applications' + }); + }); + + it('updates pagination values for large sets', () => { + component.pagination.itemsPerPage = 13; + + component.updatePagination({ currentPage: 4, totalItems: 251 }); + expect(component.pagination).toEqual({ + ...initialPaginationValues, + itemsPerPage: 13, + currentPage: 4, + totalItems: 251, + pageCount: 20, + message: 'Displaying 40 - 52 of 251 applications' + }); + }); + + it('updates pagination values on first page', () => { + component.pagination.itemsPerPage = 14; + + component.updatePagination({ currentPage: 1, totalItems: 122 }); + expect(component.pagination).toEqual({ + ...initialPaginationValues, + itemsPerPage: 14, + currentPage: 1, + totalItems: 122, + pageCount: 9, + message: 'Displaying 1 - 14 of 122 applications' + }); + }); + + it('updates pagination values on last page', () => { + component.pagination.itemsPerPage = 14; + + component.updatePagination({ currentPage: 10, totalItems: 130 }); + expect(component.pagination).toEqual({ + ...initialPaginationValues, + itemsPerPage: 14, + currentPage: 10, + totalItems: 130, + pageCount: 10, + message: 'Displaying 127 - 130 of 130 applications' + }); + }); + + it('updates pagination values when totalItems changes', () => { + component.updatePagination({ totalItems: 15 }); + expect(component.pagination).toEqual({ + ...initialPaginationValues, + totalItems: 15, + pageCount: 8, + message: 'Displaying 5 - 6 of 15 applications' + }); + }); + + it('updates pagination values when currentPage changes', () => { + component.updatePagination({ currentPage: 4 }); + expect(component.pagination).toEqual({ + ...initialPaginationValues, + currentPage: 4, + message: 'Displaying 7 - 8 of 9 applications' + }); + }); + + it('sets canned message when currentPage greater than pageCount', () => { + component.updatePagination({ currentPage: 10 }); + expect(component.pagination).toEqual({ + ...initialPaginationValues, + currentPage: 10, + message: 'Unable to display results, please clear and re-try' + }); + }); + + it('sets canned message when totalItems causes pageCount to be smaller than currentPage', () => { + component.updatePagination({ totalItems: 1 }); + expect(component.pagination).toEqual({ + ...initialPaginationValues, + totalItems: 1, + pageCount: 1, + message: 'Unable to display results, please clear and re-try' + }); + }); + }); + + describe('resetPagination', () => { + let component; + beforeEach(() => { + ({ component } = createComponent()); + }); + + it('sets current page to 1', () => { + component.pagination.currentPage = 5; + component.resetPagination(); + expect(component.pagination.currentPage).toEqual(1); + }); + + it('sets filterChanged flag to false', () => { + component.filterChanged = true; + component.resetPagination(); + expect(component.filterChanged).toEqual(false); + }); + }); + + describe('updatePage', () => { + let component; + beforeEach(() => { + ({ component } = createComponent()); + }); + + it('does nothing when page is undefined', () => { + component.pagination.currentPage = 0; + component.updatePage(undefined); + expect(component.pagination.currentPage).toEqual(0); + }); + + it('does nothing when page is null', () => { + component.pagination.currentPage = 0; + component.updatePage(null); + expect(component.pagination.currentPage).toEqual(0); + }); + + it('does nothing when page equal to 0', () => { + component.pagination.currentPage = 1; + component.updatePage(0); + expect(component.pagination.currentPage).toEqual(1); + }); + + it('does nothing when page greater than 1', () => { + component.pagination.currentPage = 1; + component.updatePage(2); + expect(component.pagination.currentPage).toEqual(1); + }); + + describe('page is -1', () => { + it('current page is greater than 1', () => { + component.pagination.currentPage = 2; + component.updatePage(-1); + expect(component.pagination.currentPage).toEqual(1); + }); + + it('current page is equal to 1', () => { + component.pagination.currentPage = 1; + component.updatePage(-1); + expect(component.pagination.currentPage).toEqual(1); + }); + + it('current page is less than 1', () => { + component.pagination.currentPage = 0; + component.updatePage(-1); + expect(component.pagination.currentPage).toEqual(0); + }); + }); + + describe('page is 1', () => { + it('page count is greater than current page + 1', () => { + component.pagination.pageCount = 3; + component.pagination.currentPage = 1; + component.updatePage(1); + expect(component.pagination.currentPage).toEqual(2); + }); + + it('page count is equal to current page + 1', () => { + component.pagination.pageCount = 2; + component.pagination.currentPage = 1; + component.updatePage(1); + expect(component.pagination.currentPage).toEqual(2); + }); + + it('page count is equal to current page', () => { + component.pagination.pageCount = 2; + component.pagination.currentPage = 2; + component.updatePage(1); + expect(component.pagination.currentPage).toEqual(2); + }); + + it('page count is less than current page', () => { + component.pagination.pageCount = 1; + component.pagination.currentPage = 2; + component.updatePage(1); + expect(component.pagination.currentPage).toEqual(2); + }); + }); + }); + + describe('setPage', () => { + let component; + beforeEach(() => { + ({ component } = createComponent()); + }); + + it('does nothing when page is undefined', () => { + component.pagination.currentPage = 0; + component.setPage(undefined); + expect(component.pagination.currentPage).toEqual(0); + }); + + it('does nothing when page is null', () => { + component.pagination.currentPage = 0; + component.setPage(null); + expect(component.pagination.currentPage).toEqual(0); + }); + + it('does nothing when page is negative', () => { + component.pagination.currentPage = 0; + component.setPage(-1); + expect(component.pagination.currentPage).toEqual(0); + }); + + it('does nothing when page is 0', () => { + component.pagination.currentPage = 1; + component.setPage(0); + expect(component.pagination.currentPage).toEqual(1); + }); + }); +}); diff --git a/src/app/list/list.component.ts b/src/app/list/list.component.ts new file mode 100644 index 00000000..4e180e5a --- /dev/null +++ b/src/app/list/list.component.ts @@ -0,0 +1,735 @@ +import { Location } from '@angular/common'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap, Params, Router } from '@angular/router'; +import { Application } from 'app/models/application'; +import { IApplicationQueryParamSet, QueryParamModifier } from 'app/services/api'; +import { ApplicationService } from 'app/services/application.service'; +import { RegionCodes, StatusCodes, ReasonCodes, PurposeCodes } from 'app/utils/constants/application'; +import { CodeType, ConstantUtils } from 'app/utils/constants/constantUtils'; +import * as _ from 'lodash'; +import * as moment from 'moment'; +import { forkJoin, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { ExportService } from 'app/services/export.service'; + +interface IPaginationParameters { + totalItems?: number; + currentPage?: number; +} + +@Component({ + selector: 'app-list', + templateUrl: './list.component.html', + styleUrls: ['./list.component.scss'] +}) +export class ListComponent implements OnInit, OnDestroy { + private ngUnsubscribe: Subject = new Subject(); + + // url parameters, used to set the initial state of the page on load + public paramMap: ParamMap = null; + + // indicates the page is loading + public loading = true; + // indicates a search is in progress + public searching = false; + // indicates an export is in progress + public exporting = false; + + // list of applications to display + public applications: Application[] = []; + + // drop down filter values + public purposeCodes = new PurposeCodes().getCodeGroups(); + public regionCodes = new RegionCodes().getCodeGroups(); + public statusCodes = new StatusCodes().getCodeGroups(); + // enforce specific comment filter order for esthetics + // public commentCodes = [CommentCodes.NOT_STARTED, CommentCodes.OPEN, CommentCodes.CLOSED, CommentCodes.NOT_OPEN]; + + // selected drop down filters + public purposeCodeFilters: string[] = []; + public regionCodeFilter = ''; + public statusCodeFilters: string[] = []; + public applicantFilter = ''; + // public commentCodeFilters: string[] = []; + + // need to reset pagination when a filter is changed, as we can't be sure how many pages of results will exist. + public filterChanged = false; + + // pagination values + public pagination = { + totalItems: 0, + currentPage: 1, + itemsPerPage: 25, + pageCount: 1, + message: '' + }; + + // sorting values + public sorting = { + column: null, + direction: 0 + }; + + constructor( + private location: Location, + private router: Router, + private route: ActivatedRoute, + private applicationService: ApplicationService, + private exportService: ExportService + ) {} + + /** + * Component init. + * + * @memberof ListComponent + */ + ngOnInit(): void { + this.route.queryParamMap.pipe(takeUntil(this.ngUnsubscribe)).subscribe(paramMap => { + this.paramMap = paramMap; + + this.setInitialQueryParameters(); + this.getApplications(); + }); + } + + /** + * Fetches applications from ACRFD based on the current filter and pagination parameters. + * + * Makes 2 calls: + * - get applications (fetches at most pagination.itemsPerPage applications) + * - get applications count (the total count of matching applications, used when rendering pagination controls) + * + * @memberof ListComponent + */ + public getApplications(): void { + this.searching = true; + + if (this.filterChanged) { + this.resetPagination(); + } + + forkJoin( + this.applicationService.getAll({ getCurrentPeriod: true }, this.getApplicationQueryParamSets()), + this.applicationService.getCount(this.getApplicationQueryParamSets()) + ) + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe( + ([applications, count]) => { + this.updatePagination({ totalItems: count }); + this.applications = applications; + + this.searching = false; + this.loading = false; + }, + error => { + console.log('error = ', error); + alert("Uh-oh, couldn't load applications"); + this.router.navigate(['/list']); + } + ); + } + + // Export + + /** + * Fetches all applications that match the filter criteria (ignores pagination) and parses the resulting json into + * a csv for download. Includes more fields than are shown on the web-page. + * + * @memberof ListComponent + */ + public export(): void { + this.exporting = true; + const queryParamsSet = this.getApplicationQueryParamSets(); + + // ignore pagination as we want to export ALL search results + queryParamsSet.forEach(element => { + delete element.pageNum; + delete element.pageSize; + }); + + this.applicationService + .getAll({ getCurrentPeriod: true }, queryParamsSet) + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe( + applications => { + // All fields that will be included in the csv, and optionally what the column header text will be. + // See www.npmjs.com/package/json2csv for details on the format of the fields array. + const fields: any[] = [ + { label: 'CL File', value: this.getExportPadStartFormatter('cl_file') }, + { label: 'Disposition ID', value: 'tantalisID' }, + { label: 'Applicant (client)', value: 'client' }, + { label: 'Business Unit', value: 'businessUnit' }, + { label: 'Location', value: 'location' }, + { label: 'Area (hectares)', value: 'areaHectares' }, + { label: 'Created Date', value: this.getExportDateFormatter('createdDate') }, + { label: 'Publish Date', value: this.getExportDateFormatter('publishDate') }, + { label: 'Purpose', value: 'purpose' }, + { label: 'Subpurpose', value: 'subpurpose' }, + { label: 'status', value: this.getExportStatusFormatter('status', 'reason') }, + { label: 'last status update date', value: this.getExportDateFormatter('statusHistoryEffectiveDate') }, + { label: 'Type', value: 'type' }, + { label: 'Subtype', value: 'subtype' }, + { label: 'Tenure Stage', value: 'tenureStage' }, + { label: 'Description', value: 'description' }, + { label: 'Legal Description', value: 'legalDescription' }, + { label: 'Is Retired', value: 'meta.isRetired' }, + { label: 'Retire Date', value: this.getExportDateFormatter('meta.retireDate') }, + { label: 'Comment Period: Status', value: 'meta.cpStatusStringLong' }, + { label: 'Comment Period: Start Date', value: this.getExportDateFormatter('meta.currentPeriod.startDate') }, + { label: 'Comment Period: End Date', value: this.getExportDateFormatter('meta.currentPeriod.endDate') }, + { label: 'Comment Period: Number of Comments', value: 'meta.numComments' } + ]; + this.exportService.exportAsCSV( + applications, + `ACRFD_Applications_Export_${moment().format('YYYY-MM-DD_HH-mm')}`, + fields + ); + this.exporting = false; + }, + error => { + this.exporting = false; + console.log('error = ', error); + alert("Uh-oh, couldn't export applications"); + } + ); + } + + /** + * Convenience method for converting an export date field to a formatted date string that is recognized by Excel as + * a Date. + * + * Note: See www.npmjs.com/package/json2csv for details on what this function is supporting. + * + * @param {string} dateProperty the object property for the date (the key path, not the value). Can be the path to a + * nested date field: 'some.nested.date' + * @returns {(row) => string} a function that takes a row and returns a string + * @memberof ListComponent + */ + public getExportDateFormatter(dateProperty: string): (row) => string { + return row => { + const dateProp = _.get(row, dateProperty); + + if (!dateProp) { + return null; + } + + const date = moment(dateProp); + + if (!date.isValid()) { + return dateProp; + } + + return date.format('YYYY-MM-DD'); + }; + } + + /** + * Convenience method for converting an export Tantalis status code into its ACRFD status code. + * + * Note: See www.npmjs.com/package/json2csv for details on what this function is supporting. + * + * @param {string} statusProperty the object property for the status (the key path, not the value). Can be the path to + * a nested status field: 'some.nested.status' + * @param {string} reasonProperty the object property for the reason (the key path, not the value). Can be the path to + * a nested reason field: 'some.nested.reason' + * @returns {(row) => string} a function that takes a row and returns a string + * @memberof ListComponent + */ + public getExportStatusFormatter(statusProperty: string, reasonProperty: string): (row) => string { + return row => { + const statusProp = _.get(row, statusProperty); + const reasonProp = _.get(row, reasonProperty); + + return this.applicationService.getStatusStringLong(new Application({ status: statusProp, reason: reasonProp })); + }; + } + + /** + * Convenience method for padding a value with 0's to at least 7 characters. + * If the string is of length 7 or more to begin with, no padding is performed. + * + * Note: See www.npmjs.com/package/json2csv for details on what this function is supporting. + * + * @param {string} property the object property for a value (the key path, not the value). Can be the path to a + * nested field: 'some.nested.value' + * @returns {(row) => string} a function that takes a row and returns a string + * @memberof ListComponent + */ + public getExportPadStartFormatter(property: string): (row) => string { + return row => { + const prop = _.get(row, property); + + if (!prop) { + return null; + } + + return prop.toString().padStart(7, '0'); + }; + } + + // URL Parameters + + /** + * Set any initial filter, pagination, and sort values that were saved in the URL. + * + * @memberof ListComponent + */ + public setInitialQueryParameters(): void { + this.pagination.currentPage = +this.paramMap.get('page') || 1; + + this.sorting.column = (this.paramMap.get('sortBy') && this.paramMap.get('sortBy').slice(1)) || null; + this.sorting.direction = + (this.paramMap.get('sortBy') && (this.paramMap.get('sortBy').charAt(0) === '-' ? -1 : 1)) || 0; + + this.purposeCodeFilters = (this.paramMap.get('purpose') && this.paramMap.get('purpose').split('|')) || []; + this.regionCodeFilter = this.paramMap.get('region') || ''; + this.statusCodeFilters = (this.paramMap.get('status') && this.paramMap.get('status').split('|')) || []; + this.applicantFilter = this.paramMap.get('applicant') || ''; + // this.commentCodeFilters = (this.paramMap.get('comment') && this.paramMap.get('comment').split('|')) || []; + } + + /** + * Builds an array of query parameter sets. + * + * Each query parameter set in the array will return a distinct set of results. + * + * The combined results from all query parameter sets is needed to fully satisfy the filters. + * + * @returns {IApplicationQueryParamSet[]} An array of distinct query parameter sets. + * @memberof ListComponent + */ + public getApplicationQueryParamSets(): IApplicationQueryParamSet[] { + let applicationQueryParamSets: IApplicationQueryParamSet[] = []; + + // None of these filters require manipulation or unique considerations + + const basicQueryParams: IApplicationQueryParamSet = { + isDeleted: false, + pageNum: this.pagination.currentPage - 1, // API starts at 0, while this component starts at 1 + pageSize: this.pagination.itemsPerPage, + purpose: { + value: _.flatMap( + this.purposeCodeFilters.map(purposeCode => ConstantUtils.getCode(CodeType.PURPOSE, purposeCode)) + ), + modifier: QueryParamModifier.Equal + }, + businessUnit: { + value: ConstantUtils.getCode(CodeType.REGION, this.regionCodeFilter), + modifier: QueryParamModifier.Equal + }, + client: { + value: this.applicantFilter, + modifier: QueryParamModifier.Text + } + }; + + if (this.sorting.column && this.sorting.direction) { + basicQueryParams.sortBy = `${this.sorting.direction === -1 ? '-' : '+'}${this.sorting.column}`; + } + + // Certain Statuses require unique considerations, which are accounted for here + + const appStatusCodeGroups = + (this.statusCodeFilters && + _.flatMap(this.statusCodeFilters, statusParam => ConstantUtils.getCodeGroup(CodeType.STATUS, statusParam))) || + []; + + appStatusCodeGroups.forEach(statusCodeGroup => { + if (statusCodeGroup === StatusCodes.ABANDONED) { + // Fetch applications with Abandoned Status that don't have a Reason indicating an amendment. + applicationQueryParamSets.push({ + ...basicQueryParams, + status: { value: StatusCodes.ABANDONED.mappedCodes, modifier: QueryParamModifier.Equal }, + reason: { + value: [ReasonCodes.AMENDMENT_APPROVED.code, ReasonCodes.AMENDMENT_NOT_APPROVED.code], + modifier: QueryParamModifier.Not_Equal + } + }); + } else if (statusCodeGroup === StatusCodes.DECISION_APPROVED) { + // Fetch applications with Approved status + applicationQueryParamSets.push({ + ...basicQueryParams, + status: { value: statusCodeGroup.mappedCodes, modifier: QueryParamModifier.Equal } + }); + + // Also fetch applications with an Abandoned status that also have a Reason indicating an approved amendment. + applicationQueryParamSets.push({ + ...basicQueryParams, + status: { value: StatusCodes.ABANDONED.mappedCodes, modifier: QueryParamModifier.Equal }, + reason: { + value: [ReasonCodes.AMENDMENT_APPROVED.code], + modifier: QueryParamModifier.Equal + } + }); + } else if (statusCodeGroup === StatusCodes.DECISION_NOT_APPROVED) { + // Fetch applications with Not Approved status + applicationQueryParamSets.push({ + ...basicQueryParams, + status: { value: statusCodeGroup.mappedCodes, modifier: QueryParamModifier.Equal } + }); + + // Also fetch applications with an Abandoned status that also have a Reason indicating a not approved amendment. + applicationQueryParamSets.push({ + ...basicQueryParams, + status: { value: StatusCodes.ABANDONED.mappedCodes, modifier: QueryParamModifier.Equal }, + reason: { + value: [ReasonCodes.AMENDMENT_NOT_APPROVED.code], + modifier: QueryParamModifier.Equal + } + }); + } else { + // This status requires no special treatment, fetch as normal + applicationQueryParamSets.push({ + ...basicQueryParams, + status: { value: statusCodeGroup.mappedCodes, modifier: QueryParamModifier.Equal } + }); + } + }); + + // if no status filters selected, still add the basic query filters + if (applicationQueryParamSets.length === 0) { + applicationQueryParamSets = [{ ...basicQueryParams }]; + } + + return applicationQueryParamSets; + } + + /** + * Save filter, pagination, and sort values as params in the URL. + * + * @memberof ListComponent + */ + public saveQueryParameters(): void { + const params: Params = {}; + + params['page'] = this.pagination.currentPage; + + if (this.sorting.column && this.sorting.direction) { + params['sortBy'] = `${this.sorting.direction === -1 ? '-' : '+'}${this.sorting.column}`; + } + + if (this.purposeCodeFilters && this.purposeCodeFilters.length) { + params['purpose'] = this.convertArrayIntoPipeString(this.purposeCodeFilters); + } + if (this.regionCodeFilter) { + params['region'] = this.regionCodeFilter; + } + if (this.statusCodeFilters && this.statusCodeFilters.length) { + params['status'] = this.convertArrayIntoPipeString(this.statusCodeFilters); + } + if (this.applicantFilter) { + params['applicant'] = this.applicantFilter; + } + // if (this.commentCodeFilters && this.commentCodeFilters.length) { + // params['comment'] = this.convertArrayIntoPipeString(this.commentCodeFilters); + // } + + // change browser URL without reloading page (so any query params are saved in history) + this.location.go(this.router.createUrlTree([], { relativeTo: this.route, queryParams: params }).toString()); + } + + /** + * Reset filter, pagination, and sort values to their defaults. + * + * @memberof ListComponent + */ + public clearQueryParameters(): void { + this.pagination.currentPage = 1; + this.pagination.totalItems = 0; + + this.sorting.column = null; + this.sorting.direction = 0; + + this.purposeCodeFilters = []; + this.regionCodeFilter = ''; + this.statusCodeFilters = []; + this.applicantFilter = ''; + // this.commentCodeFilters = []; + + this.location.go(this.router.createUrlTree([], { relativeTo: this.route }).toString()); + } + + // Filters + + /** + * Set application purpose filter. + * + * @param {string} purposeCode + * @memberof ListComponent + */ + public setPurposeFilter(purposeCode: string): void { + this.purposeCodeFilters = purposeCode ? [purposeCode] : []; + this.filterChanged = true; + this.saveQueryParameters(); + } + + /** + * Set application status filter. + * + * @param {string} statusCode + * @memberof ListComponent + */ + public setStatusFilter(statusCode: string): void { + this.statusCodeFilters = statusCode ? [statusCode] : []; + this.filterChanged = true; + this.saveQueryParameters(); + } + + /** + * Set application region filter. + * + * @param {string} regionCode + * @memberof ListComponent + */ + public setRegionFilter(regionCode: string): void { + this.regionCodeFilter = regionCode || ''; + this.filterChanged = true; + this.saveQueryParameters(); + } + + public setApplicantFilter(applicantString: string): void { + this.applicantFilter = applicantString || ''; + this.filterChanged = true; + this.saveQueryParameters(); + } + + // /** + // * Set comment period status filter. + // * + // * @param {string} commentCode + // * @memberof ListComponent + // */ + // public setCommentFilter(commentCode: string): void { + // this.commentCodeFilters = commentCode ? [commentCode] : []; + // this.filterChanged = true; + // this.saveQueryParameters(); + // } + + // /** + // * Given an array of Applications, filter out comment periods that don't match the comment period status filter. + // * + // * @param {Application[]} applications + // * @returns + // * @memberof ListComponent + // */ + // public applyCommentPeriodFilter(applications: Application[]): Application[] { + // if (!applications || !this.commentCodeFilters || !this.commentCodeFilters.length) { + // return applications; + // } + + // return applications.filter(application => { + // return _.flatMap( + // this.commentCodeFilters.map(commentCode => ConstantUtils.getTextLong(CodeType.COMMENT, commentCode)) + // ).includes(application.meta.cpStatusStringLong); + // }); + // } + + // Sorting + + /** + * Sets the sort properties (column, direction) used by the OrderBy pipe. + * + * @param {string} sortBy + * @memberof DocumentsComponent + */ + public sort(sortBy: string): void { + if (!sortBy) { + return; + } + + if (this.sorting.column === sortBy) { + // when sorting on the same column, toggle sorting + this.sorting.direction = this.sorting.direction > 0 ? -1 : 1; + } else { + // when sorting on a new column, sort descending + this.sorting.column = sortBy; + this.sorting.direction = 1; + } + + this.saveQueryParameters(); + this.getApplications(); + } + + // Pagination + + /** + * Updates the pagination variables. + * + * Note: some variables can be passed in, while others are always calculated based on other variables, and so can't + * be set manually. + * + * @param {IPaginationParameters} [paginationParams=null] + * @returns {void} + * @memberof ListComponent + */ + public updatePagination(paginationParams: IPaginationParameters = null): void { + if (!paginationParams) { + // nothing to update + return; + } + + if (paginationParams.totalItems >= 0) { + this.pagination.totalItems = paginationParams.totalItems; + } + + if (paginationParams.currentPage >= 0) { + this.pagination.currentPage = paginationParams.currentPage; + } + + this.pagination.pageCount = Math.max(1, Math.ceil(this.pagination.totalItems / this.pagination.itemsPerPage)); + + if (this.pagination.totalItems <= 0) { + this.pagination.message = 'No applications found'; + } else if (this.pagination.currentPage > this.pagination.pageCount) { + // This check is necessary due to a rare edge-case where the user has manually incremented the page parameter in + // the URL beyond what would normally be allowed. As a result when applications are fetched, there aren't enough + // to reach this page, and so the total applications found is > 0, but the applications displayed for this page + // is 0, which may confuse users. Tell them to press clear button which will reset the pagination url parameter. + this.pagination.message = 'Unable to display results, please clear and re-try'; + } else { + const low = Math.max((this.pagination.currentPage - 1) * this.pagination.itemsPerPage + 1, 1); + const high = Math.min(this.pagination.totalItems, this.pagination.currentPage * this.pagination.itemsPerPage); + this.pagination.message = `Displaying ${low} - ${high} of ${this.pagination.totalItems} applications`; + } + } + + /** + * Resets the pagination.currentPage variable locally and in the URL. + * + * This is necessary due to a rare edge-case where the user has manually incremented the page parameter in the URL + * beyond what would normally be allowed. As a result when applications are fetched, there aren't enough to reach + * this page, and so the total applications found is > 0, but the applications displayed for this page is 0. + * + * @memberof ListComponent + */ + public resetPagination(): void { + // Minor UI improvement: don't call updatePagination here directly, as it will change the message briefly, before + // it is updated by the getApplications call. + this.pagination.currentPage = 1; + this.saveQueryParameters(); + this.filterChanged = false; + } + + /** + * Increments or decrements the pagination.currentPage by 1. + * + * @param {number} [page=0] either 1 or -1 + * @memberof ListComponent + */ + public updatePage(page: number = 0): void { + if ( + (page === -1 && this.pagination.currentPage + page >= 1) || + (page === 1 && this.pagination.pageCount >= this.pagination.currentPage + page) + ) { + this.updatePagination({ currentPage: this.pagination.currentPage += page }); + this.saveQueryParameters(); + this.getApplications(); + } + } + + /** + * Jumps the pagination to the specified page. Won't allow changes to pages that have no results. + * + * @param {number} [page=0] a number > 0 + * @memberof ListComponent + */ + public setPage(page: number = 0): void { + if (page >= 1 && this.pagination.pageCount >= page) { + this.updatePagination({ currentPage: page }); + this.saveQueryParameters(); + this.getApplications(); + } + } + + // Other + + /** + * Turns an array of strings into a single string where each element is deliminited with a pipe character. + * + * Example: ['dog', 'cat', 'bird'] => 'dog|cat|bird|' + * + * @param {any[]} collection an array of strings to concatenate. + * @returns {string} + * @memberof ApiService + */ + public convertArrayIntoPipeString(collection: string[]): string { + let values = ''; + _.each(collection, a => { + values += a + '|'; + }); + // trim the last | + return values.replace(/\|$/, ''); + } + + /** + * Returns true if the application has an abandoned status AND an amendment reason. + * + * @param {Application} application + * @returns {boolean} true if the application has an abandoned status AND an amendment reason, false otherwise. + * @memberof ListComponent + */ + isAmendment(application: Application): boolean { + return !!( + application && + ConstantUtils.getCode(CodeType.STATUS, application.status) === StatusCodes.ABANDONED.code && + (ConstantUtils.getCode(CodeType.REASON, application.reason) === ReasonCodes.AMENDMENT_APPROVED.code || + ConstantUtils.getCode(CodeType.REASON, application.reason) === ReasonCodes.AMENDMENT_NOT_APPROVED.code) + ); + } + + /** + * Given an application, returns a long user-friendly status string. + * + * @param {Application} application + * @returns {string} + * @memberof ListComponent + */ + getStatusStringLong(application: Application): string { + if (!application) { + return StatusCodes.UNKNOWN.text.long; + } + + // If the application was abandoned, but the reason is due to an amendment, then return an amendment string instead + if (this.isAmendment(application)) { + return ConstantUtils.getTextLong(CodeType.REASON, application.reason); + } + + return ( + (application && ConstantUtils.getTextLong(CodeType.STATUS, application.status)) || StatusCodes.UNKNOWN.text.long + ); + } + + isApplicationRetired(application: Application): boolean { + if ( + application.statusHistoryEffectiveDate && + [StatusCodes.DECISION_APPROVED.code, StatusCodes.DECISION_NOT_APPROVED.code, StatusCodes.ABANDONED.code].includes( + ConstantUtils.getCode(CodeType.STATUS, application.status) + ) + ) { + return moment(application.statusHistoryEffectiveDate) + .endOf('day') + .add(6, 'months') + .isBefore(); + } + + return false; + } + + getFormattedDate(date: Date = null): string { + if (!Date) { + return null; + } + + return moment(date).format('YYYY-MM-DD'); + } + + /** + * Cleanup on component destroy. + * + * @memberof ListComponent + */ + ngOnDestroy(): void { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } +} diff --git a/src/app/models/application.ts b/src/app/models/application.ts index 8ad1e3bc..5cd0d07a 100644 --- a/src/app/models/application.ts +++ b/src/app/models/application.ts @@ -6,7 +6,7 @@ import { Feature } from './feature'; import { ConstantUtils, CodeType } from 'app/utils/constants/constantUtils'; export class Application { - // the following are retrieved from the API + // Database fields _id: string; agency: string; areaHectares: number; @@ -18,6 +18,7 @@ export class Application { legalDescription: string = null; location: string; name: string; + createdDate: Date = null; publishDate: Date = null; purpose: string; status: string; @@ -30,22 +31,51 @@ export class Application { statusHistoryEffectiveDate: Date = null; tags: string[] = []; - region: string; - cpStatus: string; // user-friendly comment period status - - clFile: string; - applicants: string; - retireDate: Date = null; - isRetired = false; - isPublished = false; // depends on tags; see below - - // associated data - currentPeriod: CommentPeriod = null; - decision: Decision = null; - documents: Document[] = []; - features: Feature[] = []; + /** + * This field, and its internals, are not part of the database model. + * + * They are included here as a convenient way to store various bits of data that we don't keep in the database, but + * which get used by the app in multiple places. This way, we don't need to generate them repeatedly. + * + * Example: the region field is just a user friendly version of the businessUnit. + * + * @memberof Application + */ + meta: { + region: string; + cpStatusStringLong: string; + clFile: string; + applicants: string; + retireDate: Date; + isRetired: boolean; + isPublished: boolean; + numComments: number; + isCreated: boolean; + + // Associated data from other database collections + currentPeriod: CommentPeriod; + decision: Decision; + documents: Document[]; + features: Feature[]; + } = { + region: '', + cpStatusStringLong: '', + clFile: '', + applicants: '', + retireDate: null, + isRetired: false, + isPublished: false, + numComments: null, + isCreated: false, + + currentPeriod: null, + decision: null, + documents: [], + features: [] + }; constructor(obj?: any) { + // Database fields this._id = (obj && obj._id) || null; this.agency = (obj && obj.agency) || null; this.areaHectares = (obj && obj.areaHectares) || null; @@ -63,11 +93,9 @@ export class Application { this.tenureStage = (obj && obj.tenureStage) || null; this.type = (obj && obj.type) || null; - this.region = (obj && obj.businessUnit && ConstantUtils.getTextLong(CodeType.REGION, obj.businessUnit)) || null; - this.cpStatus = (obj && obj.cpStatus) || null; - this.clFile = (obj && obj.clFile) || null; - this.applicants = (obj && obj.applicants) || null; - this.isRetired = (obj && obj.isRetired) || null; + if (obj && obj.createdDate) { + this.createdDate = new Date(obj.createdDate); + } if (obj && obj.publishDate) { this.publishDate = new Date(obj.publishDate); @@ -77,62 +105,70 @@ export class Application { this.statusHistoryEffectiveDate = new Date(obj.statusHistoryEffectiveDate); } - if (obj && obj.retireDate) { - this.retireDate = new Date(obj.retireDate); - } - - // replace \\n (JSON format) with newlines if (obj && obj.description) { + // replace \\n (JSON format) with newlines this.description = obj.description.replace(/\\n/g, '\n'); } if (obj && obj.legalDescription) { + // replace \\n (JSON format) with newlines this.legalDescription = obj.legalDescription.replace(/\\n/g, '\n'); } - // copy centroid if (obj && obj.centroid) { for (const num of obj.centroid) { this.centroid.push(num); } } - // copy tags if (obj && obj.tags) { for (const tag of obj.tags) { this.tags.push(tag); } } - if (obj && obj.currentPeriod) { - this.currentPeriod = new CommentPeriod(obj.currentPeriod); + // Associated data from other database collections + + if (obj && obj.meta && obj.meta.currentPeriod) { + this.meta.currentPeriod = new CommentPeriod(obj.meta.currentPeriod); } - if (obj && obj.decision) { - this.decision = new Decision(obj.decision); + if (obj && obj.meta && obj.meta.decision) { + this.meta.decision = new Decision(obj.meta.decision); } - // copy documents - if (obj && obj.documents) { - for (const doc of obj.documents) { - this.documents.push(doc); + if (obj && obj.meta && obj.meta.documents) { + for (const doc of obj.meta.documents) { + this.meta.documents.push(doc); } } - // copy features - if (obj && obj.features) { - for (const feature of obj.features) { - this.features.push(feature); + if (obj && obj.meta && obj.meta.features) { + for (const feature of obj.meta.features) { + this.meta.features.push(feature); } } - // wrap isPublished around the tags we receive for this object + // Non-database fields that may be manually added to this object for convenience. + + this.meta.region = + (obj && obj.businessUnit && ConstantUtils.getTextLong(CodeType.REGION, obj.businessUnit)) || null; + this.meta.cpStatusStringLong = (obj && obj.meta && obj.meta.cpStatusStringLong) || null; + this.meta.clFile = (obj && obj.meta && obj.meta.clFile) || null; + this.meta.applicants = (obj && obj.meta && obj.meta.applicants) || null; + this.meta.isRetired = (obj && obj.meta && obj.meta.isRetired) || null; + if (obj && obj.meta && obj.meta.retireDate) { + this.meta.retireDate = new Date(obj.retireDate); + } + this.meta.isCreated = (obj && obj.meta && obj.meta.isCreated) || null; if (obj && obj.tags) { + // isPublished is based on the presence of the 'public' tag for (const tag of obj.tags) { if (_.includes(tag, 'public')) { - this.isPublished = true; + this.meta.isPublished = true; break; } } } + this.meta.numComments = (obj && obj.numComments) || null; } } diff --git a/src/app/models/commentperiod.ts b/src/app/models/commentperiod.ts index 30d81f47..306fae85 100644 --- a/src/app/models/commentperiod.ts +++ b/src/app/models/commentperiod.ts @@ -1,15 +1,33 @@ import * as _ from 'lodash'; export class CommentPeriod { + // Database fields _id: string; _addedBy: string; _application: string; startDate: Date = null; endDate: Date = null; - isPublished = false; // depends on tags; see below + /** + * This field, and its internals, are not part of the database model. + * + * They are included here as a convenient way to store various bits of data that we don't keep in the database, but + * which get used by the app in multiple places. This way, we don't need to generate them repeatedly. + * + * Example: the isPublished field is just a boolean generated based on the presence of the 'public' tag. + * + * @memberof Application + */ + meta: { + isPublished: boolean; + daysRemaining: number; + } = { + isPublished: false, + daysRemaining: null + }; constructor(obj?: any) { + // Database fields this._id = (obj && obj._id) || null; this._addedBy = (obj && obj._addedBy) || null; this._application = (obj && obj._application) || null; @@ -22,14 +40,17 @@ export class CommentPeriod { this.endDate = new Date(obj.endDate); } - // wrap isPublished around the tags we receive for this object + // Non-database fields that may be manually added to this object for convenience + if (obj && obj.tags) { + // isPublished is based on the presence of the 'public' tag for (const tag of obj.tags) { if (_.includes(tag, 'public')) { - this.isPublished = true; + this.meta.isPublished = true; break; } } } + this.meta.daysRemaining = (obj && obj.meta && obj.meta.daysRemaining) || null; } } diff --git a/src/app/models/decision.ts b/src/app/models/decision.ts index 04ad3eb2..c0cdecc8 100644 --- a/src/app/models/decision.ts +++ b/src/app/models/decision.ts @@ -2,34 +2,53 @@ import * as _ from 'lodash'; import { Document } from './document'; export class Decision { + // Database fields _id: string; _addedBy: string; _application: string; // objectid -> Application name: string; - // associated data - documents: Document[] = []; + /** + * This field, and its internals, are not part of the database model. + * + * They are included here as a convenient way to store various bits of data that we don't keep in the database, but + * which get used by the app in multiple places. This way, we don't need to generate them repeatedly. + * + * Example: the region field is just a user friendly version of the businessUnit. + * + * @memberof Application + */ + meta: { + isPublished: boolean; - isPublished = false; // depends on tags; see below + // Associated data from other database collections + documents: Document[]; + } = { + isPublished: false, + + documents: [] + }; constructor(obj?: any) { + // Database fields this._id = (obj && obj._id) || null; this._addedBy = (obj && obj._addedBy) || null; this._application = (obj && obj._application) || null; this.name = (obj && obj.name) || null; - // copy documents - if (obj && obj.documents) { - for (const doc of obj.documents) { - this.documents.push(doc); + if (obj && obj.meta && obj.meta.documents) { + for (const doc of obj.meta.documents) { + this.meta.documents.push(doc); } } - // wrap isPublished around the tags we receive for this object + // Non-database fields that may be manually added to this object for convenience. + if (obj && obj.tags) { + // isPublished is based on the presence of the 'public' tag for (const tag of obj.tags) { if (_.includes(tag, 'public')) { - this.isPublished = true; + this.meta.isPublished = true; break; } } diff --git a/src/app/models/document.ts b/src/app/models/document.ts index 77704bcb..374df64d 100644 --- a/src/app/models/document.ts +++ b/src/app/models/document.ts @@ -1,6 +1,7 @@ import * as _ from 'lodash'; export class Document { + // Database fields _id: string; _addedBy: string; _application: string; // objectid -> Application @@ -12,9 +13,24 @@ export class Document { isDeleted: boolean; internalMime: string; - isPublished = false; // depends on tags; see below + /** + * This field, and its internals, are not part of the database model. + * + * They are included here as a convenient way to store various bits of data that we don't keep in the database, but + * which get used by the app in multiple places. This way, we don't need to generate them repeatedly. + * + * Example: the isPublished field is just a boolean generated based on the presence of the 'public' tag. + * + * @memberof Application + */ + meta: { + isPublished: boolean; + } = { + isPublished: false + }; constructor(obj?: any) { + // Database fields this._id = (obj && obj._id) || null; this._addedBy = (obj && obj._addedBy) || null; this._application = (obj && obj._application) || null; @@ -26,11 +42,12 @@ export class Document { this.isDeleted = (obj && obj.isDeleted) || null; this.internalMime = (obj && obj.internalMime) || null; - // wrap isPublished around the tags we receive for this object + // Non-database fields that may be manually added to this object for convenience + if (obj && obj.tags) { for (const tag of obj.tags) { if (_.includes(tag, 'public')) { - this.isPublished = true; + this.meta.isPublished = true; break; } } diff --git a/src/app/models/search.ts b/src/app/models/search.ts index 1fe21dc4..d114772b 100644 --- a/src/app/models/search.ts +++ b/src/app/models/search.ts @@ -1,7 +1,3 @@ -import { Params } from '@angular/router'; -import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap'; -import * as _ from 'lodash'; - import { Feature } from './feature'; import { InterestedParty } from './interestedParty'; @@ -15,7 +11,6 @@ export class SearchResults { hostname: string; features: Feature[] = []; - sidsFound: string[] = []; // New Data CROWN_LANDS_FILE: string; @@ -80,13 +75,6 @@ export class SearchResults { } } - // copy sidsFound - if (search && search.sidsFound) { - for (const sid of search.sidsFound) { - this.sidsFound.push(sid); - } - } - // copy centroid if (search && search.centroid) { for (const num of search.centroid) { @@ -95,80 +83,3 @@ export class SearchResults { } } } - -export class SearchArray { - items: SearchResults[] = []; - - constructor(obj?: any) { - // copy items - if (obj && obj.items) { - for (const item of obj.items) { - this.items.push(item); - } - } - } - - sort() { - this.items.sort((a: SearchResults, b: SearchResults) => { - const aDate = a && a.date ? new Date(a.date).getTime() : 0; - const bDate = b && b.date ? new Date(b.date).getTime() : 0; - return bDate - aDate; - }); - } - - get length(): number { - return this.items.length; - } - - add(search?: SearchResults) { - if (search) { - this.items.push(search); - } - } -} - -export class SearchTerms { - keywords: string; // comma- or space-delimited list - dateStart: NgbDateStruct; - dateEnd: NgbDateStruct; - - constructor(obj?: any) { - this.keywords = (obj && obj.keywords) || null; - this.dateStart = (obj && obj.dateStart) || null; - this.dateEnd = (obj && obj.dateEnd) || null; - } - - getParams(): Params { - const params = {}; - - if (this.keywords) { - // tokenize by comma, space, etc and remove duplicate items - const keywords = _.uniq(this.keywords.match(/\b(\w+)/g)); - params['keywords'] = keywords.join(','); - } - if (this.dateStart) { - params['datestart'] = this.getDateParam(this.dateStart); - } - if (this.dateEnd) { - params['dateend'] = this.getDateParam(this.dateEnd); - } - - return params; - } - - private getDateParam(date: NgbDateStruct): string { - let dateParam = date.year + '-'; - - if (date.month < 10) { - dateParam += '0'; - } - dateParam += date.month + '-'; - - if (date.day < 10) { - dateParam += '0'; - } - dateParam += date.day; - - return dateParam; - } -} diff --git a/src/app/pipes/published.pipe.ts b/src/app/pipes/published.pipe.ts index d903bf35..3e46d770 100644 --- a/src/app/pipes/published.pipe.ts +++ b/src/app/pipes/published.pipe.ts @@ -11,6 +11,6 @@ export class PublishedPipe implements PipeTransform { if (!items) { return items; } - return items.filter(item => item.isPublished); + return items.filter(item => item.meta.isPublished); } } diff --git a/src/app/search/search.component.html b/src/app/search/search.component.html index 532ce3f2..e532e1fc 100644 --- a/src/app/search/search.component.html +++ b/src/app/search/search.component.html @@ -4,11 +4,11 @@

        Find Crown Land Applications

        - +
        - @@ -21,10 +21,10 @@

        Find Crown Land Applications

        - No results found for "{{keywords.length > 0 ? keywords.join(', ') : 'unknown'}}" + No results found for "{{keywords ? keywords : 'unknown'}}"

        - {{count}} results found for "{{keywords.length > 0 ? keywords.join(', ') : 'unknown'}}" + {{count}} results found for "{{keywords ? keywords : 'unknown'}}"

        @@ -40,12 +40,14 @@

        - + @@ -55,15 +57,13 @@

        - +
        - {{application.clFile}} + {{application.meta.clFile}} {{application.tantalisID}} + {{application.purpose | titlecase}} / {{application.subpurpose | titlecase}}
        -
        +
        @@ -72,7 +72,8 @@

        insert_drive_file View Application - @@ -84,23 +85,25 @@

        - + - {{application['numComments']}} {{application['numComments'] === 1 ? 'comment' : 'comments'}} + {{application.meta.numComments}} + {{application.meta.numComments === 1 ? 'comment' : 'comments'}}  -  - {{application.cpStatus || 'Unknown'}} + {{application.meta.cpStatusStringLong || 'Unknown'}} - +  -  - {{application.currentPeriod.startDate | date:'longDate'}} to {{application.currentPeriod.endDate | date:'longDate'}} - -  ({{application.currentPeriod['daysRemaining'] + (application.currentPeriod['daysRemaining'] === 1 ? ' day ' : ' days ') + 'remaining'}}) + {{application.meta.currentPeriod.startDate | date:'longDate'}} to + {{application.meta.currentPeriod.endDate | date:'longDate'}} + +  ({{application.meta.currentPeriod.meta.daysRemaining + (application.meta.currentPeriod.meta.daysRemaining === 1 ? ' day ' : ' days ') + 'remaining'}}) diff --git a/src/app/search/search.component.spec.ts b/src/app/search/search.component.spec.ts index 11ee7ae7..bc5f7250 100644 --- a/src/app/search/search.component.spec.ts +++ b/src/app/search/search.component.spec.ts @@ -1,282 +1,347 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { async, TestBed } from '@angular/core/testing'; import { DebugElement } from '@angular/core'; import { SearchComponent } from './search.component'; import { FormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { ActivatedRoute } from '@angular/router'; import { MatSnackBar } from '@angular/material'; -import { ApplicationService } from 'app/services/application.service'; import { SearchService } from 'app/services/search.service'; import { Application } from 'app/models/application'; import { of, throwError } from 'rxjs'; import { ActivatedRouteStub } from 'app/spec/helpers'; import { By } from '@angular/platform-browser'; -import { tick } from '@angular/core/src/render3'; import { CommentPeriod } from 'app/models/commentperiod'; describe('SearchComponent', () => { - let component: SearchComponent; - let fixture: ComponentFixture; - const activatedRouteStub = new ActivatedRouteStub(); - let searchService: SearchService; - let searchInput: HTMLSelectElement; - let searchButton: DebugElement; - let searchForm: DebugElement; - - const searchServiceStub = { - getAppsByClidDtid(keys: string[]) { - const applicationOne = new Application(); - const applicationTwo = new Application(); - return of([applicationOne, applicationTwo]); - } - }; - const snackbarSpy = jasmine.createSpyObj('MatSnackBar', ['open']); - const snackBarRef = jasmine.createSpyObj('MatSnackBarRef', ['onAction', 'dismiss']); + // component constructor mocks + const mockLocation = jasmine.createSpyObj('Location', ['go']); + const mockMatSnackBar = jasmine.createSpyObj('MatSnackBar', ['open']); + const mockMatSnackBarRef = jasmine.createSpyObj('MatSnackBarRef', ['onAction', 'dismiss']); + const mockSearchService = jasmine.createSpyObj('SearchService', ['getApplicationsByCLFileAndTantalisID']); + const mockActivatedRoute = new ActivatedRouteStub(); + + /** + * Set the mocks to their default stubbed state. + */ + beforeAll(() => { + setDefaultMockBehaviour(); + }); + /** + * Initialize the test bed. + */ beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [SearchComponent], - imports: [FormsModule, RouterTestingModule], providers: [ - { provide: MatSnackBar, useValue: snackbarSpy }, - { provide: SearchService, useValue: searchServiceStub }, - { provide: ActivatedRoute, useValue: activatedRouteStub } - ] + { provide: Location, useValue: mockLocation }, + { provide: MatSnackBar, useValue: mockMatSnackBar }, + { provide: SearchService, useValue: mockSearchService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute } + ], + imports: [FormsModule, RouterTestingModule] }).compileComponents(); })); - beforeEach(() => { - fixture = TestBed.createComponent(SearchComponent); - component = fixture.componentInstance; - searchInput = fixture.nativeElement.querySelector('#keywordInput'); - searchForm = fixture.debugElement.query(By.css('.search-form')); - searchButton = fixture.debugElement.query(By.css('button[type="submit"]')); + /** + * Sets the default stubbed behaviour of all mocks used by the component. + */ + function setDefaultMockBehaviour() { + mockLocation.go.and.stub(); + mockMatSnackBarRef.onAction.and.returnValue(of({})); + mockMatSnackBar.open.and.returnValue(mockMatSnackBarRef); + mockSearchService.getApplicationsByCLFileAndTantalisID.and.returnValue(of([])); + mockActivatedRoute.clear(); + } + + /** + * Initializes the component and fixture. + * + * - In most cases, this will be called in the beforeEach. + * - In tests that require custom mock behaviour, set up the mock behaviour before calling this. + * + * @param {boolean} [detectChanges=true] set to false if you want to manually call fixture.detectChanges(), etc. + * Usually you want to control this when the timing of ngOnInit, and similar auto-exec functions, matters. + * @returns {{component, fixture}} Object containing the component and test fixture. + */ + function createComponent(detectChanges: boolean = true) { + const fixture = TestBed.createComponent(SearchComponent); + const component = fixture.componentInstance; + + if (detectChanges) { + fixture.detectChanges(); + } - fixture.detectChanges(); - }); + return { component, fixture }; + } it('should be created', () => { + const { component } = createComponent(); + expect(component).toBeTruthy(); }); describe('route behavior', () => { describe('with no params in the route', () => { + let fixture; + let searchService; + let activatedRoute: ActivatedRouteStub; + beforeEach(() => { + ({ fixture } = createComponent(false)); searchService = TestBed.get(SearchService); - activatedRouteStub.setParams({}); + + activatedRoute = TestBed.get(ActivatedRoute); + activatedRoute.clear(); }); it('does not perform a search', () => { - spyOn(searchService, 'getAppsByClidDtid').and.callThrough(); + searchService.getApplicationsByCLFileAndTantalisID.calls.reset(); - component.ngOnInit(); + fixture.detectChanges(); - expect(searchService.getAppsByClidDtid).not.toHaveBeenCalled(); + expect(searchService.getApplicationsByCLFileAndTantalisID).not.toHaveBeenCalled(); }); }); - describe('with multiple keywords in the route', () => { - const urlParams = { keywords: '88888,99999', ms: '373' }; + describe('with multiple params in the route', () => { + let fixture; + let searchService; + let activatedRoute: ActivatedRouteStub; + + const urlParams = { keywords: '88888,99999' }; + beforeEach(() => { + ({ fixture } = createComponent(false)); searchService = TestBed.get(SearchService); - activatedRouteStub.setParams(urlParams); + activatedRoute = TestBed.get(ActivatedRoute); + activatedRoute.setQueryParamMap(urlParams); }); it('performs a search with those keywords', () => { - spyOn(searchService, 'getAppsByClidDtid').and.callThrough(); - component.ngOnInit(); + searchService.getApplicationsByCLFileAndTantalisID.calls.reset(); + + fixture.detectChanges(); - expect(searchService.getAppsByClidDtid).toHaveBeenCalledWith(['88888', '99999']); + expect(searchService.getApplicationsByCLFileAndTantalisID).toHaveBeenCalledWith(['88888', '99999']); }); }); describe('with one keyword in the route', () => { - const urlParams = { keywords: '88888', ms: '373' }; + const urlParams = { keywords: '88888' }; - beforeEach(() => { - searchService = TestBed.get(SearchService); + describe('happy path', () => { + let component; + let fixture; + let searchService; + let activatedRoute: ActivatedRouteStub; - activatedRouteStub.setParams(urlParams); - }); - - it('performs a search with those keywords', () => { - spyOn(searchService, 'getAppsByClidDtid').and.callThrough(); - - component.ngOnInit(); - - expect(searchService.getAppsByClidDtid).toHaveBeenCalledWith(['88888']); - }); - - it('sets the components applications to the results from the search service', () => { - const applicationOne = new Application({ - _id: 'APP_ONE', - tantalisID: 'TANT_ONE' + const application1 = new Application({ + _id: '11', + tantalisID: '1111' + }); + const application2 = new Application({ + _id: '22', + tantalisID: '2222' }); - const applicationTwo = new Application({ - _id: 'APP_TWO', - tantalisID: 'TANT_TWO' + const application3 = new Application({ + _id: '33', + tantalisID: '3333' }); - spyOn(searchService, 'getAppsByClidDtid').and.returnValue(of([applicationOne, applicationTwo])); + beforeEach(async(() => { + activatedRoute = TestBed.get(ActivatedRoute); + activatedRoute.setQueryParamMap(urlParams); - component.ngOnInit(); - fixture.detectChanges(); + searchService = TestBed.get(SearchService); + searchService.getApplicationsByCLFileAndTantalisID.calls.reset(); + searchService.getApplicationsByCLFileAndTantalisID.and.returnValue( + of([application1, application2, application3]) + ); - expect(component.applications).toEqual([applicationOne, applicationTwo]); - expect(component.count).toEqual(2); - }); + ({ component, fixture } = createComponent(false)); - it('does not add duplicate applications if they are already there', () => { - const ogApplication = new Application({ - _id: 'APP_ONE', - tantalisID: 'TANT_ONE' - }); - const duplicateApplication = new Application({ - _id: 'APP_ONE', - tantalisID: 'TANT_ONE' - }); + // start with an application to verify duplicates aren't added. + component.applications = [application2]; - spyOn(searchService, 'getAppsByClidDtid').and.returnValue(of([duplicateApplication])); + fixture.detectChanges(); + })); - component.ngOnInit(); + it('performs a search with those keywords', () => { + expect(searchService.getApplicationsByCLFileAndTantalisID).toHaveBeenCalledWith(['88888']); + }); - expect(component.applications).toEqual([ogApplication]); - expect(component.count).toEqual(1); + it('sets the components applications to the results from the search service, excluding duplicates', () => { + expect(component.count).toEqual(3); + expect(component.applications).toEqual([application1, application2, application3]); + }); }); - it('renders an error if the search service throws one', () => { - spyOn(searchService, 'getAppsByClidDtid').and.returnValue(throwError('Something went wrong!')); + describe('on error', () => { + let component; + let fixture; + let searchService; + let activatedRoute: ActivatedRouteStub; + let matSnackBar; + let matSnackBarRef; - snackbarSpy.open.and.returnValue(snackBarRef); - snackBarRef.onAction.and.returnValue(of({})); - const navigateSpy = spyOn((component as any).router, 'navigate'); + beforeEach(async(() => { + activatedRoute = TestBed.get(ActivatedRoute); + activatedRoute.setQueryParamMap(urlParams); - component.ngOnInit(); + matSnackBarRef = jasmine.createSpyObj('MatSnackBarRef', ['onAction', 'dismiss']); + matSnackBarRef.onAction.and.returnValue(of({})); - expect(snackbarSpy.open).toHaveBeenCalledWith('Error searching applications ...', 'RETRY'); + matSnackBar = TestBed.get(MatSnackBar); + matSnackBar.open.calls.reset(); + matSnackBar.open.and.returnValue(matSnackBarRef); - expect(navigateSpy).toHaveBeenCalledWith(['search', jasmine.anything()]); - }); - }); - }); + searchService = TestBed.get(SearchService); + searchService.getApplicationsByCLFileAndTantalisID.calls.reset(); + searchService.getApplicationsByCLFileAndTantalisID.and.returnValues( + throwError('Something went wrong!'), + of([]) + ); - describe('searching', () => { - beforeEach(() => { - searchService = TestBed.get(SearchService); - }); - // I was having trouble getting the items from the search input to actually - // trigger a change and be sent to the url - // TODO: update test to actually assert the term in the search input is reflected in the route. - xit('refreshes the current route with the search params', done => { - spyOn(searchService, 'getAppsByClidDtid').and.callThrough(); - - fixture.whenStable().then(() => { - const navigateSpy = spyOn((component as any).router, 'navigate'); - searchInput.value = '77777'; - searchInput.dispatchEvent(new Event('change')); - fixture.detectChanges(); + ({ component, fixture } = createComponent(false)); - searchForm.triggerEventHandler('ngSubmit', null); - // searchButton.nativeElement.click(); - // searchForm.submit(); + fixture.detectChanges(); + })); - // expect(searchService.getAppsByClidDtid).toHaveBeenCalledWith(['77777']); - expect(navigateSpy).toHaveBeenCalledWith(['search', { ms: jasmine.anything() }]); - done(); + it('renders an error if the search service throws an error', () => { + expect(matSnackBar.open).toHaveBeenCalledWith('Error searching applications ...', 'RETRY'); + // called twice because the error handling calls onSubmit, which returns the empty array the second time to + // prevent recursing forever. + expect(searchService.getApplicationsByCLFileAndTantalisID).toHaveBeenCalledTimes(2); + }); }); }); }); - describe('rendering', () => { - const urlParams = { keywords: '88888', ms: '373' }; + // describe('searching', () => { + // let component; + // let fixture; + // let searchService; + + // let searchInput: HTMLSelectElement; + // // const searchButton: DebugElement; + // let searchForm: DebugElement; + + // beforeEach(() => { + // ({ component, fixture } = createComponent()); + // searchService = TestBed.get(SearchService); + // }); + // // I was having trouble getting the items from the search input to actually + // // trigger a change and be sent to the url + // // TODO: update test to actually assert the term in the search input is reflected in the route. + // xit('refreshes the current route with the search params', done => { + // fixture.whenStable().then(() => { + // const navigateSpy = spyOn((component as any).router, 'navigate'); + // searchInput.value = '77777'; + // searchInput.dispatchEvent(new Event('change')); + // fixture.detectChanges(); + + // searchForm.triggerEventHandler('ngSubmit', null); + // // searchButton.nativeElement.click(); + // // searchForm.submit(); + + // // expect(searchService.getApplicationsByCLFileAndTantalisID).toHaveBeenCalledWith(['77777']); + // expect(navigateSpy).toHaveBeenCalledWith(['search', { ms: jasmine.anything() }]); + // done(); + // }); + // }); + // }); + + describe('UI', () => { + let fixture; + let searchService; + let activatedRoute: ActivatedRouteStub; + + const urlParams = { keywords: '999', ms: '123' }; + const valemontCommentPeriod = new CommentPeriod({ startDate: new Date(2018, 8, 29), endDate: new Date(2018, 11, 1) }); const valemontApplication = new Application({ - _id: 'VALEMONT', - tantalisID: 'TANT_VALEMONT', - client: 'Mr Moneybags', + _id: '111111', + cl_file: '66666', + tantalisID: '123456', + applicants: 'Mr Moneybags', purpose: 'Shred', subpurpose: 'Powder', - appStatus: 'Application Under Review', - cpStatus: 'Commenting Closed', - currentPeriod: valemontCommentPeriod + status: 'Application Under Review', + meta: { + currentPeriod: valemontCommentPeriod + } }); const applicationTwo = new Application({ - _id: 'APP_TWO', - tantalisID: 'TANT_TWO' + _id: '222222', + cl_file: '77777', + tantalisID: '654321' }); - let searchTable: DebugElement; beforeEach(() => { + ({ fixture } = createComponent(false)); searchService = TestBed.get(SearchService); - activatedRouteStub.setParams(urlParams); + activatedRoute = TestBed.get(ActivatedRoute); + activatedRoute.setQueryParamMap(urlParams); }); describe('with application results', () => { beforeEach(() => { - spyOn(searchService, 'getAppsByClidDtid').and.returnValue(of([valemontApplication, applicationTwo])); + searchService.getApplicationsByCLFileAndTantalisID.and.returnValue(of([valemontApplication, applicationTwo])); }); it('displays the application details on the page', () => { - component.ngOnInit(); - fixture.detectChanges(); - searchTable = fixture.debugElement.query(By.css('.search-results table')); + const searchTable: DebugElement = fixture.debugElement.query(By.css('.search-results table')); + const firstApplicationRow = searchTable.query(By.css('.app-details')); expect(firstApplicationRow).toBeDefined(); - const firstAppRowEl = firstApplicationRow.nativeElement; + const firstApplicationRowElement = firstApplicationRow.nativeElement; - expect(firstAppRowEl.textContent).toContain('TANT_VALEMONT'); - expect(firstAppRowEl.textContent).toContain('Mr Moneybags'); - expect(firstAppRowEl.textContent).toContain('Application Under Review'); - expect(firstAppRowEl.textContent).toContain('Shred / Powder'); + expect(firstApplicationRowElement.textContent).toContain('123456'); + expect(firstApplicationRowElement.textContent).toContain('Mr Moneybags'); + expect(firstApplicationRowElement.textContent).toContain('Application Under Review'); + expect(firstApplicationRowElement.textContent).toContain('Shred / Powder'); }); - // The "isCreated" property is set in search.service. I'm not entirely clear - // on what it means, but it's pretty important. Maybe whether or not it exists in Tantalis? - describe('when the application "isCreated" property is true', () => { beforeEach(() => { valemontApplication['isCreated'] = true; + valemontApplication['numComments'] = 200; }); it('renders the comment period status and number of comments', () => { - valemontCommentPeriod.startDate = new Date(2018, 8, 29); - valemontCommentPeriod.endDate = new Date(2018, 11, 1); - - valemontApplication['numComments'] = 200; - - component.ngOnInit(); - fixture.detectChanges(); - searchTable = fixture.debugElement.query(By.css('.search-results table')); + const searchTable: DebugElement = fixture.debugElement.query(By.css('.search-results table')); + const firstCommentDetailsRow = searchTable.query(By.css('.app-comment-details')); expect(firstCommentDetailsRow).toBeDefined(); - const firstCommentRowEl = firstCommentDetailsRow.nativeElement; - expect(firstCommentRowEl.textContent).toContain('200 comments'); - expect(firstCommentRowEl.textContent).toContain('Commenting Closed'); - expect(firstCommentRowEl.textContent).toContain('September 29, 2018 to December 1, 2018'); + const firstCommentRowElement = firstCommentDetailsRow.nativeElement; + + expect(firstCommentRowElement.textContent).toContain('200 comments'); + expect(firstCommentRowElement.textContent).toContain('Commenting Closed'); + expect(firstCommentRowElement.textContent).toContain('September 29, 2018 to December 1, 2018'); }); it('renders the "Actions" button', () => { - component.ngOnInit(); - fixture.detectChanges(); - searchTable = fixture.debugElement.query(By.css('.search-results table')); + const searchTable: DebugElement = fixture.debugElement.query(By.css('.search-results table')); + const firstButton = searchTable.query(By.css('button')); - const firstButtonEl = firstButton.nativeElement; - expect(firstButtonEl.textContent).toContain('Actions'); + const firstButtonElement = firstButton.nativeElement; + expect(firstButtonElement.textContent).toContain('Actions'); }); }); @@ -286,37 +351,35 @@ describe('SearchComponent', () => { }); it('does not render commenting details', () => { - component.ngOnInit(); - fixture.detectChanges(); - searchTable = fixture.debugElement.query(By.css('.search-results table')); + const searchTable: DebugElement = fixture.debugElement.query(By.css('.search-results table')); + const firstCommentDetailsRow = searchTable.query(By.css('.app-comment-details')); expect(firstCommentDetailsRow).toBeFalsy(); }); it('renders the "Create" button', () => { - component.ngOnInit(); - fixture.detectChanges(); - searchTable = fixture.debugElement.query(By.css('.search-results table')); + const searchTable: DebugElement = fixture.debugElement.query(By.css('.search-results table')); + const firstButton = searchTable.query(By.css('button')); - const firstButtonEl = firstButton.nativeElement; - expect(firstButtonEl.textContent).toContain('Create'); + const firstButtonElement = firstButton.nativeElement; + expect(firstButtonElement.textContent).toContain('Create'); }); }); it('renders each application result on the page', () => { - component.ngOnInit(); - fixture.detectChanges(); - searchTable = fixture.debugElement.query(By.css('.search-results table')); + const searchTable: DebugElement = fixture.debugElement.query(By.css('.search-results table')); const appDetailsRows = searchTable.nativeElement.querySelectorAll('tr.app-details'); expect(appDetailsRows.length).toBe(2); }); }); + + describe('with no application results', () => {}); }); }); diff --git a/src/app/search/search.component.ts b/src/app/search/search.component.ts index 1d82ef2d..baa07e06 100644 --- a/src/app/search/search.component.ts +++ b/src/app/search/search.component.ts @@ -1,12 +1,12 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { MatSnackBarRef, SimpleSnackBar, MatSnackBar } from '@angular/material'; -import { Router, ActivatedRoute } from '@angular/router'; +import { Router, ActivatedRoute, Params, ParamMap } from '@angular/router'; +import { Location } from '@angular/common'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import * as _ from 'lodash'; import { SearchService } from 'app/services/search.service'; -import { SearchTerms } from 'app/models/search'; import { Application } from 'app/models/application'; import { ConstantUtils, CodeType } from 'app/utils/constants/constantUtils'; import { StatusCodes, ReasonCodes } from 'app/utils/constants/application'; @@ -17,54 +17,47 @@ import { StatusCodes, ReasonCodes } from 'app/utils/constants/application'; styleUrls: ['./search.component.scss'] }) export class SearchComponent implements OnInit, OnDestroy { - public terms = new SearchTerms(); - public searching = false; - public ranSearch = false; - public keywords: string[] = []; + private ngUnsubscribe = new Subject(); + private paramMap: ParamMap = null; + + public keywords: string; public applications: Application[] = []; - public count = 0; // for template + public count = 0; // used in template + private snackBarRef: MatSnackBarRef = null; - private ngUnsubscribe = new Subject(); + + public searching = false; + public ranSearch = false; constructor( + private location: Location, public snackBar: MatSnackBar, - public searchService: SearchService, // also used in template + public searchService: SearchService, // used in template private router: Router, private route: ActivatedRoute ) {} ngOnInit() { // get search terms from route - this.route.params.pipe(takeUntil(this.ngUnsubscribe)).subscribe(params => { - if (params.keywords) { - // remove empty and duplicate items - this.terms.keywords = _.uniq(_.compact(params.keywords.split(','))).join(' '); - } + this.route.queryParamMap.pipe(takeUntil(this.ngUnsubscribe)).subscribe(paramMap => { + this.paramMap = paramMap; - if (!_.isEmpty(this.terms.getParams())) { + this.setInitialQueryParameters(); + + if (this.keywords) { this.doSearch(); } }); } - ngOnDestroy() { - // dismiss any open snackbar - if (this.snackBarRef) { - this.snackBarRef.dismiss(); - } - - this.ngUnsubscribe.next(); - this.ngUnsubscribe.complete(); - } - private doSearch() { this.searching = true; + + this.applications = []; this.count = 0; - this.keywords = (this.terms.keywords && _.uniq(_.compact(this.terms.keywords.split(' ')))) || []; // safety checks - this.applications.length = 0; // empty the list this.searchService - .getAppsByClidDtid(this.keywords) + .getApplicationsByCLFileAndTantalisID(this.getQueryParameters()) .pipe(takeUntil(this.ngUnsubscribe)) .subscribe( applications => { @@ -79,7 +72,6 @@ export class SearchComponent implements OnInit, OnDestroy { error => { console.log('error =', error); - // update variables on error this.searching = false; this.ranSearch = true; @@ -87,29 +79,38 @@ export class SearchComponent implements OnInit, OnDestroy { this.snackBarRef.onAction().subscribe(() => this.onSubmit()); }, () => { - // onCompleted - // update variables on completion this.searching = false; this.ranSearch = true; } ); } - // reload page with current search terms + public setInitialQueryParameters() { + this.keywords = this.paramMap.get('keywords') || ''; + } + + public getQueryParameters() { + const queryParameters = _.uniq(_.compact(this.keywords.split(','))); + return queryParameters; + } + + public saveQueryParameters() { + const params: Params = {}; + + params['keywords'] = this.keywords; + + // change browser URL without reloading page (so any query params are saved in history) + this.location.go(this.router.createUrlTree([], { relativeTo: this.route, queryParams: params }).toString()); + } + public onSubmit() { - // dismiss any open snackbar if (this.snackBarRef) { this.snackBarRef.dismiss(); } - // NOTE: Angular Router doesn't reload page on same URL - // REF: https://stackoverflow.com/questions/40983055/how-to-reload-the-current-route-with-the-angular-2-router - // WORKAROUND: add timestamp to force URL to be different than last time - const params = this.terms.getParams(); - params['ms'] = new Date().getMilliseconds(); + this.saveQueryParameters(); - // console.log('params =', params); - this.router.navigate(['search', params]); + this.doSearch(); } public onImport(application: Application) { @@ -177,4 +178,13 @@ export class SearchComponent implements OnInit, OnDestroy { (application && ConstantUtils.getTextLong(CodeType.STATUS, application.status)) || StatusCodes.UNKNOWN.text.long ); } + + ngOnDestroy() { + if (this.snackBarRef) { + this.snackBarRef.dismiss(); + } + + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } } diff --git a/src/app/services/api.spec.ts b/src/app/services/api.spec.ts new file mode 100644 index 00000000..072256c4 --- /dev/null +++ b/src/app/services/api.spec.ts @@ -0,0 +1,112 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule /*, HttpTestingController*/ } from '@angular/common/http/testing'; +import { ApiService, IApplicationQueryParamSet, QueryParamModifier } from './api'; + +describe('ApiService', () => { + // let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ApiService], + imports: [HttpClientTestingModule] + }); + }); + + it('should be created', () => { + const service = TestBed.get(ApiService); + expect(service).toBeTruthy(); + }); + + describe('convertArrayIntoPipeString', () => { + let service; + + beforeEach(() => { + service = TestBed.get(ApiService); + }); + + it('given an empty array returns empty string', () => { + const result = service.convertArrayIntoPipeString([]); + + expect(result).toEqual(''); + }); + + it('given a valid array returns a pipe deliminated string', () => { + const result = service.convertArrayIntoPipeString(['dog', 'cat', 'bird', 'big lizard']); + + expect(result).toEqual('dog|cat|bird|big lizard'); + }); + }); + + describe('buildApplicationQueryParametersString', () => { + let service; + + beforeEach(() => { + service = TestBed.get(ApiService); + }); + + it('given undefined query params returns empty string', () => { + const result = service.buildApplicationQueryParametersString(undefined); + + expect(result).toEqual(''); + }); + + it('given null query params returns empty string', () => { + const result = service.buildApplicationQueryParametersString(null); + + expect(result).toEqual(''); + }); + + it('given all query params', () => { + const queryParams: IApplicationQueryParamSet = { + pageNum: 0, + pageSize: 30, + sortBy: 'status', + isDeleted: false, + cpStart: { value: new Date('2019-01-01') }, + cpEnd: { value: new Date('2019-02-02') }, + tantalisID: { value: 123 }, + cl_file: { value: 321 }, + purpose: { value: ['PURPOSE'] }, + subpurpose: { value: ['SUBPURPOSE'] }, + status: { value: ['STATUS'] }, + reason: { value: ['REGION'], modifier: QueryParamModifier.Not_Equal }, + subtype: { value: 'SUBTYPE' }, + agency: { value: 'AGENCY' }, + businessUnit: { value: 'BUSINESSUNIT' }, + client: { value: 'CLIENT' }, + tenureStage: { value: 'TENURESTAGE' }, + areaHectares: { value: '123.123' }, + statusHistoryEffectiveDate: { value: new Date('2019-03-03') }, + centroid: { value: '[[[123, 123]]]' }, + publishDate: { value: new Date('2019-04-04') } + }; + + const result = service.buildApplicationQueryParametersString(queryParams); + + const expectedResult = + 'pageNum=0&' + + 'pageSize=30&' + + 'sortBy=status' + + 'isDeleted=false' + + `cpStart=${new Date('2019-01-01').toISOString()}` + + `&cpEnd=${new Date('2019-02-02').toISOString()}` + + '&tantalisID=123' + + '&cl_file=321&' + + 'purpose[eq]=PURPOSE&' + + 'subpurpose[eq]=SUBPURPOSE' + + '&status[eq]=STATUS&' + + 'reason[ne]=REGION&' + + 'subtype=SUBTYPE&' + + 'agency=AGENCY&' + + 'businessUnit[eq]=BUSINESSUNIT&' + + 'client=CLIENT&' + + 'tenureStage=TENURESTAGE&' + + 'areaHectares=123.123&' + + `statusHistoryEffectiveDate=${new Date('2019-03-03').toISOString()}` + + '¢roid=[[[123, 123]]]&' + + `publishDate=${new Date('2019-04-04').toISOString()}`; + + expect(result).toEqual(expectedResult); + }); + }); +}); diff --git a/src/app/services/api.ts b/src/app/services/api.ts index 20001343..984da3b5 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -6,8 +6,6 @@ import { HttpClient, HttpResponse } from '@angular/common/http'; import { Observable, throwError } from 'rxjs'; import { map } from 'rxjs/operators'; -import { KeycloakService } from 'app/services/keycloak.service'; - import { Application } from 'app/models/application'; import { Comment } from 'app/models/comment'; import { CommentPeriod } from 'app/models/commentperiod'; @@ -17,6 +15,44 @@ import { Feature } from 'app/models/feature'; import { SearchResults } from 'app/models/search'; import { User } from 'app/models/user'; +/** + * Supported query param field modifiers used by the api to interpret the query param value. + * + * @export + * @enum {number} + */ +export enum QueryParamModifier { + Equal = 'eq', // value must be equal to this, for multiple values must match at least one + Not_Equal = 'ne', // value must not be equal to this, for multiple values must not match any + Since = 'since', // date must be on or after this date + Until = 'until', // date must be before this date + Text = 'text' // value must exist in any text indexed fields. +} + +/** + * A complete set of query param fields used to make a single call to the api. + * + * Note: this can contain multiple properties as long as the keys are strings and the values are IQueryParamValue. + * + * @export + * @interface IQueryParamSet + */ +export interface IQueryParamSet { + [key: string]: IQueryParamValue; +} + +/** + * A single query param field with optional modifier. + * + * @export + * @interface IQueryParamValue + * @template T + */ +export interface IQueryParamValue { + value: T; + modifier?: QueryParamModifier; +} + interface ILocalLoginResponse { _id: string; title: string; @@ -37,6 +73,61 @@ interface IRefreshApplicationResponse { features: Feature[]; } +/** + * Supported query parameters for application requests. + * + * Note: all parameters are optional. + * + * @export + * @interface IApplicationQueryParamSet + */ +export interface IApplicationQueryParamSet { + pageNum?: number; + pageSize?: number; + sortBy?: string; + + isDeleted?: boolean; + + agency?: IQueryParamValue; + areaHectares?: IQueryParamValue; + businessUnit?: IQueryParamValue; + centroid?: IQueryParamValue; + cl_file?: IQueryParamValue; + client?: IQueryParamValue; + cpEnd?: IQueryParamValue; + cpStart?: IQueryParamValue; + publishDate?: IQueryParamValue; + purpose?: IQueryParamValue; + reason?: IQueryParamValue; + status?: IQueryParamValue; + statusHistoryEffectiveDate?: IQueryParamValue; + subpurpose?: IQueryParamValue; + subtype?: IQueryParamValue; + tantalisID?: IQueryParamValue; + tenureStage?: IQueryParamValue; +} + +// /** +// * Supported query parameters for comment period requests. +// * +// * Note: all parameters are optional. +// * +// * @export +// * @interface ICommentPeriodQueryParamSet +// */ +// export interface ICommentPeriodQueryParamSet { +// pageNum?: number; +// pageSize?: number; +// sortBy?: string; + +// isDeleted?: boolean; + +// _application?: IQueryParamValue; // objectId +// _addedBy?: IQueryParamValue; +// startDate?: IQueryParamValue; +// endDate?: IQueryParamValue; +// } + @Injectable() export class ApiService { public token: string; @@ -44,9 +135,9 @@ export class ApiService { // private jwtHelper: JwtHelperService; pathAPI: string; // params: Params; - env: 'local' | 'dev' | 'test' | 'demo' | 'scale' | 'beta' | 'master' | 'prod'; + env: 'local' | 'dev' | 'test' | 'master' | 'prod'; - constructor(private http: HttpClient, private keycloakService: KeycloakService) { + constructor(private http: HttpClient) { // this.jwtHelper = new JwtHelperService(); const currentUser = JSON.parse(window.localStorage.getItem('currentUser')); this.token = currentUser && currentUser.token; @@ -66,36 +157,18 @@ export class ApiService { this.env = 'dev'; break; - case 'nrts-prc-test.pathfinder.gov.bc.ca': - // Test - this.pathAPI = 'https://nrts-prc-test.pathfinder.gov.bc.ca/api'; - this.env = 'test'; - break; - - case 'nrts-prc-demo.pathfinder.gov.bc.ca': - // Demo - this.pathAPI = 'https://nrts-prc-demo.pathfinder.gov.bc.ca/api'; - this.env = 'demo'; - break; - - case 'nrts-prc-scale.pathfinder.gov.bc.ca': - // Scale - this.pathAPI = 'https://nrts-prc-scale.pathfinder.gov.bc.ca/api'; - this.env = 'scale'; - break; - - case 'nrts-prc-beta.pathfinder.gov.bc.ca': - // Beta - this.pathAPI = 'https://nrts-prc-beta.pathfinder.gov.bc.ca/api'; - this.env = 'beta'; - break; - case 'nrts-prc-master.pathfinder.gov.bc.ca': // Master this.pathAPI = 'https://nrts-prc-master.pathfinder.gov.bc.ca/api'; this.env = 'master'; break; + case 'nrts-prc-test.pathfinder.gov.bc.ca': + // Test + this.pathAPI = 'https://nrts-prc-test.pathfinder.gov.bc.ca/api'; + this.env = 'test'; + break; + default: // Prod this.pathAPI = 'https://comment.nrs.gov.bc.ca/api'; @@ -112,9 +185,6 @@ export class ApiService { ? `${error.status} - ${error.statusText}` : 'Server error'; console.log('API error =', reason); - if (error && error.status === 403 && !this.keycloakService.isKeyCloakEnabled()) { - window.location.href = '/admin/login'; - } return throwError(error); } @@ -146,7 +216,15 @@ export class ApiService { // // Applications // - getApplications(pageNum: number, pageSize: number): Observable { + + /** + * Fetch all applications that match the provided parameters. + * + * @param {IApplicationQueryParamSet} [queryParams=null] optional query parameters to filter results + * @returns {Observable} + * @memberof ApiService + */ + getApplications(queryParams: IApplicationQueryParamSet = null): Observable { const fields = [ 'agency', 'areaHectares', @@ -158,6 +236,7 @@ export class ApiService { 'legalDescription', 'location', 'name', + 'createdDate', 'publishDate', 'purpose', 'status', @@ -169,14 +248,12 @@ export class ApiService { 'tenureStage', 'type' ]; - let queryString = 'application?isDeleted=false&'; - if (pageNum !== null) { - queryString += `pageNum=${pageNum}&`; - } - if (pageSize !== null) { - queryString += `pageSize=${pageSize}&`; - } - queryString += `fields=${this.buildValues(fields)}`; + + const queryString = + 'application?' + + `${this.buildApplicationQueryParametersString(queryParams)}&` + + `fields=${this.convertArrayIntoPipeString(fields)}`; + return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -193,6 +270,7 @@ export class ApiService { 'legalDescription', 'location', 'name', + 'createdDate', 'publishDate', 'purpose', 'status', @@ -204,12 +282,20 @@ export class ApiService { 'tenureStage', 'type' ]; - const queryString = `application/${id}?isDeleted=false&fields=${this.buildValues(fields)}`; + const queryString = `application/${id}?isDeleted=false&fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } - getCountApplications(): Observable { - const queryString = 'application?isDeleted=false'; + /** + * Gets the number of applications that match the provided parameters. + * + * @param {IApplicationQueryParamSet} [queryParams=null] + * @returns {Observable} + * @memberof ApiService + */ + getCountApplications(queryParams: IApplicationQueryParamSet = null): Observable { + const queryString = 'application?' + this.buildApplicationQueryParametersString(queryParams); + return this.http.head>(`${this.pathAPI}/${queryString}`, { observe: 'response' }).pipe( map(res => { // retrieve the count from the response headers @@ -232,6 +318,7 @@ export class ApiService { 'legalDescription', 'location', 'name', + 'createdDate', 'publishDate', 'purpose', 'status', @@ -243,7 +330,7 @@ export class ApiService { 'tenureStage', 'type' ]; - const queryString = `application?isDeleted=false&cl_file=${clid}&fields=${this.buildValues(fields)}`; + const queryString = `application?isDeleted=false&cl_file=${clid}&fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -260,6 +347,7 @@ export class ApiService { 'legalDescription', 'location', 'name', + 'createdDate', 'publishDate', 'purpose', 'status', @@ -271,7 +359,9 @@ export class ApiService { 'tenureStage', 'type' ]; - const queryString = `application?isDeleted=false&tantalisId=${tantalisId}&fields=${this.buildValues(fields)}`; + const queryString = `application?isDeleted=false&tantalisId=${tantalisId}&fields=${this.convertArrayIntoPipeString( + fields + )}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -308,15 +398,21 @@ export class ApiService { // // Features // + getFeaturesByTantalisId(tantalisId: number): Observable { const fields = ['type', 'tags', 'geometry', 'properties', 'isDeleted', 'applicationID']; - const queryString = `feature?isDeleted=false&tantalisId=${tantalisId}&fields=${this.buildValues(fields)}`; + const queryString = `feature?isDeleted=false&tantalisId=${tantalisId}&fields=${this.convertArrayIntoPipeString( + fields + )}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } getFeaturesByApplicationId(applicationId: string): Observable { const fields = ['type', 'tags', 'geometry', 'properties', 'isDeleted', 'applicationID']; - const queryString = `feature?isDeleted=false&applicationId=${applicationId}&fields=${this.buildValues(fields)}`; + const queryString = + 'feature?isDeleted=false&' + + `applicationId=${applicationId}&` + + `fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -338,16 +434,17 @@ export class ApiService { // // Decisions // - getDecisionsByAppId(appId: string): Observable { - const fields = ['_addedBy', '_application', 'name', 'description']; - const queryString = `decision?_application=${appId}&fields=${this.buildValues(fields)}`; + + getDecisionsByApplicationId(appId: string): Observable { + const fields = ['_addedBy', '_application', 'description']; + const queryString = `decision?_application=${appId}&fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } // NB: returns array with 1 element getDecision(id: string): Observable { - const fields = ['_addedBy', '_application', 'name', 'description']; - const queryString = `decision/${id}?fields=${this.buildValues(fields)}`; + const fields = ['_addedBy', '_application', 'description']; + const queryString = `decision/${id}?fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -379,16 +476,30 @@ export class ApiService { // // Comment Periods // - getPeriodsByAppId(appId: string): Observable { + + // getCommentPeriods(queryParams: ICommentPeriodQueryParamSet = null): Observable { + // const fields = ['_addedBy', '_application', 'description', 'startDate', 'endDate']; + + // const queryString = + // 'commentperiod?' + + // `${this.buildCommentPeriodQueryParametersString(queryParams)}&` + + // `fields=${this.convertArrayIntoPipeString(fields)}`; + + // return this.http.get(`${this.pathAPI}/${queryString}`, {}); + // } + + getCommentPeriodsByApplicationId(appId: string): Observable { const fields = ['_addedBy', '_application', 'startDate', 'endDate']; - const queryString = `commentperiod?isDeleted=false&_application=${appId}&fields=${this.buildValues(fields)}`; + const queryString = `commentperiod?isDeleted=false&_application=${appId}&fields=${this.convertArrayIntoPipeString( + fields + )}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } // NB: returns array with 1 element - getPeriod(id: string): Observable { + getCommentPeriod(id: string): Observable { const fields = ['_addedBy', '_application', 'startDate', 'endDate']; - const queryString = `commentperiod/${id}?fields=${this.buildValues(fields)}`; + const queryString = `commentperiod/${id}?fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -420,7 +531,8 @@ export class ApiService { // // Comments // - getCountCommentsByPeriodId(periodId: string): Observable { + + getCountCommentsByCommentPeriodId(periodId: string): Observable { // NB: count only pending comments const queryString = `comment?isDeleted=false&commentStatus='Pending'&_commentPeriod=${periodId}`; return this.http.head>(`${this.pathAPI}/${queryString}`, { observe: 'response' }).pipe( @@ -431,7 +543,12 @@ export class ApiService { ); } - getCommentsByPeriodId(periodId: string, pageNum: number, pageSize: number, sortBy: string): Observable { + getCommentsByCommentPeriodId( + periodId: string, + pageNum: number, + pageSize: number, + sortBy: string + ): Observable { const fields = [ '_addedBy', '_commentPeriod', @@ -453,7 +570,7 @@ export class ApiService { if (sortBy !== null) { queryString += `sortBy=${sortBy}&`; } - queryString += `fields=${this.buildValues(fields)}`; + queryString += `fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -470,7 +587,7 @@ export class ApiService { 'dateAdded', 'commentStatus' ]; - const queryString = `comment/${id}?fields=${this.buildValues(fields)}`; + const queryString = `comment/${id}?fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -497,21 +614,28 @@ export class ApiService { // // Documents // - getDocumentsByAppId(appId: string): Observable { + + getDocumentsByApplicationId(appId: string): Observable { const fields = ['_application', 'documentFileName', 'displayName', 'internalURL', 'internalMime']; - const queryString = `document?isDeleted=false&_application=${appId}&fields=${this.buildValues(fields)}`; + const queryString = `document?isDeleted=false&_application=${appId}&fields=${this.convertArrayIntoPipeString( + fields + )}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } getDocumentsByCommentId(commentId: string): Observable { const fields = ['_comment', 'documentFileName', 'displayName', 'internalURL', 'internalMime']; - const queryString = `document?isDeleted=false&_comment=${commentId}&fields=${this.buildValues(fields)}`; + const queryString = `document?isDeleted=false&_comment=${commentId}&fields=${this.convertArrayIntoPipeString( + fields + )}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } getDocumentsByDecisionId(decisionId: string): Observable { const fields = ['_decision', 'documentFileName', 'displayName', 'internalURL', 'internalMime']; - const queryString = `document?isDeleted=false&_decision=${decisionId}&fields=${this.buildValues(fields)}`; + const queryString = `document?isDeleted=false&_decision=${decisionId}&fields=${this.convertArrayIntoPipeString( + fields + )}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -538,7 +662,7 @@ export class ApiService { uploadDocument(formData: FormData): Observable { const fields = ['documentFileName', 'displayName', 'internalURL', 'internalMime']; - const queryString = `document/?fields=${this.buildValues(fields)}`; + const queryString = `document/?fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.post(`${this.pathAPI}/${queryString}`, formData, {}); } @@ -582,12 +706,13 @@ export class ApiService { // // Searching // - searchAppsByCLID(clid: string): Observable { + + searchAppsByCLFile(clid: string): Observable { const queryString = `ttlsapi/crownLandFileNumber/${clid}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } - searchAppsByDTID(dtid: number): Observable { + searchAppsByDispositionID(dtid: number): Observable { const queryString = `ttlsapi/dispositionTransactionId/${dtid}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -595,9 +720,10 @@ export class ApiService { // // Users // + getUsers(): Observable { const fields = ['displayName', 'username', 'firstName', 'lastName']; - const queryString = `user?fields=${this.buildValues(fields)}`; + const queryString = `user?fields=${this.convertArrayIntoPipeString(fields)}`; return this.http.get(`${this.pathAPI}/${queryString}`, {}); } @@ -621,11 +747,169 @@ export class ApiService { * @returns {string} * @memberof ApiService */ - private buildValues(collection: string[]): string { + public convertArrayIntoPipeString(collection: string[]): string { if (!collection || collection.length <= 0) { return ''; } return collection.join('|'); } + + /** + * Checks each application query parameter of the given queryParams and builds a single query string. + * + * @param {IApplicationQueryParamSet} queryParams + * @returns {string} + * @memberof ApiService + */ + public buildApplicationQueryParametersString(params: IApplicationQueryParamSet): string { + if (!params) { + return ''; + } + + let queryString = ''; + + if (params.pageNum >= 0) { + queryString += `pageNum=${params.pageNum}&`; + } + + if (params.pageSize >= 0) { + queryString += `pageSize=${params.pageSize}&`; + } + + if (params.cpStart && params.cpStart.value) { + queryString += `cpStart=${params.cpStart.value.toISOString()}&`; + } + + if (params.cpEnd && params.cpEnd.value) { + queryString += `cpEnd=${params.cpEnd.value.toISOString()}&`; + } + + if (params.tantalisID && params.tantalisID.value >= 0) { + queryString += `tantalisID=${params.tantalisID.value}&`; + } + + if (params.cl_file && params.cl_file.value >= 0) { + queryString += `cl_file=${params.cl_file.value}&`; + } + + if (params.purpose && params.purpose.value && params.purpose.value.length) { + params.purpose.value.forEach((purpose: string) => (queryString += `purpose[eq]=${encodeURIComponent(purpose)}&`)); + } + + if (params.subpurpose && params.subpurpose.value && params.subpurpose.value.length) { + params.subpurpose.value.forEach( + (subpurpose: string) => (queryString += `subpurpose[eq]=${encodeURIComponent(subpurpose)}&`) + ); + } + + if (params.status && params.status.value && params.status.value.length) { + params.status.value.forEach((status: string) => (queryString += `status[eq]=${encodeURIComponent(status)}&`)); + } + + if (params.reason && params.reason.value && params.reason.value.length) { + params.reason.value.forEach( + (reason: string) => (queryString += `reason[${params.reason.modifier}]=${encodeURIComponent(reason)}&`) + ); + } + + if (params.subtype && params.subtype.value) { + queryString += `subtype=${encodeURIComponent(params.subtype.value)}&`; + } + + if (params.agency && params.agency.value) { + queryString += `agency=${encodeURIComponent(params.agency.value)}&`; + } + + if (params.businessUnit && params.businessUnit.value) { + queryString += `businessUnit[eq]=${encodeURIComponent(params.businessUnit.value)}&`; + } + + if (params.client && params.client.value) { + queryString += `client[${params.client.modifier}]=${encodeURIComponent(params.client.value)}&`; + } + + if (params.tenureStage && params.tenureStage.value) { + queryString += `tenureStage=${encodeURIComponent(params.tenureStage.value)}&`; + } + + if (params.areaHectares && params.areaHectares.value) { + queryString += `areaHectares=${encodeURIComponent(params.areaHectares.value)}&`; + } + + if (params.statusHistoryEffectiveDate && params.statusHistoryEffectiveDate.value) { + queryString += `statusHistoryEffectiveDate=${params.statusHistoryEffectiveDate.value.toISOString()}&`; + } + + if (params.centroid && params.centroid.value) { + queryString += `centroid=${params.centroid.value}&`; + } + + if (params.publishDate && params.publishDate.value) { + queryString += `publishDate=${params.publishDate.value.toISOString()}&`; + } + + if ([true, false].includes(params.isDeleted)) { + queryString += `isDeleted=${params.isDeleted}&`; + } + + if (params.sortBy) { + queryString += `sortBy=${params.sortBy}&`; + } + + // trim the last & + return queryString.replace(/\&$/, ''); + } + + // /** + // * Checks each comment period query parameter of the given queryParams and builds a single query string. + // * + // * @param {ICommentPeriodQueryParamSet} queryParams + // * @returns {string} + // * @memberof ApiService + // */ + // public buildCommentPeriodQueryParametersString(params: ICommentPeriodQueryParamSet): string { + // if (!params) { + // return ''; + // } + + // let queryString = ''; + + // if (params.pageNum >= 0) { + // queryString += `pageNum=${params.pageNum}&`; + // } + + // if (params.pageSize >= 0) { + // queryString += `pageSize=${params.pageSize}&`; + // } + + // if (params.sortBy) { + // queryString += `sortBy=${params.sortBy}&`; + // } + + // if ([true, false].includes(params.isDeleted)) { + // queryString += `isDeleted=${params.isDeleted}&`; + // } + + // if (params._application && params._application.value) { + // queryString += `_application=${params._application.value}&`; + // } + + // if (params._addedBy && params._addedBy.value) { + // queryString += `_addedBy=${params._addedBy}&`; + // } + + // if (params.startDate && params.startDate.value) { + // queryString += `startDate=${params.startDate}&`; + // } + + // if (params.endDate && params.endDate.value) { + // queryString += `endDate=${params.endDate}&`; + // } + + // console.log('queryString', queryString); + + // // trim the last & + // return queryString.replace(/\&$/, ''); + // } } diff --git a/src/app/services/application.service.spec.ts b/src/app/services/application.service.spec.ts index cf210f8d..44859463 100644 --- a/src/app/services/application.service.spec.ts +++ b/src/app/services/application.service.spec.ts @@ -15,6 +15,8 @@ import { Document } from 'app/models/document'; import { CommentPeriod } from 'app/models/commentperiod'; import { Decision } from 'app/models/decision'; import { Application } from 'app/models/application'; +import { StatusCodes, ReasonCodes, RegionCodes } from 'app/utils/constants/application'; +import { CommentCodes } from 'app/utils/constants/comment'; describe('ApplicationService', () => { let service: any; @@ -37,7 +39,7 @@ describe('ApplicationService', () => { }; const featureServiceStub = { - getByApplicationId(applicationId: string) { + getByApplicationId() { const features = [ new Feature({ id: 'FFFFF', properties: { TENURE_AREA_IN_HECTARES: 12 } }), new Feature({ id: 'GGGGG', properties: { TENURE_AREA_IN_HECTARES: 13 } }) @@ -47,14 +49,14 @@ describe('ApplicationService', () => { }; const documentServiceStub = { - getAllByApplicationId(applicationId: string) { + getAllByApplicationId() { const documents = [new Document({ _id: 'DDDDD' }), new Document({ _id: 'EEEEE' })]; return of(documents); } }; const commentPeriodServiceStub = { - getAllByApplicationId(applicationId: string) { + getAllByApplicationId() { const commentPeriods = [ new CommentPeriod({ _id: 'DDDDD', startDate: new Date(2018, 10, 1), endDate: new Date(2018, 11, 10) }), new CommentPeriod({ _id: 'EEEEE', startDate: new Date(2018, 10, 1), endDate: new Date(2018, 11, 10) }) @@ -66,27 +68,23 @@ describe('ApplicationService', () => { return periods.length > 0 ? periods[0] : null; }, - getStatusCode(period: CommentPeriod): string { - return service.OPEN; + getCode(): string { + return CommentCodes.OPEN.code; }, - getStatusString(statusCode: string): string { - return 'Commenting Open'; - }, - - isOpen(period: CommentPeriod): boolean { + isOpen(): boolean { return true; } }; const decisionServiceStub = { - getByApplicationId(applicationId: string) { + getByApplicationId() { return of(new Decision({ _id: 'IIIII' })); } }; const commentServiceStub = { - getCountByPeriodId(periodId: string): Observable { + getCountByPeriodId(): Observable { return of(42); } }; @@ -131,19 +129,11 @@ describe('ApplicationService', () => { spyOn(apiService, 'getApplications').and.returnValue(of([existingApplication])); }); - it('sets the appStatus property', () => { - existingApplication.status = 'ACCEPTED'; - service.getAll().subscribe(applications => { - const application = applications[0]; - expect(application.appStatus).toBe('Application Under Review'); - }); - }); - it('clFile property is padded to be seven digits', () => { existingApplication.cl_file = 7777; service.getAll().subscribe(applications => { const application = applications[0]; - expect(application.clFile).toBe('0007777'); + expect(application.meta.clFile).toBe('0007777'); }); }); @@ -151,7 +141,7 @@ describe('ApplicationService', () => { existingApplication.cl_file = null; service.getAll().subscribe(applications => { const application = applications[0]; - expect(application.clFile).toBeUndefined(); + expect(application.meta.clFile).toBeUndefined(); }); }); @@ -159,8 +149,8 @@ describe('ApplicationService', () => { existingApplication.businessUnit = 'SK - LAND MGMNT - SKEENA FIELD OFFICE'; service.getAll().subscribe(applications => { const application = applications[0]; - expect(application.region).toBeDefined(); - expect(application.region).toEqual(service.SKEENA); + expect(application.meta.region).toBeDefined(); + expect(application.meta.region).toEqual(RegionCodes.SKEENA.text.long); }); }); }); @@ -198,21 +188,14 @@ describe('ApplicationService', () => { it('makes a call to commentPeriodService.getAllByApplicationId for each application and retrieves the comment period', () => { service.getAll({ getCurrentPeriod: true }).subscribe(applications => { const firstApplication = applications[0]; - expect(firstApplication.currentPeriod).toBeDefined(); - expect(firstApplication.currentPeriod).not.toBeNull(); - expect(firstApplication.currentPeriod._id).toBe('CP_FOR_FIRST_APP'); + expect(firstApplication.meta.currentPeriod).toBeDefined(); + expect(firstApplication.meta.currentPeriod).not.toBeNull(); + expect(firstApplication.meta.currentPeriod._id).toBe('CP_FOR_FIRST_APP'); const secondApplication = applications[1]; - expect(secondApplication.currentPeriod).toBeDefined(); - expect(secondApplication.currentPeriod).not.toBeNull(); - expect(secondApplication.currentPeriod._id).toBe('CP_FOR_SECOND_APP'); - }); - }); - - it('sets the cpStatus to the commentPeriodService.getStatusString result', () => { - service.getAll({ getCurrentPeriod: true }).subscribe(applications => { - const firstApplication = applications[0]; - expect(firstApplication.cpStatus).toBe('Commenting Open'); + expect(secondApplication.meta.currentPeriod).toBeDefined(); + expect(secondApplication.meta.currentPeriod).not.toBeNull(); + expect(secondApplication.meta.currentPeriod._id).toBe('CP_FOR_SECOND_APP'); }); }); @@ -239,9 +222,9 @@ describe('ApplicationService', () => { service.getAll({ getCurrentPeriod: true }).subscribe(applications => { const firstApplication = applications[0]; - expect(firstApplication.currentPeriod.daysRemaining).toBeDefined(); + expect(firstApplication.meta.currentPeriod.meta.daysRemaining).toBeDefined(); - expect(firstApplication.currentPeriod.daysRemaining).toEqual(10); + expect(firstApplication.meta.currentPeriod.meta.daysRemaining).toEqual(10); }); }); }); @@ -256,8 +239,8 @@ describe('ApplicationService', () => { // TODO: Stub isOpen method properly to get this to pass. xit('does not set the daysRemaining value', () => { service.getAll({ getCurrentPeriod: true }).subscribe(applications => { - expect(applications[0].currentPeriod.daysRemaining).not.toBeDefined(); - expect(applications[1].currentPeriod.daysRemaining).not.toBeDefined(); + expect(applications[0].meta.currentPeriod.meta.daysRemaining).not.toBeDefined(); + expect(applications[1].meta.currentPeriod.meta.daysRemaining).not.toBeDefined(); }); }); }); @@ -271,8 +254,8 @@ describe('ApplicationService', () => { it('sets the numComments value to the commentService.getCountByPeriodId function', () => { service.getAll({ getCurrentPeriod: true }).subscribe(applications => { - expect(applications[0].numComments).toEqual(42); - expect(applications[1].numComments).toEqual(42); + expect(applications[0].meta.numComments).toEqual(42); + expect(applications[1].meta.numComments).toEqual(42); }); }); }); @@ -287,8 +270,8 @@ describe('ApplicationService', () => { it('has no attached comment period', () => { service.getAll({ getCurrentPeriod: false }).subscribe(applications => { - expect(applications[0].currentPeriod).toBeNull(); - expect(applications[1].currentPeriod).toBeNull(); + expect(applications[0].meta.currentPeriod).toBeNull(); + expect(applications[1].meta.currentPeriod).toBeNull(); }); }); }); @@ -313,32 +296,25 @@ describe('ApplicationService', () => { spyOn(apiService, 'getApplication').and.returnValue(of([existingApplication])); }); - it('sets the appStatus property', () => { - existingApplication.status = 'ACCEPTED'; - service.getById('AAAA').subscribe(application => { - expect(application.appStatus).toBe('Application Under Review'); - }); - }); - it('clFile property is padded to be seven digits', () => { existingApplication.cl_file = 7777; service.getById('AAAA').subscribe(application => { - expect(application.clFile).toBe('0007777'); + expect(application.meta.clFile).toBe('0007777'); }); }); it('clFile property is null if there is no cl_file property', () => { existingApplication.cl_file = null; service.getById('AAAA').subscribe(application => { - expect(application.clFile).toBeUndefined(); + expect(application.meta.clFile).toBeUndefined(); }); }); it('sets the region property', () => { existingApplication.businessUnit = 'SK - LAND MGMNT - SKEENA FIELD OFFICE'; service.getById('AAAA').subscribe(application => { - expect(application.region).toBeDefined(); - expect(application.region).toEqual(service.SKEENA); + expect(application.meta.region).toBeDefined(); + expect(application.meta.region).toEqual(RegionCodes.SKEENA.text.long); }); }); }); @@ -346,10 +322,10 @@ describe('ApplicationService', () => { describe('with the getFeatures Parameter', () => { it('makes a call to featureService.getByApplicationId and attaches the resulting features', () => { service.getById('AAAA', { getFeatures: true }).subscribe(application => { - expect(application.features).toBeDefined(); - expect(application.features).not.toBeNull(); - expect(application.features[0].id).toBe('FFFFF'); - expect(application.features[1].id).toBe('GGGGG'); + expect(application.meta.features).toBeDefined(); + expect(application.meta.features).not.toBeNull(); + expect(application.meta.features[0].id).toBe('FFFFF'); + expect(application.meta.features[1].id).toBe('GGGGG'); }); }); }); @@ -363,8 +339,8 @@ describe('ApplicationService', () => { it('has no attached features', () => { service.getById('AAAA', { getFeatures: false }).subscribe(application => { - expect(application.features).toBeDefined(); - expect(application.features).toEqual([]); + expect(application.meta.features).toBeDefined(); + expect(application.meta.features).toEqual([]); }); }); }); @@ -372,10 +348,10 @@ describe('ApplicationService', () => { describe('with the getDocuments Parameter', () => { it('makes a call to documentService.getAllByApplicationId and attaches the resulting documents', () => { service.getById('AAAA', { getDocuments: true }).subscribe(application => { - expect(application.documents).toBeDefined(); - expect(application.documents).not.toBeNull(); - expect(application.documents[0]._id).toBe('DDDDD'); - expect(application.documents[1]._id).toBe('EEEEE'); + expect(application.meta.documents).toBeDefined(); + expect(application.meta.documents).not.toBeNull(); + expect(application.meta.documents[0]._id).toBe('DDDDD'); + expect(application.meta.documents[1]._id).toBe('EEEEE'); }); }); }); @@ -389,8 +365,8 @@ describe('ApplicationService', () => { it('has no attached documents', () => { service.getById('AAAA', { getDocuments: false }).subscribe(application => { - expect(application.documents).toBeDefined(); - expect(application.documents).toEqual([]); + expect(application.meta.documents).toBeDefined(); + expect(application.meta.documents).toEqual([]); }); }); }); @@ -399,15 +375,9 @@ describe('ApplicationService', () => { // tslint:disable-next-line:max-line-length it('makes a call to commentPeriodService.getAllByApplicationId and attaches the first resulting comment period', () => { service.getById('AAAA', { getCurrentPeriod: true }).subscribe(application => { - expect(application.currentPeriod).toBeDefined(); - expect(application.currentPeriod).not.toBeNull(); - expect(application.currentPeriod._id).toBe('DDDDD'); - }); - }); - - it('sets the cpStatus to the commentPeriodService.getStatusString result', () => { - service.getById('AAAA', { getCurrentPeriod: true }).subscribe(application => { - expect(application.cpStatus).toBe('Commenting Open'); + expect(application.meta.currentPeriod).toBeDefined(); + expect(application.meta.currentPeriod).not.toBeNull(); + expect(application.meta.currentPeriod._id).toBe('DDDDD'); }); }); @@ -436,8 +406,8 @@ describe('ApplicationService', () => { it('sets the daysRemaining value to the endDate minus the current time', () => { service.getById('AAAA', { getCurrentPeriod: true }).subscribe(application => { - expect(application.currentPeriod.daysRemaining).toBeDefined(); - expect(application.currentPeriod.daysRemaining).toEqual(10); + expect(application.meta.currentPeriod.meta.daysRemaining).toBeDefined(); + expect(application.meta.currentPeriod.meta.daysRemaining).toEqual(10); }); }); }); @@ -451,7 +421,7 @@ describe('ApplicationService', () => { it('does not set the daysRemaining value', () => { service.getById('AAAA', { getCurrentPeriod: true }).subscribe(application => { - expect(application.currentPeriod.daysRemaining).not.toBeDefined(); + expect(application.meta.currentPeriod.meta.daysRemaining).not.toBeDefined(); }); }); }); @@ -465,7 +435,7 @@ describe('ApplicationService', () => { it('sets the numComments value to the commentService.getCountByPeriodId function', () => { service.getById('AAAA', { getCurrentPeriod: true }).subscribe(application => { - expect(application.numComments).toEqual(42); + expect(application.meta.numComments).toEqual(42); }); }); }); @@ -480,7 +450,7 @@ describe('ApplicationService', () => { it('has no attached comment period', () => { service.getById('AAAA', { getCurrentPeriod: false }).subscribe(application => { - expect(application.currentPeriod).toBeNull(); + expect(application.meta.currentPeriod).toBeNull(); }); }); }); @@ -488,9 +458,9 @@ describe('ApplicationService', () => { describe('with the getDecision Parameter', () => { it('makes a call to decisionService.getByApplicationId and attaches the resulting decision', () => { service.getById('AAAA', { getDecision: true }).subscribe(application => { - expect(application.decision).toBeDefined(); - expect(application.decision).not.toBeNull(); - expect(application.decision._id).toBe('IIIII'); + expect(application.meta.decision).toBeDefined(); + expect(application.meta.decision).not.toBeNull(); + expect(application.meta.decision._id).toBe('IIIII'); }); }); }); @@ -504,368 +474,64 @@ describe('ApplicationService', () => { it('has no attached decision', () => { service.getById('AAAA', { getDecision: false }).subscribe(application => { - expect(application.decision).toBeDefined(); - expect(application.decision).toBeNull(); + expect(application.meta.decision).toBeDefined(); + expect(application.meta.decision).toBeNull(); }); }); }); }); - describe('getStatusCode()', () => { + describe('getStatusStringShort()', () => { it('with "ABANDONED" status it returns "AB" code', () => { - expect(service.getStatusCode('ABANDONED')).toEqual(service.ABANDONED); - }); - - it('with "CANCELLED" status it returns "AB" code', () => { - expect(service.getStatusCode('CANCELLED')).toEqual(service.ABANDONED); - }); - - it('with "OFFER NOT ACCEPTED" status it returns "AB" code', () => { - expect(service.getStatusCode('OFFER NOT ACCEPTED')).toEqual(service.ABANDONED); - }); - - it('with "OFFER RESCINDED" status it returns "AB" code', () => { - expect(service.getStatusCode('OFFER RESCINDED')).toEqual(service.ABANDONED); - }); - - it('with "RETURNED" status it returns "AB" code', () => { - expect(service.getStatusCode('RETURNED')).toEqual(service.ABANDONED); - }); - - it('with "REVERTED" status it returns "AB" code', () => { - expect(service.getStatusCode('REVERTED')).toEqual(service.ABANDONED); - }); - - it('with "SOLD" status it returns "AB" code', () => { - expect(service.getStatusCode('SOLD')).toEqual(service.ABANDONED); - }); - - it('with "SUSPENDED" status it returns "AB" code', () => { - expect(service.getStatusCode('SUSPENDED')).toEqual(service.ABANDONED); - }); - - it('with "WITHDRAWN" status it returns "AB" code', () => { - expect(service.getStatusCode('WITHDRAWN')).toEqual(service.ABANDONED); - }); - - it('with "ACCEPTED" status it returns "AUR" code', () => { - expect(service.getStatusCode('ACCEPTED')).toEqual(service.APPLICATION_UNDER_REVIEW); - }); - - it('with "ALLOWED" status it returns "AUR" code', () => { - expect(service.getStatusCode('ALLOWED')).toEqual(service.APPLICATION_UNDER_REVIEW); - }); - - it('with "PENDING" status it returns "AUR" code', () => { - expect(service.getStatusCode('PENDING')).toEqual(service.APPLICATION_UNDER_REVIEW); - }); - - it('with "RECEIVED" status it returns "AUR" code', () => { - expect(service.getStatusCode('RECEIVED')).toEqual(service.APPLICATION_UNDER_REVIEW); - }); - - it('with "OFFER ACCEPTED" status it returns "ARC" code', () => { - expect(service.getStatusCode('OFFER ACCEPTED')).toEqual(service.APPLICATION_REVIEW_COMPLETE); - }); - - it('with "OFFERED" status it returns "ARC" code', () => { - expect(service.getStatusCode('OFFERED')).toEqual(service.APPLICATION_REVIEW_COMPLETE); - }); - - it('with "ACTIVE" status it returns "DA" code', () => { - expect(service.getStatusCode('ACTIVE')).toEqual(service.DECISION_APPROVED); - }); - - it('with "COMPLETED" status it returns "DA" code', () => { - expect(service.getStatusCode('COMPLETED')).toEqual(service.DECISION_APPROVED); - }); - - it('with "DISPOSITION IN GOOD STANDING" status it returns "DA" code', () => { - expect(service.getStatusCode('DISPOSITION IN GOOD STANDING')).toEqual(service.DECISION_APPROVED); - }); - - it('with "EXPIRED" status it returns "DA" code', () => { - expect(service.getStatusCode('EXPIRED')).toEqual(service.DECISION_APPROVED); - }); - - it('with "HISTORIC" status it returns "DA" code', () => { - expect(service.getStatusCode('HISTORIC')).toEqual(service.DECISION_APPROVED); - }); - - it('with "DISALLOWED" status it returns "DNA" code', () => { - expect(service.getStatusCode('DISALLOWED')).toEqual(service.DECISION_NOT_APPROVED); - }); - - it('with "NOT USED" status it returns "UN" code', () => { - expect(service.getStatusCode('NOT USED')).toEqual(service.UNKNOWN); - }); - - it('with "PRE-TANTALIS" status it returns "UN" code', () => { - expect(service.getStatusCode('PRE-TANTALIS')).toEqual(service.UNKNOWN); - }); - - it('returns "UN" if status is empty', () => { - expect(service.getStatusCode('')).toEqual(service.UNKNOWN); - }); - - it('returns "UN" if status is undefined', () => { - expect(service.getStatusCode(undefined)).toEqual(service.UNKNOWN); - }); - - it('returns "UN" if status is null', () => { - expect(service.getStatusCode(null)).toEqual(service.UNKNOWN); - }); - }); - - describe('getTantalisStatus()', () => { - it('with "AB" status it returns Abandoned codes', () => { - expect(service.getTantalisStatus(service.ABANDONED)).toEqual([ - 'ABANDONED', - 'CANCELLED', - 'OFFER NOT ACCEPTED', - 'OFFER RESCINDED', - 'RETURNED', - 'REVERTED', - 'SOLD', - 'SUSPENDED', - 'WITHDRAWN' - ]); - }); - - it('with "AUR" status it returns Application Under Review codes', () => { - expect(service.getTantalisStatus(service.APPLICATION_UNDER_REVIEW)).toEqual([ - 'ACCEPTED', - 'ALLOWED', - 'PENDING', - 'RECEIVED' - ]); - }); - - it('with "ARC" status it returns Application Review Complete codes', () => { - expect(service.getTantalisStatus(service.APPLICATION_REVIEW_COMPLETE)).toEqual(['OFFER ACCEPTED', 'OFFERED']); - }); - - it('with "DA" status it returns Decision Approved codes', () => { - expect(service.getTantalisStatus(service.DECISION_APPROVED)).toEqual([ - 'ACTIVE', - 'COMPLETED', - 'DISPOSITION IN GOOD STANDING', - 'EXPIRED', - 'HISTORIC' - ]); - }); - - it('with "DNA" status it returns Decision Not Approved codes', () => { - expect(service.getTantalisStatus(service.DECISION_NOT_APPROVED)).toEqual(['DISALLOWED']); + expect(service.getStatusStringShort('ABANDONED')).toEqual(StatusCodes.ABANDONED.text.short); }); }); - describe('getLongStatusString()', () => { + describe('getStatusStringLong()', () => { it('with "AB" code it returns "Abandoned" string', () => { - expect(service.getLongStatusString(service.ABANDONED)).toBe('Abandoned'); - }); - - it('with "AUR" code it returns "Application Under Review" string', () => { - expect(service.getLongStatusString(service.APPLICATION_UNDER_REVIEW)).toBe('Application Under Review'); - }); - - it('with "ARC" code it returns "Application Review Complete - Decision Pending" string', () => { - expect(service.getLongStatusString(service.APPLICATION_REVIEW_COMPLETE)).toBe( - 'Application Review Complete - Decision Pending' + expect(service.getStatusStringLong(new Application({ status: StatusCodes.ABANDONED.param }))).toBe( + StatusCodes.ABANDONED.text.long ); }); - it('with "DA" code it returns "Decision: Approved - Tenure Issued" string', () => { - expect(service.getLongStatusString(service.DECISION_APPROVED)).toBe('Decision: Approved - Tenure Issued'); - }); - - it('with "DNA" code it returns "Decision: Not Approved" string', () => { - expect(service.getLongStatusString(service.DECISION_NOT_APPROVED)).toBe('Decision: Not Approved'); - }); - it('with "UN" code it returns "Unknown status" string', () => { - expect(service.getLongStatusString(service.UNKNOWN)).toBe('Unknown Status'); - }); - }); - - describe('isAbandoned()', () => { - it('with "AB" code it returns true', () => { - expect(service.isAbandoned(service.ABANDONED)).toBe(true); - }); - - it('with "xx" code it returns false', () => { - expect(service.isAbandoned('xx')).toBe(false); - }); - - it('with null code it returns false', () => { - expect(service.isAbandoned(null)).toBe(false); - }); - }); - - describe('isApplicationUnderReview()', () => { - it('with "AUR" code it returns true', () => { - expect(service.isApplicationUnderReview(service.APPLICATION_UNDER_REVIEW)).toBe(true); - }); - - it('with "xx" code it returns false', () => { - expect(service.isApplicationUnderReview('xx')).toBe(false); - }); - - it('with null code it returns false', () => { - expect(service.isApplicationUnderReview(null)).toBe(false); - }); - }); - - describe('isApplicationReviewComplete()', () => { - it('with "ARC" code it returns true', () => { - expect(service.isApplicationReviewComplete(service.APPLICATION_REVIEW_COMPLETE)).toBe(true); - }); - - it('with "xx" code it returns false', () => { - expect(service.isApplicationReviewComplete('xx')).toBe(false); - }); - - it('with null code it returns false', () => { - expect(service.isApplicationReviewComplete(null)).toBe(false); - }); - }); - - describe('isDecisionApproved()', () => { - it('with "DA" code it returns true', () => { - expect(service.isDecisionApproved(service.DECISION_APPROVED)).toBe(true); - }); - - it('with "xx" code it returns false', () => { - expect(service.isDecisionApproved('xx')).toBe(false); - }); - - it('with null code it returns false', () => { - expect(service.isDecisionApproved(null)).toBe(false); - }); - }); - - describe('isDecisionNotApproved()', () => { - it('with "DNA" code it returns true', () => { - expect(service.isDecisionNotApproved(service.DECISION_NOT_APPROVED)).toBe(true); - }); - - it('with "xx" code it returns false', () => { - expect(service.isDecisionNotApproved('xx')).toBe(false); - }); - - it('with null code it returns false', () => { - expect(service.isDecisionNotApproved(null)).toBe(false); - }); - }); - - describe('isUnknown()', () => { - it('with "UN" code it returns true', () => { - expect(service.isUnknown(service.UNKNOWN)).toBe(true); - }); - - it('with "xx" code it returns false', () => { - expect(service.isUnknown('xx')).toBe(false); - }); - - it('with null code it returns false', () => { - expect(service.isUnknown(null)).toBe(false); - }); - }); - - describe('getRegionCode()', () => { - it('with "CA - LAND MGMNT - CARIBOO FIELD OFFICE" it returns "CA" code', () => { - expect(service.getRegionCode('CA - LAND MGMNT - CARIBOO FIELD OFFICE')).toEqual(service.CARIBOO); - }); - - it('with "KO - LAND MGMNT - KOOTENAY FIELD OFFICE" it returns "KO" code', () => { - expect(service.getRegionCode('KO - LAND MGMNT - KOOTENAY FIELD OFFICE')).toEqual(service.KOOTENAY); - }); - - it('with "LM - LAND MGMNT - LOWER MAINLAND SERVICE REGION" it returns "LM" code', () => { - expect(service.getRegionCode('LM - LAND MGMNT - LOWER MAINLAND SERVICE REGION')).toEqual(service.LOWER_MAINLAND); - }); - - it('with "OM - LAND MGMNT - NORTHERN SERVICE REGION" it returns "OM" code', () => { - expect(service.getRegionCode('OM - LAND MGMNT - NORTHERN SERVICE REGION')).toEqual(service.OMENICA); - }); - - it('with "PE - LAND MGMNT - PEACE FIELD OFFICE" it returns "PE" code', () => { - expect(service.getRegionCode('PE - LAND MGMNT - PEACE FIELD OFFICE')).toEqual(service.PEACE); - }); - - it('with "SK - LAND MGMNT - SKEENA FIELD OFFICE" it returns "SK" code', () => { - expect(service.getRegionCode('SK - LAND MGMNT - SKEENA FIELD OFFICE')).toEqual(service.SKEENA); - }); - - it('with "SI - LAND MGMNT - SOUTHERN SERVICE REGION" it returns "SI" code', () => { - expect(service.getRegionCode('SI - LAND MGMNT - SOUTHERN SERVICE REGION')).toEqual(service.SOUTHERN_INTERIOR); - }); - - it('with "VI - LAND MGMNT - VANCOUVER ISLAND SERVICE REGION" it returns "VI" code', () => { - expect(service.getRegionCode('VI - LAND MGMNT - VANCOUVER ISLAND SERVICE REGION')).toEqual( - service.VANCOUVER_ISLAND + expect(service.getStatusStringLong(new Application({ status: StatusCodes.UNKNOWN.param }))).toBe( + 'Unknown Status' ); }); - - it('returns Falsy if Business Unit is empty', () => { - expect(service.getRegionCode('')).toBeFalsy(); - }); - - it('returns Falsy if Business Unit is undefined', () => { - expect(service.getRegionCode(undefined)).toBeFalsy(); - }); - it('returns Falsy if Business Unit is null', () => { - expect(service.getRegionCode(null)).toBeFalsy(); - }); }); - describe('getRegionString()', () => { - it('with "CA" code it returns "Cariboo, Williams Lake"', () => { - expect(service.getRegionString(service.CARIBOO)).toBe('Cariboo, Williams Lake'); - }); - - it('with "KO" code it returns "Kootenay, Cranbrook"', () => { - expect(service.getRegionString(service.KOOTENAY)).toBe('Kootenay, Cranbrook'); - }); - - it('with "LM" code it returns "Lower Mainland, Surrey"', () => { - expect(service.getRegionString(service.LOWER_MAINLAND)).toBe('Lower Mainland, Surrey'); - }); - - it('with "OM" code it returns "Omenica/Peace, Prince George"', () => { - expect(service.getRegionString(service.OMENICA)).toBe('Omenica/Peace, Prince George'); - }); - - it('with "PE" code it returns "Peace, Ft. St. John"', () => { - expect(service.getRegionString(service.PEACE)).toBe('Peace, Ft. St. John'); - }); - - it('with "SK" code it returns "Skeena, Smithers"', () => { - expect(service.getRegionString(service.SKEENA)).toBe('Skeena, Smithers'); + describe('isAmendment()', () => { + it('returns false if the application is undefined', () => { + expect(service.isAmendment(undefined)).toBe(false); }); - it('with "SI" code it returns "Thompson Okanagan, Kamloops"', () => { - expect(service.getRegionString(service.SOUTHERN_INTERIOR)).toBe('Thompson Okanagan, Kamloops'); + it('returns false if the application is null', () => { + expect(service.isAmendment(null)).toBe(false); }); - it('with "VI" code it returns "West Coast, Nanaimo"', () => { - expect(service.getRegionString(service.VANCOUVER_ISLAND)).toBe('West Coast, Nanaimo'); + it('returns false if the status is not Abandoned', () => { + expect(service.isAmendment({ status: 'notAbandoned' })).toBe(false); }); - it('returns Falsy if code is not recognized', () => { - expect(service.getRegionString('WTF')).toBeFalsy(); + it('returns false if the status is Abandoned but the reason is undefined', () => { + expect(service.isAmendment({ status: StatusCodes.ABANDONED.code, reason: undefined })).toBe(false); }); - it('returns Falsy if code is empty', () => { - expect(service.getRegionString('')).toBeFalsy(); + it('returns false if the status is Abandoned but the reason is null', () => { + expect(service.isAmendment({ status: StatusCodes.ABANDONED.code, reason: null })).toBe(false); }); - it('returns Falsy if code is undefined', () => { - expect(service.getRegionString(undefined)).toBeFalsy(); + it('returns true if the status is Abandoned and the reason indicates an approved Amendment', () => { + expect( + service.isAmendment({ status: StatusCodes.ABANDONED.code, reason: ReasonCodes.AMENDMENT_APPROVED.code }) + ).toBe(true); }); - it('returns Falsy if code is null', () => { - expect(service.getRegionString(null)).toBeFalsy(); + it('returns true if the status is Abandoned and the reason indicates a not approved Amendment', () => { + expect( + service.isAmendment({ status: StatusCodes.ABANDONED.code, reason: ReasonCodes.AMENDMENT_NOT_APPROVED.code }) + ).toBe(true); }); }); }); diff --git a/src/app/services/application.service.ts b/src/app/services/application.service.ts index b1ef050e..ad076c35 100644 --- a/src/app/services/application.service.ts +++ b/src/app/services/application.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@angular/core'; -import { Observable, of, forkJoin } from 'rxjs'; -import { flatMap, map, catchError } from 'rxjs/operators'; +import { Observable, of, combineLatest, forkJoin } from 'rxjs'; +import { mergeMap, map, catchError } from 'rxjs/operators'; import * as moment from 'moment'; import * as _ from 'lodash'; -import { ApiService } from './api'; +import { ApiService, IApplicationQueryParamSet } from './api'; import { DocumentService } from './document.service'; import { CommentPeriodService } from './commentperiod.service'; import { CommentService } from './comment.service'; @@ -12,7 +12,6 @@ import { DecisionService } from './decision.service'; import { FeatureService } from './feature.service'; import { Application } from 'app/models/application'; -import { CommentPeriod } from 'app/models/commentperiod'; import { StatusCodes, ReasonCodes } from 'app/utils/constants/application'; import { ConstantUtils, CodeType } from 'app/utils/constants/constantUtils'; @@ -49,40 +48,76 @@ export class ApplicationService { /** * Get applications count. * - * @returns {Observable} + * @param {IApplicationQueryParamSet} [queryParams={ isDeleted: false }] * @memberof ApplicationService */ - getCount(): Observable { - return this.api.getCountApplications().pipe(catchError(error => this.api.handleError(error))); + getCount(queryParamSets: IApplicationQueryParamSet[] = null): Observable { + if (!queryParamSets || !queryParamSets.length) { + queryParamSets = [{ isDeleted: false }]; + } + + const observables: Array> = queryParamSets.map(queryParamSet => + this.api.getCountApplications(queryParamSet).pipe(catchError(this.api.handleError)) + ); + + return combineLatest(observables, (...args: number[]) => args.reduce((sum, arg) => (sum += arg))).pipe( + catchError(this.api.handleError) + ); } /** * Get all applications. * - * Note: currently returns at most 1000 records. - * - * @param {IGetParameters} [params=null] + * @param {IGetParameters} [dataParams=null] + * @param {IApplicationQueryParamSet[]} [queryParamSets=null] * @returns {Observable} * @memberof ApplicationService */ - getAll(params: IGetParameters = null): Observable { + getAll( + dataParams: IGetParameters = null, + queryParamSets: IApplicationQueryParamSet[] = null + ): Observable { // first get just the applications - // NB: max 1000 records - return this.api.getApplications(0, 1000).pipe( - flatMap(apps => { - if (!apps || apps.length === 0) { - // NB: forkJoin([]) will complete immediately - // so return empty observable instead + // return this.api.getApplications(queryParamSets).pipe( + // mergeMap(apps => { + // if (!apps || apps.length === 0) { + // // NB: forkJoin([]) will complete immediately + // // so return empty observable instead + // return of([] as Application[]); + // } + // const observables: Array> = []; + // apps.forEach(app => { + // // now get the rest of the data for each application + // observables.push(this._getExtraAppData(new Application(app), dataParams || {})); + // }); + // return forkJoin(observables); + // }), + // catchError(error => this.api.handleError(error)) + // ); + + let observables: Array>; + + if (queryParamSets) { + observables = queryParamSets.map(queryParamSet => this.api.getApplications(queryParamSet)); + } else { + observables = [this.api.getApplications()]; + } + + return combineLatest(...observables).pipe( + mergeMap((res: Application[]) => { + const resApps = _.flatten(res); + if (!resApps || resApps.length === 0) { return of([] as Application[]); } - const observables: Array> = []; - apps.forEach(app => { + + const dataObservables: Array> = []; + resApps.forEach(app => { // now get the rest of the data for each application - observables.push(this._getExtraAppData(new Application(app), params || {})); + dataObservables.push(this._getExtraAppData(new Application(app), dataParams || {})); }); - return forkJoin(observables); + return forkJoin(dataObservables); }), - catchError(error => this.api.handleError(error)) + catchError(this.api.handleError) ); } @@ -97,7 +132,7 @@ export class ApplicationService { getByCrownLandID(clid: string, params: IGetParameters = null): Observable { // first get just the applications return this.api.getApplicationsByCrownLandID(clid).pipe( - flatMap(apps => { + mergeMap(apps => { if (!apps || apps.length === 0) { // NB: forkJoin([]) will complete immediately // so return empty observable instead @@ -125,7 +160,7 @@ export class ApplicationService { getByTantalisID(tantalisID: number, params: IGetParameters = null): Observable { // first get just the application return this.api.getApplicationByTantalisId(tantalisID).pipe( - flatMap(apps => { + mergeMap(apps => { if (!apps || apps.length === 0) { return of(null as Application); } @@ -147,7 +182,7 @@ export class ApplicationService { getById(appId: string, params: IGetParameters = null): Observable { // first get just the application return this.api.getApplication(appId).pipe( - flatMap(apps => { + mergeMap(apps => { if (!apps || apps.length === 0) { return of(null as Application); } @@ -158,6 +193,73 @@ export class ApplicationService { ); } + // /** + // * Search by applications auto-complete. + // * + // * @param {string} appId + // * @param {IGetParameters} [params=null] + // * @returns {Observable} + // * @memberof ApplicationService + // */ + // getByApplicant(searchString: string): Observable { + // const queryParams: IApplicationQueryParamSet = { + // client: { value: searchString, modifier: QueryParamModifier.Text } + // }; + // this.api.getApplications(queryParams, ['applicant']).pipe( + // mergeMap(apps => { + // if (!apps || apps.length === 0) { + // return of(null as Application); + // } + // }), + // catchError(error => this.api.handleError(error)) + // ); + // } + + /** + * Fetches comment data. + * + * @private + * @param {Application} application + * @returns + * @memberof ApplicationService + */ + private _getExtraCommentData(application: Application) { + return this.commentPeriodService.getAllByApplicationId(application._id).pipe( + mergeMap(periods => { + application.meta.currentPeriod = this.commentPeriodService.getCurrent(periods); + + // user-friendly comment period long status string + const commentPeriodCode = this.commentPeriodService.getCode(application.meta.currentPeriod); + application.meta.cpStatusStringLong = ConstantUtils.getTextLong(CodeType.COMMENT, commentPeriodCode); + + // derive days remaining for display + // use moment to handle Daylight Saving Time changes + if (application.meta.currentPeriod && this.commentPeriodService.isOpen(commentPeriodCode)) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + application.meta.currentPeriod.meta.daysRemaining = + moment(application.meta.currentPeriod.endDate).diff(moment(today), 'days') + 1; // including today + } + + // get the number of comments for the current comment period only + // multiple comment periods are currently not supported + if (!application.meta.currentPeriod) { + application.meta.numComments = 0; + return of(application); + } + + return forkJoin( + this.commentService.getCountByPeriodId(application.meta.currentPeriod._id).pipe( + map(numComments => { + application.meta.numComments = numComments; + return of(application); + }) + ) + ); + }) + ); + } + /** * Fetches application data. * @@ -175,57 +277,31 @@ export class ApplicationService { return forkJoin( getFeatures ? this.featureService.getByApplicationId(application._id) : of(null), getDocuments ? this.documentService.getAllByApplicationId(application._id) : of(null), - getCurrentPeriod ? this.commentPeriodService.getAllByApplicationId(application._id) : of(null), + getCurrentPeriod ? this._getExtraCommentData(application) : of(null), getDecision ? this.decisionService.getByApplicationId(application._id, { getDocuments: true }) : of(null) ).pipe( map(payloads => { if (getFeatures) { - application.features = payloads[0]; + application.meta.features = payloads[0]; } if (getDocuments) { - application.documents = payloads[1]; - } - - if (getCurrentPeriod) { - const periods: CommentPeriod[] = payloads[2]; - application.currentPeriod = this.commentPeriodService.getCurrent(periods); - - // user-friendly comment period long status string - const commentPeriodCode = this.commentPeriodService.getCode(application.currentPeriod); - application.cpStatus = ConstantUtils.getTextLong(CodeType.COMMENT, commentPeriodCode); - - // derive days remaining for display - // use moment to handle Daylight Saving Time changes - if (application.currentPeriod && this.commentPeriodService.isOpen(commentPeriodCode)) { - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - application.currentPeriod['daysRemaining'] = - moment(application.currentPeriod.endDate).diff(moment(today), 'days') + 1; // including today - } - - // get the number of comments for the current comment period only - // multiple comment periods are currently not supported - if (application.currentPeriod) { - this.commentService.getCountByPeriodId(application.currentPeriod._id).subscribe(numComments => { - application['numComments'] = numComments; - }); - } + application.meta.documents = payloads[1]; } if (getDecision) { - application.decision = payloads[3]; + application.meta.decision = payloads[3]; } // 7-digit CL File number for display if (application.cl_file) { - application.clFile = application.cl_file.toString().padStart(7, '0'); + application.meta.clFile = application.cl_file.toString().padStart(7, '0'); } // derive unique applicants if (application.client) { const clients = application.client.split(', '); - application.applicants = _.uniq(clients).join(', '); + application.meta.applicants = _.uniq(clients).join(', '); } // derive retire date @@ -237,12 +313,12 @@ export class ApplicationService { StatusCodes.ABANDONED.code ].includes(ConstantUtils.getCode(CodeType.STATUS, application.status)) ) { - application.retireDate = moment(application.statusHistoryEffectiveDate) + application.meta.retireDate = moment(application.statusHistoryEffectiveDate) .endOf('day') .add(6, 'months') .toDate(); // set flag if retire date is in the past - application.isRetired = moment(application.retireDate).isBefore(); + application.meta.isRetired = moment(application.meta.retireDate).isBefore(); } // finally update the object and return @@ -269,10 +345,10 @@ export class ApplicationService { delete app._id; // don't send attached data (features, documents, etc) - delete app.features; - delete app.documents; - delete app.currentPeriod; - delete app.decision; + delete app.meta.features; + delete app.meta.documents; + delete app.meta.currentPeriod; + delete app.meta.decision; // replace newlines with \\n (JSON format) if (app.description) { @@ -297,10 +373,10 @@ export class ApplicationService { const app = _.cloneDeep(orig); // don't send attached data (features, documents, etc) - delete app.features; - delete app.documents; - delete app.currentPeriod; - delete app.decision; + delete app.meta.features; + delete app.meta.documents; + delete app.meta.currentPeriod; + delete app.meta.decision; // replace newlines with \\n (JSON format) if (app.description) { @@ -333,7 +409,7 @@ export class ApplicationService { * @memberof ApplicationService */ isAmendment(application: Application): boolean { - return ( + return !!( application && ConstantUtils.getCode(CodeType.STATUS, application.status) === StatusCodes.ABANDONED.code && (ConstantUtils.getCode(CodeType.REASON, application.reason) === ReasonCodes.AMENDMENT_APPROVED.code || diff --git a/src/app/services/comment.service.ts b/src/app/services/comment.service.ts index 348b6005..66f2f706 100644 --- a/src/app/services/comment.service.ts +++ b/src/app/services/comment.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { Observable, of, forkJoin } from 'rxjs'; -import { map, flatMap, mergeMap, catchError } from 'rxjs/operators'; +import { map, mergeMap, catchError } from 'rxjs/operators'; import * as _ from 'lodash'; import { ApiService } from './api'; @@ -26,7 +26,7 @@ export class CommentService { // get count of comments for the specified comment period id getCountByPeriodId(periodId: string): Observable { - return this.api.getCountCommentsByPeriodId(periodId).pipe(catchError(error => this.api.handleError(error))); + return this.api.getCountCommentsByCommentPeriodId(periodId).pipe(catchError(error => this.api.handleError(error))); } // get all comments for the specified application id @@ -62,7 +62,7 @@ export class CommentService { params: IGetParameters = null ): Observable { // first get just the comments - return this.api.getCommentsByPeriodId(periodId, pageNum, pageSize, sortBy).pipe( + return this.api.getCommentsByCommentPeriodId(periodId, pageNum, pageSize, sortBy).pipe( map(res => { if (res && res.length > 0) { const comments: Comment[] = []; @@ -73,7 +73,7 @@ export class CommentService { } return []; }), - flatMap(comments => { + mergeMap(comments => { // now get the documents for each comment if (params && params.getDocuments) { const observables: Array> = []; @@ -103,7 +103,7 @@ export class CommentService { getById(commentId: string, params: IGetParameters = null): Observable { // first get the comment data return this.api.getComment(commentId).pipe( - flatMap(comments => { + mergeMap(comments => { if (comments && comments.length > 0) { // return the first (only) comment const comment = new Comment(comments[0]); diff --git a/src/app/services/commentperiod.service.spec.ts b/src/app/services/commentperiod.service.spec.ts index a558b817..c2796f09 100644 --- a/src/app/services/commentperiod.service.spec.ts +++ b/src/app/services/commentperiod.service.spec.ts @@ -3,6 +3,7 @@ import { CommentPeriod } from 'app/models/commentperiod'; import { ApiService } from 'app/services/api'; import { of, throwError } from 'rxjs'; import { CommentPeriodService } from './commentperiod.service'; +import { CommentCodes } from 'app/utils/constants/comment'; describe('CommentPeriodService', () => { let service: CommentPeriodService; @@ -14,8 +15,8 @@ describe('CommentPeriodService', () => { { provide: ApiService, useValue: jasmine.createSpyObj('ApiService', [ - 'getPeriodsByAppId', - 'getPeriod', + 'getCommentPeriodsByApplicationId', + 'getCommentPeriod', 'addCommentPeriod', 'saveCommentPeriod', 'deleteCommentPeriod', @@ -41,7 +42,7 @@ describe('CommentPeriodService', () => { describe('when no comment periods are returned by the Api', () => { it('returns an empty CommentPeriod array', async(() => { - apiSpy.getPeriodsByAppId.and.returnValue(of([] as CommentPeriod[])); + apiSpy.getCommentPeriodsByApplicationId.and.returnValue(of([] as CommentPeriod[])); service.getAllByApplicationId('123').subscribe(result => expect(result).toEqual([] as CommentPeriod[])); })); @@ -51,7 +52,7 @@ describe('CommentPeriodService', () => { it('returns an array with one CommentPeriod element', async(() => { const today = new Date(); const commentPeriods: CommentPeriod[] = [new CommentPeriod({ _id: '1', startDate: today, endDate: today })]; - apiSpy.getPeriodsByAppId.and.returnValue(of(commentPeriods)); + apiSpy.getCommentPeriodsByApplicationId.and.returnValue(of(commentPeriods)); service.getAllByApplicationId('123').subscribe(result => { expect(result).toEqual(commentPeriods); @@ -68,7 +69,7 @@ describe('CommentPeriodService', () => { new CommentPeriod({ _id: '3', startDate: today, endDate: today }) ]; - apiSpy.getPeriodsByAppId.and.returnValue(of(commentPeriods)); + apiSpy.getCommentPeriodsByApplicationId.and.returnValue(of(commentPeriods)); service.getAllByApplicationId('123').subscribe(result => { expect(result).toEqual(commentPeriods); @@ -78,7 +79,7 @@ describe('CommentPeriodService', () => { describe('when an exception is thrown', () => { it('ApiService.handleError is called and the error is re-thrown', async(() => { - apiSpy.getPeriodsByAppId.and.returnValue(throwError(new Error('someError'))); + apiSpy.getCommentPeriodsByApplicationId.and.returnValue(throwError(new Error('someError'))); apiSpy.handleError.and.callFake(error => { expect(error).toEqual(Error('someError')); @@ -105,7 +106,7 @@ describe('CommentPeriodService', () => { describe('when no comment period is returned by the Api', () => { it('returns an empty CommentPeriod array', async(() => { - apiSpy.getPeriod.and.returnValue(of(null as CommentPeriod)); + apiSpy.getCommentPeriod.and.returnValue(of(null as CommentPeriod)); service.getById('123').subscribe(result => expect(result).toEqual(null)); })); @@ -119,7 +120,7 @@ describe('CommentPeriodService', () => { startDate: today, endDate: today }); - apiSpy.getPeriod.and.returnValue(of()); + apiSpy.getCommentPeriod.and.returnValue(of()); service.getById('123').subscribe(result => { expect(result).toEqual(commentPeriod); @@ -136,7 +137,7 @@ describe('CommentPeriodService', () => { new CommentPeriod({ _id: '3', startDate: today, endDate: today }) ]; - apiSpy.getPeriod.and.returnValue(of(commentPeriod)); + apiSpy.getCommentPeriod.and.returnValue(of(commentPeriod)); service.getById('123').subscribe(result => { expect(result).toEqual(commentPeriod[0]); @@ -146,7 +147,7 @@ describe('CommentPeriodService', () => { describe('when an exception is thrown', () => { it('ApiService.handleError is called and the error is re-thrown', async(() => { - apiSpy.getPeriod.and.returnValue(throwError(new Error('someError'))); + apiSpy.getCommentPeriod.and.returnValue(throwError(new Error('someError'))); apiSpy.handleError.and.callFake(error => { expect(error).toEqual(Error('someError')); @@ -226,7 +227,7 @@ describe('CommentPeriodService', () => { describe('when an exception is thrown', () => { it('ApiService.handleError is called and the error is re-thrown', async(() => { - apiSpy.getPeriod.and.returnValue(throwError(new Error('someError'))); + apiSpy.getCommentPeriod.and.returnValue(throwError(new Error('someError'))); apiSpy.handleError.and.callFake(error => { expect(error).toEqual(Error('someError')); @@ -597,10 +598,10 @@ describe('CommentPeriodService', () => { }); }); - describe('getStatusCode()', () => { + describe('getCode()', () => { describe('without a comment period', () => { it('returns "NOT_OPEN"', () => { - expect(service.getStatusCode(null)).toEqual(service.NOT_OPEN); + expect(service.getCode(null)).toEqual(CommentCodes.NOT_OPEN.code); }); }); @@ -609,7 +610,7 @@ describe('CommentPeriodService', () => { const commentPeriod = new CommentPeriod({ endDate: Date.now() }); - expect(service.getStatusCode(commentPeriod)).toEqual(service.NOT_OPEN); + expect(service.getCode(commentPeriod)).toEqual(CommentCodes.NOT_OPEN.code); }); }); @@ -618,7 +619,7 @@ describe('CommentPeriodService', () => { const commentPeriod = new CommentPeriod({ startDate: Date.now() }); - expect(service.getStatusCode(commentPeriod)).toEqual(service.NOT_OPEN); + expect(service.getCode(commentPeriod)).toEqual(CommentCodes.NOT_OPEN.code); }); }); @@ -628,7 +629,7 @@ describe('CommentPeriodService', () => { startDate: new Date('September 28, 2018 08:24:00'), endDate: new Date('December 1, 2018 16:24:00') }); - expect(service.getStatusCode(commentPeriod)).toEqual(service.CLOSED); + expect(service.getCode(commentPeriod)).toEqual(CommentCodes.CLOSED.code); }); }); @@ -638,7 +639,7 @@ describe('CommentPeriodService', () => { startDate: new Date('September 28, 2050 08:24:00'), endDate: new Date('December 1, 2050 16:24:00') }); - expect(service.getStatusCode(commentPeriod)).toEqual(service.NOT_STARTED); + expect(service.getCode(commentPeriod)).toEqual(CommentCodes.NOT_STARTED.code); }); }); @@ -648,7 +649,7 @@ describe('CommentPeriodService', () => { startDate: new Date('September 28, 2010 08:24:00'), endDate: new Date('December 1, 2050 16:24:00') }); - expect(service.getStatusCode(commentPeriod)).toEqual(service.OPEN); + expect(service.getCode(commentPeriod)).toEqual(CommentCodes.OPEN.code); }); }); }); @@ -659,7 +660,7 @@ describe('CommentPeriodService', () => { startDate: new Date('September 28, 2018 08:24:00'), endDate: new Date('December 1, 2018 16:24:00') }); - const statusCode = service.getStatusCode(commentPeriod); + const statusCode = service.getCode(commentPeriod); expect(service.isClosed(statusCode)).toBe(true); }); @@ -668,14 +669,14 @@ describe('CommentPeriodService', () => { startDate: new Date('September 28, 2018 08:24:00'), endDate: new Date('December 1, 2050 16:24:00') }); - const statusCode = service.getStatusCode(commentPeriod); + const statusCode = service.getCode(commentPeriod); expect(service.isClosed(statusCode)).toBe(false); }); }); describe('isNotOpen()', () => { it('returns "true" if the comment period status is null', () => { - const statusCode = service.getStatusCode(null); + const statusCode = service.getCode(null); expect(service.isNotOpen(statusCode)).toBe(true); }); @@ -683,7 +684,7 @@ describe('CommentPeriodService', () => { const commentPeriod = new CommentPeriod({ endDate: new Date('December 1, 2050 16:24:00') }); - const statusCode = service.getStatusCode(commentPeriod); + const statusCode = service.getCode(commentPeriod); expect(service.isNotOpen(statusCode)).toBe(true); }); @@ -691,7 +692,7 @@ describe('CommentPeriodService', () => { const commentPeriod = new CommentPeriod({ startDate: new Date('September 28, 2018 08:24:00') }); - const statusCode = service.getStatusCode(commentPeriod); + const statusCode = service.getCode(commentPeriod); expect(service.isNotOpen(statusCode)).toBe(true); }); @@ -700,7 +701,7 @@ describe('CommentPeriodService', () => { startDate: new Date('September 28, 2018 08:24:00'), endDate: new Date('December 1, 2050 16:24:00') }); - const statusCode = service.getStatusCode(commentPeriod); + const statusCode = service.getCode(commentPeriod); expect(service.isClosed(statusCode)).toBe(false); }); }); @@ -711,7 +712,7 @@ describe('CommentPeriodService', () => { startDate: new Date('December 1, 2050 8:24:00'), endDate: new Date('December 31, 2050 16:24:00') }); - const statusCode = service.getStatusCode(commentPeriod); + const statusCode = service.getCode(commentPeriod); expect(service.isNotStarted(statusCode)).toBe(true); }); @@ -720,7 +721,7 @@ describe('CommentPeriodService', () => { startDate: new Date('September 28, 2018 08:24:00'), endDate: new Date('December 1, 2018 16:24:00') }); - const statusCode = service.getStatusCode(commentPeriod); + const statusCode = service.getCode(commentPeriod); expect(service.isNotStarted(statusCode)).toBe(false); }); }); @@ -731,7 +732,7 @@ describe('CommentPeriodService', () => { startDate: new Date('September 28, 2010 08:24:00'), endDate: new Date('December 1, 2050 08:24:00') }); - const statusCode = service.getStatusCode(commentPeriod); + const statusCode = service.getCode(commentPeriod); expect(service.isOpen(statusCode)).toBe(true); }); @@ -740,7 +741,7 @@ describe('CommentPeriodService', () => { startDate: new Date('September 28, 2018 08:24:00'), endDate: new Date('December 1, 2018 16:24:00') }); - const statusCode = service.getStatusCode(commentPeriod); + const statusCode = service.getCode(commentPeriod); expect(service.isOpen(statusCode)).toBe(false); }); }); diff --git a/src/app/services/commentperiod.service.ts b/src/app/services/commentperiod.service.ts index f5bec315..f148559d 100644 --- a/src/app/services/commentperiod.service.ts +++ b/src/app/services/commentperiod.service.ts @@ -12,9 +12,44 @@ import { CommentCodes } from 'app/utils/constants/comment'; export class CommentPeriodService { constructor(private api: ApiService) {} - // get all comment periods for the specified application id + // /** + // * Get all comment periods. + // * + // * @param {ICommentPeriodQueryParamSet[]} [queryParamSets=null] + // * @returns {Observable} + // * @memberof CommentPeriodService + // */ + // getAll(queryParamSets: ICommentPeriodQueryParamSet[] = null): Observable { + // let observables: Array>; + + // if (queryParamSets) { + // observables = queryParamSets.map(queryParamSet => this.api.getCommentPeriods(queryParamSet)); + // } else { + // observables = [this.api.getCommentPeriods()]; + // } + + // return combineLatest(...observables).pipe( + // mergeMap((res: CommentPeriod[]) => { + // const resCommentPeriods = _.flatten(res); + // if (!resCommentPeriods || resCommentPeriods.length === 0) { + // return of([] as CommentPeriod[]); + // } + + // return of(resCommentPeriods); + // }), + // catchError(this.api.handleError) + // ); + // } + + /** + * Get all comment periods for the specified application id + * + * @param {string} appId + * @returns {Observable} + * @memberof CommentPeriodService + */ getAllByApplicationId(appId: string): Observable { - return this.api.getPeriodsByAppId(appId).pipe( + return this.api.getCommentPeriodsByApplicationId(appId).pipe( map(res => { if (res && res.length > 0) { const periods: CommentPeriod[] = []; @@ -29,9 +64,15 @@ export class CommentPeriodService { ); } - // get a specific comment period by its id + /** + * get a specific comment period by its id + * + * @param {string} periodId + * @returns {Observable} + * @memberof CommentPeriodService + */ getById(periodId: string): Observable { - return this.api.getPeriod(periodId).pipe( + return this.api.getCommentPeriod(periodId).pipe( map(res => { if (res && res.length > 0) { // return the first (only) comment period @@ -72,8 +113,15 @@ export class CommentPeriodService { return this.api.unPublishCommentPeriod(period).pipe(catchError(error => this.api.handleError(error))); } - // returns first period - // multiple comment periods are currently not supported + /** + * Returns the first period in the array. + * + * Note: multiple comment periods are not supported. + * + * @param {CommentPeriod[]} periods + * @returns {CommentPeriod} + * @memberof CommentPeriodService + */ getCurrent(periods: CommentPeriod[]): CommentPeriod { return periods.length > 0 ? periods[0] : null; } diff --git a/src/app/services/decision.service.ts b/src/app/services/decision.service.ts index 7fa8ae0d..4cb14d0a 100644 --- a/src/app/services/decision.service.ts +++ b/src/app/services/decision.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { map, flatMap, catchError } from 'rxjs/operators'; +import { map, mergeMap, catchError } from 'rxjs/operators'; import * as _ from 'lodash'; import { ApiService } from './api'; @@ -18,8 +18,8 @@ export class DecisionService { // get decision for the specified application id getByApplicationId(appId: string, params: IGetParameters = null): Observable { // first get the decision data - return this.api.getDecisionsByAppId(appId).pipe( - flatMap(res => { + return this.api.getDecisionsByApplicationId(appId).pipe( + mergeMap(res => { if (res && res.length > 0) { // return the first (only) decision const decision = new Decision(res[0]); @@ -28,7 +28,7 @@ export class DecisionService { if (params && params.getDocuments) { return this.documentService.getAllByDecisionId(decision._id).pipe( map(documents => { - decision.documents = documents; + decision.meta.documents = documents; return decision; }) ); @@ -46,7 +46,7 @@ export class DecisionService { getById(decisionId, params: IGetParameters = null): Observable { // first get the decision data return this.api.getDecision(decisionId).pipe( - flatMap(res => { + mergeMap(res => { if (res && res.length > 0) { // return the first (only) decision const decision = new Decision(res[0]); @@ -55,7 +55,7 @@ export class DecisionService { if (params && params.getDocuments) { return this.documentService.getAllByDecisionId(decision._id).pipe( map(documents => { - decision.documents = documents; + decision.meta.documents = documents; return decision; }) ); @@ -77,7 +77,7 @@ export class DecisionService { delete decision._id; // don't send documents - delete decision.documents; + delete decision.meta.documents; return this.api.addDecision(decision).pipe(catchError(error => this.api.handleError(error))); } @@ -87,7 +87,7 @@ export class DecisionService { const decision = _.cloneDeep(orig); // don't send documents - delete decision.documents; + delete decision.meta.documents; return this.api.saveDecision(decision).pipe(catchError(error => this.api.handleError(error))); } diff --git a/src/app/services/document.service.ts b/src/app/services/document.service.ts index c4e0f073..ba255ba9 100644 --- a/src/app/services/document.service.ts +++ b/src/app/services/document.service.ts @@ -11,7 +11,7 @@ export class DocumentService { // get all documents for the specified application id getAllByApplicationId(id: string): Observable { - return this.api.getDocumentsByAppId(id).pipe( + return this.api.getDocumentsByApplicationId(id).pipe( map(res => { if (res && res.length > 0) { const documents: Document[] = []; diff --git a/src/app/services/excel.service.spec.ts b/src/app/services/excel.service.spec.ts deleted file mode 100644 index 0c6d9505..00000000 --- a/src/app/services/excel.service.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { TestBed, inject } from '@angular/core/testing'; - -import { ExcelService } from './excel.service'; - -describe('ExcelService', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ExcelService] - }); - }); - - it('should be created', inject([ExcelService], (service: ExcelService) => { - expect(service).toBeTruthy(); - })); -}); diff --git a/src/app/services/excel.service.ts b/src/app/services/excel.service.ts deleted file mode 100644 index 71bc2bc5..00000000 --- a/src/app/services/excel.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Injectable } from '@angular/core'; -import * as XLSX from 'xlsx'; - -// Refs: -// https://github.com/luwojtaszek/ngx-excel-export -// https://www.npmjs.com/package/xlsx -// https://github.com/SheetJS/js-xlsx - -@Injectable() -export class ExcelService { - constructor() {} - - // data: array of objects (flattened) - // excelFileName: filename without extension - // columnOrder: array of keys - public exportAsExcelFile(data: any[], excelFileName: string, columnOrder: string[] = []): void { - const json_opts: XLSX.JSON2SheetOpts = { header: columnOrder }; - const worksheet: XLSX.WorkSheet = XLSX.utils.json_to_sheet(data, json_opts); - const workbook: XLSX.WorkBook = { Sheets: { data: worksheet }, SheetNames: ['data'] }; - const write_opts: XLSX.WritingOptions = { bookType: 'xlsx' }; - XLSX.writeFile(workbook, excelFileName + '.xlsx', write_opts); - } -} diff --git a/src/app/services/export.service.spec.ts b/src/app/services/export.service.spec.ts new file mode 100644 index 00000000..207d53b2 --- /dev/null +++ b/src/app/services/export.service.spec.ts @@ -0,0 +1,15 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { ExportService } from './export.service'; + +describe('ExportService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ExportService] + }); + }); + + it('should be created', inject([ExportService], (service: ExportService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/src/app/services/export.service.ts b/src/app/services/export.service.ts new file mode 100644 index 00000000..429b1846 --- /dev/null +++ b/src/app/services/export.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import * as XLSX from 'xlsx'; +import { saveAs } from 'file-saver'; +import { Parser } from 'json2csv'; + +/** + * Service to generate and download an excel or csv file. + * + * @export + * @class ExportService + */ +@Injectable() +export class ExportService { + constructor() {} + + /** + * Generates and downloads the given data as an excel file. + * + * @param {any[]} data array of flattened objects + * @param {string} fileName file name, not including extension + * @param {string[]} [columns=[]] data fields to include in excel, in order + * @memberof ExportService + */ + public exportAsExcelFile(data: any[], fileName: string, columns: string[] = []): void { + const json_opts: XLSX.JSON2SheetOpts = { header: columns }; + const worksheet: XLSX.WorkSheet = XLSX.utils.json_to_sheet(data, json_opts); + const workbook: XLSX.WorkBook = { Sheets: { data: worksheet }, SheetNames: ['data'] }; + const write_opts: XLSX.WritingOptions = { bookType: 'xlsx' }; + XLSX.writeFile(workbook, `${fileName}.xlsx`, write_opts); + } + + /** + * Generates and downloads the given data as a csv file. + * + * @param {any[]} data array of objects + * @param {string} fileName file name, not including extension + * @param {string[]} [fields=[]] data fields include in csv, in order + * @memberof ExportService + */ + public exportAsCSV(data: any[], fileName: string, fields: any[] = []): void { + const csvData: string = new Parser({ fields: fields }).parse(data); + const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8' }); + saveAs(blob, `${fileName}.csv`); + } +} diff --git a/src/app/services/keycloak.service.ts b/src/app/services/keycloak.service.ts index 6e8e5359..ebd39486 100644 --- a/src/app/services/keycloak.service.ts +++ b/src/app/services/keycloak.service.ts @@ -16,12 +16,9 @@ export class KeycloakService { constructor() { switch (window.location.origin) { case 'http://localhost:4200': - case 'https://nrts-prc-demo.pathfinder.gov.bc.ca': - case 'https://nrts-prc-scale.pathfinder.gov.bc.ca': - case 'https://nrts-prc-beta.pathfinder.gov.bc.ca': - case 'https://nrts-prc-master.pathfinder.gov.bc.ca': case 'https://nrts-prc-dev.pathfinder.gov.bc.ca': - // Local, Dev etc + case 'https://nrts-prc-master.pathfinder.gov.bc.ca': + // Local, Dev, Master this.keycloakEnabled = true; this.keycloakUrl = 'https://sso-dev.pathfinder.gov.bc.ca/auth'; this.keycloakRealm = 'prc'; diff --git a/src/app/services/search.service.spec.ts b/src/app/services/search.service.spec.ts index e8201d99..37b79fe5 100644 --- a/src/app/services/search.service.spec.ts +++ b/src/app/services/search.service.spec.ts @@ -6,6 +6,7 @@ import { ApplicationService } from './application.service'; import { SearchService } from './search.service'; import { SearchResults } from 'app/models/search'; import { InterestedParty } from 'app/models/interestedParty'; +import { ReasonCodes, StatusCodes } from 'app/utils/constants/application'; describe('SearchService', () => { beforeEach(() => { @@ -14,7 +15,11 @@ describe('SearchService', () => { SearchService, { provide: ApiService, - useValue: jasmine.createSpyObj('ApiService', ['searchAppsByCLID', 'searchAppsByDTID', 'handleError']) + useValue: jasmine.createSpyObj('ApiService', [ + 'searchAppsByCLFile', + 'searchAppsByDispositionID', + 'handleError' + ]) }, { provide: ApplicationService, @@ -35,7 +40,7 @@ describe('SearchService', () => { expect(service).toBeTruthy(); }); - describe('getAppsByClidDtid', () => { + describe('getApplicationsByCLFileAndTantalisID', () => { let service; let apiSpy; let ApplicationServiceSpy; @@ -45,19 +50,19 @@ describe('SearchService', () => { ApplicationServiceSpy = TestBed.get(ApplicationService); }); - describe('when getAppsByCLID returns results', () => { + describe('when getApplicationsByCLFile returns results', () => { beforeEach(() => { - // Only testing calls to getAppsByCLID - spyOn(service, 'getAppByDTID').and.returnValue(of([] as Application[])); + // Only testing calls to getApplicationsByCLFile + spyOn(service, 'getApplicationsByDispositionID').and.returnValue(of([] as Application[])); }); describe('when no applications or search results are returned', () => { it('returns an empty array of applications', async(() => { ApplicationServiceSpy.getByCrownLandID.and.returnValue(of([] as Application[])); - apiSpy.searchAppsByCLID.and.returnValue(of([] as SearchResults[])); + apiSpy.searchAppsByCLFile.and.returnValue(of([] as SearchResults[])); - service.getAppsByClidDtid(['123']).subscribe(result => { + service.getApplicationsByCLFileAndTantalisID(['123']).subscribe(result => { expect(result).toEqual([] as Application[]); }); })); @@ -72,14 +77,14 @@ describe('SearchService', () => { of([new Application({ _id: '1' }), new Application({ _id: '2' })]) ); - apiSpy.searchAppsByCLID.and.returnValue(of([] as SearchResults[])); + apiSpy.searchAppsByCLFile.and.returnValue(of([] as SearchResults[])); - service.getAppsByClidDtid(['123']).subscribe(res => { + service.getApplicationsByCLFileAndTantalisID(['123']).subscribe(res => { result.push(res); }); })); - it('getAppsByClidDtid emits twice', async(() => { + it('getApplicationsByCLFileAndTantalisID emits twice', async(() => { expect(result.length).toEqual(2); })); @@ -123,7 +128,7 @@ describe('SearchService', () => { ApplicationServiceSpy.getByCrownLandID.and.returnValue(of([] as Application[])); - apiSpy.searchAppsByCLID.and.returnValue( + apiSpy.searchAppsByCLFile.and.returnValue( of([ new SearchResults({ type: 'type', @@ -144,11 +149,12 @@ describe('SearchService', () => { ], CROWN_LANDS_FILE: '11', DISPOSITION_TRANSACTION_SID: '33', - RESPONSIBLE_BUSINESS_UNIT: '44', + RESPONSIBLE_BUSINESS_UNIT: 'KO - LAND MGMNT - KOOTENAY FIELD OFFICE', TENURE_PURPOSE: 'tenure-purpose-1', TENURE_LOCATION: 'tenure-location-1', TENURE_STAGE: 'tenure-stage-1', - TENURE_STATUS: 'tenure-status-1', + TENURE_STATUS: StatusCodes.ABANDONED.code, + TENURE_REASON: ReasonCodes.AMENDMENT_APPROVED.code, TENURE_SUBPURPOSE: 'tenure-subpurpose-1', TENURE_SUBTYPE: 'tenure-subtype-1', TENURE_TYPE: 'tenure-type-1' @@ -166,11 +172,12 @@ describe('SearchService', () => { ], CROWN_LANDS_FILE: '22', DISPOSITION_TRANSACTION_SID: '55', - RESPONSIBLE_BUSINESS_UNIT: '66', + RESPONSIBLE_BUSINESS_UNIT: 'OM - LAND MGMNT - NORTHERN SERVICE REGION', TENURE_PURPOSE: 'tenure-purpose-2', TENURE_LOCATION: 'tenure-location-2', TENURE_STAGE: 'tenure-stage-2', - TENURE_STATUS: 'tenure-status-2', + TENURE_STATUS: StatusCodes.APPLICATION_REVIEW_COMPLETE.code, + TENURE_REASON: ReasonCodes.AMENDMENT_APPROVED.code, TENURE_SUBPURPOSE: 'tenure-subpurpose-2', TENURE_SUBTYPE: 'tenure-subtype-2', TENURE_TYPE: 'tenure-type-2' @@ -178,13 +185,7 @@ describe('SearchService', () => { ]) ); - ApplicationServiceSpy.getStatusString.and.callFake(arg => { - return 'someStatusString'; - }); - ApplicationServiceSpy.getStatusCode.and.returnValue('someStatusCode'); - ApplicationServiceSpy.getRegionCode.and.returnValue('someRegionCode'); - - service.getAppsByClidDtid(['123']).subscribe( + service.getApplicationsByCLFileAndTantalisID(['123']).subscribe( res => { result.push(res); }, @@ -194,7 +195,7 @@ describe('SearchService', () => { ); })); - it('getAppsByClidDtid emits twice', async(() => { + it('getApplicationsByCLFileAndTantalisID emits twice', async(() => { expect(result.length).toEqual(2); })); @@ -214,23 +215,27 @@ describe('SearchService', () => { expect(prcResult[0].subpurpose).toBe('tenure-subpurpose-1'); expect(prcResult[0].type).toBe('tenure-type-1'); expect(prcResult[0].subtype).toBe('tenure-subtype-1'); - expect(prcResult[0].status).toBe('tenure-status-1'); + expect(prcResult[0].status).toBe('ABANDONED'); + expect(prcResult[0].reason).toBe('AMENDMENT APPROVED - APPLICATION'); expect(prcResult[0].tenureStage).toBe('tenure-stage-1'); expect(prcResult[0].location).toBe('tenure-location-1'); - expect(prcResult[0].businessUnit).toBe('44'); + expect(prcResult[0].businessUnit).toBe('KO - LAND MGMNT - KOOTENAY FIELD OFFICE'); expect(prcResult[0].cl_file).toBe(11); expect(prcResult[0].tantalisID).toBe(33); + expect(prcResult[0].meta.region).toBe('Kootenay, Cranbrook'); expect(prcResult[1].purpose).toBe('tenure-purpose-2'); expect(prcResult[1].subpurpose).toBe('tenure-subpurpose-2'); expect(prcResult[1].type).toBe('tenure-type-2'); expect(prcResult[1].subtype).toBe('tenure-subtype-2'); - expect(prcResult[1].status).toBe('tenure-status-2'); + expect(prcResult[1].status).toBe('APPLICATION REVIEW COMPLETE'); + expect(prcResult[1].reason).toBe('AMENDMENT APPROVED - APPLICATION'); expect(prcResult[1].tenureStage).toBe('tenure-stage-2'); expect(prcResult[1].location).toBe('tenure-location-2'); - expect(prcResult[1].businessUnit).toBe('66'); + expect(prcResult[1].businessUnit).toBe('OM - LAND MGMNT - NORTHERN SERVICE REGION'); expect(prcResult[1].cl_file).toBe(22); expect(prcResult[1].tantalisID).toBe(55); + expect(prcResult[0].meta.region).toBe('Kootenay, Cranbrook'); }); it('builds and sets a client string', () => { @@ -249,46 +254,26 @@ describe('SearchService', () => { }); it('has a clFile property padded to 7 digits', () => { - expect(prcResult[0].clFile).toBe('0000011'); - expect(prcResult[1].clFile).toBe('0000022'); - }); - - it('has an appStatus', () => { - expect(prcResult[0].appStatus).toBe('someStatusString'); - }); - - it('has a region', () => { - expect(prcResult[0].region).toBe('someRegionCode'); + expect(prcResult[0].meta.clFile).toBe('0000011'); + expect(prcResult[1].meta.clFile).toBe('0000022'); }); }); - - it('calls ApplicationService getStatusString', () => { - expect(ApplicationServiceSpy.getStatusString).toHaveBeenCalledWith('someStatusCode'); - }); - - it('calls ApplicationService getStatusCode', () => { - expect(ApplicationServiceSpy.getStatusCode).toHaveBeenCalled(); - }); - - it('calls ApplicationService getRegionCode', () => { - expect(ApplicationServiceSpy.getRegionCode).toHaveBeenCalled(); - }); }); }); - describe('when getAppByDTID returns results', () => { + describe('when getApplicationsByDispositionID returns results', () => { beforeEach(() => { - // Only testing calls to getAppByDTID - spyOn(service, 'getAppsByCLID').and.returnValue(of([] as Application[])); + // Only testing calls to getApplicationsByDispositionID + spyOn(service, 'getApplicationsByCLFile').and.returnValue(of([] as Application[])); }); describe('when no applications or search results are returned', () => { it('returns an empty array of applications', async(() => { ApplicationServiceSpy.getByTantalisID.and.returnValue(of(null as Application)); - apiSpy.searchAppsByDTID.and.returnValue(of(null as SearchResults)); + apiSpy.searchAppsByDispositionID.and.returnValue(of(null as SearchResults)); - service.getAppsByClidDtid(['123']).subscribe(result => { + service.getApplicationsByCLFileAndTantalisID(['123']).subscribe(result => { expect(result).toEqual([] as Application[]); }); })); @@ -301,14 +286,14 @@ describe('SearchService', () => { ApplicationServiceSpy.getByTantalisID.and.returnValue(of(new Application({ _id: '2' }))); - apiSpy.searchAppsByDTID.and.returnValue(of(null as SearchResults)); + apiSpy.searchAppsByDispositionID.and.returnValue(of(null as SearchResults)); - service.getAppsByClidDtid(['123']).subscribe(res => { + service.getApplicationsByCLFileAndTantalisID(['123']).subscribe(res => { result.push(res); }); })); - it('getAppsByClidDtid emits twice', async(() => { + it('getApplicationsByCLFileAndTantalisID emits twice', async(() => { expect(result.length).toEqual(2); })); @@ -328,18 +313,6 @@ describe('SearchService', () => { expect(prcResult[0]['isCreated']).toBe(true); }); }); - - it('does not call ApplicationService getStatusString', () => { - expect(ApplicationServiceSpy.getStatusString).not.toHaveBeenCalled(); - }); - - it('does not call ApplicationService getStatusCode', () => { - expect(ApplicationServiceSpy.getStatusCode).not.toHaveBeenCalled(); - }); - - it('does not call ApplicationService getRegionCode', () => { - expect(ApplicationServiceSpy.getRegionCode).not.toHaveBeenCalled(); - }); }); describe('when application results from Tantalis are returned', () => { @@ -349,7 +322,7 @@ describe('SearchService', () => { ApplicationServiceSpy.getByTantalisID.and.returnValue(of(null as Application)); - apiSpy.searchAppsByDTID.and.returnValue( + apiSpy.searchAppsByDispositionID.and.returnValue( of( new SearchResults({ type: 'type', @@ -370,11 +343,12 @@ describe('SearchService', () => { ], CROWN_LANDS_FILE: '11', DISPOSITION_TRANSACTION_SID: '33', - RESPONSIBLE_BUSINESS_UNIT: '44', + RESPONSIBLE_BUSINESS_UNIT: 'CA - LAND MGMNT - CARIBOO FIELD OFFICE', TENURE_PURPOSE: 'tenure-purpose-1', TENURE_LOCATION: 'tenure-location-1', TENURE_STAGE: 'tenure-stage-1', - TENURE_STATUS: 'tenure-status-1', + TENURE_STATUS: StatusCodes.DECISION_APPROVED.code, + TENURE_REASON: 'tenure-reason-1', TENURE_SUBPURPOSE: 'tenure-subpurpose-1', TENURE_SUBTYPE: 'tenure-subtype-1', TENURE_TYPE: 'tenure-type-1' @@ -382,13 +356,7 @@ describe('SearchService', () => { ) ); - ApplicationServiceSpy.getStatusString.and.callFake(arg => { - return 'someStatusString'; - }); - ApplicationServiceSpy.getStatusCode.and.returnValue('someStatusCode'); - ApplicationServiceSpy.getRegionCode.and.returnValue('someRegionCode'); - - service.getAppsByClidDtid(['123']).subscribe( + service.getApplicationsByCLFileAndTantalisID(['123']).subscribe( res => { result.push(res); }, @@ -398,7 +366,7 @@ describe('SearchService', () => { ); })); - it('getAppsByClidDtid emits twice', async(() => { + it('getApplicationsByCLFileAndTantalisID emits twice', async(() => { expect(result.length).toEqual(2); })); @@ -418,12 +386,14 @@ describe('SearchService', () => { expect(tantalisResult[0].subpurpose).toBe('tenure-subpurpose-1'); expect(tantalisResult[0].type).toBe('tenure-type-1'); expect(tantalisResult[0].subtype).toBe('tenure-subtype-1'); - expect(tantalisResult[0].status).toBe('tenure-status-1'); + expect(tantalisResult[0].status).toBe('DECISION APPROVED'); + expect(tantalisResult[0].reason).toBe(null); expect(tantalisResult[0].tenureStage).toBe('tenure-stage-1'); expect(tantalisResult[0].location).toBe('tenure-location-1'); - expect(tantalisResult[0].businessUnit).toBe('44'); + expect(tantalisResult[0].businessUnit).toBe('CA - LAND MGMNT - CARIBOO FIELD OFFICE'); expect(tantalisResult[0].cl_file).toBe(11); expect(tantalisResult[0].tantalisID).toBe(33); + expect(tantalisResult[0].meta.region).toBe('Cariboo, Williams Lake'); }); it('builds and sets a client string', () => { @@ -439,42 +409,24 @@ describe('SearchService', () => { }); it('has a clFile property padded to 7 digits', () => { - expect(tantalisResult[0].clFile).toBe('0000011'); - }); - - it('has an appStatus', () => { - expect(tantalisResult[0].appStatus).toBe('someStatusString'); - }); - - it('has a region', () => { - expect(tantalisResult[0].region).toBe('someRegionCode'); + expect(tantalisResult[0].meta.clFile).toBe('0000011'); }); }); - - it('calls ApplicationService getStatusString', () => { - expect(ApplicationServiceSpy.getStatusString).toHaveBeenCalledWith('someStatusCode'); - }); - - it('calls ApplicationService getStatusCode', () => { - expect(ApplicationServiceSpy.getStatusCode).toHaveBeenCalled(); - }); - - it('calls ApplicationService getRegionCode', () => { - expect(ApplicationServiceSpy.getRegionCode).toHaveBeenCalled(); - }); }); }); describe('when multiple applications are returned', () => { it('returns a merged array of applications', async(() => { - spyOn(service, 'getAppsByCLID').and.returnValue(of([new Application({ _id: 1 }), new Application({ _id: 2 })])); + spyOn(service, 'getApplicationsByCLFile').and.returnValue( + of([new Application({ _id: 1 }), new Application({ _id: 2 })]) + ); - spyOn(service, 'getAppByDTID').and.returnValue( + spyOn(service, 'getApplicationsByDispositionID').and.returnValue( of([new Application({ _id: 2 }), new Application({ _id: 3 }), new Application({ _id: 4 })]) ); const resultingApplications = new Set(); - service.getAppsByClidDtid(['123', '234']).subscribe( + service.getApplicationsByCLFileAndTantalisID(['123', '234']).subscribe( result => { result.forEach(element => { resultingApplications.add(element); @@ -484,11 +436,11 @@ describe('SearchService', () => { fail(error); }, () => { - expect(service.getAppsByCLID).toHaveBeenCalledWith('123'); - expect(service.getAppsByCLID).toHaveBeenCalledWith('234'); + expect(service.getApplicationsByCLFile).toHaveBeenCalledWith('123'); + expect(service.getApplicationsByCLFile).toHaveBeenCalledWith('234'); - expect(service.getAppByDTID).toHaveBeenCalledWith(123); - expect(service.getAppByDTID).toHaveBeenCalledWith(234); + expect(service.getApplicationsByDispositionID).toHaveBeenCalledWith(123); + expect(service.getApplicationsByDispositionID).toHaveBeenCalledWith(234); const finalResults = Array.from(resultingApplications); expect(finalResults).toEqual([ @@ -505,16 +457,16 @@ describe('SearchService', () => { describe('when an exception is thrown', () => { it('ApiService.handleError is called and the error is re-thrown', async(() => { - spyOn(service, 'getAppsByCLID').and.returnValue(throwError(Error('someError'))); + spyOn(service, 'getApplicationsByCLFile').and.returnValue(throwError(Error('someError'))); - spyOn(service, 'getAppByDTID').and.returnValue(of([])); + spyOn(service, 'getApplicationsByDispositionID').and.returnValue(of([])); apiSpy.handleError.and.callFake(error => { expect(error).toEqual(Error('someError')); return throwError(Error('someRethrownError')); }); - service.getAppsByClidDtid(['123']).subscribe( + service.getApplicationsByCLFileAndTantalisID(['123']).subscribe( () => { fail('An error was expected.'); }, diff --git a/src/app/services/search.service.ts b/src/app/services/search.service.ts index 70d49c4f..5ba7a9d8 100644 --- a/src/app/services/search.service.ts +++ b/src/app/services/search.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { Observable, of, merge, forkJoin } from 'rxjs'; -import { map, catchError } from 'rxjs/operators'; +import { map, catchError, toArray, mergeMap } from 'rxjs/operators'; import * as _ from 'lodash'; import * as moment from 'moment'; @@ -18,18 +18,39 @@ export class SearchService { constructor(private api: ApiService, private applicationService: ApplicationService) {} - // get search results by array of CLIDs or DTIDs - getAppsByClidDtid(keys: string[]): Observable { + /** + * Get application search results by array of Crown Land File or Disposition ID + * + * Note: DispositionID is called TantalisID in ACRFD + * + * @param {string[]} keys + * @returns {Observable} + * @memberof SearchService + */ + getApplicationsByCLFileAndTantalisID(keys: string[]): Observable { this.isError = false; - const observables = keys.map(clid => this.getAppsByCLID(clid)).concat(keys.map(dtid => this.getAppByDTID(+dtid))); - return merge(...observables).pipe(catchError(error => this.api.handleError(error))); + const observables = keys + .map(clid => this.getApplicationsByCLFile(clid)) + .concat(keys.map(dtid => this.getApplicationsByDispositionID(+dtid))); + return merge(...observables).pipe( + toArray(), + mergeMap(items => of(...items)), + catchError(error => this.api.handleError(error)) + ); } - // get search results by CL File # - private getAppsByCLID(clid: string): Observable { + /** + * Get application search results by Crown Land File # + * + * @private + * @param {string} clid + * @returns {Observable} + * @memberof SearchService + */ + private getApplicationsByCLFile(clid: string): Observable { const getByCrownLandID = this.applicationService.getByCrownLandID(clid, { getCurrentPeriod: true }); - const searchAppsByCLID = this.api.searchAppsByCLID(clid).pipe( + const searchAppsByCLFile = this.api.searchAppsByCLFile(clid).pipe( catchError(() => { this.isError = true; // if search call fails, return null results @@ -37,7 +58,7 @@ export class SearchService { }) ); - return forkJoin(getByCrownLandID, searchAppsByCLID).pipe( + return forkJoin(getByCrownLandID, searchAppsByCLFile).pipe( map(payloads => { const applications: Application[] = payloads[0]; const searchResults: SearchResults[] = payloads[1]; @@ -45,7 +66,7 @@ export class SearchService { // first look at PRC results applications.forEach(app => { - app['isCreated'] = true; + app.meta.isCreated = true; results.push(app); }); @@ -83,12 +104,12 @@ export class SearchService { }); // 7-digit CL File number for display - app.clFile = searchResult.CROWN_LANDS_FILE.padStart(7, '0'); + app.meta.clFile = searchResult.CROWN_LANDS_FILE.padStart(7, '0'); // derive unique applicants if (app.client) { const clients = app.client.split(', '); - app.applicants = _.uniq(clients).join(', '); + app.meta.applicants = _.uniq(clients).join(', '); } // derive retire date @@ -100,12 +121,12 @@ export class SearchService { StatusCodes.ABANDONED.code ].includes(ConstantUtils.getCode(CodeType.STATUS, app.status)) ) { - app.retireDate = moment(app.statusHistoryEffectiveDate) + app.meta.retireDate = moment(app.statusHistoryEffectiveDate) .endOf('day') .add(6, 'months') .toDate(); // set flag if retire date is in the past - app.isRetired = moment(app.retireDate).isBefore(); + app.meta.isRetired = moment(app.meta.retireDate).isBefore(); } results.push(app); @@ -117,11 +138,18 @@ export class SearchService { ); } - // get search results by Disposition Transaction ID - private getAppByDTID(dtid: number): Observable { + /** + * Get application search results by Disposition Transaction ID + * + * @private + * @param {number} dtid + * @returns {Observable} + * @memberof SearchService + */ + private getApplicationsByDispositionID(dtid: number): Observable { const getByTantalisID = this.applicationService.getByTantalisID(dtid, { getCurrentPeriod: true }); - const searchAppsByDTID = this.api.searchAppsByDTID(dtid).pipe( + const searchAppsByDispositionID = this.api.searchAppsByDispositionID(dtid).pipe( map(res => { return res ? new SearchResults(res) : null; }), @@ -132,14 +160,14 @@ export class SearchService { }) ); - return forkJoin(getByTantalisID, searchAppsByDTID).pipe( + return forkJoin(getByTantalisID, searchAppsByDispositionID).pipe( map(payloads => { const application: Application = payloads[0]; const searchResult: SearchResults = payloads[1]; // first look at PRC result if (application) { - application['isCreated'] = true; + application.meta.isCreated = true; // found a unique application in PRC -- no need to look at Tantalis results return [application]; } @@ -179,12 +207,12 @@ export class SearchService { }); // 7-digit CL File number for display - app.clFile = searchResult.CROWN_LANDS_FILE.padStart(7, '0'); + app.meta.clFile = searchResult.CROWN_LANDS_FILE.padStart(7, '0'); // derive unique applicants if (app.client) { const clients = app.client.split(', '); - app.applicants = _.uniq(clients).join(', '); + app.meta.applicants = _.uniq(clients).join(', '); } // derive retire date @@ -196,12 +224,12 @@ export class SearchService { StatusCodes.ABANDONED.code ].includes(ConstantUtils.getCode(CodeType.STATUS, app.status)) ) { - app.retireDate = moment(app.statusHistoryEffectiveDate) + app.meta.retireDate = moment(app.statusHistoryEffectiveDate) .endOf('day') .add(6, 'months') .toDate(); // set flag if retire date is in the past - app.isRetired = moment(app.retireDate).isBefore(); + app.meta.isRetired = moment(app.meta.retireDate).isBefore(); } results.push(app); diff --git a/src/app/shared.module.ts b/src/app/shared.module.ts index 79cc521d..4f214de3 100644 --- a/src/app/shared.module.ts +++ b/src/app/shared.module.ts @@ -8,20 +8,11 @@ import { PublishedPipe } from 'app/pipes/published.pipe'; import { ObjectFilterPipe } from 'app/pipes/object-filter.pipe'; import { LinkifyPipe } from 'app/pipes/linkify.pipe'; -import { VarDirective } from 'app/utils/ng-var.directive'; import { FileUploadComponent } from 'app/file-upload/file-upload.component'; @NgModule({ imports: [BrowserModule, MatSlideToggleModule, MatSnackBarModule], - declarations: [ - OrderByPipe, - NewlinesPipe, - PublishedPipe, - ObjectFilterPipe, - LinkifyPipe, - VarDirective, - FileUploadComponent - ], + declarations: [OrderByPipe, NewlinesPipe, PublishedPipe, ObjectFilterPipe, LinkifyPipe, FileUploadComponent], exports: [ MatSlideToggleModule, MatSnackBarModule, @@ -29,7 +20,6 @@ import { FileUploadComponent } from 'app/file-upload/file-upload.component'; NewlinesPipe, PublishedPipe, LinkifyPipe, - VarDirective, FileUploadComponent ] }) diff --git a/src/app/spec/helpers.ts b/src/app/spec/helpers.ts index b551ee9f..adef8eeb 100644 --- a/src/app/spec/helpers.ts +++ b/src/app/spec/helpers.ts @@ -1,20 +1,61 @@ +import { convertToParamMap } from '@angular/router'; import { of } from 'rxjs'; +/** + * Helper class to make mocking ActivatedRoute easier. + * + * @export + * @class ActivatedRouteStub + */ export class ActivatedRouteStub { public data = of({}); public params = of({}); + public queryParamMap = of(convertToParamMap({})); constructor(initialData = {}) { this.setData(initialData); } + /** + * Sets the data property. + * + * @param {{}} data + * @memberof ActivatedRouteStub + */ public setData(data: {}) { this.data = of(data); } + /** + * Sets the params property. + * + * @param {{}} params + * @memberof ActivatedRouteStub + */ public setParams(params: {}) { this.params = of(params); } + + /** + * Sets the queryParamMap property. + * + * @param {{}} params + * @memberof ActivatedRouteStub + */ + public setQueryParamMap(params: {}) { + this.queryParamMap = of(convertToParamMap(params)); + } + + /** + * Clears data, params, and queryParamMap properties, setting them all to return empty objects. + * + * @memberof ActivatedRouteStub + */ + public clear() { + this.data = of({}); + this.params = of({}); + this.queryParamMap = of(convertToParamMap({})); + } } export default ActivatedRouteStub; diff --git a/src/app/utils/constants/application.spec.ts b/src/app/utils/constants/application.spec.ts index 30552b3e..fca3b39d 100644 --- a/src/app/utils/constants/application.spec.ts +++ b/src/app/utils/constants/application.spec.ts @@ -30,7 +30,7 @@ describe('application constants', () => { describe('RegionCodes', () => { describe('getCodeGroups()', () => { it('returns all 8 code groups', () => { - const codeGroups = new StatusCodes().getCodeGroups(); + const codeGroups = new RegionCodes().getCodeGroups(); expect(codeGroups.length).toEqual(8); expect(codeGroups).toContain(RegionCodes.CARIBOO); expect(codeGroups).toContain(RegionCodes.KOOTENAY); @@ -47,7 +47,7 @@ describe('application constants', () => { describe('PurposeCodes', () => { describe('getCodeGroups()', () => { it('returns all 22 code groups', () => { - const codeGroups = new StatusCodes().getCodeGroups(); + const codeGroups = new PurposeCodes().getCodeGroups(); expect(codeGroups.length).toEqual(22); expect(codeGroups).toContain(PurposeCodes.AGRICULTURE); expect(codeGroups).toContain(PurposeCodes['ALL SEASONS RESORT']); @@ -78,7 +78,7 @@ describe('application constants', () => { describe('LandUseTypeCodes', () => { describe('getCodeGroups()', () => { it('returns all 16 code groups', () => { - const codeGroups = new StatusCodes().getCodeGroups(); + const codeGroups = new LandUseTypeCodes().getCodeGroups(); expect(codeGroups.length).toEqual(16); expect(codeGroups).toContain(LandUseTypeCodes['CERTIFICATE OF PURCHASE']); expect(codeGroups).toContain(LandUseTypeCodes['CROWN GRANT']); diff --git a/src/app/utils/constants/application.ts b/src/app/utils/constants/application.ts index 9f65e3bc..11902140 100644 --- a/src/app/utils/constants/application.ts +++ b/src/app/utils/constants/application.ts @@ -3,6 +3,10 @@ import { ICodeSet, ICodeGroup } from './interfaces'; /** * Application Status codes. * + * Note: the code only exists in ACRFD, while the mapped codes are the actual Tantalis status codes and should be used + * when making calls to the Tantalis API. + * + * * @export * @class StatusCodes * @implements {ICodeSet} @@ -78,6 +82,7 @@ export class StatusCodes implements ICodeSet { * Application Reason codes. * * Note: + * - the code is the actual Tantalis reason code. * - the reason code indicates additional information about the status code. * - the reason codes don't have unique text, instead they use the same text as their Status * counterpart: (Decision_Approved and Decision_Not_Approved). @@ -111,6 +116,8 @@ export class ReasonCodes implements ICodeSet { /** * Application Region codes. * + * Note: the code is the actual Tantalis businessUnit code. + * * @export * @class RegionCodes * @implements {ICodeSet} @@ -119,56 +126,56 @@ export class RegionCodes implements ICodeSet { public static readonly CARIBOO: ICodeGroup = { code: 'CA - LAND MGMNT - CARIBOO FIELD OFFICE', param: 'CA', - text: { long: 'Cariboo, Williams Lake', short: '' }, + text: { long: 'Cariboo, Williams Lake', short: 'Cariboo, Williams Lake' }, mappedCodes: [] }; public static readonly KOOTENAY: ICodeGroup = { code: 'KO - LAND MGMNT - KOOTENAY FIELD OFFICE', param: 'KO', - text: { long: 'Kootenay, Cranbrook', short: '' }, + text: { long: 'Kootenay, Cranbrook', short: 'Kootenay, Cranbrook' }, mappedCodes: [] }; public static readonly LOWER_MAINLAND: ICodeGroup = { code: 'LM - LAND MGMNT - LOWER MAINLAND SERVICE REGION', param: 'LM', - text: { long: 'Lower Mainland, Surrey', short: '' }, + text: { long: 'Lower Mainland, Surrey', short: 'Lower Mainland, Surrey' }, mappedCodes: [] }; public static readonly OMENICA: ICodeGroup = { code: 'OM - LAND MGMNT - NORTHERN SERVICE REGION', param: 'OM', - text: { long: 'Omenica/Peace, Prince George', short: '' }, + text: { long: 'Omenica/Peace, Prince George', short: 'Omenica/Peace, Prince George' }, mappedCodes: [] }; public static readonly PEACE: ICodeGroup = { code: 'PE - LAND MGMNT - PEACE FIELD OFFICE', param: 'PE', - text: { long: 'Peace, Ft. St. John', short: '' }, + text: { long: 'Peace, Ft. St. John', short: 'Peace, Ft. St. John' }, mappedCodes: [] }; public static readonly SKEENA: ICodeGroup = { code: 'SK - LAND MGMNT - SKEENA FIELD OFFICE', param: 'SK', - text: { long: 'Skeena, Smithers', short: '' }, + text: { long: 'Skeena, Smithers', short: 'Skeena, Smithers' }, mappedCodes: [] }; public static readonly SOUTHERN_INTERIOR: ICodeGroup = { code: 'SI - LAND MGMNT - SOUTHERN SERVICE REGION', param: 'SI', - text: { long: 'Thompson Okanagan, Kamloops', short: '' }, + text: { long: 'Thompson Okanagan, Kamloops', short: 'Thompson Okanagan, Kamloops' }, mappedCodes: [] }; public static readonly VANCOUVER_ISLAND: ICodeGroup = { code: 'VI - LAND MGMNT - VANCOUVER ISLAND SERVICE REGION', param: 'VI', - text: { long: 'West Coast, Nanaimo', short: '' }, + text: { long: 'West Coast, Nanaimo', short: 'West Coast, Nanaimo' }, mappedCodes: [] }; @@ -191,6 +198,9 @@ export class RegionCodes implements ICodeSet { /** * Application Purpose codes. * + * Note: the code is the actual Tantalis purpose code, while the mappedCodes are the subPurpose codes that belong to + * this parent code. + * * @export * @class PurposeCodes * @implements {ICodeSet} @@ -199,21 +209,21 @@ export class PurposeCodes implements ICodeSet { public static readonly AGRICULTURE: ICodeGroup = { code: 'AGRICULTURE', param: 'AGR', - text: { long: 'Agriculture', short: '' }, + text: { long: 'Agriculture', short: 'Agriculture' }, mappedCodes: ['EXTENSIVE', 'INTENSIVE', 'GRAZING'] }; public static readonly 'ALL SEASONS RESORT': ICodeGroup = { code: 'ALL SEASONS RESORT', param: 'ALL', - text: { long: 'All Seasons Resort', short: '' }, + text: { long: 'All Seasons Resort', short: 'All Seasons Resort' }, mappedCodes: ['MISCELLANEOUS'] }; public static readonly 'ALPINE SKIING': ICodeGroup = { code: 'ALPINE SKIING', param: 'ALP', - text: { long: 'Alpine Skiing', short: '' }, + text: { long: 'Alpine Skiing', short: 'Alpine Skiing' }, mappedCodes: [ 'GENERAL', 'LIFTS', @@ -233,14 +243,14 @@ export class PurposeCodes implements ICodeSet { public static readonly AQUACULTURE: ICodeGroup = { code: 'AQUACULTURE', param: 'AQU', - text: { long: 'Aquaculture', short: '' }, + text: { long: 'Aquaculture', short: 'Aquaculture' }, mappedCodes: ['FIN FISH', 'SHELL FISH', 'PLANTS', 'CRUSTACEANS'] }; public static readonly COMMERCIAL: ICodeGroup = { code: 'COMMERCIAL', param: 'COM', - text: { long: 'Commercial', short: '' }, + text: { long: 'Commercial', short: 'Commercial' }, mappedCodes: [ 'GENERAL', 'COMMERCIAL A', @@ -263,7 +273,7 @@ export class PurposeCodes implements ICodeSet { public static readonly 'COMMERCIAL RECREATION': ICodeGroup = { code: 'COMMERCIAL RECREATION', param: 'CR', - text: { long: 'Commercial Recreation', short: '' }, + text: { long: 'Commercial Recreation', short: 'Commercial Recreation' }, mappedCodes: [ 'HELI SKI', 'CAT SKI', @@ -291,21 +301,21 @@ export class PurposeCodes implements ICodeSet { public static readonly COMMUNICATION: ICodeGroup = { code: 'COMMUNICATION', param: 'CMU', - text: { long: 'Communication', short: '' }, + text: { long: 'Communication', short: 'Communication' }, mappedCodes: ['COMMUNICATION SITES', 'COMBINED USES'] }; public static readonly COMMUNITY: ICodeGroup = { code: 'COMMUNITY', param: 'CMY', - text: { long: 'Community', short: '' }, + text: { long: 'Community', short: 'Community' }, mappedCodes: ['COMMUNITY FACILITY', 'MISCELLANEOUS', 'TRAIL MAINTENANCE'] }; public static readonly 'ENERGY PRODUCTION': ICodeGroup = { code: 'ENERGY PRODUCTION', param: 'EP', - text: { long: 'Energy Production', short: '' }, + text: { long: 'Energy Production', short: 'Energy Production' }, mappedCodes: [ 'GENERAL', 'BATTERY SITE', @@ -328,7 +338,7 @@ export class PurposeCodes implements ICodeSet { public static readonly 'ENVIRONMENT, CONSERVATION, & RECR': ICodeGroup = { code: 'ENVIRONMENT, CONSERVATION, & RECR', param: 'ECR', - text: { long: 'Environment, Conservation, & Recreation', short: '' }, + text: { long: 'Environment, Conservation, & Recreation', short: 'Environment, Conservation, & Recreation' }, mappedCodes: [ 'ECOLOGICAL RESERVE', 'GREENBELT', @@ -352,7 +362,7 @@ export class PurposeCodes implements ICodeSet { public static readonly 'FIRST NATIONS': ICodeGroup = { code: 'FIRST NATIONS', param: 'FN', - text: { long: 'First Nations', short: '' }, + text: { long: 'First Nations', short: 'First Nations' }, mappedCodes: [ 'INDIAN CUT-OFF', 'RESERVE EXPANSION', @@ -370,7 +380,7 @@ export class PurposeCodes implements ICodeSet { public static readonly INDUSTRIAL: ICodeGroup = { code: 'INDUSTRIAL', param: 'IND', - text: { long: 'Industrial', short: '' }, + text: { long: 'Industrial', short: 'Industrial' }, mappedCodes: [ 'GENERAL', 'LIGHT INDUSTRIAL', @@ -385,7 +395,7 @@ export class PurposeCodes implements ICodeSet { public static readonly INSTITUTIONAL: ICodeGroup = { code: 'INSTITUTIONAL', param: 'INS', - text: { long: 'Institutional', short: '' }, + text: { long: 'Institutional', short: 'Institutional' }, mappedCodes: [ 'FIRE HALL', 'LOCAL/REGIONAL PARK', @@ -405,21 +415,21 @@ export class PurposeCodes implements ICodeSet { public static readonly 'MISCELLANEOUS LAND USES': ICodeGroup = { code: 'MISCELLANEOUS LAND USES', param: 'MLU', - text: { long: 'Miscellaneous Land Uses', short: '' }, + text: { long: 'Miscellaneous Land Uses', short: 'Miscellaneous Land Uses' }, mappedCodes: ['PLANNING/MARKETING/DEVELOP PROJECTS', 'LAND EXCHANGE', 'OTHER', 'LAND USE PLAN INTERIM AGREEMENT'] }; public static readonly 'OCEAN ENERGY': ICodeGroup = { code: 'OCEAN ENERGY', param: 'OE', - text: { long: 'Ocean Energy', short: '' }, + text: { long: 'Ocean Energy', short: 'Ocean Energy' }, mappedCodes: ['INVESTIGATIVE AND MONITORING PHASE', 'GENERAL AREA'] }; public static readonly QUARRYING: ICodeGroup = { code: 'QUARRYING', param: 'QRY', - text: { long: 'Quarrying', short: '' }, + text: { long: 'Quarrying', short: 'Quarrying' }, mappedCodes: [ 'SAND AND GRAVEL', 'PEAT AND SOIL', @@ -436,7 +446,7 @@ export class PurposeCodes implements ICodeSet { public static readonly RESIDENTIAL: ICodeGroup = { code: 'RESIDENTIAL', param: 'RES', - text: { long: 'Residential', short: '' }, + text: { long: 'Residential', short: 'Residential' }, mappedCodes: [ 'URBAN RESIDENTIAL', 'RURAL RESIDENTIAL', @@ -455,14 +465,14 @@ export class PurposeCodes implements ICodeSet { public static readonly 'SOLAR POWER': ICodeGroup = { code: 'SOLAR POWER', param: 'SP', - text: { long: 'Solar Power', short: '' }, + text: { long: 'Solar Power', short: 'Solar Power' }, mappedCodes: ['INVESTIGATIVE PHASE'] }; public static readonly 'TRANSPORTATION': ICodeGroup = { code: 'TRANSPORTATION', param: 'TRN', - text: { long: 'Transportation', short: '' }, + text: { long: 'Transportation', short: 'Transportation' }, mappedCodes: [ 'AIRPORT/AIRSTRIP', 'ROADWAY', @@ -477,7 +487,7 @@ export class PurposeCodes implements ICodeSet { public static readonly UTILITY: ICodeGroup = { code: 'UTILITY', param: 'UTL', - text: { long: 'Utility', short: '' }, + text: { long: 'Utility', short: 'Utility' }, mappedCodes: [ 'ELECTRIC POWER LINE', 'GAS AND OIL PIPELINE', @@ -492,7 +502,7 @@ export class PurposeCodes implements ICodeSet { public static readonly WATERPOWER: ICodeGroup = { code: 'WATERPOWER', param: 'WAT', - text: { long: 'Waterpower', short: '' }, + text: { long: 'Waterpower', short: 'Waterpower' }, mappedCodes: [ 'GENERAL AREA', 'POWERHOUSE SITE', @@ -511,7 +521,7 @@ export class PurposeCodes implements ICodeSet { public static readonly WINDPOWER: ICodeGroup = { code: 'WINDPOWER', param: 'WND', - text: { long: 'Windpower', short: '' }, + text: { long: 'Windpower', short: 'Windpower' }, mappedCodes: [ 'INVESTIGATIVE AND MONITORING PHASE', 'DEVELOPMENT PHASE', @@ -561,6 +571,8 @@ export class PurposeCodes implements ICodeSet { /** * Application Land Use Type codes. * + * Note: these codes are currently not used in ACRFD. + * * @export * @class LandUseTypeCodes * @implements {ICodeSet} diff --git a/src/app/utils/constants/constantUtils.spec.ts b/src/app/utils/constants/constantUtils.spec.ts index cc06c520..187ad717 100644 --- a/src/app/utils/constants/constantUtils.spec.ts +++ b/src/app/utils/constants/constantUtils.spec.ts @@ -82,7 +82,7 @@ describe('constantUtils', () => { }); it('returns reason code group if reason codeType provided and mataching searchString provided', () => { - const codeGroup = ConstantUtils.getCodeGroup(CodeType.REASON, 'AMENDMENT NOT APPROVED APPROVED - APPLICATION'); + const codeGroup = ConstantUtils.getCodeGroup(CodeType.REASON, 'AMENDMENT NOT APPROVED - APPLICATION'); expect(codeGroup).toEqual(ReasonCodes.AMENDMENT_NOT_APPROVED); }); diff --git a/src/app/utils/ng-var.directive.spec.ts b/src/app/utils/ng-var.directive.spec.ts deleted file mode 100644 index 76dba207..00000000 --- a/src/app/utils/ng-var.directive.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Component, DebugElement } from '@angular/core'; -import { VarDirective } from './ng-var.directive'; - -@Component({ - template: '