Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ <h3>DATE RANGE:&nbsp;</h3>
<div ngbDropdown container="body" placement="bottom-end top-end">
<button class="btn btn-primary" id="actionDropdown" (click)="checkIfCanDelete()" ngbDropdownToggle>Actions</button>
<div class="dropdown-menu" aria-labelledby="actionDropdown" ngbDropdownMenu>
<button ngbDropdownItem (click)="editCommentPeriod()">Edit</button>
<button ngbDropdownItem (click)="addComment()">Add a comment</button>
@if (!commentPeriod().readOnly) {
<button ngbDropdownItem (click)="editCommentPeriod()">Edit</button>
<button ngbDropdownItem (click)="addComment()">Add a comment</button>
}
<button ngbDropdownItem (click)="exportCommentsForStaff()">Export All Comments</button>
@if (projectType === 'currentProject') {
<button ngbDropdownItem (click)="exportCommentsForProponents()">Export Comments For Proponents</button>
}
<button ngbDropdownItem (click)="togglePublishState()">{{publishAction}}</button>
@if (canDeleteCommentPeriod) {
@if (!commentPeriod().readOnly) {
<button ngbDropdownItem (click)="togglePublishState()">{{publishAction}}</button>
}
@if (canDeleteCommentPeriod && !commentPeriod().readOnly) {
<button ngbDropdownItem (click)="deleteCommentPeriod()">Delete</button>
}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CommentPeriodDetailsTabComponent>;
let http: HttpTestingController;
let link: jasmine.Spy;

function create(row: Partial<DemiCommentPeriod> = {}) {
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']);
});
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -31,14 +34,16 @@ 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);
private logger = inject(LoggingService);
private _cdr = inject(ChangeDetectorRef);
private destroyRef = inject(DestroyRef);

commentPeriod = input.required<CommentPeriod>();
commentPeriod = input.required<LegacyCommentPeriod>();
project = input.required<any>();

public commentPeriodPublishedStatus: string;
Expand Down Expand Up @@ -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<string, string> = 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) {
Expand All @@ -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);
}
4 changes: 2 additions & 2 deletions src/app/comment-period/comment-period.component.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,7 +26,7 @@ export class CommentPeriodComponent implements OnInit {
private route = inject(ActivatedRoute);

project = input.required<any>();
commentPeriod = input.required<CommentPeriod>();
commentPeriod = input.required<LegacyCommentPeriod>();

public baseRouteUrl: string;
public selectedTab = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommentPeriodService>;
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,7 +51,7 @@ export class AddEditCommentPeriodComponent implements OnInit {
private logger = inject(LoggingService);

project = input.required<any>();
commentPeriod = input<CommentPeriod>(undefined);
commentPeriod = input<LegacyCommentPeriod>(undefined);
public componentBaseUrl: string;
public currentCommentPeriod = new CommentPeriod;
public milestones: any[] = [];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: () => {
Expand Down
62 changes: 62 additions & 0 deletions src/app/comment-periods/comment-period-legacy.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
39 changes: 39 additions & 0 deletions src/app/comment-periods/comment-period-legacy.ts
Original file line number Diff line number Diff line change
@@ -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<LegacyCommentPeriod> = { ...period };
delete body.demiId;
delete body.readOnly;
delete body.userCan;
return body as CommentPeriod;
}
Loading
Loading