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.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/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..11869bc0b 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;
}
@@ -140,11 +132,9 @@ 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',
- // TODO: Figure out pending, deferred, published, rejected
- // commmentData:
_id: commentPeriod._id,
+ demiId: commentPeriod.demiId,
project: commentPeriod.project
}
);
@@ -163,16 +153,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/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();
- }
- }
}
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;