From 7b8bae82fa2f2caea4465f0ec429621d15dccbd9 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 17:04:56 +0000 Subject: [PATCH 1/3] refactor: move remaining reads to DEMI - Remove eagle-api reads with no callers left: project getAll, getById, getPins, getGroupMembers; comment getById, getByPeriodId, getCountByPeriodId; comment period getById, getAllByProjectId; document getById, getByMultiId, downloadResource, downloadDocument. - Related documents, downloads and private Update thumbnails read DEMI (by id, presigned URL). - Project groups use the resolved project's demiId instead of a second projects read. - toLegacyProject maps projectState to status, address to location. - One eagleWriteId in demi-ids; saveBlob in shared utils; one DEMI_MAX_PAGE_SIZE constant; drop unused getFormattedTime. - Pin the AppComponent dropdown spec host to the viewport: text the karma reporter drew above the fixture pushed the toggle out of the 437px frame, which the hide modifier treats as hidden. --- src/app/activity/activity.component.ts | 3 +- .../add-edit-activity.component.spec.ts | 16 ++- .../add-edit-activity.component.ts | 36 +++--- src/app/app.component.spec.ts | 6 +- .../add-comment/add-comment.component.spec.ts | 2 - .../add-comment/add-comment.component.ts | 8 -- .../comment-period-details-tab.component.html | 2 +- ...mment-period-details-tab.component.spec.ts | 33 +++++- .../comment-period-details-tab.component.ts | 22 +--- .../review-comment.component.ts | 6 +- .../add-documents/add-documents.component.ts | 5 +- .../add-edit-comment-period.component.spec.ts | 20 +++- .../add-edit-comment-period.component.ts | 6 +- .../comment-periods/comment-period-legacy.ts | 4 +- src/app/models/demi/comment-period.ts | 13 +- src/app/models/demi/demi-ids.spec.ts | 28 ++++- src/app/models/demi/demi-ids.ts | 15 +++ src/app/models/demi/demi-list.ts | 3 + src/app/models/demi/document-write.spec.ts | 24 +--- src/app/models/demi/document-write.ts | 5 - src/app/models/demi/project-legacy.spec.ts | 4 + src/app/models/demi/project-legacy.ts | 4 +- src/app/models/demi/project.ts | 4 + src/app/models/demi/update.spec.ts | 8 +- src/app/models/demi/update.ts | 6 - ...ject-notification-detail.component.spec.ts | 2 +- .../project-notification-detail.component.ts | 8 +- ...t-notification-documents.component.spec.ts | 16 +++ ...roject-notification-documents.component.ts | 7 +- ...add-edit-project-notification.component.ts | 19 ++- .../detail/detail.component.ts | 2 +- .../document-edit/document-edit.component.ts | 3 +- .../document-edit/legacy-document-row.ts | 3 +- .../project-document-table-rows.component.ts | 2 +- .../project-documents.component.ts | 2 +- .../project-groups.component.spec.ts | 19 +-- .../project-groups.component.ts | 19 +-- src/app/services/comment.service.spec.ts | 2 +- src/app/services/comment.service.ts | 87 +------------- src/app/services/commentperiod.service.ts | 53 +-------- src/app/services/document.service.ts | 112 ++++-------------- src/app/services/project.service.spec.ts | 8 +- src/app/services/project.service.ts | 65 +--------- src/app/services/user.service.spec.ts | 9 -- src/app/services/user.service.ts | 9 +- src/app/shared/utils/utils.ts | 16 +-- 46 files changed, 263 insertions(+), 483 deletions(-) diff --git a/src/app/activity/activity.component.ts b/src/app/activity/activity.component.ts index d7998cc0e..4b7fdb1b6 100644 --- a/src/app/activity/activity.component.ts +++ b/src/app/activity/activity.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { switchMap } from 'rxjs/operators'; import { ActivityTableRowsComponent } from './activity-table-rows/activity-table-rows.component'; +import { DEMI_MAX_PAGE_SIZE } from '../models/demi/demi-list'; import { DemiUpdate, nativeUpdateSort } from '../models/demi/update'; import { LegacyProject, toLegacyProject } from '../models/demi/project-legacy'; import { SearchTerms } from '../models/search'; @@ -109,7 +110,7 @@ export class ActivityComponent { ]; constructor() { - this.projectService.list({ pageSize: 1000 }) + this.projectService.list({ pageSize: DEMI_MAX_PAGE_SIZE }) .pipe( switchMap(page => { this.projects = page.rows.map(toLegacyProject).filter(project => project._id); diff --git a/src/app/activity/add-edit-activity/add-edit-activity.component.spec.ts b/src/app/activity/add-edit-activity/add-edit-activity.component.spec.ts index e76167814..afa466e48 100644 --- a/src/app/activity/add-edit-activity/add-edit-activity.component.spec.ts +++ b/src/app/activity/add-edit-activity/add-edit-activity.component.spec.ts @@ -24,7 +24,7 @@ describe('AddEditActivityComponent', () => { // Test rows use Eagle field names; DEMI answers with `id`, `eagleId` and the parent's Eagle id in `projectId`. const nativeOf = ({ _id, project, ...rest }: any) => ({ ...rest, id: _id, eagleId: _id, projectId: project ?? null }); - const demiProject = (eagleId: string) => ({ id: `track-${eagleId}`, eagleId, name: eagleId, location: 'Kamloops' }); + const demiProject = (eagleId: string) => ({ id: `track-${eagleId}`, eagleId, name: eagleId, address: 'Kamloops' }); const docs = (rows: any[]) => of({ rows } as any); const documentSearches = () => demiApi.list.calls.allArgs().filter(args => args[0] === 'documents').map(args => args[1]!.filters as any); const searchedSources = () => documentSearches().map(filters => filters.documentSource); @@ -43,8 +43,8 @@ describe('AddEditActivityComponent', () => { projectService.getNative.and.callFake((id: string) => of(demiProject(id))); commentPeriodService = jasmine.createSpyObj('CommentPeriodService', ['listByProject']); commentPeriodService.listByProject.and.returnValue(of({ rows: periods })); - documentService = jasmine.createSpyObj('DocumentService', ['addWithProgress', 'downloadResource']); - documentService.downloadResource.and.resolveTo(new Blob()); + documentService = jasmine.createSpyObj('DocumentService', ['addWithProgress', 'downloadUrlNative']); + documentService.downloadUrlNative.and.returnValue(of('https://files.example/plan.jpg')); TestBed.configureTestingModule({ imports: [AddEditActivityComponent], @@ -623,6 +623,16 @@ describe('AddEditActivityComponent', () => { }); }); + it('shows a photo the public cannot see from its presigned DEMI URL', () => { + const component = create(null); + demiApi.list.and.returnValue(docs([privateDoc('i3', 'plan.jpg')])); + component.addPhotos([{ doc: privateDoc('i3', 'plan.jpg') }]); + fillNews(component); + + expect(documentService.downloadUrlNative).toHaveBeenCalledOnceWith('i3'); + expect(component.imageSrcs().get('i3')).toBe('https://files.example/plan.jpg'); + }); + it('previews public photos from the fetch URL and new uploads from their local copy, caption and credit on one line', () => { const component = render(null, null, true); TestBed.inject(NgbConfig).animation = false; diff --git a/src/app/activity/add-edit-activity/add-edit-activity.component.ts b/src/app/activity/add-edit-activity/add-edit-activity.component.ts index 2947caba5..d84c17963 100644 --- a/src/app/activity/add-edit-activity/add-edit-activity.component.ts +++ b/src/app/activity/add-edit-activity/add-edit-activity.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, DestroyRef, TemplateRef, computed, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { Observable, Subject, catchError, distinctUntilChanged, forkJoin, map, of, shareReplay, switchMap } from 'rxjs'; +import { Observable, Subject, catchError, distinctUntilChanged, finalize, forkJoin, map, of, shareReplay, switchMap } from 'rxjs'; import { DatePipe } from '@angular/common'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Router, RouterModule, ActivatedRoute } from '@angular/router'; @@ -17,11 +17,12 @@ import { ConfigService } from 'src/app/services/config.service'; import { LoggingService } from 'src/app/services/logging.service'; import { ProjectService } from 'src/app/services/project.service'; import { RecentActivityService } from 'src/app/services/recent-activity'; +import { DEMI_MAX_PAGE_SIZE } from 'src/app/models/demi/demi-list'; import { DemiDocument } from 'src/app/models/demi/document'; import { DemiCommentPeriod } from 'src/app/models/demi/comment-period'; -import { isEagleId } from 'src/app/models/demi/demi-ids'; +import { eagleWriteId } from 'src/app/models/demi/demi-ids'; import { LegacyProject, toLegacyProject } from 'src/app/models/demi/project-legacy'; -import { DemiUpdate, updateEagleId, updateParentId } from 'src/app/models/demi/update'; +import { DemiUpdate, updateParentId } from 'src/app/models/demi/update'; import { legacyDocumentRow, sourceFilters } from 'src/app/project/project-documents/document-edit/legacy-document-row'; import { Constants } from 'src/app/shared/utils/constants'; import { convertJSDateToNGBDate, convertFormGroupNGBDateToJSDate } from 'src/app/shared/utils/utils'; @@ -176,7 +177,7 @@ export class AddEditActivityComponent implements OnInit { this.recentActivityService.getNative(activityId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe((row: DemiUpdate) => { this.isEditing = true; // Eagle ids, as the form and eagle-api writes use them. - const data = { ...row, _id: updateEagleId(row), project: updateParentId(row) }; + const data = { ...row, _id: eagleWriteId(row), project: updateParentId(row) }; this.buildForm(data); this.activity = data; this.statusText = statusLabel(data); @@ -203,7 +204,7 @@ export class AddEditActivityComponent implements OnInit { }); } - this.projectService.list({ pageSize: 1000 }).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + this.projectService.list({ pageSize: DEMI_MAX_PAGE_SIZE }).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ next: page => { this.projects = page.rows.map(toLegacyProject).filter(project => project._id); this.loading = false; @@ -213,7 +214,7 @@ export class AddEditActivityComponent implements OnInit { }); // A project notification's DEMI id is its Eagle id. - this.demiApi.list<{ id: string; name: string }>('notifications', { pageSize: 1000, sort: 'name' }) + this.demiApi.list<{ id: string; name: string }>('notifications', { pageSize: DEMI_MAX_PAGE_SIZE, sort: 'name' }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: page => { this.projectNotifications = page.rows.map(row => ({ _id: row.id, name: row.name })); }, @@ -485,22 +486,21 @@ export class AddEditActivityComponent implements OnInit { } } - /** The admin fetch URL needs no token only for public documents; load the rest with the staff token. */ + /** The admin fetch URL needs no token only for public documents; the rest show from a presigned DEMI URL. */ private loadPrivateThumbnails() { this.pickedImageIds() .filter(id => !this.imageSrcs().has(id) && !this.srcsLoading.has(id) && !isPublicDocument(this.imageDocs().get(id))) .forEach(id => { this.srcsLoading.add(id); - this.documentService.downloadResource(id) - .then(blob => { - // The form may be gone or the image removed while the file downloaded. - if (blob?.size && !this.destroyed && this.pickedImageIds().includes(id)) { - this.setImageSrc(id, URL.createObjectURL(blob)); + this.documentService.downloadUrlNative(id) + .pipe(finalize(() => this.srcsLoading.delete(id))) + .subscribe(url => { + // The form may be gone or the image removed while the URL loaded; with no URL the row shows the placeholder. + if (url && !this.destroyed && this.pickedImageIds().includes(id)) { + this.setImageSrc(id, url); this._cdr.markForCheck(); } - }) - .catch(() => { /* the row shows the placeholder */ }) - .finally(() => this.srcsLoading.delete(id)); + }); }); } @@ -599,7 +599,7 @@ export class AddEditActivityComponent implements OnInit { private watchProjectDocuments() { // First 1000 of the parent's documents of one source, newest first. Null marks a failed search. const search = (parentId: string | null, source: string): Observable => parentId - ? this.demiApi.list('documents', { pageSize: 1000, sort: '-datePosted', filters: sourceFilters(parentId, source) }) + ? this.demiApi.list('documents', { pageSize: DEMI_MAX_PAGE_SIZE, sort: '-datePosted', filters: sourceFilters(parentId, source) }) .pipe( map(page => page.rows.map(legacyDocumentRow)), catchError(error => { @@ -677,13 +677,13 @@ export class AddEditActivityComponent implements OnInit { public loadPcpsForProject(projectId: string) { this.demiProject(projectId) .pipe( - switchMap(project => project ? this.commentPeriodService.listByProject(project.demiId, { pageSize: 1000 }) : of({ rows: [] })), + switchMap(project => project ? this.commentPeriodService.listByProject(project.demiId, { pageSize: DEMI_MAX_PAGE_SIZE }) : of({ rows: [] })), takeUntilDestroyed(this.destroyRef) ) .subscribe(page => { // An update's pcp is the period's Eagle id; a period eagle-api does not hold cannot be picked. this.periods = page.rows - .map((period: DemiCommentPeriod) => ({ ...period, _id: period.eagleId ?? (isEagleId(period.id) ? period.id : null) })) + .map((period: DemiCommentPeriod) => ({ ...period, _id: eagleWriteId(period) })) .filter(period => period._id); // Preserve the current PCP selection if it exists in the loaded periods. // Only reset when the current value is no longer valid (e.g. user switched projects). diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index e2b6784d2..c4c8e2433 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -7,12 +7,12 @@ import { AnalyticsService } from './services/analytics/analytics.service'; import { KeycloakService } from './services/keycloak.service'; import { SideBarService } from './services/sidebar.service'; -// A body-container dropdown whose toggle sits inside a small scroll area, -// like the Actions menus inside .app-body. +// A body-container dropdown whose toggle sits inside a small scroll area, like the Actions menus inside .app-body. +// Fixed to the viewport: karma's frame is ~440px tall, so content above the fixture (reporter failure text) hid the toggle. @Component({ imports: [NgbDropdownModule], template: ` -
+
diff --git a/src/app/comment-period/add-comment/add-comment.component.spec.ts b/src/app/comment-period/add-comment/add-comment.component.spec.ts index 8aeaacdbd..ca74b7cc5 100644 --- a/src/app/comment-period/add-comment/add-comment.component.spec.ts +++ b/src/app/comment-period/add-comment/add-comment.component.spec.ts @@ -2,7 +2,6 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { AddCommentComponent } from './add-comment.component'; import { CommentService } from 'src/app/services/comment.service'; -import { DocumentService } from 'src/app/services/document.service'; import { LoggingService } from 'src/app/services/logging.service'; import { ToastService } from 'src/app/services/toast.service'; @@ -13,7 +12,6 @@ describe('AddCommentComponent', () => { providers: [ provideRouter([]), { provide: CommentService, useValue: {} }, - { provide: DocumentService, useValue: {} }, { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error', 'success']) }, { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['debug', 'error']) } ] diff --git a/src/app/comment-period/add-comment/add-comment.component.ts b/src/app/comment-period/add-comment/add-comment.component.ts index 66f3dfa11..9e34ed79b 100644 --- a/src/app/comment-period/add-comment/add-comment.component.ts +++ b/src/app/comment-period/add-comment/add-comment.component.ts @@ -9,7 +9,6 @@ import { RouterModule } from '@angular/router'; import { NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; import { FileUploadComponent } from '../../file-upload/file-upload.component'; import { CommentPeriod } from 'src/app/models/commentPeriod'; -import { DocumentService } from 'src/app/services/document.service'; import { CommentService } from 'src/app/services/comment.service'; import { convertJSDateToNGBDate, convertFormGroupNGBDateToJSDate } from 'src/app/shared/utils/utils'; import { Comment } from 'src/app/models/comment'; @@ -33,7 +32,6 @@ import { LoggingService } from 'src/app/services/logging.service'; }) export class AddCommentComponent implements OnInit { - private api = inject(DocumentService); private router = inject(Router); private route = inject(ActivatedRoute); private commentService = inject(CommentService); @@ -212,12 +210,6 @@ export class AddCommentComponent implements OnInit { } } - public downloadFile(document: Document) { - return this.api.downloadDocument(document).then(() => { - this.logger.debug('Download initiated for file(s)', 'AddCommentComponent'); - }); - } - public toggleDocumentPublish(document: any, action: string) { if (action === 'Published' && !this.addCommentForm.get('isRejected').value) { document.eaoStatus = 'Published'; diff --git a/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.html b/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.html index 86dda5bd7..53b3e6f7c 100644 --- a/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.html +++ b/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.html @@ -98,7 +98,7 @@

- {{doc.read.includes("public") ? 'cloud_done' : 'cloud_off'}} + {{doc.isPublished ? 'cloud_done' : 'cloud_off'}}

diff --git a/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.spec.ts b/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.spec.ts index ece90af0c..892bb64a6 100644 --- a/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.spec.ts +++ b/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; import { CommentPeriodDetailsTabComponent } from './comment-period-details-tab.component'; import { ConfigService } from 'src/app/services/config.service'; @@ -20,7 +21,14 @@ describe('CommentPeriodDetailsTabComponent', () => { let http: HttpTestingController; let link: jasmine.Spy; + let documents: jasmine.SpyObj; + function create(row: Partial = {}) { + documents = jasmine.createSpyObj('DocumentService', ['byIdsNative', 'downloadNative']); + documents.byIdsNative.and.returnValue(of([ + { id: 'd1', projectId: 'track-1', displayName: 'Public notice', isPublished: true }, + { id: 'd2', projectId: 'track-1', displayName: 'Draft letter', isPublished: false } + ])); TestBed.configureTestingModule({ imports: [CommentPeriodDetailsTabComponent], providers: [ @@ -28,7 +36,7 @@ describe('CommentPeriodDetailsTabComponent', () => { provideHttpClient(), provideHttpClientTesting(), { provide: ConfigService, useValue: { getDemiApiPath: () => '/demi/api', lists: [] } }, - { provide: DocumentService, useValue: jasmine.createSpyObj('DocumentService', ['getByMultiId']) }, + { provide: DocumentService, useValue: documents }, { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error', 'debug']) }, { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['info', 'error', 'success']) }, { provide: StorageService, useValue: { state: { currentProject: { type: 'currentProject' }, canDeleteCommentPeriod: { data: false } } } } @@ -99,4 +107,27 @@ describe('CommentPeriodDetailsTabComponent', () => { expect(menuItems()).toEqual(['Export All Comments', 'Export Comments For Proponents']); }); }); + + describe('related documents', () => { + beforeEach(() => create({ relatedDocuments: ['d1', 'd2'] })); + + function rows(): HTMLElement[] { + return Array.from(fixture.nativeElement.querySelectorAll('.doc-list li')); + } + + it('reads the related documents from DEMI by id', () => { + expect(documents.byIdsNative).toHaveBeenCalledWith(['d1', 'd2']); + expect(rows().map(row => row.querySelector('.cell__txt-content').textContent.trim())).toEqual(['Public notice', 'Draft letter']); + }); + + it('marks each document published or not from isPublished', () => { + expect(rows().map(row => row.querySelector('.publish-state .material-icons').textContent.trim())).toEqual(['cloud_done', 'cloud_off']); + }); + + it('downloads a document by its DEMI id', () => { + rows()[1].click(); + + expect(documents.downloadNative).toHaveBeenCalledWith('d2'); + }); + }); }); diff --git a/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.ts b/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.ts index a73465678..d1d4c3e3d 100644 --- a/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.ts +++ b/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.ts @@ -6,7 +6,8 @@ import { ToastService } from 'src/app/services/toast.service'; import { Router, RouterModule } from '@angular/router'; import { LegacyCommentPeriod } from 'src/app/comment-periods/comment-period-legacy'; import { ConfigService } from 'src/app/services/config.service'; -import { encodeString, formatDate } from 'src/app/shared/utils/utils'; +import { encodeString, formatDate, saveBlob } from 'src/app/shared/utils/utils'; +import { DemiDocument } from 'src/app/models/demi/document'; import { DocumentService } from 'src/app/services/document.service'; import { CommentPeriodService } from 'src/app/services/commentperiod.service'; import { StorageService } from 'src/app/services/storage.service'; @@ -53,7 +54,7 @@ export class CommentPeriodDetailsTabComponent implements OnInit { public projectType: string; public baseRouteUrl: string; public loading = true; - public commentPeriodDocs = []; + public commentPeriodDocs: DemiDocument[] = []; public canDeleteCommentPeriod = false; ngOnInit() { @@ -67,7 +68,7 @@ export class CommentPeriodDetailsTabComponent implements OnInit { this.baseRouteUrl = this.projectType === 'currentProject' ? '/p' : '/pn'; if (this.commentPeriod().relatedDocuments && this.commentPeriod().relatedDocuments.length > 0) { - this.documentService.getByMultiId(this.commentPeriod().relatedDocuments) + this.documentService.byIdsNative(this.commentPeriod().relatedDocuments) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe( data => { @@ -172,10 +173,8 @@ export class CommentPeriodDetailsTabComponent implements OnInit { }); } - public downloadDocument(document) { - return this.documentService.downloadDocument(document).then(() => { - this.logger.debug('Download initiated for file', 'CommentPeriodDetailsTabComponent'); - }); + public downloadDocument(document: DemiDocument) { + return this.documentService.downloadNative(document.id); } public checkIfCanDelete() { @@ -187,12 +186,3 @@ export class CommentPeriodDetailsTabComponent implements OnInit { void document; } } - -function saveBlob(blob: Blob, filename: string) { - const url = window.URL.createObjectURL(blob); - const a = window.document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - window.URL.revokeObjectURL(url); -} diff --git a/src/app/comment-period/review-comment/review-comment.component.ts b/src/app/comment-period/review-comment/review-comment.component.ts index 354d6c595..cc5aef7c7 100644 --- a/src/app/comment-period/review-comment/review-comment.component.ts +++ b/src/app/comment-period/review-comment/review-comment.component.ts @@ -7,7 +7,7 @@ import { ToastService } from 'src/app/services/toast.service'; import { CommentPeriod } from 'src/app/models/commentPeriod'; import { DemiComment, NextPendingComment } from 'src/app/models/demi/comment'; import { DemiDocument } from 'src/app/models/demi/document'; -import { eagleWriteId as documentEagleId } from 'src/app/models/demi/document-write'; +import { eagleWriteId } from 'src/app/models/demi/demi-ids'; import { ReviewComment } from 'src/app/resolvers/comment.resolver'; import { DocumentService } from 'src/app/services/document.service'; import { CommentService } from 'src/app/services/comment.service'; @@ -115,8 +115,8 @@ export class ReviewCommentComponent implements OnChanges { } // eagle-api publishes attachments by Eagle id; a DEMI-only document is left as it is. const documentsList = this.commentData.documentsList - .filter(document => documentEagleId(document)) - .map(document => ({ _id: documentEagleId(document), eaoStatus: document.eaoStatus })); + .filter(document => eagleWriteId(document)) + .map(document => ({ _id: eagleWriteId(document), eaoStatus: document.eaoStatus })); const { nextId } = this.nextPending(); // save() reads `documentsList` off the comment; a non-literal argument keeps it out of the excess-property check. diff --git a/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.ts b/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.ts index f7d4ea163..dda1e04d3 100644 --- a/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.ts +++ b/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.ts @@ -47,7 +47,7 @@ export class AddDocumentComponent implements OnInit { private destroyRef = inject(DestroyRef); public terms = new SearchTerms(); - public documents: Document[] = null; + public documents: Array = null; public loading = true; public isEditing = false; @@ -190,7 +190,7 @@ export class AddDocumentComponent implements OnInit { const promises = []; this.documentTableData.data.map((item) => { if (item.checkbox === true) { - promises.push(this.api.downloadDocument(this.documents.filter(d => d._id === item._id)[0])); + promises.push(this.api.downloadNative(item.id)); } }); return Promise.all(promises).then(() => { @@ -300,6 +300,7 @@ export class AddDocumentComponent implements OnInit { datePosted: document.datePosted, type: document.type, milestone: document.milestone, + id: document.id, _id: document._id, project: document.project, documentFileName: document.documentFileName diff --git a/src/app/comment-periods/add-edit-comment-period/add-edit-comment-period.component.spec.ts b/src/app/comment-periods/add-edit-comment-period/add-edit-comment-period.component.spec.ts index d201bcb6c..4189ac142 100644 --- a/src/app/comment-periods/add-edit-comment-period/add-edit-comment-period.component.spec.ts +++ b/src/app/comment-periods/add-edit-comment-period/add-edit-comment-period.component.spec.ts @@ -14,11 +14,18 @@ import { nativeCommentPeriod } from 'src/app/models/demi/comment-period.fixture' describe('AddEditCommentPeriodComponent', () => { let commentPeriodService: jasmine.SpyObj; + let documentService: jasmine.SpyObj; + let storage: { state: any }; // Form-logic specs skip detectChanges, so the TinyMCE template never renders. function openForEdit(period: CommentPeriod): AddEditCommentPeriodComponent { commentPeriodService = jasmine.createSpyObj('CommentPeriodService', ['save', 'add']); commentPeriodService.save.and.returnValue(of(period)); + documentService = jasmine.createSpyObj('DocumentService', ['byIdsNative']); + documentService.byIdsNative.and.returnValue(of([ + { id: '5c0a6c1b2e3f4a5b6c7d8e01', eagleId: '5c0a6c1b2e3f4a5b6c7d8e01', projectId: 'track-1', displayName: 'Notice', documentFileName: 'notice.pdf' } + ])); + storage = { state: {} }; TestBed.configureTestingModule({ imports: [AddEditCommentPeriodComponent], @@ -33,8 +40,8 @@ describe('AddEditCommentPeriodComponent', () => { { provide: Router, useValue: jasmine.createSpyObj('Router', ['navigate']) }, { provide: CommentPeriodService, useValue: commentPeriodService }, { provide: ConfigService, useValue: { lists: [] } }, - { provide: DocumentService, useValue: jasmine.createSpyObj('DocumentService', ['getByMultiId']) }, - { provide: StorageService, useValue: { state: {} } }, + { provide: DocumentService, useValue: documentService }, + { provide: StorageService, useValue: storage }, { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['success', 'error']) }, { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['warn', 'error', 'info', 'debug']) } ] @@ -70,4 +77,13 @@ describe('AddEditCommentPeriodComponent', () => { expect(component.commentPeriodForm).toBeUndefined(); expect(commentPeriodService.save).not.toHaveBeenCalled(); }); + + it('reads the related documents from DEMI by id as rows keyed on the Eagle id', () => { + openForEdit(new CommentPeriod({ _id: 'cp1', relatedDocuments: ['5c0a6c1b2e3f4a5b6c7d8e01'], read: ['staff'] })); + + expect(documentService.byIdsNative).toHaveBeenCalledWith(['5c0a6c1b2e3f4a5b6c7d8e01']); + const [doc] = storage.state.selectedDocumentsForCP.data; + expect(doc._id).toBe('5c0a6c1b2e3f4a5b6c7d8e01'); + expect(doc.documentFileName).toBe('notice.pdf'); + }); }); diff --git a/src/app/comment-periods/add-edit-comment-period/add-edit-comment-period.component.ts b/src/app/comment-periods/add-edit-comment-period/add-edit-comment-period.component.ts index 1630a2240..9ae855bfb 100644 --- a/src/app/comment-periods/add-edit-comment-period/add-edit-comment-period.component.ts +++ b/src/app/comment-periods/add-edit-comment-period/add-edit-comment-period.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit, DestroyRef, input, inject, ChangeDetectionStrategy} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { map } from 'rxjs/operators'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { UntypedFormGroup, UntypedFormControl, UntypedFormBuilder, UntypedFormArray, ReactiveFormsModule, FormsModule } from '@angular/forms'; import { ToastService } from 'src/app/services/toast.service'; @@ -14,6 +15,7 @@ import { LegacyCommentPeriod, toEagleSaveBody } from '../comment-period-legacy'; import { CommentPeriodService } from 'src/app/services/commentperiod.service'; import { ConfigService } from 'src/app/services/config.service'; import { DocumentService } from 'src/app/services/document.service'; +import { legacyDocumentRow } from 'src/app/project/project-documents/document-edit/legacy-document-row'; import { StorageService } from 'src/app/services/storage.service'; import { convertJSDateToNGBDate, convertFormGroupNGBDateToJSDate } from 'src/app/shared/utils/utils'; import { LoggingService } from 'src/app/services/logging.service'; @@ -202,8 +204,8 @@ export class AddEditCommentPeriodComponent implements OnInit { private initSelectedDocs() { if (this.storageService.state.selectedDocumentsForCP == null) { if (this.currentCommentPeriod.relatedDocuments && this.currentCommentPeriod.relatedDocuments.length > 0) { - this.documentService.getByMultiId(this.currentCommentPeriod.relatedDocuments) - .pipe(takeUntilDestroyed(this.destroyRef)) + this.documentService.byIdsNative(this.currentCommentPeriod.relatedDocuments) + .pipe(map(rows => rows.map(legacyDocumentRow)), takeUntilDestroyed(this.destroyRef)) .subscribe( data => { this.storageService.state.selectedDocumentsForCP = { type: 'selectedDocumentsForCP', data: data }; diff --git a/src/app/comment-periods/comment-period-legacy.ts b/src/app/comment-periods/comment-period-legacy.ts index 4b03cf39c..8e142ed79 100644 --- a/src/app/comment-periods/comment-period-legacy.ts +++ b/src/app/comment-periods/comment-period-legacy.ts @@ -1,6 +1,6 @@ import { CommentPeriod } from '../models/commentPeriod'; import { DemiCommentPeriod } from '../models/demi/comment-period'; -import { isEagleId } from '../models/demi/demi-ids'; +import { eagleWriteId } from '../models/demi/demi-ids'; /** The realm role the admin app signs staff in with (see `KeycloakService.isAuthorized`). */ export const STAFF_ROLE = 'sysadmin'; @@ -18,7 +18,7 @@ export type LegacyCommentPeriod = CommentPeriod & { demiId: string; readOnly: bo */ export function toLegacyCommentPeriod(row: DemiCommentPeriod, roles: string[] = []): LegacyCommentPeriod { const { id, eagleId, projectId, projectEagleId, ...rest } = row; - const writeId = eagleId ?? (isEagleId(id) ? id : null); + const writeId = eagleWriteId({ id, eagleId }); const period = new CommentPeriod({ ...rest, _id: writeId ?? id, diff --git a/src/app/models/demi/comment-period.ts b/src/app/models/demi/comment-period.ts index 8fb7c6e1e..74dadc64a 100644 --- a/src/app/models/demi/comment-period.ts +++ b/src/app/models/demi/comment-period.ts @@ -1,4 +1,4 @@ -import { isEagleId } from './demi-ids'; +import { WriteIds, eagleWriteId } from './demi-ids'; /** * A native DEMI comment period row (`GET /comment-periods`). Fields hidden from the caller are left out. @@ -81,15 +81,12 @@ const DEMI_ONLY_FIELDS = [ 'id', 'eagleId', 'periodId', 'projectId', 'projectEagleId', 'projectName', 'sourceSystem', 'read', 'sources', 'isDeleted' ]; -/** - * The Eagle id eagle-api writes a row under: `_id` on a legacy body, else the native row's Eagle id. ENGAGE owns its - * rows, so they are read-only here, as is a native row with no Eagle id; both throw. - */ -export function eagleWriteId(row: { _id?: string; id?: string; eagleId?: string; sourceSystem?: string }, kind: string): string { +/** {@link eagleWriteId} for a comment or period write. ENGAGE rows and rows with no Eagle id are read-only here: both throw. */ +export function requireEagleWriteId(row: WriteIds & { sourceSystem?: string }, kind: string): string { if (!('_id' in row) && row.sourceSystem === 'engage') { throw new Error(`This ${kind} is managed in ENGAGE and cannot be changed here.`); } - const id = '_id' in row ? row._id : row.eagleId ?? (isEagleId(row.id) ? row.id : null); + const id = eagleWriteId(row); if (!id) { throw new Error(notInEagle(kind)); } @@ -109,7 +106,7 @@ export function toEagleWriteBody(row: object, kind: string, parent: Record delete body[field]); return body; diff --git a/src/app/models/demi/demi-ids.spec.ts b/src/app/models/demi/demi-ids.spec.ts index 3e5f960a5..eac011de9 100644 --- a/src/app/models/demi/demi-ids.spec.ts +++ b/src/app/models/demi/demi-ids.spec.ts @@ -1,4 +1,4 @@ -import { isEagleId } from './demi-ids'; +import { eagleWriteId, isEagleId } from './demi-ids'; describe('isEagleId', () => { it('accepts a 24-hex Mongo ObjectId in lower case', () => { @@ -29,3 +29,29 @@ describe('isEagleId', () => { expect(isEagleId(null)).toBeFalse(); }); }); + +describe('eagleWriteId', () => { + it('keeps the _id of a legacy row', () => { + expect(eagleWriteId({ _id: '5886936ba4acd4014b81ef3c' })).toBe('5886936ba4acd4014b81ef3c'); + }); + + it('uses the eagleId of a native row whose own id is a DEMI uuid', () => { + expect(eagleWriteId({ eagleId: '5886936ba4acd4014b81ef3c' })).toBe('5886936ba4acd4014b81ef3c'); + }); + + it('prefers the legacy _id when a row carries both ids', () => { + expect(eagleWriteId({ _id: 'legacy', eagleId: 'native' })).toBe('legacy'); + }); + + it('treats an empty _id as absent', () => { + expect(eagleWriteId({ _id: '', eagleId: 'native' })).toBe('native'); + }); + + it('uses the DEMI id of a mirrored row, which is the Eagle id', () => { + expect(eagleWriteId({ id: '5886936ba4acd4014b81ef3c' })).toBe('5886936ba4acd4014b81ef3c'); + }); + + it('answers null for a DEMI-only row', () => { + expect(eagleWriteId({ id: '3f2b8a1e-6c1d-4b8e-9f3a-2d7c5e0a1b4c', eagleId: null })).toBeNull(); + }); +}); diff --git a/src/app/models/demi/demi-ids.ts b/src/app/models/demi/demi-ids.ts index 5fb1fdaf5..482f41d47 100644 --- a/src/app/models/demi/demi-ids.ts +++ b/src/app/models/demi/demi-ids.ts @@ -4,3 +4,18 @@ const EAGLE_ID = /^[0-9a-f]{24}$/i; export function isEagleId(id: string | null | undefined): boolean { return typeof id === 'string' && EAGLE_ID.test(id); } + +/** Ids a row may carry: legacy `_id`, or native `id` and `eagleId`. */ +export interface WriteIds { + _id?: string | null; + id?: string | null; + eagleId?: string | null; +} + +/** + * Eagle id eagle-api writes the row under: a legacy `_id`, else the native `eagleId`, else a DEMI `id` that is itself + * an Eagle id (mirrored rows keep it). Null means a row only DEMI holds, which is read-only here. + */ +export function eagleWriteId(row: WriteIds): string | null { + return row._id || row.eagleId || (isEagleId(row.id) ? row.id : null); +} diff --git a/src/app/models/demi/demi-list.ts b/src/app/models/demi/demi-list.ts index 58931e773..e110394f1 100644 --- a/src/app/models/demi/demi-list.ts +++ b/src/app/models/demi/demi-list.ts @@ -1,3 +1,6 @@ +/** DEMI's largest `pageSize` for a signed-in read. */ +export const DEMI_MAX_PAGE_SIZE = 1000; + /** One page of a DEMI native list. `total` is set only when the request asked for `count=true`. */ export interface DemiList { rows: T[]; diff --git a/src/app/models/demi/document-write.spec.ts b/src/app/models/demi/document-write.spec.ts index 1a6a87cbd..106a8ca4c 100644 --- a/src/app/models/demi/document-write.spec.ts +++ b/src/app/models/demi/document-write.spec.ts @@ -1,26 +1,4 @@ -import { eagleWriteId, eagleWriteProjectId } from './document-write'; - -describe('eagleWriteId', () => { - it('keeps the _id of a legacy row', () => { - expect(eagleWriteId({ _id: '5886936ba4acd4014b81ef3c' })).toBe('5886936ba4acd4014b81ef3c'); - }); - - it('uses the eagleId of a native row whose own id is a DEMI uuid', () => { - expect(eagleWriteId({ eagleId: '5886936ba4acd4014b81ef3c' })).toBe('5886936ba4acd4014b81ef3c'); - }); - - it('prefers the legacy _id when a row carries both ids', () => { - expect(eagleWriteId({ _id: 'legacy', eagleId: 'native' })).toBe('legacy'); - }); - - it('treats an empty _id as absent', () => { - expect(eagleWriteId({ _id: '', eagleId: 'native' })).toBe('native'); - }); - - it('answers null for a DEMI-only row', () => { - expect(eagleWriteId({ eagleId: null })).toBeNull(); - }); -}); +import { eagleWriteProjectId } from './document-write'; describe('eagleWriteProjectId', () => { it('keeps the project of a legacy row', () => { diff --git a/src/app/models/demi/document-write.ts b/src/app/models/demi/document-write.ts index d37c86be0..d8a039c97 100644 --- a/src/app/models/demi/document-write.ts +++ b/src/app/models/demi/document-write.ts @@ -6,11 +6,6 @@ export interface DocumentWriteIds { projectEagleId?: string | null; } -/** Eagle document id for an eagle-api write. Null means a DEMI-only row, which is read-only here. */ -export function eagleWriteId(row: DocumentWriteIds): string | null { - return row._id || row.eagleId || null; -} - /** Eagle project id for an eagle-api document write. Null also when the caller cannot read the parent project: refuse the write then. */ export function eagleWriteProjectId(row: DocumentWriteIds): string | null { return row.project || row.projectEagleId || null; diff --git a/src/app/models/demi/project-legacy.spec.ts b/src/app/models/demi/project-legacy.spec.ts index f08c61baf..15f2a48c8 100644 --- a/src/app/models/demi/project-legacy.spec.ts +++ b/src/app/models/demi/project-legacy.spec.ts @@ -6,6 +6,8 @@ describe('toLegacyProject', () => { eagleId: '5885109faaecd9001b81314c', name: 'Ashcroft Ranch Landfill', projectType: 'Waste Disposal', + projectState: 'Closed', + address: 'Ashcroft Ranch, Ashcroft, BC', proponentId: 'org1', proponentName: 'Greater Vancouver Sewerage and Drainage District', centroid: { type: 'Point', coordinates: [-121.3333, 50.7167] }, @@ -28,6 +30,8 @@ describe('toLegacyProject', () => { expect(project.type).toBe('Waste Disposal'); expect(project.proponent).toEqual({ _id: 'org1', name: 'Greater Vancouver Sewerage and Drainage District' }); expect(project.centroid).toEqual([-121.3333, 50.7167]); + expect(project.status).toBe('Closed'); + expect(project.location).toBe('Ashcroft Ranch, Ashcroft, BC'); }); it('passes fields that share the Eagle name through', () => { diff --git a/src/app/models/demi/project-legacy.ts b/src/app/models/demi/project-legacy.ts index 7ab5417ef..a59214ca8 100644 --- a/src/app/models/demi/project-legacy.ts +++ b/src/app/models/demi/project-legacy.ts @@ -7,11 +7,13 @@ export type LegacyProject = Project & { demiId: string }; /** Builds the legacy `Project` screens still read. A row without `eagleId` gets `_id = null`, so writes refuse it. */ export function toLegacyProject(row: DemiProject): LegacyProject { - const { id, eagleId, projectType, proponentId, proponentName, centroid, ...rest } = row; + const { id, eagleId, projectType, projectState, address, proponentId, proponentName, centroid, ...rest } = row; const project = new Project({ ...rest, _id: eagleId ?? null, type: projectType, + status: projectState, + location: address, proponent: proponentId || proponentName ? { _id: proponentId ?? null, name: proponentName ?? null } : undefined, // Project's constructor copies centroid as an array; DEMI stores a GeoJSON point. centroid: centroid?.coordinates ?? [], diff --git a/src/app/models/demi/project.ts b/src/app/models/demi/project.ts index 8b228ec9f..debefc71b 100644 --- a/src/app/models/demi/project.ts +++ b/src/app/models/demi/project.ts @@ -28,6 +28,10 @@ export interface DemiProject { proponentId?: string | null; proponentName?: string | null; region?: string; + /** Eagle `status`. */ + projectState?: string; + /** Eagle `location`. */ + address?: string; build?: string; centroid?: { type: string; coordinates: number[] } | null; eacDecision?: DemiListRef; diff --git a/src/app/models/demi/update.spec.ts b/src/app/models/demi/update.spec.ts index 27167f5aa..ae266f4d3 100644 --- a/src/app/models/demi/update.spec.ts +++ b/src/app/models/demi/update.spec.ts @@ -1,4 +1,4 @@ -import { nativeUpdateSort, updateEagleId, updateParentId } from './update'; +import { nativeUpdateSort, updateParentId } from './update'; describe('DEMI update helpers', () => { it('turns a table sort into a native sort, dropping keys DEMI cannot sort by', () => { @@ -11,12 +11,6 @@ describe('DEMI update helpers', () => { expect(nativeUpdateSort(null)).toBeUndefined(); }); - it('reads the Eagle id from eagleId, or from an Eagle-shaped id', () => { - expect(updateEagleId({ id: 'x', eagleId: '5885109faaecd9001b81314c' })).toBe('5885109faaecd9001b81314c'); - expect(updateEagleId({ id: '5885109faaecd9001b81314c' })).toBe('5885109faaecd9001b81314c'); - expect(updateEagleId({ id: 'demi-only', eagleId: null })).toBeNull(); - }); - it('prefers the joined project Eagle id, then the stored parent id', () => { expect(updateParentId({ projectId: 'p', projectEagleId: 'e' })).toBe('e'); expect(updateParentId({ projectId: 'p' })).toBe('p'); diff --git a/src/app/models/demi/update.ts b/src/app/models/demi/update.ts index ce9146e60..c7507915f 100644 --- a/src/app/models/demi/update.ts +++ b/src/app/models/demi/update.ts @@ -1,5 +1,4 @@ import { UpdateImage } from '../recentActivity'; -import { isEagleId } from './demi-ids'; /** * A native DEMI update row (`GET /updates`). `id` is the Eagle id. @@ -49,11 +48,6 @@ export function nativeUpdateSort(sortBy: string | null | undefined): string | un return match && UPDATE_SORT_KEYS.includes(match[2]) ? `${match[1] === '-' ? '-' : ''}${match[2]}` : undefined; } -/** The update's Eagle id, or null for a row eagle-api does not hold. */ -export function updateEagleId(row: Pick): string | null { - return row.eagleId ?? (isEagleId(row.id) ? row.id : null); -} - /** The Eagle id of the parent project or project notification, or null for an update with none. */ export function updateParentId(row: Pick): string | null { return row.projectEagleId ?? row.projectId ?? null; diff --git a/src/app/project-notification/detail/project-notification-detail.component.spec.ts b/src/app/project-notification/detail/project-notification-detail.component.spec.ts index e92ebdd3c..a4d495324 100644 --- a/src/app/project-notification/detail/project-notification-detail.component.spec.ts +++ b/src/app/project-notification/detail/project-notification-detail.component.spec.ts @@ -30,7 +30,7 @@ describe('ProjectNotificationDetailComponent', () => { } beforeEach(() => { - documents = jasmine.createSpyObj('DocumentService', ['listNative', 'publish', 'unPublish', 'downloadDocument']); + documents = jasmine.createSpyObj('DocumentService', ['listNative', 'publish', 'unPublish', 'downloadNative']); documents.listNative.and.returnValue(of({ rows: [LINKED, UNLINKED] as any[], total: 2 })); documents.publish.and.returnValue(of({} as any)); documents.unPublish.and.returnValue(of({} as any)); diff --git a/src/app/project-notification/detail/project-notification-detail.component.ts b/src/app/project-notification/detail/project-notification-detail.component.ts index 15be47f57..4e624e0ef 100644 --- a/src/app/project-notification/detail/project-notification-detail.component.ts +++ b/src/app/project-notification/detail/project-notification-detail.component.ts @@ -9,7 +9,6 @@ import { NotificationProjectService } from 'src/app/services/notification-projec import { StorageService } from 'src/app/services/storage.service'; import { DatePipe } from '@angular/common'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; -import { LoggingService } from 'src/app/services/logging.service'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -26,7 +25,6 @@ export class ProjectNotificationDetailComponent implements OnInit { documentService = inject(DocumentService); private storageService = inject(StorageService); private destroyRef = inject(DestroyRef); - private logger = inject(LoggingService); private _cdr = inject(ChangeDetectorRef); public projectNotification: LegacyNotification = null; @@ -115,10 +113,8 @@ export class ProjectNotificationDetailComponent implements OnInit { }); } - public downloadDocument(document) { - return this.documentService.downloadDocument(document).then(() => { - this.logger.debug('Download initiated for file', 'ProjectNotificationDetailComponent'); - }); + public downloadDocument(document: { id: string }) { + return this.documentService.downloadNative(document.id); } } diff --git a/src/app/project-notification/documents/project-notification-documents.component.spec.ts b/src/app/project-notification/documents/project-notification-documents.component.spec.ts index 04ae9b5b5..605e46ba1 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.spec.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.spec.ts @@ -69,6 +69,22 @@ describe('ProjectNotificationDocumentsComponent', () => { expect(fixture.componentInstance.selectedCount().total).toBe(1); }); + it('downloads a picked document from DEMI by its DEMI id', async () => { + const open = spyOn(window, 'open'); + const fixture = TestBed.createComponent(ProjectNotificationDocumentsComponent); + fixture.detectChanges(); + http.expectOne(r => r.url === '/demi/api/documents').flush([ + { id: 'uuid-1', eagleId: 'eagle-doc-1', projectId: 'notification-1', documentSource: 'PROJECT-NOTIFICATION', displayName: 'Notice letter' } + ]); + fixture.componentInstance.selectAction('selectAll'); + + const done = fixture.componentInstance.selectAction('download'); + http.expectOne('/demi/api/documents/uuid-1/download').flush({ url: 'https://files.example/notice.pdf' }); + await done; + + expect(open).toHaveBeenCalledWith('https://files.example/notice.pdf', '_self'); + }); + it('loads the notification from DEMI by route id when none is stored, then lists its documents', () => { const storage = TestBed.inject(StorageService); storage.state = { type: 'currentProject', data: null }; diff --git a/src/app/project-notification/documents/project-notification-documents.component.ts b/src/app/project-notification/documents/project-notification-documents.component.ts index 2a31c60dd..c39ab5481 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.ts @@ -153,11 +153,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit { if (this.documentTableData()) { this.documentTableData().data.map(item => { if (item.checkbox === true) { - promises.push( - this.documentService.downloadDocument( - this.docs.filter(d => d._id === item._id)[0] - ) - ); + promises.push(this.documentService.downloadNative(item.id)); } }); } @@ -365,6 +361,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit { type: '', milestone: '', legislation: 2018, + id: document.id, _id: document._id, project: document.project, isFeatured: false, diff --git a/src/app/project-notifications/add-edit-project-notification/add-edit-project-notification.component.ts b/src/app/project-notifications/add-edit-project-notification/add-edit-project-notification.component.ts index 329afc6a0..c149935b1 100644 --- a/src/app/project-notifications/add-edit-project-notification/add-edit-project-notification.component.ts +++ b/src/app/project-notifications/add-edit-project-notification/add-edit-project-notification.component.ts @@ -11,8 +11,11 @@ import { DateTime } from 'luxon'; import { ProjectNotification } from 'src/app/models/projectNotification'; import { ConfigService } from 'src/app/services/config.service'; import { NotificationProjectService } from 'src/app/services/notification-project.service'; +import { DEMI_MAX_PAGE_SIZE } from 'src/app/models/demi/demi-list'; +import { LoggingService } from 'src/app/services/logging.service'; import { ProjectService } from 'src/app/services/project.service'; import { StorageService } from 'src/app/services/storage.service'; +import { ToastService } from 'src/app/services/toast.service'; import { Constants } from 'src/app/shared/utils/constants'; import { convertJSDateToNGBDate, convertFormGroupNGBDateToJSDate } from 'src/app/shared/utils/utils'; @@ -33,6 +36,8 @@ export class AddEditProjectNotificationComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private projectService = inject(ProjectService); + private logger = inject(LoggingService); + private toastService = inject(ToastService); public isAdd = false; public isPublished = false; @@ -136,13 +141,17 @@ export class AddEditProjectNotificationComponent implements OnInit { }); } - // loads all the projects for the projects list + /** Projects the notification may point at. eagle-api stores the Eagle project id, so Track-only projects are left out. */ public getAllProjectsList() { - this.projectService.getAll(1, 1000, '+name') + this.projectService.list({ pageSize: DEMI_MAX_PAGE_SIZE }) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((res2: any) => { - if (res2) { - this.projects = res2.data; + .subscribe({ + next: ({ rows }) => { + this.projects = rows.filter(row => row.eagleId).map(row => ({ _id: row.eagleId, name: row.name })); + }, + error: error => { + this.logger.error('Failed to load projects', 'AddEditProjectNotificationComponent', error); + this.toastService.error('Failed to load projects.'); } }); } diff --git a/src/app/project/project-documents/detail/detail.component.ts b/src/app/project/project-documents/detail/detail.component.ts index ab937247d..fa69e3971 100644 --- a/src/app/project/project-documents/detail/detail.component.ts +++ b/src/app/project/project-documents/detail/detail.component.ts @@ -6,7 +6,7 @@ import { DocumentService } from 'src/app/services/document.service'; import { StorageService } from 'src/app/services/storage.service'; import { formatBytes } from 'src/app/shared/utils/utils'; import { DemiDocument } from 'src/app/models/demi/document'; -import { eagleWriteId } from 'src/app/models/demi/document-write'; +import { eagleWriteId } from 'src/app/models/demi/demi-ids'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { LoggingService } from 'src/app/services/logging.service'; import { ConfigService } from 'src/app/services/config.service'; diff --git a/src/app/project/project-documents/document-edit/document-edit.component.ts b/src/app/project/project-documents/document-edit/document-edit.component.ts index 6e6f63154..e01f460d2 100644 --- a/src/app/project/project-documents/document-edit/document-edit.component.ts +++ b/src/app/project/project-documents/document-edit/document-edit.component.ts @@ -12,7 +12,8 @@ import { StorageService } from 'src/app/services/storage.service'; import { convertJSDateToNGBDate, convertFormGroupNGBDateToJSDate } from 'src/app/shared/utils/utils'; import { LoggingService } from 'src/app/services/logging.service'; import { List } from 'src/app/models/demi/list'; -import { eagleWriteId, eagleWriteProjectId } from 'src/app/models/demi/document-write'; +import { eagleWriteId } from 'src/app/models/demi/demi-ids'; +import { eagleWriteProjectId } from 'src/app/models/demi/document-write'; import { legacyDocumentRow } from './legacy-document-row'; @Component({ diff --git a/src/app/project/project-documents/document-edit/legacy-document-row.ts b/src/app/project/project-documents/document-edit/legacy-document-row.ts index 64fded29e..03eb76eef 100644 --- a/src/app/project/project-documents/document-edit/legacy-document-row.ts +++ b/src/app/project/project-documents/document-edit/legacy-document-row.ts @@ -1,5 +1,6 @@ import { DemiDocument, DemiDocumentFilters } from 'src/app/models/demi/document'; -import { eagleWriteId, eagleWriteProjectId } from 'src/app/models/demi/document-write'; +import { eagleWriteId } from 'src/app/models/demi/demi-ids'; +import { eagleWriteProjectId } from 'src/app/models/demi/document-write'; /** * Document row in the Eagle field names the edit form and document pickers read. diff --git a/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.ts b/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.ts index 006ba688c..944d894c5 100644 --- a/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.ts +++ b/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.ts @@ -7,7 +7,7 @@ import { TableComponent } from 'src/app/shared/components/table-template/table.c import { DatePipe } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { LoggingService } from 'src/app/services/logging.service'; -import { eagleWriteId } from 'src/app/models/demi/document-write'; +import { eagleWriteId } from 'src/app/models/demi/demi-ids'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/app/project/project-documents/project-documents.component.ts b/src/app/project/project-documents/project-documents.component.ts index be3048767..c9c277914 100644 --- a/src/app/project/project-documents/project-documents.component.ts +++ b/src/app/project/project-documents/project-documents.component.ts @@ -20,7 +20,7 @@ import { encodeParams, encodeString, filterCompareWith } from 'src/app/shared/ut import { DocumentTableRowsComponent } from './project-document-table-rows/project-document-table-rows.component'; import { Document } from 'src/app/models/document'; import { DemiDocument, DemiDocumentFilters, DemiDocumentListParams } from 'src/app/models/demi/document'; -import { eagleWriteId } from 'src/app/models/demi/document-write'; +import { eagleWriteId } from 'src/app/models/demi/demi-ids'; import { FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { TableTemplateComponent } from 'src/app/shared/components/table-template/table-template.component'; diff --git a/src/app/project/project-groups/project-groups.component.spec.ts b/src/app/project/project-groups/project-groups.component.spec.ts index a60b8c9a9..a8fa95206 100644 --- a/src/app/project/project-groups/project-groups.component.spec.ts +++ b/src/app/project/project-groups/project-groups.component.spec.ts @@ -1,7 +1,7 @@ import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { BehaviorSubject, defer, of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { ProjectGroupsComponent } from './project-groups.component'; import { ExcelService } from 'src/app/services/excel.service'; import { LoggingService } from 'src/app/services/logging.service'; @@ -17,15 +17,12 @@ describe('ProjectGroupsComponent', () => { let component: ProjectGroupsComponent; let params: BehaviorSubject>; let userService: jasmine.SpyObj; - let resolves: number; let toast: jasmine.SpyObj; beforeEach(() => { params = new BehaviorSubject>({ currentPage: '2', pageSize: '25', sortBy: '-name' }); - userService = jasmine.createSpyObj('UserService', ['getDemiProjectId', 'listProjectGroups', 'getUsersByIds']); - resolves = 0; + userService = jasmine.createSpyObj('UserService', ['listProjectGroups', 'getUsersByIds']); toast = jasmine.createSpyObj('ToastService', ['success', 'warning', 'error']); - userService.getDemiProjectId.and.returnValue(defer(() => { resolves++; return of('track-42'); })); userService.listProjectGroups.and.returnValue(of({ rows: [{ id: 'g1', projectId: 'track-42', name: 'Agencies', members: [] }], total: 26 })); TestBed.configureTestingModule({ @@ -41,8 +38,9 @@ describe('ProjectGroupsComponent', () => { } }, { provide: UserService, useValue: userService }, + // No project methods: the groups read must not look the project up again. { provide: ProjectService, useValue: {} }, - { provide: StorageService, useValue: { currentProjectData: { _id: '5885109faaecd9001b81314c' }, state: {} } }, + { provide: StorageService, useValue: { currentProjectData: { _id: '5885109faaecd9001b81314c', demiId: 'track-42' }, state: {} } }, { provide: NgbModal, useValue: {} }, { provide: ExcelService, useValue: jasmine.createSpyObj('ExcelService', ['exportAsExcelFile']) }, { provide: NavigationStackUtils, useValue: {} }, @@ -55,11 +53,7 @@ describe('ProjectGroupsComponent', () => { component.ngOnInit(); }); - it('resolves the route\'s Eagle project id to the DEMI one', () => { - expect(userService.getDemiProjectId).toHaveBeenCalledWith('5885109faaecd9001b81314c'); - }); - - it('lists groups under the DEMI project id with the URL page and name sort', () => { + it('lists groups under the resolved project\'s DEMI id with the URL page and name sort', () => { expect(userService.listProjectGroups).toHaveBeenCalledWith('track-42', 2, 25, '-name'); }); @@ -67,10 +61,9 @@ describe('ProjectGroupsComponent', () => { expect(component.tableParams.totalListItems).toBe(26); }); - it('resolves the project once across page changes', () => { + it('keeps the DEMI project id across page changes', () => { params.next({ currentPage: '3', pageSize: '25', sortBy: '+name' }); - expect(resolves).toBe(1); expect(userService.listProjectGroups).toHaveBeenCalledWith('track-42', 3, 25, 'name'); }); diff --git a/src/app/project/project-groups/project-groups.component.ts b/src/app/project/project-groups/project-groups.component.ts index fac67fb35..858f1c04b 100644 --- a/src/app/project/project-groups/project-groups.component.ts +++ b/src/app/project/project-groups/project-groups.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit, DestroyRef, inject, ChangeDetectionStrategy, signal, computed } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { switchMap, shareReplay } from 'rxjs/operators'; -import { Observable, firstValueFrom } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { firstValueFrom } from 'rxjs'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { GroupsTableRowsComponent } from './project-groups-table-rows/project-groups-table-rows.component'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; @@ -82,16 +82,6 @@ export class ProjectGroupsComponent implements OnInit { } ]; - private demiProject: { eagleId: string; id$: Observable } | null = null; - - /** DEMI project id for the route's Eagle id, read once per project. */ - private demiProjectId(eagleId: string): Observable { - if (this.demiProject?.eagleId !== eagleId) { - this.demiProject = { eagleId, id$: this.userService.getDemiProjectId(eagleId).pipe(shareReplay(1)) }; - } - return this.demiProject.id$; - } - ngOnInit() { this.currentProject.set(this.storageService.currentProjectData); @@ -107,12 +97,11 @@ export class ProjectGroupsComponent implements OnInit { this.tableTemplateUtils.updateUrl(this.tableParams.sortBy, this.tableParams.currentPage, this.tableParams.pageSize, null, this.tableParams.keywords); } - const eagleProjectId = this.route.parent?.snapshot.paramMap.get('projId') ?? this.route.snapshot.params['projId']; const pageNum = this.tableParams.currentPage || 1; const pageSize = this.tableParams.pageSize || 25; - return this.demiProjectId(eagleProjectId).pipe( - switchMap(projectId => this.userService.listProjectGroups(projectId, pageNum, pageSize, sort)), + // Groups are keyed by the DEMI project id, which the project resolver keeps beside the Eagle `_id`. + return this.userService.listProjectGroups(this.currentProject().demiId, pageNum, pageSize, sort).pipe( emptyPageOnError(err => { this.logger.error('Failed to load groups', 'ProjectGroupsComponent', err); this.toastService.error('Groups could not be loaded.'); diff --git a/src/app/services/comment.service.spec.ts b/src/app/services/comment.service.spec.ts index ed7250285..219418703 100644 --- a/src/app/services/comment.service.spec.ts +++ b/src/app/services/comment.service.spec.ts @@ -40,7 +40,7 @@ describe('CommentService', () => { { provide: KeycloakService, useValue: { keycloakEnabled: true } }, { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error', 'warn', 'info', 'debug']) }, { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error', 'success']) }, - { provide: DocumentService, useValue: jasmine.createSpyObj('DocumentService', ['add', 'publish', 'unPublish', 'getByMultiId']) } + { provide: DocumentService, useValue: jasmine.createSpyObj('DocumentService', ['add', 'publish', 'unPublish']) } ] }); service = TestBed.inject(CommentService); diff --git a/src/app/services/comment.service.ts b/src/app/services/comment.service.ts index c50733075..6cbd94387 100644 --- a/src/app/services/comment.service.ts +++ b/src/app/services/comment.service.ts @@ -14,7 +14,7 @@ import { withLoading } from 'src/app/shared/utils/rxjs-operators'; import { Comment } from '../models/comment'; import { Document } from '../models/document'; import { DemiComment, DemiCommentQuery, NextPendingComment } from '../models/demi/comment'; -import { eagleWriteId, toEagleWriteBody } from '../models/demi/comment-period'; +import { requireEagleWriteId, toEagleWriteBody } from '../models/demi/comment-period'; import { DemiList } from '../models/demi/demi-list'; import { isEagleId } from '../models/demi/demi-ids'; @@ -39,59 +39,6 @@ export class CommentService { private toast = inject(ToastService); private logger = inject(LoggingService); - public pendingCommentCount = 0; - public nextCommentId = null; - - private readonly commentFields = [ - '_id', 'author', 'comment', 'commentId', 'dateAdded', 'dateUpdated', - 'documents', 'isAnonymous', 'location', 'eaoStatus', 'submittedCAC', 'period', 'read' - ]; - - private readonly commentDetailFields = [ - '_id', 'author', 'comment', 'commentId', 'dateAdded', 'datePosted', 'dateUpdated', - 'documents', 'eaoNotes', 'eaoStatus', 'submittedCAC', 'isAnonymous', 'location', - 'period', 'proponentNotes', 'proponentStatus', 'publishedNotes', 'rejectedNotes', - 'rejectedReason', 'read', 'write', 'delete' - ]; - - getCountByPeriodId(periodId: string): Observable { - const qs = `comment?isDeleted=false&commentStatus='Pending'&_commentPeriod=${periodId}`; - return this.api.head(qs, { observe: 'response' }).pipe( - withLoading(this.loadingState, `comment-count-${periodId}`), - map((res: any) => parseInt(res.headers.get('x-total-count'), 10)), - catchError(error => { - this.logger.error(`Failed to get comment count for period ${periodId}`, 'CommentService', error); - this.toast.error('Failed to load comment count.'); - return of(0); - }) - ); - } - - getById(commentId: string, populateNextComment = false): Observable { - let qs = `comment/${commentId}?fields=${this.api.buildValues(this.commentDetailFields)}`; - if (populateNextComment) { qs += '&populateNextComment=true'; } - return this.api.get(qs, { observe: 'response' }).pipe( - withLoading(this.loadingState, `comment-${commentId}`), - mergeMap((res: any) => { - this.pendingCommentCount = res.headers.get('x-pending-comment-count'); - this.nextCommentId = res.headers.get('x-next-comment-id'); - const comments = res.body; - if (!comments || comments.length === 0) { - return of(null as Comment); - } - if (comments[0].documents === null || comments[0].documents && comments[0].documents.length === 0) { - return of(new Comment(comments[0])); - } - return this._getExtraAppData(new Comment(comments[0])); - }), - catchError(error => { - this.logger.error(`Failed to get comment ${commentId}`, 'CommentService', error); - this.toast.error('Failed to load comment.'); - return of(null as Comment); - }) - ); - } - add(comment: Comment | DemiComment, documentForms: Array = []): Observable { return deferWrite(this.api, () => { // Mapped before any upload, so a comment eagle-api cannot take leaves no documents behind. @@ -188,38 +135,8 @@ export class CommentService { ); } - getByPeriodId(periodId: string, pageNum: number = null, pageSize: number = null, sortBy = '', count = true, filter: object = {}): Observable { - let qs = `comment?&period=${periodId}`; - if (pageNum !== null) { qs += `&pageNum=${pageNum - 1}`; } - if (pageSize !== null) { qs += `&pageSize=${pageSize}`; } - if (sortBy !== '' && sortBy !== null) { qs += `&sortBy=${sortBy}`; } - if (count !== null) { qs += `&count=${count}`; } - Object.keys(filter).forEach(key => { qs += `&${key}=${filter[key]}`; }); - qs += `&fields=${this.api.buildValues(this.commentFields)}`; - return this.api.get(qs).pipe( - withLoading(this.loadingState, `comments-by-period-${periodId}`), - map((res: any) => { - if (!res || res.length === 0) { return { totalCount: 0, data: [] }; } - const comments = res[0].results.map((c: any) => new Comment(c)); - return { totalCount: res[0].total_items, data: comments }; - }), - catchError(error => this.api.handleError(error)) - ); - } - private setStatus(comment: Comment | DemiComment, status: string): Observable { - return deferWrite(this.api, () => this.api.put(`comment/${eagleWriteId(comment, 'comment')}/status`, { status })); - } - - private _getExtraAppData(comment: Comment): Observable { - return forkJoin( - this.documentService.getByMultiId(comment.documents) - ).pipe( - map(payloads => { - comment.documentsList = payloads[0]; - return comment; - }) - ); + return deferWrite(this.api, () => this.api.put(`comment/${requireEagleWriteId(comment, 'comment')}/status`, { status })); } } diff --git a/src/app/services/commentperiod.service.ts b/src/app/services/commentperiod.service.ts index 251d41495..9b0065764 100644 --- a/src/app/services/commentperiod.service.ts +++ b/src/app/services/commentperiod.service.ts @@ -11,22 +11,11 @@ import { withLoading } from 'src/app/shared/utils/rxjs-operators'; import { CommentPeriod } from '../models/commentPeriod'; import { CommentPeriodSummary } from '../models/commentPeriodSummary'; import { - DemiCommentPeriod, DemiCommentPeriodQuery, DemiCommentPeriodSummary, eagleWriteId, toEagleWriteBody + DemiCommentPeriod, DemiCommentPeriodQuery, DemiCommentPeriodSummary, requireEagleWriteId, toEagleWriteBody } from '../models/demi/comment-period'; import { DemiList } from '../models/demi/demi-list'; import { isEagleId } from '../models/demi/demi-ids'; -const PERIOD_FIELDS = [ - '_id', '__v', '_schemaName', 'addedBy', 'additionalText', 'ceaaAdditionalText', - 'ceaaInformationLabel', 'ceaaRelatedDocuments', 'classificationRoles', 'classifiedPercent', - 'commenterRoles', 'commentTip', 'dateAdded', 'dateCompleted', 'dateCompletedEst', - 'dateStarted', 'dateStartedEst', 'dateUpdated', 'downloadRoles', 'informationLabel', - 'instructions', 'isClassified', 'isPublished', 'isResolved', 'isVetted', 'milestone', - 'openCommentPeriod', 'openHouses', 'periodType', 'phase', 'phaseName', 'project', - 'publishedPercent', 'rangeOption', 'rangeType', 'relatedDocuments', 'resolvedPercent', - 'updatedBy', 'userCan', 'vettedPercent', 'vettingRoles' -]; - /** eagle-api POST/PUT body; see {@link toEagleWriteBody}. `project` is the Eagle project id. */ function toEaglePeriodBody(period: CommentPeriod | DemiCommentPeriod, isNew = false): any { if ('_id' in period) { @@ -61,40 +50,6 @@ export class CommentPeriodService { 'Open': 'Commenting Open', }; - getAllByProjectId(projId: string, pageNum = 1, pageSize = 10, sortBy: string = null): Observable { - const fields = ['project', 'dateStarted', 'dateCompleted', 'isMet', 'metURLAdmin', 'isPublished']; - let qs = `commentperiod?&project=${projId}&`; - if (pageNum !== null) { qs += `pageNum=${pageNum - 1}&`; } - if (pageSize !== null) { qs += `pageSize=${pageSize}&`; } - if (sortBy !== '' && sortBy !== null) { qs += `sortBy=${sortBy}&`; } - qs += `count=true&fields=${this.api.buildValues(fields)}`; - return this.api.get(qs).pipe( - withLoading(this.loadingState, `comment-periods-${projId}`), - map((res: any) => { - if (!res || res.length === 0) { return { totalCount: 0, data: [] }; } - const periods = res[0].results.map((cp: any) => new CommentPeriod(cp)); - return { totalCount: res[0].total_items, data: periods }; - }), - catchError(error => { - this.logger.error(`Failed to get comment periods for project ${projId}`, 'CommentPeriodService', error); - this.toast.error('Failed to load comment periods.'); - return of({ totalCount: 0, data: [] }); - }) - ); - } - - getById(periodId: string): Observable { - return this.api.get(`commentperiod/${periodId}?fields=${this.api.buildValues(PERIOD_FIELDS)}`).pipe( - withLoading(this.loadingState, `comment-period-${periodId}`), - map((res: any) => res?.length ? new CommentPeriod(res[0]) : null), - catchError(error => { - this.logger.error(`Failed to get comment period ${periodId}`, 'CommentPeriodService', error); - this.toast.error('Failed to load comment period.'); - return of(null as CommentPeriod); - }) - ); - } - getSummaryById(periodId: string): Observable { return this.getNativeSummary(periodId).pipe( map(summary => summary ? new CommentPeriodSummary(summary.eaoStatus) : null) @@ -151,18 +106,18 @@ export class CommentPeriodService { } delete(period: CommentPeriod | DemiCommentPeriod): Observable { - return deferWrite(this.api, () => this.api.delete(`commentperiod/${eagleWriteId(period, 'comment period')}`)); + return deferWrite(this.api, () => this.api.delete(`commentperiod/${requireEagleWriteId(period, 'comment period')}`)); } // eagle-api's publish routes read only the id, so a native row sends no body. publish(period: CommentPeriod | DemiCommentPeriod): Observable { return deferWrite(this.api, () => - this.api.put(`commentperiod/${eagleWriteId(period, 'comment period')}/publish`, '_id' in period ? period : {})); + this.api.put(`commentperiod/${requireEagleWriteId(period, 'comment period')}/publish`, '_id' in period ? period : {})); } unPublish(period: CommentPeriod | DemiCommentPeriod): Observable { return deferWrite(this.api, () => - this.api.put(`commentperiod/${eagleWriteId(period, 'comment period')}/unpublish`, '_id' in period ? period : {})); + this.api.put(`commentperiod/${requireEagleWriteId(period, 'comment period')}/unpublish`, '_id' in period ? period : {})); } getCurrent(periods: CommentPeriod[]): CommentPeriod { diff --git a/src/app/services/document.service.ts b/src/app/services/document.service.ts index 9a4031c97..e8f0c53de 100644 --- a/src/app/services/document.service.ts +++ b/src/app/services/document.service.ts @@ -28,27 +28,6 @@ export class DocumentService { private toast = inject(ToastService); private logger = inject(LoggingService); - public isMS = !!(window.navigator as any).msSaveOrOpenBlob; - private _cachedDocument: Document | null = null; - - private readonly documentFields = [ - '_addedBy', 'documentFileName', 'labels', 'internalOriginalName', 'displayName', - 'documentType', 'datePosted', 'dateUploaded', 'dateReceived', 'documentSource', - 'internalURL', 'internalMime', 'internalSize', 'checkbox', 'project', 'type', - 'documentAuthor', 'documentAuthorType', 'projectPhase', 'legislation', 'milestone', - 'description', 'isPublished', 'isFeatured', 'sortOrder', 'secureHitCount', 'publicHitCount', - 'extractionMethod', 'contentExtractedAt' - ]; - - private readonly multiDocumentFields = [ - '_id', 'eaoStatus', 'submittedCAC', 'internalOriginalName', 'documentFileName', - 'labels', 'internalSize', 'displayName', 'documentType', 'datePosted', - 'dateUploaded', 'dateReceived', 'documentSource', 'internalURL', 'internalMime', - 'checkbox', 'project', 'type', 'documentAuthor', 'documentAuthorType', - 'projectPhase', 'milestone', 'description', 'isPublished', 'isFeatured', - 'sortOrder', 'secureHitCount', 'publicHitCount' - ]; - /** One page of DEMI documents. `filters.project` takes the DEMI project id. */ listNative(params: DemiDocumentListParams = {}): Observable> { return this.demiApi.list('documents', params).pipe( @@ -73,21 +52,28 @@ export class DocumentService { ); } + /** Presigned https URL for the document's file; null when DEMI hides the document or answers no https URL. */ + downloadUrlNative(id: string): Observable { + // Asked with the bearer, so staff-only rows resolve. DemiApiService.get builds `/`: this reads `documents//download`. + return this.demiApi.get(`documents/${encodeURIComponent(id)}`, 'download').pipe( + map(body => { + const url: unknown = body?.url; + if (typeof url === 'string' && /^https:\/\//i.test(url)) { + return url; + } + this.logger.error(`Download URL for document ${id} is missing or not https`, 'DocumentService'); + return null; + }), + catchError(() => of(null)) + ); + } + /** Saves the document's file by navigating to its presigned URL; the bytes never pass through HttpClient. */ async downloadNative(id: string): Promise { - let url: unknown; - try { - // Asked with the bearer, so staff-only rows resolve. DemiApiService.get builds `/`: this reads `documents//download`. - const body = await firstValueFrom(this.demiApi.get(`documents/${encodeURIComponent(id)}`, 'download').pipe( - withLoading(this.loadingState, `demi-document-download-${id}`, 'Preparing download') - )); - url = body?.url; - } catch { - this.toast.error('Failed to download document.'); - return; - } - if (typeof url !== 'string' || !/^https:\/\//i.test(url)) { - this.logger.error(`Download URL for document ${id} is missing or not https`, 'DocumentService'); + const url = await firstValueFrom(this.downloadUrlNative(id).pipe( + withLoading(this.loadingState, `demi-document-download-${id}`, 'Preparing download') + )); + if (!url) { this.toast.error('Failed to download document.'); return; } @@ -101,41 +87,6 @@ export class DocumentService { return of(fallback); } - getByMultiId(ids: Array): Observable> { - const qs = `document?docIds=${this.api.buildValues(ids)}&fields=${this.api.buildValues(this.multiDocumentFields)}`; - return this.api.get(qs).pipe( - withLoading(this.loadingState, `documents-multi-${ids.join(',')}`, 'Loading documents'), - map((res: any) => res?.length ? res.map((doc: any) => new Document(doc)) : []), - catchError(error => { - this.logger.error('Failed to get documents by ids', 'DocumentService', error); - this.toast.error('Failed to load documents.'); - return of([] as Document[]); - }) - ); - } - - getById(documentId: string, forceReload = false): Observable { - if (!forceReload && this._cachedDocument && this._cachedDocument._id === documentId) { - return of(this._cachedDocument); - } - const qs = `document/${documentId}?fields=${this.api.buildValues(this.documentFields)}`; - return this.api.get(qs).pipe( - withLoading(this.loadingState, `document-${documentId}`, 'Loading document'), - map((res: any) => { - if (res?.length) { - this._cachedDocument = new Document(res[0]); - return this._cachedDocument; - } - return null; - }), - catchError(error => { - this.logger.error(`Failed to get document ${documentId}`, 'DocumentService', error); - this.toast.error('Failed to load document.'); - return of(null as Document); - }) - ); - } - add(formData: FormData, publish = false): Observable { return this.api.post(this.addPath(publish), formData).pipe( catchError(error => this.api.handleError(error)) @@ -191,29 +142,6 @@ export class DocumentService { ); } - downloadResource(id: string): Promise { - return this.api.get(`document/${id}/download`, { responseType: 'blob' }).toPromise(); - } - - public async downloadDocument(document: Document): Promise { - const blob = await this.downloadResource(document._id); - let filename = document.documentSource === 'COMMENT' ? document.internalOriginalName : document.documentFileName; - filename = encodeString(filename, false); - if (this.isMS) { - (window.navigator as any).msSaveBlob(blob, filename); - } else { - const url = window.URL.createObjectURL(blob); - const a = window.document.createElement('a'); - window.document.body.appendChild(a); - a.setAttribute('style', 'display: none'); - a.href = url; - a.download = filename; - a.click(); - window.URL.revokeObjectURL(url); - a.remove(); - } - } - public async openDocument(document: Document): Promise { window.open(documentFetchUrl(document), '_blank'); } diff --git a/src/app/services/project.service.spec.ts b/src/app/services/project.service.spec.ts index 22fff8290..a0059c9d4 100644 --- a/src/app/services/project.service.spec.ts +++ b/src/app/services/project.service.spec.ts @@ -95,8 +95,8 @@ describe('ProjectService', () => { expect((await project)._id).toBe(EAGLE_ID); }); - it('getById fills only the lead when the EPD is null, from DEMI users', async () => { - const project = firstValueFrom(service.getById('p1')); + it('getForEdit fills only the lead when the EPD is null, from DEMI users', async () => { + const project = firstValueFrom(service.getForEdit('p1')); http.expectOne(r => r.url.startsWith('/api/project/p1?')) .flush([{ _id: 'p1', projectLeadId: 'u1', responsibleEPDId: null }]); @@ -124,8 +124,8 @@ describe('ProjectService', () => { expect(legislation2002.projectLeadObj._id).toBe('u1'); }); - it('getById fills the EPD when the lead is null', async () => { - const project = firstValueFrom(service.getById('p1')); + it('getForEdit fills the EPD when the lead is null', async () => { + const project = firstValueFrom(service.getForEdit('p1')); http.expectOne(r => r.url.startsWith('/api/project/p1?')) .flush([{ _id: 'p1', projectLeadId: null, responsibleEPDId: 'u2' }]); diff --git a/src/app/services/project.service.ts b/src/app/services/project.service.ts index ac5b08f59..afc1fa565 100644 --- a/src/app/services/project.service.ts +++ b/src/app/services/project.service.ts @@ -10,7 +10,6 @@ import { ToastService } from './toast.service'; import { withLoading } from 'src/app/shared/utils/rxjs-operators'; import { CommentPeriod } from '../models/commentPeriod'; import { FullProject } from '../models/fullProject'; -import { Org } from '../models/org'; import { Project } from '../models/project'; import { DemiProject } from '../models/demi/project'; import { toLegacyProject } from '../models/demi/project-legacy'; @@ -55,8 +54,6 @@ export class ProjectService { private toast = inject(ToastService); private logger = inject(LoggingService); - private projectList: Project[] = []; - /** One page of DEMI projects, sorted by name unless `params.sort` says otherwise. */ list(params: DemiListParams = {}): Observable> { return this.demiApi.list('projects', { count: true, sort: 'name', ...params }); @@ -84,47 +81,9 @@ export class ProjectService { }); } - getAll(pageNum = 1, pageSize = 20, sortBy: string = null): Observable { - const fields = [ - 'eacDecision', 'name', 'proponent', 'region', 'type', - 'code', 'currentPhaseName', 'epicProjectID', 'decisionDate' - ]; - let qs = `project?`; - if (pageNum !== null) { qs += `pageNum=${pageNum - 1}&`; } - if (pageSize !== null) { qs += `pageSize=${pageSize}&`; } - if (sortBy !== '' && sortBy !== null) { qs += `sortBy=${sortBy}&`; } - qs += `populate=true&fields=${this.api.buildValues(fields)}`; - return this.api.get(qs).pipe( - withLoading(this.loadingState, 'projects-all'), - map((res: any) => { - if (res) { - this.projectList = res[0].results.map((p: any) => new Project(p)); - return { totalCount: res[0].total_items, data: this.projectList }; - } - return {}; - }), - catchError(error => { - this.logger.error('Failed to get projects', 'ProjectService', error); - this.toast.error('Failed to load projects.'); - return of({}); - }) - ); - } - - getById(projId: string, cpStart: string = null, cpEnd: string = null): Observable { - return this.loadFromEagle(projId, cpStart, cpEnd); - } - /** The project as eagle-api stores it, for the edit form. Reads `project/` until the form saves through DEMI. */ - getForEdit(eagleId: string): Observable { - return this.loadFromEagle(eagleId, null, null); - } - - private loadFromEagle(projId: string, cpStart: string, cpEnd: string): Observable { - let qs = `project/${projId}?populate=true`; - if (cpStart !== null) { qs += `&cpStart[since]=${cpStart}`; } - if (cpEnd !== null) { qs += `&cpEnd[until]=${cpEnd}`; } - qs += `&fields=${this.api.buildValues(PROJECT_FIELDS)}`; + getForEdit(projId: string): Observable { + const qs = `project/${projId}?populate=true&fields=${this.api.buildValues(PROJECT_FIELDS)}`; return this.api.get(qs).pipe( withLoading(this.loadingState, `project-${projId}`), map(projects => { @@ -290,16 +249,6 @@ export class ProjectService { ); } - getPins(proj: string, pageNum: number, pageSize: number, sortBy: any): Observable { - let qs = `project/${proj}/pin`; - if (pageNum !== null) { qs += `?pageNum=${pageNum - 1}`; } - if (pageSize !== null) { qs += `&pageSize=${pageSize}`; } - if (sortBy !== '' && sortBy !== null) { qs += `&sortBy=${sortBy}`; } - return this.api.get(qs).pipe( - catchError(error => this.api.handleError(error)) - ); - } - addGroup(proj: Project | DemiProject, group: any): Observable { return this.eagleWrite(proj, p => this.api.post(`project/${p._id}/group`, { group })); } @@ -314,16 +263,6 @@ export class ProjectService { return this.eagleWrite(proj, p => this.api.delete(`project/${p._id}/group/${group}`)); } - getGroupMembers(proj: string, groupId: string, pageNum: number, pageSize: number, sortBy: any): Observable { - let qs = `project/${proj}/group/${groupId}/members`; - if (pageNum !== null) { qs += `?pageNum=${pageNum - 1}`; } - if (pageSize !== null) { qs += `&pageSize=${pageSize}`; } - if (sortBy !== '' && sortBy !== null) { qs += `&sortBy=${sortBy}`; } - return this.api.get(qs).pipe( - catchError(error => this.api.handleError(error)) - ); - } - addGroupMembers(proj: Project | DemiProject, groupId: string, members: any): Observable { return this.eagleWrite(proj, p => this.api.post(`project/${p._id}/group/${groupId}/members`, members)); } diff --git a/src/app/services/user.service.spec.ts b/src/app/services/user.service.spec.ts index 1e83cc14d..79edf153a 100644 --- a/src/app/services/user.service.spec.ts +++ b/src/app/services/user.service.spec.ts @@ -92,15 +92,6 @@ describe('UserService', () => { expect(req.request.params.get('count')).toBe('true'); req.flush([]); }); - - it('resolves the DEMI project id from the Eagle project id', async () => { - const id = firstValueFrom(service.getDemiProjectId('5885109faaecd9001b81314c')); - - http.expectOne('/demi/api/projects/5885109faaecd9001b81314c') - .flush({ id: 'track-42', eagleId: '5885109faaecd9001b81314c' }); - - expect(await id).toBe('track-42'); - }); }); describe('save', () => { diff --git a/src/app/services/user.service.ts b/src/app/services/user.service.ts index ee9055f80..c77a60b1a 100644 --- a/src/app/services/user.service.ts +++ b/src/app/services/user.service.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { catchError, map } from 'rxjs/operators'; +import { catchError } from 'rxjs/operators'; import { ApiService } from './api'; import { DemiApiService } from './demi-api.service'; @@ -46,16 +46,11 @@ export class UserService { return this.demi.get('groups', id); } - /** `projectId` is the DEMI project id; see {@link getDemiProjectId}. */ + /** `projectId` is the DEMI project id (`LegacyProject.demiId`), not the Eagle id. */ listProjectGroups(projectId: string, page: number, pageSize: number, sort: string): Observable> { return this.demi.list('groups', { filters: { project: projectId }, page, pageSize, sort, count: true }); } - /** Groups are keyed by the DEMI project id; admin routes carry the Eagle one, which DEMI's by-id route accepts. */ - getDemiProjectId(eagleProjectId: string): Observable { - return this.demi.get<{ id: string }>('projects', eagleProjectId).pipe(map(project => project.id)); - } - save(user: LegacyUser | User): Observable { const body = toEagleUserBody(user); return this.api.put(`user/${body._id}`, body).pipe( diff --git a/src/app/shared/utils/utils.ts b/src/app/shared/utils/utils.ts index 503dc96db..15aafd490 100644 --- a/src/app/shared/utils/utils.ts +++ b/src/app/shared/utils/utils.ts @@ -59,14 +59,14 @@ export function encodeString(filename: string, isUrl: boolean): string { return filename.replace(/\(/g, '%28').replace(/\)/g, '%29').replace(/\\/g, '_').replace(/\//g, '_'); } -export function getFormattedTime(date: Date): string { - const y = date.getFullYear(); - const m = date.getMonth() + 1; - const d = date.getDate(); - const h = date.getHours(); - const mi = date.getMinutes(); - const s = date.getSeconds(); - return `${y}-${m}-${d}-${h}-${mi}-${s}`; +/** Saves a blob the page already holds as a file named `filename`. */ +export function saveBlob(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(blob); + const a = window.document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); } export function extractFromSearchResults(results: ISearchResults[]): T[] | null { From 6d0a81f4e24bc4a9c1a07f328c44292989d8e8b9 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 17:16:18 +0000 Subject: [PATCH 2/3] refactor: move application sort to DEMI documents - Application sort lists /documents with sort=sortOrder and native type, milestone and phase filters. List rows match on type too, so 'Application Review' no longer resolves to the phase id. - Publish checks read isPublished: DEMI rows carry no read list. - One native name sort in table-template-utils replaces orgSort and its copies; shared Proponent type. - Drop unused jszip. --- angular.json | 1 - package.json | 1 - .../add-edit-activity.component.spec.ts | 5 +- .../update-image-field/update-images.spec.ts | 28 ++- .../update-image-field/update-images.ts | 15 +- src/app/contacts/contacts.component.ts | 4 +- src/app/models/demi/document.ts | 4 +- src/app/models/demi/organization.ts | 3 + src/app/models/demi/user.spec.ts | 30 +--- src/app/models/demi/user.ts | 13 -- .../organizations/organizations.component.ts | 6 +- .../application-sort.component.spec.ts | 156 ++++++++++------- .../application-sort.component.ts | 165 ++++++++---------- .../document-edit/legacy-document-row.spec.ts | 1 + .../document-edit/legacy-document-row.ts | 8 +- .../group-contact-select.component.ts | 4 +- .../project-groups.component.ts | 4 +- .../project-list/project-list.component.ts | 18 +- src/app/services/org.service.spec.ts | 12 +- src/app/services/org.service.ts | 5 - .../contact-select.component.ts | 4 +- .../link-organization.component.ts | 6 +- .../table-template.component.spec.ts | 7 - .../table-template.component.ts | 8 +- .../shared/utils/table-template-utils.spec.ts | 30 +++- src/app/shared/utils/table-template-utils.ts | 13 ++ src/app/shared/utils/utils.spec.ts | 23 ++- src/app/shared/utils/utils.ts | 33 ++-- yarn.lock | 104 +---------- 29 files changed, 309 insertions(+), 402 deletions(-) diff --git a/angular.json b/angular.json index d39537e50..a11840d85 100644 --- a/angular.json +++ b/angular.json @@ -15,7 +15,6 @@ "rxjs", "xlsx", "tinymce", - "jszip", "js-sha256", "dlv" ], diff --git a/package.json b/package.json index 7d6354e7e..41d0c068f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "@popperjs/core": "^2.11.8", "@tinymce/tinymce-angular": "^9", "bootstrap": "^5.3.8", - "jszip": "^3.10.1", "keycloak-angular": "^22.0.0", "keycloak-js": "^26.2.4", "luxon": "^3.7.2", diff --git a/src/app/activity/add-edit-activity/add-edit-activity.component.spec.ts b/src/app/activity/add-edit-activity/add-edit-activity.component.spec.ts index afa466e48..b45131e98 100644 --- a/src/app/activity/add-edit-activity/add-edit-activity.component.spec.ts +++ b/src/app/activity/add-edit-activity/add-edit-activity.component.spec.ts @@ -491,8 +491,9 @@ describe('AddEditActivityComponent', () => { }); describe('photos and featured image', () => { - const publicDoc = (_id: string, name: string) => ({ _id, displayName: name, documentFileName: name, documentSource: 'PROJECT', read: ['public'] }); - const privateDoc = (_id: string, name: string) => ({ _id, displayName: name, documentFileName: name, documentSource: 'PROJECT', read: ['staff'] }); + // DEMI rows leave out `read`; `isPublished` says whether the public can read them. + const publicDoc = (_id: string, name: string) => ({ _id, displayName: name, documentFileName: name, documentSource: 'PROJECT', isPublished: true }); + const privateDoc = (_id: string, name: string) => ({ _id, displayName: name, documentFileName: name, documentSource: 'PROJECT', isPublished: false }); const photoIds = (component: AddEditActivityComponent) => component.photoRows.map(row => row.document.value); const statusMessage = () => (query('[role=status] #altNeeded')?.textContent ?? '').trim(); diff --git a/src/app/activity/update-image-field/update-images.spec.ts b/src/app/activity/update-image-field/update-images.spec.ts index 1b79811fb..82ea9fb16 100644 --- a/src/app/activity/update-image-field/update-images.spec.ts +++ b/src/app/activity/update-image-field/update-images.spec.ts @@ -1,4 +1,4 @@ -import { blocksPublish, captionLine, imageSrc, imageType, toUpdateImage } from './update-images'; +import { blocksPublish, captionLine, imageSrc, imageType, isPublicDocument, toUpdateImage } from './update-images'; describe('update images', () => { describe('blocksPublish', () => { @@ -21,6 +21,32 @@ describe('update images', () => { it('does not block a missing document', () => { expect(blocksPublish(undefined)).toBeFalse(); }); + + it('blocks an unpublished DEMI row, which has no read list', () => { + expect(blocksPublish({ documentSource: 'PROJECT', isPublished: false })).toBeTrue(); + }); + + it('lets a published DEMI row through', () => { + expect(blocksPublish({ documentSource: 'PROJECT', isPublished: true })).toBeFalse(); + }); + }); + + describe('isPublicDocument', () => { + it('reads a published DEMI row as public', () => { + expect(isPublicDocument({ _id: 'd1', isPublished: true })).toBeTrue(); + }); + + it('reads an unpublished DEMI row as not public', () => { + expect(isPublicDocument({ _id: 'd1', isPublished: false })).toBeFalse(); + }); + + it('takes an Eagle read list over isPublished', () => { + expect(isPublicDocument({ _id: 'd1', read: ['staff'], isPublished: true })).toBeFalse(); + }); + + it('treats a row with neither field as not public', () => { + expect(isPublicDocument({ _id: 'd1' })).toBeFalse(); + }); }); describe('imageSrc', () => { diff --git a/src/app/activity/update-image-field/update-images.ts b/src/app/activity/update-image-field/update-images.ts index 378e76d1c..368ffdc2c 100644 --- a/src/app/activity/update-image-field/update-images.ts +++ b/src/app/activity/update-image-field/update-images.ts @@ -13,11 +13,22 @@ export const IMAGE_MAX_MB = 10; /** MIME type the API expects for an image file name, or undefined when it is not an image name. */ export const imageType = (fileName: string): string | undefined => IMAGE_TYPES.get(/\.([^.]+)$/.exec(fileName)?.[1].toLowerCase() ?? ''); -export const isPublicDocument = (doc: any): boolean => !!doc?.read?.includes('public'); +/** + * Whether the public can read a document; undefined when the row says nothing. + * Eagle upload answers carry `read`; DEMI rows leave `read` out and carry `isPublished`. + */ +function publicState(doc: any): boolean | undefined { + if (Array.isArray(doc?.read)) { + return doc.read.includes('public'); + } + return typeof doc?.isPublished === 'boolean' ? doc.isPublished : undefined; +} + +export const isPublicDocument = (doc: any): boolean => publicState(doc) === true; /** A picked project document the public cannot read. Update uploads do not count: they go public with the Update. */ export const blocksPublish = (doc: any): boolean => - Array.isArray(doc?.read) && doc.documentSource !== UPDATE_IMAGE_SOURCE && !doc.read.includes('public'); + publicState(doc) === false && doc.documentSource !== UPDATE_IMAGE_SOURCE; /** Name staff see for a document. */ export const documentName = (doc: any): string => doc?.displayName || doc?.documentFileName || ''; diff --git a/src/app/contacts/contacts.component.ts b/src/app/contacts/contacts.component.ts index 3e23b5d7e..c8977c65d 100644 --- a/src/app/contacts/contacts.component.ts +++ b/src/app/contacts/contacts.component.ts @@ -4,12 +4,12 @@ import { switchMap } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { UserTableRowsComponent } from './user-table-rows/user-table-rows.component'; import { SearchTerms } from '../models/search'; -import { User, nativeSort, tableSort } from '../models/demi/user'; +import { User } from '../models/demi/user'; import { StorageService } from '../services/storage.service'; import { TableObject, TableColumn } from '../shared/components/table-template/table-object'; import { TableParamsObject } from '../shared/components/table-template/table-params-object'; import { NavigationStackUtils } from '../shared/utils/navigation-stack-utils'; -import { TableTemplateUtils, nextSortBy } from '../shared/utils/table-template-utils'; +import { TableTemplateUtils, nextSortBy, nativeSort, tableSort } from '../shared/utils/table-template-utils'; import { UserService, emptyPageOnError } from '../services/user.service'; import { LoggingService } from '../services/logging.service'; import { ToastService } from '../services/toast.service'; diff --git a/src/app/models/demi/document.ts b/src/app/models/demi/document.ts index 69f94bfbf..7f61ee358 100644 --- a/src/app/models/demi/document.ts +++ b/src/app/models/demi/document.ts @@ -33,6 +33,8 @@ export interface DemiDocument { dateUploaded?: string | null; isFeatured?: boolean; isPublished?: boolean; + /** Staff-set order on the application sort screen. */ + sortOrder?: number | null; eaoStatus?: string; legislation?: number | null; region?: string; @@ -56,7 +58,7 @@ export type DemiDocumentFilters = { datePostedEnd?: string; }; -/** List query for documents. Sortable fields: `datePosted`, `displayName`. */ +/** List query for documents. Sortable fields: `datePosted`, `displayName`, `sortOrder`. */ export type DemiDocumentListParams = Omit & { filters?: DemiDocumentFilters }; /** `GET /documents/:id/download` answer. `url` is presigned and short-lived. */ diff --git a/src/app/models/demi/organization.ts b/src/app/models/demi/organization.ts index a8b1b3e67..335c5c4e2 100644 --- a/src/app/models/demi/organization.ts +++ b/src/app/models/demi/organization.ts @@ -25,3 +25,6 @@ export interface Organization { dateAdded?: string | null; dateUpdated?: string | null; } + +/** A proponent picker option: an organization of company type `Proponent/Certificate Holder`. */ +export type Proponent = Pick; diff --git a/src/app/models/demi/user.spec.ts b/src/app/models/demi/user.spec.ts index 947b10a5d..d2b009983 100644 --- a/src/app/models/demi/user.spec.ts +++ b/src/app/models/demi/user.spec.ts @@ -1,32 +1,4 @@ -import { User, contactExportRow, nativeSort, tableSort } from './user'; - -describe('nativeSort', () => { - it('keeps a descending sort on the allowed field', () => { - expect(nativeSort('-displayName', 'displayName')).toBe('-displayName'); - }); - - it('sends an ascending table sort without the plus sign', () => { - expect(nativeSort('+displayName', 'displayName')).toBe('displayName'); - }); - - it('falls back to the allowed field for an old multi-column sort', () => { - expect(nativeSort('-lastName,+firstName', 'displayName')).toBe('displayName'); - }); - - it('falls back to the allowed field when there is no sort', () => { - expect(nativeSort(undefined, 'name')).toBe('name'); - }); -}); - -describe('tableSort', () => { - it('adds the plus sign the table header expects', () => { - expect(tableSort('displayName')).toBe('+displayName'); - }); - - it('keeps a descending sort as is', () => { - expect(tableSort('-name')).toBe('-name'); - }); -}); +import { User, contactExportRow } from './user'; describe('contactExportRow', () => { it('joins both address lines with a space', () => { diff --git a/src/app/models/demi/user.ts b/src/app/models/demi/user.ts index 386cd9417..683a1765f 100644 --- a/src/app/models/demi/user.ts +++ b/src/app/models/demi/user.ts @@ -65,16 +65,3 @@ export function contactExportRow(user: User) { email: user.email }; } - -/** - * DEMI `sort` for a kind that sorts by one `field` only: a table `sortBy` of `-` sorts descending, - * anything else (another column, an old URL) falls back to ascending `field`. - */ -export function nativeSort(sortBy: string | null | undefined, field: string): string { - return sortBy?.split(',')[0] === `-${field}` ? `-${field}` : field; -} - -/** Table `sortBy` for a DEMI sort, so the header arrow matches what the list was sorted by. */ -export function tableSort(sort: string): string { - return sort.startsWith('-') ? sort : `+${sort}`; -} diff --git a/src/app/organizations/organizations.component.ts b/src/app/organizations/organizations.component.ts index e5bb9b6b8..956ea6b9e 100644 --- a/src/app/organizations/organizations.component.ts +++ b/src/app/organizations/organizations.component.ts @@ -9,8 +9,8 @@ import { StorageService } from '../services/storage.service'; import { TableObject, TableColumn } from '../shared/components/table-template/table-object'; import { TableParamsObject } from '../shared/components/table-template/table-params-object'; import { NavigationStackUtils } from '../shared/utils/navigation-stack-utils'; -import { TableTemplateUtils, nextSortBy } from '../shared/utils/table-template-utils'; -import { OrgService, ORG_LIST_LOADING, orgSort } from '../services/org.service'; +import { TableTemplateUtils, nextSortBy, nativeSort } from '../shared/utils/table-template-utils'; +import { OrgService, ORG_LIST_LOADING } from '../services/org.service'; import { LoadingStateService } from '../services/loading-state.service'; import { FormsModule } from '@angular/forms'; @@ -117,7 +117,7 @@ export class OrganizationsComponent implements OnInit { page: this.tableParams.currentPage, pageSize: this.tableParams.pageSize, count: true, - sort: orgSort(this.tableParams.sortBy), + sort: nativeSort(this.tableParams.sortBy, 'name'), q: this.tableParams.keywords || undefined, filters: { companyType: companyType.length ? companyType : undefined } }); diff --git a/src/app/project/project-documents/application-sort/application-sort.component.spec.ts b/src/app/project/project-documents/application-sort/application-sort.component.spec.ts index d17b01e31..af725696b 100644 --- a/src/app/project/project-documents/application-sort/application-sort.component.spec.ts +++ b/src/app/project/project-documents/application-sort/application-sort.component.spec.ts @@ -1,82 +1,114 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, TestRequest, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; import { of } from 'rxjs'; -import { TableTemplateComponent } from 'src/app/shared/components/table-template/table-template.component'; -import { ApiService } from 'src/app/services/api'; -import { StorageService } from 'src/app/services/storage.service'; -import { SearchService } from 'src/app/services/search.service'; -import { DocumentService } from 'src/app/services/document.service'; + +import { DocumentApplicationSortComponent } from './application-sort.component'; import { ConfigService } from 'src/app/services/config.service'; +import { KeycloakService } from 'src/app/services/keycloak.service'; +import { LoggingService } from 'src/app/services/logging.service'; +import { StorageService } from 'src/app/services/storage.service'; import { TableTemplateUtils } from 'src/app/shared/utils/table-template-utils'; -import { AjaxData } from 'src/app/shared/utils/mock-data'; -import { TableParamsObject } from 'src/app/shared/components/table-template/table-params-object'; -import { DocumentApplicationSortComponent } from './application-sort.component'; +const LISTS = [ + { id: 'type18', type: 'doctype', name: 'Application Materials', legislation: 2018 }, + { id: 'memo18', type: 'doctype', name: 'Independent Memo', legislation: 2018 }, + { id: 'mile18', type: 'label', name: 'Revised EAC Application', legislation: 2018 }, + { id: 'amend18', type: 'projectPhase', name: 'Post Decision - Amendment', legislation: 2018 }, + { id: 'app18', type: 'projectPhase', name: 'Application Development & Review', legislation: 2018 } +]; + +// Mirrored DEMI row: `id` and `eagleId` are the Eagle id, `projectId` the DEMI project id. +const ROW = { + id: 'doc-eagle-1', eagleId: 'doc-eagle-1', projectId: 'demi-p1', projectEagleId: 'eagle-p1', + displayName: 'Application.pdf', typeId: 'type18', milestoneId: 'mile18', isPublished: false, sortOrder: 3 +}; describe('DocumentApplicationSortComponent', () => { - let component: DocumentApplicationSortComponent; - let fixture: ComponentFixture; + let http: HttpTestingController; + let storage: { currentProjectData: any; state: any }; + + function init(params: Record): DocumentApplicationSortComponent { + storage = { currentProjectData: { _id: 'eagle-p1', demiId: 'demi-p1' }, state: {} }; + TestBed.configureTestingModule({ + imports: [DocumentApplicationSortComponent], + providers: [ + provideRouter([]), + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: ActivatedRoute, useValue: { params: of(params) } }, + { provide: StorageService, useValue: storage }, + { provide: ConfigService, useValue: { lists: LISTS, getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } }, + { provide: KeycloakService, useValue: { keycloakEnabled: true } }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error', 'warn', 'info', 'debug']) } + ] + }); + spyOn(TestBed.inject(TableTemplateUtils), 'updateUrl'); + http = TestBed.inject(HttpTestingController); + const component = TestBed.createComponent(DocumentApplicationSortComponent).componentInstance; + component.ngOnInit(); + return component; + } + + function documentsRequest(): TestRequest { + return http.expectOne(r => r.url === '/demi/api/documents'); + } - const mockApiService = jasmine.createSpyObj('ApiService', [ - 'downloadDocument' - ]); + afterEach(() => http.verify()); - const mockStorageService = jasmine.createSpyObj('StorageService', [ - 'state' - ]); + it('asks DEMI for the project application documents by DEMI project id, in sort order', () => { + init({}); - const mockSearchService = jasmine.createSpyObj('SearchService', [ - 'res', 'getSearchResults' - ]); - mockSearchService.getSearchResults.and.returnValue(of([{ data: { meta: [], searchResults: [] } }])); + const params = documentsRequest().request.params; + expect(params.get('project')).toBe('demi-p1'); + expect(params.get('sort')).toBe('sortOrder'); + expect(params.get('documentSource')).toBe('PROJECT'); + expect(params.getAll('typeId')).toEqual(['type18', 'memo18']); + expect(params.getAll('milestoneId')).toEqual(['mile18']); + expect(params.getAll('projectPhaseId')).toEqual(['app18']); + expect(params.get('count')).toBe('true'); + }); - const mockDocumentService = jasmine.createSpyObj('DocumentService', [ - 'res' - ]); + it('falls back to sort order for an old multi-key sort URL', () => { + const component = init({ sortBy: '+sortOrder,-datePosted,+displayName' }); - const mockConfigService = { lists: [] }; + expect(documentsRequest().request.params.get('sort')).toBe('sortOrder'); + expect(component.tableParams.sortBy).toBe('+sortOrder'); + }); - const mockTableTemplateUtils = jasmine.createSpyObj('TableTemplateUtils', [ - 'updateUrl', - 'updateTableParams', - 'getParamsFromUrl' - ]); + it('keeps a descending date sort', () => { + init({ sortBy: '-datePosted' }); - mockStorageService.currentProjectData = AjaxData; - mockStorageService.state.currentProject = { - data: AjaxData - }; + expect(documentsRequest().request.params.get('sort')).toBe('-datePosted'); + }); - const defaultParams = new TableParamsObject(); + it('sends the keywords as q', () => { + init({ keywords: 'memo' }); + expect(documentsRequest().request.params.get('q')).toBe('memo'); + }); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ - DocumentApplicationSortComponent, - TableTemplateComponent - ], - providers: [ - provideRouter([]), - { provide: ApiService, useValue: mockApiService }, - { provide: StorageService, useValue: mockStorageService }, - { provide: SearchService, useValue: mockSearchService }, - { provide: DocumentService, useValue: mockDocumentService }, - { provide: ConfigService, useValue: mockConfigService }, - { provide: TableTemplateUtils, useValue: mockTableTemplateUtils } - ] - }) - .compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(DocumentApplicationSortComponent); - component = fixture.componentInstance; - mockTableTemplateUtils.getParamsFromUrl.and.returnValue(defaultParams); - fixture.detectChanges(); + it('shows rows under their Eagle id with the publish status and total', () => { + const component = init({}); + + documentsRequest().flush([ROW], { headers: { 'x-total-count': '12' } }); + + expect(component.documents[0]._id).toBe('doc-eagle-1'); + expect(component.documents[0].status).toBe('Not Published'); + expect(component.tableParams.totalListItems).toBe(12); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('keeps a sort order edited on an earlier page', () => { + const component = init({}); + documentsRequest().flush([]); + storage.state.editedDocs = [{ _id: 'doc-eagle-1', sortOrder: 1 }]; + + component.getPaginatedDocs(2); + const req = documentsRequest(); + expect(req.request.params.get('page')).toBe('2'); + req.flush([ROW]); + + expect(component.documents[0].sortOrder).toBe(1); }); }); diff --git a/src/app/project/project-documents/application-sort/application-sort.component.ts b/src/app/project/project-documents/application-sort/application-sort.component.ts index a314389aa..cd3150d80 100644 --- a/src/app/project/project-documents/application-sort/application-sort.component.ts +++ b/src/app/project/project-documents/application-sort/application-sort.component.ts @@ -1,19 +1,21 @@ import { Component, OnInit, ChangeDetectorRef, inject, ChangeDetectionStrategy, DestroyRef} from '@angular/core'; import { Router, ActivatedRoute, RouterModule } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Observable } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { ToastService } from 'src/app/services/toast.service'; import { ApplicationSortTableRowsComponent } from './application-sort-table-rows/application-sort-table-rows.component'; -import { Project } from 'src/app/models/project'; -import { User } from 'src/app/models/user'; +import { DemiDocument } from 'src/app/models/demi/document'; +import { DemiList } from 'src/app/models/demi/demi-list'; +import { LegacyProject } from 'src/app/models/demi/project-legacy'; import { ConfigService } from 'src/app/services/config.service'; import { DocumentService } from 'src/app/services/document.service'; -import { SearchService } from 'src/app/services/search.service'; import { StorageService } from 'src/app/services/storage.service'; import { TableObject, TableColumn } from 'src/app/shared/components/table-template/table-object'; import { TableParamsObject } from 'src/app/shared/components/table-template/table-params-object'; -import { TableTemplateUtils, nextSortBy } from 'src/app/shared/utils/table-template-utils'; -import { createProjectTabModifiers } from 'src/app/shared/utils/utils'; +import { TableTemplateUtils, nextSortBy, tableSort } from 'src/app/shared/utils/table-template-utils'; +import { applicationDocumentFilters } from 'src/app/shared/utils/utils'; +import { legacyDocumentRow, nativeDocumentSort } from '../document-edit/legacy-document-row'; import { TableTemplateComponent } from 'src/app/shared/components/table-template/table-template.component'; @@ -33,16 +35,15 @@ export class DocumentApplicationSortComponent implements OnInit { private router = inject(Router); private _changeDetectionRef = inject(ChangeDetectorRef); private storageService = inject(StorageService); - private searchService = inject(SearchService); private toastService = inject(ToastService); private documentService = inject(DocumentService); private tableTemplateUtils = inject(TableTemplateUtils); private configService = inject(ConfigService); private destroyRef = inject(DestroyRef); - public currentProject: Project = null; + public currentProject: LegacyProject = null; public loading = true; - public documents: User[] = null; + public documents: any[] = null; public tableParams: TableParamsObject = new TableParamsObject(); public tableData: TableObject; @@ -60,7 +61,8 @@ export class DocumentApplicationSortComponent implements OnInit { { name: 'Status', value: 'status', - width: '5%' + width: '5%', + nosort: true }, { name: 'Date', @@ -70,17 +72,20 @@ export class DocumentApplicationSortComponent implements OnInit { { name: 'Type', value: 'type', - width: '15%' + width: '15%', + nosort: true }, { name: 'Milestone', value: 'milestone', - width: '15%' + width: '15%', + nosort: true }, { name: 'Legislation', value: 'legislation', - width: '10%' + width: '10%', + nosort: true } ]; @@ -91,44 +96,49 @@ export class DocumentApplicationSortComponent implements OnInit { .pipe( switchMap(params => { this.tableParams = this.tableTemplateUtils.getParamsFromUrl(params, null, 10); - if (this.tableParams.sortBy === '') { - this.tableParams.sortBy = '+sortOrder,-datePosted,+displayName'; - this.tableTemplateUtils.updateUrl(this.tableParams.sortBy, this.tableParams.currentPage, this.tableParams.pageSize, null, this.tableParams.keywords); - } + this.tableParams.sortBy = tableSort(this.nativeSort(this.tableParams.sortBy)); + this.tableTemplateUtils.updateUrl(this.tableParams.sortBy, this.tableParams.currentPage, this.tableParams.pageSize, null, this.tableParams.keywords); this._changeDetectionRef.markForCheck(); - - const projectId = this.currentProject._id; - const tabModifier = createProjectTabModifiers(this.configService.lists); - return this.searchService.getSearchResults( - this.tableParams.keywords || '', - 'Document', - [{ 'name': 'project', 'value': projectId }], - this.tableParams.currentPage, - this.tableParams.pageSize, - this.tableParams.sortBy, - tabModifier, - true - ); + return this.fetchDocuments(this.tableParams.currentPage, this.tableParams.pageSize, this.tableParams.sortBy, this.tableParams.keywords); }), takeUntilDestroyed(this.destroyRef) ) - .subscribe((res: any) => { - if (res) { - if (res[0].data.meta && res[0].data.meta.length > 0) { - this.tableParams.totalListItems = res[0].data.meta[0].searchResultsTotal; - this.documents = res[0].data.searchResults; - } else { - this.tableParams.totalListItems = 0; - this.documents = []; - } - this.setRowData(); - this.loading = false; - this._changeDetectionRef.markForCheck(); - } else { - this.toastService.error('Uh-oh, couldn\'t load documents'); - this.router.navigate(['/search']); - } - }); + .subscribe(page => this.showPage(page)); + } + + /** DEMI sorts documents by one key; any other sort, such as an old multi-key URL, falls back to the sort order. */ + private nativeSort(sortBy: string | null | undefined): string { + return nativeDocumentSort(sortBy) ?? 'sortOrder'; + } + + /** One page of this project's application documents. Documents carry the DEMI project id, not the Eagle one. */ + private fetchDocuments(page: number, pageSize: number, sortBy: string, keywords: string): Observable> { + return this.documentService.listNative({ + page: page || 1, + pageSize: pageSize || 10, + count: true, + sort: this.nativeSort(sortBy), + q: keywords || undefined, + filters: applicationDocumentFilters(this.configService.lists, this.currentProject.demiId) + }); + } + + /** Shows a page, keeping sort orders edited on earlier pages. */ + private showPage(page: DemiList) { + const edited = new Map((this.storageService.state.editedDocs ?? []).map((doc: any) => [doc._id, doc])); + this.tableParams.totalListItems = page.total ?? 0; + this.documents = page.rows.map(row => { + const doc = legacyDocumentRow(row); + const previous = doc._id ? edited.get(doc._id) : undefined; + return { + ...doc, + status: doc.isPublished ? 'Published' : 'Not Published', + sortOrder: previous ? previous.sortOrder : doc.sortOrder + }; + }); + this.setRowData(); + this.loading = false; + this._changeDetectionRef.markForCheck(); } setRowData() { @@ -147,7 +157,8 @@ export class DocumentApplicationSortComponent implements OnInit { onSave() { const formData = new FormData(); - this.storageService.state.editedDocs.forEach((document: any) => { + // A document with no Eagle id cannot be written through eagle-api. + this.storageService.state.editedDocs.filter((document: any) => document._id).forEach((document: any) => { // document service put id and sort order formData.set('sortOrder', document.sortOrder); this.documentService.update(formData, document._id) @@ -172,55 +183,19 @@ export class DocumentApplicationSortComponent implements OnInit { window.scrollTo(0, 0); this.loading = true; - this.tableParams.currentPage = pageNumber; - - const projectId = this.currentProject._id; - const currentPage = pageNumber ? pageNumber : 1; - const pageSize = this.tableData.paginationData.pageSize ? this.tableData.paginationData.pageSize : 10; - const sortBy = this.tableParams.sortBy && this.tableParams.sortBy !== 'null' ? this.tableParams.sortBy : '+sortOrder,-datePosted,+displayName'; - const keywords = this.tableParams.keywords ? this.tableParams.keywords : ''; - - const tabModifier = createProjectTabModifiers(this.configService.lists); - this.searchService.getSearchResults( - keywords, - 'Document', - [{ 'name': 'project', 'value': projectId }], - currentPage, - pageSize, - sortBy, - tabModifier, - true) + const currentPage = pageNumber || 1; + const pageSize = this.tableData?.paginationData.pageSize || 10; + const sortBy = tableSort(this.nativeSort(this.tableParams.sortBy)); + const keywords = this.tableParams.keywords || ''; + this.tableParams.currentPage = currentPage; + this.tableParams.sortBy = sortBy; + + this.fetchDocuments(currentPage, pageSize, sortBy, keywords) .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((res: any) => { - if (res && res[0].data.meta && res[0].data.meta.length > 0) { - this.tableParams.totalListItems = res[0].data.meta[0].searchResultsTotal; - this.documents = res[0].data.searchResults; - // update values that have been edited previously - this.documents.forEach((document: any) => { - if (this.storageService.state.editedDocs && this.storageService.state.editedDocs.length > 0) { - this.storageService.state.editedDocs.forEach((editedDocument: any) => { - if (document._id === editedDocument._id) { - document.sortOrder = editedDocument.sortOrder; - } - }); - } - }); - this.tableTemplateUtils.updateUrl( - sortBy, - currentPage, - pageSize, - null, - keywords - ); - this.setRowData(); - } else { - this.toastService.error('Uh-oh, couldn\'t load documents'); - this.tableParams.totalListItems = 0; - this.documents = []; - } - this.loading = false; - this._changeDetectionRef.markForCheck(); - }); + .subscribe(page => { + this.tableTemplateUtils.updateUrl(sortBy, currentPage, pageSize, null, keywords); + this.showPage(page); + }); } } diff --git a/src/app/project/project-documents/document-edit/legacy-document-row.spec.ts b/src/app/project/project-documents/document-edit/legacy-document-row.spec.ts index 27bf55f0d..92f40e4f7 100644 --- a/src/app/project/project-documents/document-edit/legacy-document-row.spec.ts +++ b/src/app/project/project-documents/document-edit/legacy-document-row.spec.ts @@ -44,6 +44,7 @@ describe('nativeDocumentSort', () => { it('keeps the sort keys DEMI allows, without a + prefix', () => { expect(nativeDocumentSort('-datePosted')).toBe('-datePosted'); expect(nativeDocumentSort('+displayName')).toBe('displayName'); + expect(nativeDocumentSort('-sortOrder')).toBe('-sortOrder'); }); it('drops keys DEMI cannot sort by', () => { diff --git a/src/app/project/project-documents/document-edit/legacy-document-row.ts b/src/app/project/project-documents/document-edit/legacy-document-row.ts index 03eb76eef..2a79c9a6b 100644 --- a/src/app/project/project-documents/document-edit/legacy-document-row.ts +++ b/src/app/project/project-documents/document-edit/legacy-document-row.ts @@ -31,13 +31,11 @@ export function isSelectableDocument(row: { _id?: string | null; documentSource? /** `GET /documents` filters for one parent's documents of one `documentSource`. */ export function sourceFilters(project: string, documentSource: string): DemiDocumentFilters { - // Typed, not cast: `documentSource` is not in DemiDocumentFilters on this branch yet. - const filters: DemiDocumentFilters & { documentSource: string } = { project, documentSource }; - return filters; + return { project, documentSource }; } -/** Table sort (`-datePosted`, `+displayName`) as a native documents `sort`; keys DEMI cannot sort by are dropped. */ +/** Table sort (`-datePosted`, `+sortOrder`) as a native documents `sort`; keys DEMI cannot sort by are dropped. */ export function nativeDocumentSort(sortBy: string | null | undefined): string | undefined { const key = sortBy?.replace(/^\+/, ''); - return key && /^-?(datePosted|displayName)$/.test(key) ? key : undefined; + return key && /^-?(datePosted|displayName|sortOrder)$/.test(key) ? key : undefined; } diff --git a/src/app/project/project-groups/group-contact/group-contact-select/group-contact-select.component.ts b/src/app/project/project-groups/group-contact/group-contact-select/group-contact-select.component.ts index 891c886b2..3e22a19c0 100644 --- a/src/app/project/project-groups/group-contact/group-contact-select/group-contact-select.component.ts +++ b/src/app/project/project-groups/group-contact/group-contact-select/group-contact-select.component.ts @@ -3,12 +3,12 @@ import { switchMap } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { SearchTerms } from 'src/app/models/search'; -import { User, nativeSort, tableSort } from 'src/app/models/demi/user'; +import { User } from 'src/app/models/demi/user'; import { StorageService } from 'src/app/services/storage.service'; import { TableObject, TableColumn } from 'src/app/shared/components/table-template/table-object'; import { TableParamsObject } from 'src/app/shared/components/table-template/table-params-object'; import { NavigationStackUtils } from 'src/app/shared/utils/navigation-stack-utils'; -import { TableTemplateUtils, nextSortBy } from 'src/app/shared/utils/table-template-utils'; +import { TableTemplateUtils, nextSortBy, nativeSort, tableSort } from 'src/app/shared/utils/table-template-utils'; import { UserService, emptyPageOnError } from 'src/app/services/user.service'; import { LoggingService } from 'src/app/services/logging.service'; import { ToastService } from 'src/app/services/toast.service'; diff --git a/src/app/project/project-groups/project-groups.component.ts b/src/app/project/project-groups/project-groups.component.ts index 858f1c04b..cf3dd3e2c 100644 --- a/src/app/project/project-groups/project-groups.component.ts +++ b/src/app/project/project-groups/project-groups.component.ts @@ -10,7 +10,7 @@ import { ConfirmComponent } from 'src/app/confirm/confirm.component'; import { InputModalComponent } from 'src/app/input-modal/input-modal.component'; import { SearchTerms } from 'src/app/models/search'; import { Group } from 'src/app/models/demi/group'; -import { User, contactExportRow, nativeSort, tableSort } from 'src/app/models/demi/user'; +import { User, contactExportRow } from 'src/app/models/demi/user'; import { ExcelService } from 'src/app/services/excel.service'; import { ProjectService } from 'src/app/services/project.service'; import { UserService, emptyPageOnError } from 'src/app/services/user.service'; @@ -19,7 +19,7 @@ import { LoadingStateService } from 'src/app/services/loading-state.service'; import { TableObject, TableColumn } from 'src/app/shared/components/table-template/table-object'; import { TableParamsObject } from 'src/app/shared/components/table-template/table-params-object'; import { NavigationStackUtils } from 'src/app/shared/utils/navigation-stack-utils'; -import { TableTemplateUtils, nextSortBy } from 'src/app/shared/utils/table-template-utils'; +import { TableTemplateUtils, nextSortBy, nativeSort, tableSort } from 'src/app/shared/utils/table-template-utils'; import { TableTemplateComponent } from 'src/app/shared/components/table-template/table-template.component'; import { LoggingService } from 'src/app/services/logging.service'; diff --git a/src/app/projects/project-list/project-list.component.ts b/src/app/projects/project-list/project-list.component.ts index c99b82763..f34d0d165 100644 --- a/src/app/projects/project-list/project-list.component.ts +++ b/src/app/projects/project-list/project-list.component.ts @@ -8,7 +8,8 @@ import { NgSelectModule } from '@ng-select/ng-select'; import { NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; import { DateTime } from 'luxon'; -import { DemiList } from 'src/app/models/demi/demi-list'; +import { DEMI_MAX_PAGE_SIZE, DemiList } from 'src/app/models/demi/demi-list'; +import { Proponent } from 'src/app/models/demi/organization'; import { DemiProject } from 'src/app/models/demi/project'; import { LegacyProject, toLegacyProject } from 'src/app/models/demi/project-legacy'; import { SearchTerms } from 'src/app/models/search'; @@ -20,7 +21,7 @@ import { TableObject, TableColumn } from 'src/app/shared/components/table-templa import { TableParamsObject } from 'src/app/shared/components/table-template/table-params-object'; import { Constants } from 'src/app/shared/utils/constants'; import { NavigationStackUtils } from 'src/app/shared/utils/navigation-stack-utils'; -import { TableTemplateUtils, nextSortBy } from 'src/app/shared/utils/table-template-utils'; +import { TableTemplateUtils, nextSortBy, nativeSort } from 'src/app/shared/utils/table-template-utils'; import { filterCompareWith } from 'src/app/shared/utils/utils'; import { ProjectListTableRowsComponent } from './project-list-table-rows/project-list-table-rows.component'; import { TableTemplateComponent } from 'src/app/shared/components/table-template/table-template.component'; @@ -28,14 +29,6 @@ import { LoggingService } from 'src/app/services/logging.service'; import { LoadingStateService } from 'src/app/services/loading-state.service'; import { withLoading } from 'src/app/shared/utils/rxjs-operators'; -interface Proponent { - id: string; - name: string; -} - -/** DEMI's page cap for signed-in reads; test holds about 300 proponents. */ -const PROPONENT_PAGE_SIZE = 1000; - class ProjectFilterObject { constructor( public type: Array = [], @@ -189,7 +182,7 @@ export class ProjectListComponent implements OnInit { private loadProponents(): Observable { return this.demiApi.list('organizations', { sort: 'name', - pageSize: PROPONENT_PAGE_SIZE, + pageSize: DEMI_MAX_PAGE_SIZE, filters: { companyType: 'Proponent/Certificate Holder' } }).pipe( map(page => page.rows), @@ -204,8 +197,7 @@ export class ProjectListComponent implements OnInit { return this.projectService.list({ page: this.tableParams.currentPage, pageSize: this.tableParams.pageSize, - // DEMI sorts projects by name only. - sort: this.tableParams.sortBy === '-name' ? '-name' : 'name', + sort: nativeSort(this.tableParams.sortBy, 'name'), q: this.tableParams.keywords || undefined, filters: this.filterForAPI }).pipe(withLoading(this.loadingState, 'project-list')); diff --git a/src/app/services/org.service.spec.ts b/src/app/services/org.service.spec.ts index fac665382..fe49d0212 100644 --- a/src/app/services/org.service.spec.ts +++ b/src/app/services/org.service.spec.ts @@ -3,7 +3,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { firstValueFrom } from 'rxjs'; -import { OrgService, orgSort } from './org.service'; +import { OrgService } from './org.service'; import { ConfigService } from './config.service'; import { KeycloakService } from './keycloak.service'; import { LoggingService } from './logging.service'; @@ -99,16 +99,6 @@ describe('OrgService', () => { }); }); - describe('orgSort', () => { - it('keeps a descending name sort', () => { - expect(orgSort('-name')).toBe('-name'); - }); - - it('falls back to name for a column DEMI cannot sort on', () => { - expect(orgSort('+companyType')).toBe('name'); - }); - }); - describe('writes', () => { it('sends an Eagle-shaped body unchanged on save', () => { const legacy = new Org({ _id: 'abc123', name: 'Acme', companyType: 'Ministry' }); diff --git a/src/app/services/org.service.ts b/src/app/services/org.service.ts index a8d82ee90..20b926242 100644 --- a/src/app/services/org.service.ts +++ b/src/app/services/org.service.ts @@ -14,11 +14,6 @@ import { withLoading } from 'src/app/shared/utils/rxjs-operators'; /** Loading-state key for {@link OrgService.listOrganizations}. */ export const ORG_LIST_LOADING = 'orgs-list'; -/** DEMI sorts organizations on `name` only, so any other table sort falls back to name ascending. */ -export function orgSort(sortBy: string | null | undefined): string { - return sortBy === '-name' ? '-name' : 'name'; -} - /** * Eagle write body for an organization. A body with `_id` is already Eagle-shaped and goes unchanged; * native input (`id` or, for a new row, no id at all) is converted. Temporary until writes move off eagle-api. diff --git a/src/app/shared/components/contact-select/contact-select.component.ts b/src/app/shared/components/contact-select/contact-select.component.ts index ef6ec3beb..4cd0914ac 100644 --- a/src/app/shared/components/contact-select/contact-select.component.ts +++ b/src/app/shared/components/contact-select/contact-select.component.ts @@ -6,10 +6,10 @@ import { TableObject, TableColumn } from '../table-template/table-object'; import { ActivatedRoute, Router } from '@angular/router'; import { SearchTerms } from 'src/app/models/search'; import { User } from 'src/app/models/user'; -import { User as DemiUser, nativeSort, tableSort } from 'src/app/models/demi/user'; +import { User as DemiUser } from 'src/app/models/demi/user'; import { StorageService } from 'src/app/services/storage.service'; import { NavigationStackUtils } from '../../utils/navigation-stack-utils'; -import { TableTemplateUtils, nextSortBy } from '../../utils/table-template-utils'; +import { TableTemplateUtils, nextSortBy, nativeSort, tableSort } from '../../utils/table-template-utils'; import { UserService, emptyPageOnError } from 'src/app/services/user.service'; import { LoggingService } from 'src/app/services/logging.service'; import { ToastService } from 'src/app/services/toast.service'; diff --git a/src/app/shared/components/link-organization/link-organization.component.ts b/src/app/shared/components/link-organization/link-organization.component.ts index dd8590102..293e85f7f 100644 --- a/src/app/shared/components/link-organization/link-organization.component.ts +++ b/src/app/shared/components/link-organization/link-organization.component.ts @@ -11,13 +11,13 @@ import { Organization } from 'src/app/models/demi/organization'; import { SearchTerms } from 'src/app/models/search'; import { StorageService } from 'src/app/services/storage.service'; import { NavigationStackUtils } from '../../utils/navigation-stack-utils'; -import { TableTemplateUtils, nextSortBy } from '../../utils/table-template-utils'; +import { TableTemplateUtils, nextSortBy, nativeSort } from '../../utils/table-template-utils'; import { TableObject, TableColumn } from '../table-template/table-object'; import { TableParamsObject } from '../table-template/table-params-object'; import { TableTemplateComponent } from '../table-template/table-template.component'; import { LoggingService } from 'src/app/services/logging.service'; import { LoadingStateService } from 'src/app/services/loading-state.service'; -import { OrgService, ORG_LIST_LOADING, orgSort } from 'src/app/services/org.service'; +import { OrgService, ORG_LIST_LOADING } from 'src/app/services/org.service'; @Component({ @@ -107,7 +107,7 @@ export class LinkOrganizationComponent implements OnInit, OnDestroy { page: this.tableParams.currentPage, pageSize: this.tableParams.pageSize, count: true, - sort: orgSort(this.tableParams.sortBy), + sort: nativeSort(this.tableParams.sortBy, 'name'), q: this.tableParams.keywords || undefined, filters: { companyType } }); diff --git a/src/app/shared/components/table-template/table-template.component.spec.ts b/src/app/shared/components/table-template/table-template.component.spec.ts index a2f083c86..2fd720b2c 100644 --- a/src/app/shared/components/table-template/table-template.component.spec.ts +++ b/src/app/shared/components/table-template/table-template.component.spec.ts @@ -14,7 +14,6 @@ class StubRowsComponent { const columns: TableColumn[] = [ { name: 'Name', value: 'name', width: '30%' }, { name: 'Date Posted', value: 'datePosted', width: '20%' }, - { name: 'Contact', value: 'lastName,+firstName', width: '30%' }, { name: 'Status', value: 'status', width: '20%', nosort: true } ]; @@ -82,12 +81,6 @@ describe('TableTemplateComponent headers', () => { expect(header(fixture, 'Date Posted').getAttribute('aria-sort')).toBe('descending'); }); - it('marks a column whose own value is multi-key when that sort is active', () => { - const fixture = render('+lastName,+firstName'); - - expect(header(fixture, 'Contact').getAttribute('aria-sort')).toBe('ascending'); - }); - it('renders a nosort column without a sort button', () => { expect(header(render('+name'), 'Status').querySelector('button')).toBeNull(); }); diff --git a/src/app/shared/components/table-template/table-template.component.ts b/src/app/shared/components/table-template/table-template.component.ts index e9841120c..77c486a59 100644 --- a/src/app/shared/components/table-template/table-template.component.ts +++ b/src/app/shared/components/table-template/table-template.component.ts @@ -72,13 +72,9 @@ export class TableTemplateComponent implements OnInit, OnChanges, OnDestroy { } // Value for aria-sort, null when unsorted (ARIA allows it on one header); also drives the header arrow. - // Whole sort matches multi-key columns (e.g. Contacts Name `lastName,+firstName`); falls back to the first key otherwise. + // A multi-key sort from an old URL (e.g. `-datePosted,+displayName`) marks its first key. public sortState(property: string): 'ascending' | 'descending' | null { - const whole = this.column ?? ''; - if (whole.slice(1) === property) { - return whole.charAt(0) === '+' ? 'ascending' : 'descending'; - } - const primary = whole.split(',')[0] ?? ''; + const primary = (this.column ?? '').split(',')[0]; if (primary.slice(1) !== property) { return null; } diff --git a/src/app/shared/utils/table-template-utils.spec.ts b/src/app/shared/utils/table-template-utils.spec.ts index 74a6894eb..837042900 100644 --- a/src/app/shared/utils/table-template-utils.spec.ts +++ b/src/app/shared/utils/table-template-utils.spec.ts @@ -1,4 +1,4 @@ -import { nextSortBy } from './table-template-utils'; +import { nativeSort, nextSortBy, tableSort } from './table-template-utils'; describe('nextSortBy', () => { it('flips an ascending sort on the same column to descending', () => { @@ -24,12 +24,32 @@ describe('nextSortBy', () => { it('starts ascending when the current sort is empty', () => { expect(nextSortBy('', 'name')).toBe('+name'); }); +}); + +describe('nativeSort', () => { + it('keeps a descending sort on the allowed field', () => { + expect(nativeSort('-displayName', 'displayName')).toBe('-displayName'); + }); + + it('sends an ascending table sort without the plus sign', () => { + expect(nativeSort('+displayName', 'displayName')).toBe('displayName'); + }); + + it('falls back to the allowed field for an old multi-column sort', () => { + expect(nativeSort('-lastName,+firstName', 'displayName')).toBe('displayName'); + }); + + it('falls back to the allowed field when there is no sort', () => { + expect(nativeSort(undefined, 'name')).toBe('name'); + }); +}); - it('flips a multi-key column sort on its first key', () => { - expect(nextSortBy('+lastName,+firstName', 'lastName,+firstName')).toBe('-lastName,+firstName'); +describe('tableSort', () => { + it('adds the plus sign the table header expects', () => { + expect(tableSort('displayName')).toBe('+displayName'); }); - it('flips a descending multi-key column sort back to ascending', () => { - expect(nextSortBy('-lastName,+firstName', 'lastName,+firstName')).toBe('+lastName,+firstName'); + it('keeps a descending sort as is', () => { + expect(tableSort('-name')).toBe('-name'); }); }); diff --git a/src/app/shared/utils/table-template-utils.ts b/src/app/shared/utils/table-template-utils.ts index 9c5359e66..3fbab6f77 100644 --- a/src/app/shared/utils/table-template-utils.ts +++ b/src/app/shared/utils/table-template-utils.ts @@ -9,6 +9,19 @@ export function nextSortBy(current: string, column: string): string { return current === `+${column}` ? `-${column}` : `+${column}`; } +/** + * DEMI `sort` for a kind that sorts by one `field` only: a table `sortBy` of `-` sorts descending, + * anything else (another column, an old URL) falls back to ascending `field`. + */ +export function nativeSort(sortBy: string | null | undefined, field: string): string { + return sortBy?.split(',')[0] === `-${field}` ? `-${field}` : field; +} + +/** Table `sortBy` for a DEMI sort, so the header arrow matches what the list was sorted by. */ +export function tableSort(sort: string): string { + return sort.startsWith('-') ? sort : `+${sort}`; +} + @Injectable({ providedIn: 'root' }) export class TableTemplateUtils { private platformLocation = inject(PlatformLocation); diff --git a/src/app/shared/utils/utils.spec.ts b/src/app/shared/utils/utils.spec.ts index cc7cd9d36..bbf8d1174 100644 --- a/src/app/shared/utils/utils.spec.ts +++ b/src/app/shared/utils/utils.spec.ts @@ -1,4 +1,4 @@ -import { createProjectTabModifiers, encodeString, filterCompareWith } from './utils'; +import { applicationDocumentFilters, encodeString, filterCompareWith } from './utils'; import { sanitizeHighlight } from './sanitize-highlight'; describe('Utils', () => { @@ -50,23 +50,28 @@ describe('filterCompareWith', () => { }); }); -describe('createProjectTabModifiers', () => { +describe('applicationDocumentFilters', () => { const lists = [ { id: 'type02', type: 'doctype', name: 'Application Materials', legislation: 2002 }, + { id: 'phase02', type: 'projectPhase', name: 'Application Review', legislation: 2002 }, + { id: 'mile02', type: 'label', name: 'Application Review', legislation: 2002 }, { id: 'mile18', type: 'label', name: 'Revised EAC Application', legislation: 2018 }, { id: 'amend02', type: 'projectPhase', name: 'Post Decision - Amendment', legislation: 2002 }, { id: 'early02', type: 'projectPhase', name: 'Early Engagement', legislation: 2002 } ]; - it('builds the query from List row ids', () => { - const modifier = createProjectTabModifiers(lists); - - expect(modifier.type.split(',')).toContain('type02'); - expect(modifier.milestone.split(',')).toContain('mile18'); + it('builds native filters from List row ids, leaving out terms with no List row', () => { + expect(applicationDocumentFilters(lists, 'demi-p1')).toEqual({ + project: 'demi-p1', + documentSource: 'PROJECT', + typeId: ['type02'], + milestoneId: ['mile02', 'mile18'], + projectPhaseId: ['phase02', 'early02'] + }); }); - it('leaves the amendment phase out of the project phases', () => { - expect(createProjectTabModifiers(lists).projectPhase).toBe('early02'); + it('sends no phase filter when the List has no phases', () => { + expect(applicationDocumentFilters([], 'demi-p1').projectPhaseId).toBeUndefined(); }); }); diff --git a/src/app/shared/utils/utils.ts b/src/app/shared/utils/utils.ts index 15aafd490..73cf22ff1 100644 --- a/src/app/shared/utils/utils.ts +++ b/src/app/shared/utils/utils.ts @@ -1,5 +1,6 @@ import { Constants } from './constants'; import { ISearchResults } from 'src/app/models/search'; +import { DemiDocumentFilters } from 'src/app/models/demi/document'; // Patch encodeURIComponent once at module load to also encode !, ', (, ), * const _origEncode = encodeURIComponent; @@ -90,14 +91,8 @@ export function natureBuildMapper(key: string): string { return natureObj ? natureObj.nature : key; } -function getIdsByName(terms: Array, list: Array) { - return terms.map(term => { - const listItem = list.find(item => item.name === term.name && item.legislation === term.legislation); - return { name: term.name, id: listItem?.id ?? null }; - }); -} - -export function createProjectTabModifiers(list: Array) { +/** `GET /documents` filters for the application sort screen: application materials and memos at the application milestones, outside amendment phases. */ +export function applicationDocumentFilters(list: Array, project: string): DemiDocumentFilters { const types = [ { legislation: 2002, name: 'Application Materials' }, { legislation: 2018, name: 'Application Materials' }, @@ -113,18 +108,22 @@ export function createProjectTabModifiers(list: Array) { { legislation: 2018, name: 'Post Decision - Amendment' } ]; - const amendmentPhaseIds = getIdsByName(applications, list).map(t => t.id); + // Match on type too: some names, such as 'Application Review', are both a phase and a milestone. + const ids = (type: string, terms: Array<{ legislation: number; name: string }>): string[] => list + .filter(item => item.type === type && terms.some(term => term.name === item.name && term.legislation === item.legislation)) + .map(item => item.id); + const amendmentPhaseIds = ids('projectPhase', applications); const phases = list .filter(item => item.type === 'projectPhase' && !amendmentPhaseIds.includes(item.id)) - .map(item => item.id) - .join(','); - - const typeIds = getIdsByName(types, list).map(t => t.id).join(','); - const milestoneIds = getIdsByName(milestones, list).map(m => m.id).join(','); + .map(item => item.id); - const queryModifier: any = { documentSource: 'PROJECT', type: typeIds, milestone: milestoneIds }; - if (phases) { queryModifier['projectPhase'] = phases; } - return queryModifier; + return { + project, + documentSource: 'PROJECT', + typeId: ids('doctype', types), + milestoneId: ids('label', milestones), + projectPhaseId: phases.length ? phases : undefined + }; } /** ng-select `compareWith` for filter options: regions by `code`, organizations by `_id`, List rows by `id`. */ diff --git a/yarn.lock b/yarn.lock index 4a64ff55c..f57385704 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3184,7 +3184,6 @@ __metadata: eslint: "npm:^10.6.0" jasmine-core: "npm:~6.3.0" jasmine-spec-reporter: "npm:~7.0.0" - jszip: "npm:^3.10.1" karma: "npm:~6.4.4" karma-chrome-launcher: "npm:~3.2.0" karma-cli: "npm:~2.0.0" @@ -3596,13 +3595,6 @@ __metadata: languageName: node linkType: hard -"core-util-is@npm:~1.0.0": - version: 1.0.3 - resolution: "core-util-is@npm:1.0.3" - checksum: 10c0/90a0e40abbddfd7618f8ccd63a74d88deea94e77d0e8dbbea059fa7ebebb8fbb4e2909667fe26f3a467073de1a542ebe6ae4c73a73745ac5833786759cd906c9 - languageName: node - linkType: hard - "cors@npm:^2.8.5, cors@npm:~2.8.5": version: 2.8.5 resolution: "cors@npm:2.8.5" @@ -4783,13 +4775,6 @@ __metadata: languageName: node linkType: hard -"immediate@npm:~3.0.5": - version: 3.0.6 - resolution: "immediate@npm:3.0.6" - checksum: 10c0/f8ba7ede69bee9260241ad078d2d535848745ff5f6995c7c7cb41cfdc9ccc213f66e10fa5afb881f90298b24a3f7344b637b592beb4f54e582770cdce3f1f039 - languageName: node - linkType: hard - "immutable@npm:^5.1.5": version: 5.1.9 resolution: "immutable@npm:5.1.9" @@ -4814,7 +4799,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inherits@npm:2, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -4932,13 +4917,6 @@ __metadata: languageName: node linkType: hard -"isarray@npm:~1.0.0": - version: 1.0.0 - resolution: "isarray@npm:1.0.0" - checksum: 10c0/18b5be6669be53425f0b84098732670ed4e727e3af33bc7f948aac01782110eb9a18b3b329c5323bcdd3acdaae547ee077d3951317e7f133bff7105264b3003d - languageName: node - linkType: hard - "isbinaryfile@npm:^4.0.8": version: 4.0.10 resolution: "isbinaryfile@npm:4.0.10" @@ -5135,18 +5113,6 @@ __metadata: languageName: node linkType: hard -"jszip@npm:^3.10.1": - version: 3.10.1 - resolution: "jszip@npm:3.10.1" - dependencies: - lie: "npm:~3.3.0" - pako: "npm:~1.0.2" - readable-stream: "npm:~2.3.6" - setimmediate: "npm:^1.0.5" - checksum: 10c0/58e01ec9c4960383fb8b38dd5f67b83ccc1ec215bf74c8a5b32f42b6e5fb79fada5176842a11409c4051b5b94275044851814a31076bf49e1be218d3ef57c863 - languageName: node - linkType: hard - "karma-chrome-launcher@npm:~3.2.0": version: 3.2.0 resolution: "karma-chrome-launcher@npm:3.2.0" @@ -5278,15 +5244,6 @@ __metadata: languageName: node linkType: hard -"lie@npm:~3.3.0": - version: 3.3.0 - resolution: "lie@npm:3.3.0" - dependencies: - immediate: "npm:~3.0.5" - checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808 - languageName: node - linkType: hard - "listr2@npm:10.2.1": version: 10.2.1 resolution: "listr2@npm:10.2.1" @@ -6059,13 +6016,6 @@ __metadata: languageName: node linkType: hard -"pako@npm:~1.0.2": - version: 1.0.11 - resolution: "pako@npm:1.0.11" - checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe - languageName: node - linkType: hard - "parse5-html-rewriting-stream@npm:8.0.1": version: 8.0.1 resolution: "parse5-html-rewriting-stream@npm:8.0.1" @@ -6235,13 +6185,6 @@ __metadata: languageName: node linkType: hard -"process-nextick-args@npm:~2.0.0": - version: 2.0.1 - resolution: "process-nextick-args@npm:2.0.1" - checksum: 10c0/bec089239487833d46b59d80327a1605e1c5287eaad770a291add7f45fda1bb5e28b38e0e061add0a1d0ee0984788ce74fa394d345eed1c420cacf392c554367 - languageName: node - linkType: hard - "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -6324,21 +6267,6 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:~2.3.6": - version: 2.3.8 - resolution: "readable-stream@npm:2.3.8" - dependencies: - core-util-is: "npm:~1.0.0" - inherits: "npm:~2.0.3" - isarray: "npm:~1.0.0" - process-nextick-args: "npm:~2.0.0" - safe-buffer: "npm:~5.1.1" - string_decoder: "npm:~1.1.1" - util-deprecate: "npm:~1.0.1" - checksum: 10c0/7efdb01f3853bc35ac62ea25493567bf588773213f5f4a79f9c365e1ad13bab845ac0dae7bc946270dc40c3929483228415e92a3fc600cc7e4548992f41ee3fa - languageName: node - linkType: hard - "readdirp@npm:^4.0.1": version: 4.1.2 resolution: "readdirp@npm:4.1.2" @@ -6653,13 +6581,6 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": - version: 5.1.2 - resolution: "safe-buffer@npm:5.1.2" - checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21 - languageName: node - linkType: hard - "safe-regex-test@npm:^1.1.0": version: 1.1.0 resolution: "safe-regex-test@npm:1.1.0" @@ -6762,13 +6683,6 @@ __metadata: languageName: node linkType: hard -"setimmediate@npm:^1.0.5": - version: 1.0.5 - resolution: "setimmediate@npm:1.0.5" - checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49 - languageName: node - linkType: hard - "setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" @@ -7092,15 +7006,6 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:~1.1.1": - version: 1.1.1 - resolution: "string_decoder@npm:1.1.1" - dependencies: - safe-buffer: "npm:~5.1.0" - checksum: 10c0/b4f89f3a92fd101b5653ca3c99550e07bdf9e13b35037e9e2a1c7b47cec4e55e06ff3fc468e314a0b5e80bfbaf65c1ca5a84978764884ae9413bec1fc6ca924e - languageName: node - linkType: hard - "strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -7384,13 +7289,6 @@ __metadata: languageName: node linkType: hard -"util-deprecate@npm:~1.0.1": - version: 1.0.2 - resolution: "util-deprecate@npm:1.0.2" - checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942 - languageName: node - linkType: hard - "utils-merge@npm:1.0.1": version: 1.0.1 resolution: "utils-merge@npm:1.0.1" From 432d22d1c5de877a39268f1145f36878674a198f Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 17:27:48 +0000 Subject: [PATCH 3/3] test(project-notifications): cover project list --- ...dit-project-notification.component.spec.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/app/project-notifications/add-edit-project-notification/add-edit-project-notification.component.spec.ts diff --git a/src/app/project-notifications/add-edit-project-notification/add-edit-project-notification.component.spec.ts b/src/app/project-notifications/add-edit-project-notification/add-edit-project-notification.component.spec.ts new file mode 100644 index 000000000..583582340 --- /dev/null +++ b/src/app/project-notifications/add-edit-project-notification/add-edit-project-notification.component.spec.ts @@ -0,0 +1,96 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { Observable, of, throwError } from 'rxjs'; +import { AddEditProjectNotificationComponent } from './add-edit-project-notification.component'; +import { DemiList } from 'src/app/models/demi/demi-list'; +import { DemiProject } from 'src/app/models/demi/project'; +import { ConfigService } from 'src/app/services/config.service'; +import { LoggingService } from 'src/app/services/logging.service'; +import { NotificationProjectService } from 'src/app/services/notification-project.service'; +import { ProjectService } from 'src/app/services/project.service'; +import { StorageService } from 'src/app/services/storage.service'; +import { ToastService } from 'src/app/services/toast.service'; + +describe('AddEditProjectNotificationComponent', () => { + let projectService: jasmine.SpyObj; + let toastService: jasmine.SpyObj; + let fixture: ComponentFixture; + + // `id` is the Track id; `eagleId` is set only on projects mirrored from Eagle. + const project = (id: string, name: string, eagleId?: string | null): DemiProject => ({ id, name, eagleId }); + const rows = [ + project('track-1', 'Alpha Mine', 'e1'), + project('track-2', 'Track Only Dam'), + project('track-3', 'Null Id Road', null), + project('track-4', 'Gamma Pipeline', 'e4') + ]; + + // Edit route on a notification referred to the Minister, so the template renders the project select. + function create(list: Observable>, associatedProjectId = '') { + projectService = jasmine.createSpyObj('ProjectService', ['list']); + projectService.list.and.returnValue(list); + toastService = jasmine.createSpyObj('ToastService', ['success', 'error']); + + TestBed.configureTestingModule({ + imports: [AddEditProjectNotificationComponent], + providers: [ + provideRouter([]), + { provide: ActivatedRoute, useValue: { url: of([{ path: 'edit' }]) } }, + { provide: ProjectService, useValue: projectService }, + { provide: ToastService, useValue: toastService }, + { provide: NotificationProjectService, useValue: jasmine.createSpyObj('NotificationProjectService', ['add', 'save']) }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['warn', 'error', 'info', 'debug']) }, + { provide: ConfigService, useValue: { regions: [] } } + ] + }); + TestBed.inject(StorageService).state = { + type: 'currentProject', + data: { + type: 'currentProjectNotification', + data: { + _id: 'pn1', name: 'Notice', read: ['staff'], type: 'Mines', trigger: '', centroid: [50, -120], + decision: 'Referred to Minister for Designation Decision', decisionDate: null, + notificationReceivedDate: null, associatedProjectId + } + } + }; + fixture = TestBed.createComponent(AddEditProjectNotificationComponent); + fixture.detectChanges(); + } + + const select = () => fixture.nativeElement.querySelector('select#project') as HTMLSelectElement; + const optionNames = () => Array.from(select().options).map(option => option.textContent.trim()); + + it('requests one page of up to 1000 projects', () => { + create(of({ rows })); + + expect(projectService.list).toHaveBeenCalledOnceWith({ pageSize: 1000 }); + }); + + it('offers only projects that have an Eagle id', () => { + create(of({ rows })); + + expect(optionNames()).toEqual(['None', 'Alpha Mine', 'Gamma Pipeline']); + }); + + it('maps each project to its Eagle id and name', () => { + create(of({ rows })); + + expect(fixture.componentInstance.projects).toEqual([ + { _id: 'e1', name: 'Alpha Mine' }, + { _id: 'e4', name: 'Gamma Pipeline' } + ]); + }); + + it('preselects the associated project stored by Eagle id', () => { + create(of({ rows }), 'e4'); + + expect(select().selectedOptions[0].textContent.trim()).toBe('Gamma Pipeline'); + }); + + it('shows an error toast when the project list fails', () => { + create(throwError(() => new Error('DEMI down'))); + + expect(toastService.error).toHaveBeenCalledOnceWith('Failed to load projects.'); + }); +});