From 8d42efc7c7569402ffc669e041e31d151cf6a587 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 03:19:35 +0000 Subject: [PATCH 1/2] feat(comment-periods): read periods from DEMI - List, resolver and details read DEMI comment periods. The list filters on the DEMI project id (notification id for notification periods) and sorts only on dateStarted or dateCompleted. - Comment export calls DEMI /commentperiods/:id/comments/export; the proponent file sends audience=proponent. - ENGAGE periods, periods with no Eagle id and tokens without the staff role are read-only. --- .../comment-period-details-tab.component.html | 12 +- ...mment-period-details-tab.component.spec.ts | 102 ++++++++++++++++ .../comment-period-details-tab.component.ts | 42 +++++-- .../comment-period.component.ts | 4 +- .../add-edit-comment-period.component.ts | 10 +- .../comment-period-legacy.spec.ts | 62 ++++++++++ .../comment-periods/comment-period-legacy.ts | 39 ++++++ .../comment-periods.component.spec.ts | 113 +++++++++++++----- .../comment-periods.component.ts | 82 +++++++------ src/app/models/demi/comment-period.fixture.ts | 13 ++ .../resolvers/comment-period.resolver.spec.ts | 78 ++++++++++++ src/app/resolvers/comment-period.resolver.ts | 18 +-- .../comment-stats/comment-stats.component.ts | 7 +- 13 files changed, 492 insertions(+), 90 deletions(-) create mode 100644 src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.spec.ts create mode 100644 src/app/comment-periods/comment-period-legacy.spec.ts create mode 100644 src/app/comment-periods/comment-period-legacy.ts create mode 100644 src/app/models/demi/comment-period.fixture.ts create mode 100644 src/app/resolvers/comment-period.resolver.spec.ts 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 ff4e6ec78..86dda5bd7 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 @@ -19,14 +19,18 @@

DATE RANGE: 

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 new file mode 100644 index 000000000..ece90af0c --- /dev/null +++ b/src/app/comment-period/comment-period-details-tabs/comment-period-details-tab.component.spec.ts @@ -0,0 +1,102 @@ +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 { CommentPeriodDetailsTabComponent } from './comment-period-details-tab.component'; +import { ConfigService } from 'src/app/services/config.service'; +import { DocumentService } from 'src/app/services/document.service'; +import { LoggingService } from 'src/app/services/logging.service'; +import { StorageService } from 'src/app/services/storage.service'; +import { ToastService } from 'src/app/services/toast.service'; +import { toLegacyCommentPeriod } from 'src/app/comment-periods/comment-period-legacy'; +import { nativeCommentPeriod } from 'src/app/models/demi/comment-period.fixture'; +import { DemiCommentPeriod } from 'src/app/models/demi/comment-period'; + +const EXPORT_URL = '/demi/api/commentperiods/engage-7/comments/export'; + +describe('CommentPeriodDetailsTabComponent', () => { + let fixture: ComponentFixture; + let http: HttpTestingController; + let link: jasmine.Spy; + + function create(row: Partial = {}) { + TestBed.configureTestingModule({ + imports: [CommentPeriodDetailsTabComponent], + providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { getDemiApiPath: () => '/demi/api', lists: [] } }, + { provide: DocumentService, useValue: jasmine.createSpyObj('DocumentService', ['getByMultiId']) }, + { 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 } } } } + ] + }); + http = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(CommentPeriodDetailsTabComponent); + const period = toLegacyCommentPeriod(nativeCommentPeriod({ id: 'engage-7', ...row }), ['sysadmin']); + fixture.componentRef.setInput('commentPeriod', period); + fixture.componentRef.setInput('project', { _id: '59679ade3acbf9001de7ea8b', name: 'Big Mine' }); + fixture.detectChanges(); + } + + function menuItems(): string[] { + fixture.nativeElement.querySelector('#actionDropdown').click(); + fixture.detectChanges(); + const items: HTMLElement[] = Array.from(document.querySelectorAll('[ngbDropdownItem]')); + return items.map(item => item.textContent.trim()); + } + + beforeEach(() => { + link = spyOn(HTMLAnchorElement.prototype, 'click'); + spyOn(URL, 'createObjectURL').and.returnValue('blob:csv'); + spyOn(URL, 'revokeObjectURL'); + }); + + afterEach(() => http.verify()); + + describe('export', () => { + beforeEach(() => create()); + + it('asks DEMI for the staff file by the DEMI period id, with no audience', () => { + fixture.componentInstance.exportCommentsForStaff(); + + const req = http.expectOne(r => r.url === EXPORT_URL); + expect(req.request.params.keys()).toEqual([]); + expect(req.request.responseType).toBe('blob'); + req.flush(new Blob(['a,b'])); + expect((link.calls.mostRecent().object as HTMLAnchorElement).download).toMatch(/^Big_Mine-eao-.*\.csv$/); + }); + + it('asks DEMI for the proponent file with audience=proponent', () => { + fixture.componentInstance.exportCommentsForProponents(); + + const req = http.expectOne(r => r.url === EXPORT_URL); + expect(req.request.params.get('audience')).toBe('proponent'); + req.flush(new Blob(['a,b'])); + expect((link.calls.mostRecent().object as HTMLAnchorElement).download).toMatch(/^Big_Mine-proponent-.*\.csv$/); + }); + + it('tells the user when the export fails', () => { + fixture.componentInstance.exportCommentsForStaff(); + http.expectOne(r => r.url === EXPORT_URL).flush(new Blob(), { status: 500, statusText: 'Server Error' }); + + expect(TestBed.inject(ToastService).error).toHaveBeenCalledWith('Failed to export comments.'); + expect(link).not.toHaveBeenCalled(); + }); + }); + + describe('actions', () => { + it('offers edit and publish on an Eagle period', () => { + create(); + expect(menuItems()).toEqual(jasmine.arrayContaining(['Edit', 'Un-Publish'])); + }); + + it('offers only the exports on an ENGAGE period', () => { + create({ sourceSystem: 'engage' }); + expect(menuItems()).toEqual(['Export All Comments', 'Export Comments For Proponents']); + }); + }); +}); 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 fcd5aced2..a73465678 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 @@ -1,9 +1,12 @@ import { Component, OnInit, ChangeDetectorRef, DestroyRef, inject, ChangeDetectionStrategy, input } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DatePipe } from '@angular/common'; import { ToastService } from 'src/app/services/toast.service'; import { Router, RouterModule } from '@angular/router'; -import { CommentPeriod } from 'src/app/models/commentPeriod'; +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 { DocumentService } from 'src/app/services/document.service'; import { CommentPeriodService } from 'src/app/services/commentperiod.service'; import { StorageService } from 'src/app/services/storage.service'; @@ -31,6 +34,8 @@ import { LoggingService } from 'src/app/services/logging.service'; export class CommentPeriodDetailsTabComponent implements OnInit { private commentPeriodService = inject(CommentPeriodService); private documentService = inject(DocumentService); + private http = inject(HttpClient); + private configService = inject(ConfigService); private router = inject(Router); private toastService = inject(ToastService); private storageService = inject(StorageService); @@ -38,7 +43,7 @@ export class CommentPeriodDetailsTabComponent implements OnInit { private _cdr = inject(ChangeDetectorRef); private destroyRef = inject(DestroyRef); - commentPeriod = input.required(); + commentPeriod = input.required(); project = input.required(); public commentPeriodPublishedStatus: string; @@ -143,14 +148,28 @@ export class CommentPeriodDetailsTabComponent implements OnInit { } public exportCommentsForStaff() { - // Export comments with fields relevant to staff to CSV - this.toastService.info('Download Initiated'); - this.documentService.exportComments(this.commentPeriod()._id, this.projectName, 'staff'); + this.exportComments('eao'); } + public exportCommentsForProponents() { - // Export comments with fields relevant to proponents to CSV + this.exportComments('proponent'); + } + + /** DEMI sends staff columns unless `audience=proponent` asks for the proponent file. */ + private exportComments(audience: 'eao' | 'proponent') { this.toastService.info('Download Initiated'); - this.documentService.exportComments(this.commentPeriod()._id, this.projectName, 'proponent'); + const periodId = encodeURIComponent(this.commentPeriod().demiId); + const url = `${this.configService.getDemiApiPath()}/commentperiods/${periodId}/comments/export`; + const params: Record = audience === 'proponent' ? { audience } : {}; + this.http.get(url, { params, responseType: 'blob' }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: blob => saveBlob(blob, encodeString(`${this.projectName.split(' ').join('_')}-${audience}-${formatDate(new Date())}.csv`, true)), + error: err => { + this.logger.error(`Failed to export comments for period ${periodId}`, 'CommentPeriodDetailsTabComponent', err); + this.toastService.error('Failed to export comments.'); + } + }); } public downloadDocument(document) { @@ -168,3 +187,12 @@ 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/comment-period.component.ts b/src/app/comment-period/comment-period.component.ts index 477a3b04f..e2f96ba1f 100644 --- a/src/app/comment-period/comment-period.component.ts +++ b/src/app/comment-period/comment-period.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit, input, inject, ChangeDetectionStrategy} from '@angular/core'; import { ActivatedRoute, RouterModule } from '@angular/router'; -import { CommentPeriod } from '../models/commentPeriod'; +import { LegacyCommentPeriod } from '../comment-periods/comment-period-legacy'; import { StorageService } from '../services/storage.service'; import { DatePipe } from '@angular/common'; import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; @@ -26,7 +26,7 @@ export class CommentPeriodComponent implements OnInit { private route = inject(ActivatedRoute); project = input.required(); - commentPeriod = input.required(); + commentPeriod = input.required(); public baseRouteUrl: string; public selectedTab = 0; 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 5517ca3dd..1630a2240 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 @@ -10,6 +10,7 @@ import { EditorModule } from '@tinymce/tinymce-angular'; import { SafeHtmlPipe } from 'src/app/shared/pipes/safe-html-converter.pipe'; import { CommentPeriod } from 'src/app/models/commentPeriod'; +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'; @@ -50,7 +51,7 @@ export class AddEditCommentPeriodComponent implements OnInit { private logger = inject(LoggingService); project = input.required(); - commentPeriod = input(undefined); + commentPeriod = input(undefined); public componentBaseUrl: string; public currentCommentPeriod = new CommentPeriod; public milestones: any[] = []; @@ -102,7 +103,10 @@ export class AddEditCommentPeriodComponent implements OnInit { if (segment.path === 'edit') { this.isEditing = true; const cp = this.commentPeriod(); - if (cp) { + if (cp?.readOnly) { + this.toastService.error('This comment period cannot be edited here.'); + this.router.navigate([this.componentBaseUrl, this.project()._id, 'cp', cp._id]); + } else if (cp) { this.currentCommentPeriod = cp; this.initSelectedDocs(); this.initForm(); @@ -281,7 +285,7 @@ export class AddEditCommentPeriodComponent implements OnInit { // Submit if (this.isEditing) { - this.commentPeriodService.save(this.currentCommentPeriod) + this.commentPeriodService.save(toEagleSaveBody(this.currentCommentPeriod)) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ error: () => { diff --git a/src/app/comment-periods/comment-period-legacy.spec.ts b/src/app/comment-periods/comment-period-legacy.spec.ts new file mode 100644 index 000000000..fec715a6e --- /dev/null +++ b/src/app/comment-periods/comment-period-legacy.spec.ts @@ -0,0 +1,62 @@ +import { toEagleSaveBody, toLegacyCommentPeriod } from './comment-period-legacy'; +import { nativeCommentPeriod } from '../models/demi/comment-period.fixture'; + +const STAFF = ['sysadmin', 'offline_access']; + +describe('toLegacyCommentPeriod', () => { + it('keys the period on its Eagle id and keeps the DEMI id beside it', () => { + const period = toLegacyCommentPeriod(nativeCommentPeriod({ id: 'engage-7', eagleId: '598b6eb61ecbc9001dfeba56' }), STAFF); + expect(period._id).toBe('598b6eb61ecbc9001dfeba56'); + expect(period.demiId).toBe('engage-7'); + }); + + it('routes under the Eagle project id', () => { + expect(toLegacyCommentPeriod(nativeCommentPeriod(), STAFF).project).toBe('59679ade3acbf9001de7ea8b'); + }); + + it('routes a notification-parented period under its notification id', () => { + const row = nativeCommentPeriod({ projectId: '5f0a6c1b2e3f4a5b6c7d8e01', projectEagleId: undefined }); + expect(toLegacyCommentPeriod(row, STAFF).project).toBe('5f0a6c1b2e3f4a5b6c7d8e01'); + }); + + it('fills userCan from the token roles, not the row', () => { + expect(toLegacyCommentPeriod(nativeCommentPeriod({ userCan: 'stored' }), STAFF).userCan).toBe('sysadmin,offline_access'); + }); + + it('lets staff edit an Eagle period', () => { + expect(toLegacyCommentPeriod(nativeCommentPeriod(), STAFF).readOnly).toBeFalse(); + }); + + it('is read-only for a token without the staff role', () => { + expect(toLegacyCommentPeriod(nativeCommentPeriod(), ['offline_access']).readOnly).toBeTrue(); + }); + + it('is read-only when ENGAGE owns the period, even with an Eagle id', () => { + expect(toLegacyCommentPeriod(nativeCommentPeriod({ sourceSystem: 'engage' }), STAFF).readOnly).toBeTrue(); + }); + + it('is read-only when the period has no Eagle id, but still routes on its DEMI id', () => { + const period = toLegacyCommentPeriod(nativeCommentPeriod({ id: 'engage-7', eagleId: undefined }), STAFF); + expect(period.readOnly).toBeTrue(); + expect(period._id).toBe('engage-7'); + }); + + it('is read-only when the row was read without staff fields', () => { + const { milestone, ...row } = nativeCommentPeriod(); + void milestone; + expect(toLegacyCommentPeriod(row, STAFF).readOnly).toBeTrue(); + }); + + it('keeps token roles and screen fields out of the eagle-api save body', () => { + const body = toEagleSaveBody(toLegacyCommentPeriod(nativeCommentPeriod(), STAFF)); + expect(Object.keys(body)).not.toContain('userCan'); + expect(Object.keys(body)).not.toContain('demiId'); + expect(Object.keys(body)).not.toContain('readOnly'); + expect(body._id).toBe('598b6eb61ecbc9001dfeba55'); + }); + + it('works out the status the screens show from the native dates', () => { + const period = toLegacyCommentPeriod(nativeCommentPeriod({ dateStarted: '2020-01-01', dateCompleted: '2020-02-01' })); + expect(period.commentPeriodStatus).toBe('Closed'); + }); +}); diff --git a/src/app/comment-periods/comment-period-legacy.ts b/src/app/comment-periods/comment-period-legacy.ts new file mode 100644 index 000000000..4b03cf39c --- /dev/null +++ b/src/app/comment-periods/comment-period-legacy.ts @@ -0,0 +1,39 @@ +import { CommentPeriod } from '../models/commentPeriod'; +import { DemiCommentPeriod } from '../models/demi/comment-period'; +import { isEagleId } from '../models/demi/demi-ids'; + +/** The realm role the admin app signs staff in with (see `KeycloakService.isAuthorized`). */ +export const STAFF_ROLE = 'sysadmin'; + +/** + * A legacy `CommentPeriod` built from a DEMI row. `_id` is the Eagle id (or the DEMI id when there is none, so routes + * still resolve), `project` the Eagle project or notification id the routes use, `demiId` the DEMI id. + */ +export type LegacyCommentPeriod = CommentPeriod & { demiId: string; readOnly: boolean }; + +/** + * Builds the legacy `CommentPeriod` the period screens read. `userCan` lists the caller's token roles: DEMI does not + * work it out per caller. A period is read-only when ENGAGE owns it, it has no Eagle id, it was read without staff + * fields, or the caller lacks the staff role. + */ +export function toLegacyCommentPeriod(row: DemiCommentPeriod, roles: string[] = []): LegacyCommentPeriod { + const { id, eagleId, projectId, projectEagleId, ...rest } = row; + const writeId = eagleId ?? (isEagleId(id) ? id : null); + const period = new CommentPeriod({ + ...rest, + _id: writeId ?? id, + project: projectEagleId ?? projectId, + userCan: roles.join(',') + }); + const readOnly = row.sourceSystem === 'engage' || !writeId || !('milestone' in row) || !roles.includes(STAFF_ROLE); + return Object.assign(period, { demiId: id, readOnly }); +} + +/** The eagle-api save body: drops the screen-only fields, and `userCan`, which holds token roles here. */ +export function toEagleSaveBody(period: CommentPeriod): CommentPeriod { + const body: Partial = { ...period }; + delete body.demiId; + delete body.readOnly; + delete body.userCan; + return body as CommentPeriod; +} diff --git a/src/app/comment-periods/comment-periods.component.spec.ts b/src/app/comment-periods/comment-periods.component.spec.ts index 44502ab0b..9f27d6e3e 100644 --- a/src/app/comment-periods/comment-periods.component.spec.ts +++ b/src/app/comment-periods/comment-periods.component.spec.ts @@ -1,70 +1,125 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, TestRequest, provideHttpClientTesting } from '@angular/common/http/testing'; import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { CommentPeriodsComponent } from './comment-periods.component'; -import { CommentPeriodService } from '../services/commentperiod.service'; +import { ConfigService } from '../services/config.service'; +import { LoggingService } from '../services/logging.service'; import { StorageService } from '../services/storage.service'; +import { ToastService } from '../services/toast.service'; +import { DemiCommentPeriod } from '../models/demi/comment-period'; +import { nativeCommentPeriod } from '../models/demi/comment-period.fixture'; -function createFixture(periods: object[]): ComponentFixture { +const EAGLE_PROJECT = '59679ade3acbf9001de7ea8b'; +const DEMI_PROJECT = '207'; + +let http: HttpTestingController; + +function createFixture(routeParams: object = {}, parent: { params: object; data?: object } = { + params: { projId: EAGLE_PROJECT }, data: { project: { _id: EAGLE_PROJECT, demiId: DEMI_PROJECT } } +}): ComponentFixture { // Keep the Karma page URL untouched when the table utils rewrite the address bar. spyOn(window.history, 'replaceState'); TestBed.configureTestingModule({ imports: [CommentPeriodsComponent], providers: [ provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error', 'debug']) }, + { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error']) }, { provide: ActivatedRoute, - useValue: { params: of({}), parent: { snapshot: { paramMap: convertToParamMap({ projId: 'p1' }) } } } - }, - { - provide: CommentPeriodService, - useValue: { getAllByProjectId: () => of({ totalCount: periods.length, data: periods }), getSummaryById: () => of({}) } + useValue: { params: of(routeParams), parent: { snapshot: { paramMap: convertToParamMap(parent.params), data: parent.data ?? {} } } } }, - { provide: StorageService, useValue: { state: { currentProject: { type: 'currentProject', data: { _id: 'p1', name: 'Mine' } } } } } + { provide: StorageService, useValue: { state: { currentProject: { type: 'currentProject', data: { _id: EAGLE_PROJECT, name: 'Mine' } } } } } ] }); + http = TestBed.inject(HttpTestingController); const fixture = TestBed.createComponent(CommentPeriodsComponent); fixture.detectChanges(); return fixture; } -const basePeriod = { - _id: 'cp1', project: 'p1', read: ['public'], commentPeriodStatus: 'Open', - dateStarted: '2026-09-01', dateCompleted: '2026-09-30', daysRemaining: '6 days' -}; +function listRequest(): TestRequest { + return http.expectOne(req => req.url === '/demi/api/comment-periods'); +} -describe('CommentPeriodsComponent headers', () => { - let fixture: ComponentFixture; +function show(fixture: ComponentFixture, rows: DemiCommentPeriod[]) { + listRequest().flush(rows, { headers: { 'x-total-count': String(rows.length) } }); + fixture.detectChanges(); + http.match(req => req.url.endsWith('/summary')).forEach(req => req.flush(null)); +} - beforeEach(() => { - fixture = createFixture([basePeriod]); +describe('CommentPeriodsComponent list query', () => { + it('asks DEMI for the project periods by DEMI project id, newest first', () => { + createFixture(); + const params = listRequest().request.params; + expect(params.get('project')).toBe(DEMI_PROJECT); + expect(params.get('sort')).toBe('-dateStarted'); + expect(params.get('page')).toBe('1'); + expect(params.get('pageSize')).toBe('10'); + expect(params.get('count')).toBe('true'); + }); + + it('asks for a notification periods by the notification id', () => { + createFixture({}, { params: { notificationProjectId: '5f0a6c1b2e3f4a5b6c7d8e01' } }); + expect(listRequest().request.params.get('project')).toBe('5f0a6c1b2e3f4a5b6c7d8e01'); }); + it('sends an ascending sort without the plus sign, which DEMI would read as a space', () => { + createFixture({ sortBy: '+dateCompleted', currentPage: 2, pageSize: 25 }); + const params = listRequest().request.params; + expect(params.get('sort')).toBe('dateCompleted'); + expect(params.get('page')).toBe('2'); + expect(params.get('pageSize')).toBe('25'); + }); + + it('falls back to the default sort for a column DEMI cannot sort on', () => { + createFixture({ sortBy: '-isPublished' }); + expect(listRequest().request.params.get('sort')).toBe('-dateStarted'); + }); +}); + +describe('CommentPeriodsComponent table', () => { + let fixture: ComponentFixture; + function header(name: string): HTMLElement { const cells: HTMLElement[] = Array.from(fixture.nativeElement.querySelectorAll('thead th')); return cells.find(th => th.textContent.trim() === name); } + function cells(label: string): string[] { + const tds: HTMLElement[] = Array.from(fixture.nativeElement.querySelectorAll(`td[data-label="${label}"]`)); + return tds.map(td => td.textContent.trim()); + } + + beforeEach(() => { + fixture = createFixture(); + show(fixture, [ + nativeCommentPeriod({ id: 'cp1', isPublished: false }), + nativeCommentPeriod({ id: 'cp2', isPublished: true, dateStarted: '2020-01-01', dateCompleted: '2020-02-01' }) + ]); + }); + + afterEach(() => http.verify()); + it('renders Days Remaining as plain text with no sort button', () => { expect(header('Days Remaining').querySelector('button')).toBeNull(); }); + it('renders Published as plain text, since DEMI cannot sort on it', () => { + expect(header('Published').querySelector('button')).toBeNull(); + }); + it('still renders End Date as a sort button', () => { expect(header('End Date').querySelector('button')).not.toBeNull(); }); -}); - -describe('CommentPeriodsComponent Published cell', () => { - function publishedCells(periods: object[]): string[] { - const fixture = createFixture(periods); - const cells: HTMLElement[] = Array.from(fixture.nativeElement.querySelectorAll('td[data-label="Published"]')); - return cells.map(td => td.textContent.trim()); - } - it('follows isPublished, not read, so the cell matches the sort key', () => { - expect(publishedCells([ - { ...basePeriod, _id: 'cp1', read: ['public'], isPublished: null }, - { ...basePeriod, _id: 'cp2', read: ['staff'], isPublished: true } - ])).toEqual(['Not Published', 'Published']); + it('shows the published state and status of each native row', () => { + expect(cells('Published')).toEqual(['Not Published', 'Published']); + expect(cells('Status')[1]).toBe('Closed'); }); }); diff --git a/src/app/comment-periods/comment-periods.component.ts b/src/app/comment-periods/comment-periods.component.ts index 2b283ac0b..e899eb565 100644 --- a/src/app/comment-periods/comment-periods.component.ts +++ b/src/app/comment-periods/comment-periods.component.ts @@ -1,8 +1,9 @@ import { ActivatedRoute } from '@angular/router'; import { Component, OnInit, ChangeDetectorRef, inject, signal, computed, ChangeDetectionStrategy, DestroyRef} from '@angular/core'; +import { Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { CommentPeriod } from '../models/commentPeriod'; +import { LegacyCommentPeriod, toLegacyCommentPeriod } from './comment-period-legacy'; import { CommentPeriodService } from '../services/commentperiod.service'; import { StorageService } from '../services/storage.service'; import { TableObject, TableColumn } from '../shared/components/table-template/table-object'; @@ -13,6 +14,11 @@ import { CommentPeriodsTableRowsComponent } from './comment-periods-table-rows/c import { RouterModule } from '@angular/router'; import { TableTemplateComponent } from '../shared/components/table-template/table-template.component'; import { LoadingStateService } from '../services/loading-state.service'; +import { DemiCommentPeriod } from '../models/demi/comment-period'; +import { DemiList } from '../models/demi/demi-list'; + +const SORT_FIELDS = ['dateStarted', 'dateCompleted']; +const DEFAULT_SORT = '-dateStarted'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -32,7 +38,7 @@ export class CommentPeriodsComponent implements OnInit { private tableTemplateUtils = inject(TableTemplateUtils); private destroyRef = inject(DestroyRef); - public commentPeriods: CommentPeriod[] = null; + public commentPeriods: LegacyCommentPeriod[] = null; public commentPeriodTableColumns: TableColumn[] = [ { name: 'Status', @@ -61,7 +67,9 @@ export class CommentPeriodsComponent implements OnInit { { name: 'Published', value: 'isPublished', - width: '15%' + width: '15%', + // DEMI sorts comment periods on dateStarted and dateCompleted only. + nosort: true }, { name: 'Comment Data', @@ -73,11 +81,12 @@ export class CommentPeriodsComponent implements OnInit { public commentPeriodTableData: TableObject; public loadingState = inject(LoadingStateService); + /** DEMI project id, or the notification id for a notification's periods. */ private currentProjId = signal(null); public loading = computed(() => { const projId = this.currentProjId(); if (!projId) return false; - return this.loadingState.isOperationLoading('comment-periods-' + projId); + return this.loadingState.isOperationLoading('comment-periods-native-' + projId); }); public currentProject; public baseRouteUrl: string; @@ -96,31 +105,14 @@ export class CommentPeriodsComponent implements OnInit { this.route.params.pipe( switchMap(params => { this.tableParams = this.tableTemplateUtils.getParamsFromUrl(params); - if (this.tableParams.sortBy === '') { - this.tableParams.sortBy = '-dateStarted'; + if (!SORT_FIELDS.includes(this.tableParams.sortBy?.replace(/^[-+]/, ''))) { + this.tableParams.sortBy = DEFAULT_SORT; } - - const projectId = this.route.parent.snapshot.paramMap.get('projId') - || this.route.parent.snapshot.paramMap.get('notificationProjectId'); - this.currentProjId.set(projectId); - const pageNum = this.tableParams.currentPage || 1; - const pageSize = this.tableParams.pageSize || 10; - const sortBy = this.tableParams.sortBy || '-dateStarted'; - - return this.commentPeriodService.getAllByProjectId(projectId, pageNum, pageSize, sortBy); + this.currentProjId.set(this.parentId()); + return this.listPeriods(); }), takeUntilDestroyed(this.destroyRef) - ).subscribe((res: any) => { - if (res) { - this.tableParams.totalListItems = res.totalCount; - this.commentPeriods = res.totalCount > 0 ? res.data : []; - } else { - this.tableParams.totalListItems = 0; - this.commentPeriods = []; - } - this.setCPRowData(); - this._changeDetectionRef.markForCheck(); - }); + ).subscribe(page => this.showPage(page)); this.storageService.state.selectedTab = 0; } @@ -142,9 +134,8 @@ export class CommentPeriodsComponent implements OnInit { metURLAdmin: commentPeriod.metURLAdmin, // Same field the Published column sorts on in the API. read: commentPeriod.isPublished ? 'Published' : 'Not Published', - // TODO: Figure out pending, deferred, published, rejected - // commmentData: _id: commentPeriod._id, + demiId: commentPeriod.demiId, project: commentPeriod.project } ); @@ -163,16 +154,39 @@ export class CommentPeriodsComponent implements OnInit { this.tableParams = this.tableTemplateUtils.updateTableParams(this.tableParams, pageNumber, this.tableParams.sortBy); - this.commentPeriodService.getAllByProjectId(this.currentProject.data._id, pageNumber, this.tableParams.pageSize, this.tableParams.sortBy) + this.listPeriods() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((res: any) => { - this.tableParams.totalListItems = res.totalCount; - this.commentPeriods = res.data; + .subscribe(page => { this.tableTemplateUtils.updateUrl(this.tableParams.sortBy, this.tableParams.currentPage, this.tableParams.pageSize); - this.setCPRowData(); - this._changeDetectionRef.markForCheck(); + this.showPage(page); }); } + /** Periods store the DEMI project id; a notification's periods store the notification id, which is its route id. */ + private parentId(): string | null { + const parent = this.route.parent.snapshot; + return parent.paramMap.get('notificationProjectId') ?? parent.data?.['project']?.demiId ?? null; + } + + private listPeriods(): Observable> { + const projectId = this.currentProjId(); + if (!projectId) { + return of({ rows: [], total: 0 }); + } + return this.commentPeriodService.listByProject(projectId, { + page: this.tableParams.currentPage || 1, + pageSize: this.tableParams.pageSize || 10, + // A leading `+` would reach DEMI as a space. + sort: this.tableParams.sortBy.replace(/^\+/, '') + }); + } + + private showPage(page: DemiList) { + this.commentPeriods = page.rows.map(row => toLegacyCommentPeriod(row)); + this.tableParams.totalListItems = page.total ?? this.commentPeriods.length; + this.setCPRowData(); + this._changeDetectionRef.markForCheck(); + } + } diff --git a/src/app/models/demi/comment-period.fixture.ts b/src/app/models/demi/comment-period.fixture.ts new file mode 100644 index 000000000..0fa26ba8f --- /dev/null +++ b/src/app/models/demi/comment-period.fixture.ts @@ -0,0 +1,13 @@ +import { DemiCommentPeriod } from './comment-period'; + +/** A staff-read DEMI comment period mirrored from Eagle: its `id` is the Eagle id. */ +export function nativeCommentPeriod(overrides: Partial = {}): DemiCommentPeriod { + return { + id: '598b6eb61ecbc9001dfeba55', eagleId: '598b6eb61ecbc9001dfeba55', projectId: '207', + projectEagleId: '59679ade3acbf9001de7ea8b', projectName: 'Mine', sourceSystem: 'eagle', + dateStarted: '2026-01-01T00:00:00Z', dateCompleted: '2026-02-01T00:00:00Z', dateAdded: null, + isMet: false, metURL: '', informationLabel: '', instructions: 'Comment here', additionalText: '', commentTip: '', + openHouses: [], relatedDocuments: [], milestone: '5c0a6c1b2e3f4a5b6c7d8e00', isPublished: true, + ...overrides + }; +} diff --git a/src/app/resolvers/comment-period.resolver.spec.ts b/src/app/resolvers/comment-period.resolver.spec.ts new file mode 100644 index 000000000..083af9333 --- /dev/null +++ b/src/app/resolvers/comment-period.resolver.spec.ts @@ -0,0 +1,78 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ActivatedRouteSnapshot, RedirectCommand, convertToParamMap, provideRouter } from '@angular/router'; +import { Observable, firstValueFrom } from 'rxjs'; + +import { commentPeriodResolver } from './comment-period.resolver'; +import { ConfigService } from '../services/config.service'; +import { KeycloakService } from '../services/keycloak.service'; +import { LoggingService } from '../services/logging.service'; +import { ToastService } from '../services/toast.service'; +import { LegacyCommentPeriod } from '../comment-periods/comment-period-legacy'; +import { nativeCommentPeriod } from '../models/demi/comment-period.fixture'; + +const PERIOD = '598b6eb61ecbc9001dfeba55'; + +describe('commentPeriodResolver', () => { + let http: HttpTestingController; + let keycloak: jasmine.SpyObj; + + beforeEach(() => { + keycloak = jasmine.createSpyObj('KeycloakService', ['getToken', 'getUserRoles']); + keycloak.getToken.and.returnValue('token'); + keycloak.getUserRoles.and.returnValue(['sysadmin']); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } }, + { provide: KeycloakService, useValue: keycloak }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error', 'debug']) }, + { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error']) } + ] + }); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + function resolve(commentPeriodId: string): Promise { + const route = { paramMap: convertToParamMap({ commentPeriodId }) } as ActivatedRouteSnapshot; + const result = TestBed.runInInjectionContext(() => commentPeriodResolver(route, null)); + return firstValueFrom(result as Observable); + } + + function answer(row: object, summary: object | null) { + http.expectOne(`/demi/api/comment-periods/${PERIOD}`).flush(row); + http.expectOne(`/demi/api/comment-periods/${PERIOD}/summary`).flush(summary); + } + + it('gives the screens the legacy period with its summary, Eagle ids and token roles', async () => { + const result = resolve(PERIOD); + answer(nativeCommentPeriod(), { id: PERIOD, total: 3, eaoStatus: { Pending: 2, Published: 1, Rejected: 0, Deferred: 0 } }); + + const period = await result as LegacyCommentPeriod; + expect(period._id).toBe(PERIOD); + expect(period.project).toBe('59679ade3acbf9001de7ea8b'); + expect(period.summary.Pending).toBe(2); + expect(period.userCan).toBe('sysadmin'); + expect(period.readOnly).toBeFalse(); + }); + + it('marks an ENGAGE period read-only', async () => { + const result = resolve(PERIOD); + answer(nativeCommentPeriod({ sourceSystem: 'engage' }), null); + + expect((await result as LegacyCommentPeriod).readOnly).toBeTrue(); + }); + + it('redirects to search when the period cannot be read', async () => { + const result = resolve(PERIOD); + http.expectOne(`/demi/api/comment-periods/${PERIOD}`).flush({ error: 'Comment period not found' }, { status: 404, statusText: 'Not Found' }); + http.expectOne(`/demi/api/comment-periods/${PERIOD}/summary`).flush(null); + + expect(await result).toBeInstanceOf(RedirectCommand); + }); +}); diff --git a/src/app/resolvers/comment-period.resolver.ts b/src/app/resolvers/comment-period.resolver.ts index ba28eb34b..b50092b69 100644 --- a/src/app/resolvers/comment-period.resolver.ts +++ b/src/app/resolvers/comment-period.resolver.ts @@ -3,24 +3,26 @@ import { RedirectCommand, Router } from '@angular/router'; import { catchError, forkJoin, map, of } from 'rxjs'; import type { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; import { CommentPeriodService } from '../services/commentperiod.service'; -import type { CommentPeriod } from '../models/commentPeriod'; +import { KeycloakService } from '../services/keycloak.service'; +import { LegacyCommentPeriod, toLegacyCommentPeriod } from '../comment-periods/comment-period-legacy'; -export const commentPeriodResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { +/** Loads the DEMI period for `:commentPeriodId` (Eagle or DEMI id) as the legacy `CommentPeriod` the screens read. */ +export const commentPeriodResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { const cpService = inject(CommentPeriodService); + const keycloak = inject(KeycloakService); const router = inject(Router); const id = route.paramMap.get('commentPeriodId')!; return forkJoin([ cpService.getSummaryById(id), - cpService.getById(id) + cpService.getNative(id) ]).pipe( - map(([summary, cp]: [any, CommentPeriod | null]) => { - if (!cp) { + map(([summary, row]) => { + if (!row) { return new RedirectCommand(router.parseUrl('/search')); } - // getById already constructs CommentPeriod — attach summary - (cp as any).summary = summary; - return cp; + const roles: string[] = keycloak.getToken() ? keycloak.getUserRoles() ?? [] : []; + return Object.assign(toLegacyCommentPeriod(row, roles), { summary }); }), catchError(() => of(new RedirectCommand(router.parseUrl('/search')))) ); diff --git a/src/app/shared/components/comment-stats/comment-stats.component.ts b/src/app/shared/components/comment-stats/comment-stats.component.ts index 95d7cad36..3065f3552 100644 --- a/src/app/shared/components/comment-stats/comment-stats.component.ts +++ b/src/app/shared/components/comment-stats/comment-stats.component.ts @@ -7,7 +7,6 @@ import { CommentPeriodService } from 'src/app/services/commentperiod.service'; selector: 'app-comment-stats', templateUrl: './comment-stats.component.html', styleUrl: './comment-stats.component.css', - }) export class CommentStatsComponent implements OnInit { @@ -19,10 +18,12 @@ export class CommentStatsComponent implements OnInit { public summary: any; ngOnInit() { - if (!this.period()?._id) { + // DEMI keys a period's comments on its DEMI id, which differs from `_id` on a period ENGAGE created. + const id = this.period()?.demiId ?? this.period()?._id; + if (!id) { return; } - this.api.getSummaryById(this.period()._id) + this.api.getSummaryById(id) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(summary => { this.summary = summary; From dbe0c37fcbc887c9a340f76fc49aa58edefd05e3 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 03:56:08 +0000 Subject: [PATCH 2/2] fix(comment-periods): test edit guard, drop old export - Cover the edit-route guard that keeps read-only periods out of the form. - Remove DocumentService.exportComments; the details tab exports through DEMI. --- .../add-edit-comment-period.component.spec.ts | 12 ++++++++ .../comment-periods.component.ts | 1 - src/app/services/document.service.ts | 30 +------------------ 3 files changed, 13 insertions(+), 30 deletions(-) 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 0a1ef3c30..d201bcb6c 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 @@ -9,6 +9,8 @@ import { DocumentService } from 'src/app/services/document.service'; import { LoggingService } from 'src/app/services/logging.service'; import { StorageService } from 'src/app/services/storage.service'; import { ToastService } from 'src/app/services/toast.service'; +import { toLegacyCommentPeriod } from '../comment-period-legacy'; +import { nativeCommentPeriod } from 'src/app/models/demi/comment-period.fixture'; describe('AddEditCommentPeriodComponent', () => { let commentPeriodService: jasmine.SpyObj; @@ -58,4 +60,14 @@ describe('AddEditCommentPeriodComponent', () => { expect(commentPeriodService.save.calls.mostRecent().args[0].isPublished).toBe(true); }); + + it('sends a read-only period back to its details page without opening the form', () => { + const period = toLegacyCommentPeriod(nativeCommentPeriod({ sourceSystem: 'engage' }), ['sysadmin']); + const component = openForEdit(period); + + expect(TestBed.inject(ToastService).error).toHaveBeenCalledWith('This comment period cannot be edited here.'); + expect(TestBed.inject(Router).navigate).toHaveBeenCalledWith(['/p', 'p1', 'cp', period._id]); + expect(component.commentPeriodForm).toBeUndefined(); + expect(commentPeriodService.save).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/comment-periods/comment-periods.component.ts b/src/app/comment-periods/comment-periods.component.ts index e899eb565..11869bc0b 100644 --- a/src/app/comment-periods/comment-periods.component.ts +++ b/src/app/comment-periods/comment-periods.component.ts @@ -132,7 +132,6 @@ export class CommentPeriodsComponent implements OnInit { daysRemaining: commentPeriod.daysRemaining, isMet: commentPeriod.isMet, metURLAdmin: commentPeriod.metURLAdmin, - // Same field the Published column sorts on in the API. read: commentPeriod.isPublished ? 'Published' : 'Not Published', _id: commentPeriod._id, demiId: commentPeriod.demiId, diff --git a/src/app/services/document.service.ts b/src/app/services/document.service.ts index 6ccd9f1c2..9a4031c97 100644 --- a/src/app/services/document.service.ts +++ b/src/app/services/document.service.ts @@ -12,7 +12,7 @@ import { LoadingStateService } from './loading-state.service'; import { LoggingService } from './logging.service'; import { ToastService } from './toast.service'; import { withLoading } from 'src/app/shared/utils/rxjs-operators'; -import { encodeString, formatDate } from '../shared/utils/utils'; +import { encodeString } from '../shared/utils/utils'; /** Admin URL that streams a document inline. */ export function documentFetchUrl(document: Pick): string { @@ -217,32 +217,4 @@ export class DocumentService { public async openDocument(document: Document): Promise { window.open(documentFetchUrl(document), '_blank'); } - - public async exportComments(period: string, projectName: string, format: string): Promise { - const blob = await this.api.get(`comment/export/${period}?format=${format}`, { responseType: 'blob' }).toPromise(); - projectName = projectName.split(' ').join('_'); - const currentDate = formatDate(new Date()); - let filename = ''; - if (format === 'staff') { - filename = `${projectName}-eao-${currentDate}.csv`; - } else if (format === 'proponent') { - filename = `${projectName}-proponent-${currentDate}.csv`; - } else { - filename = 'export.csv'; - } - filename = encodeString(filename, true); - 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(); - } - } }