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 @@ -2,7 +2,8 @@
<tr
class="clickable-row">
<td data-label="Select" (click)="selectItem(document)" scope="row" [style.width]="!useSmallTable && columns[0].width">
<input type="checkbox" aria-label="Checkbox for row item" [(ngModel)]='document.checkbox'>
<input type="checkbox" aria-label="Checkbox for row item" [(ngModel)]='document.checkbox' [disabled]="!document.selectable"
[title]="document.selectable ? '' : 'This document cannot be linked to a comment period'">
</td>
<td data-label="Name" (click)="selectItem(document)" scope="row" [style.width]="!useSmallTable && columns[1].width">{{document.displayName}}</td>
<td data-label="Date" (click)="selectItem(document)" scope="row" [style.width]="!useSmallTable && columns[2].width">{{document.datePosted | date:'longDate'}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { TableObject, TableColumn } from 'src/app/shared/components/table-templa
import { TableComponent } from 'src/app/shared/components/table-template/table.component';
import { FormsModule } from '@angular/forms';
import { ListConverterPipe } from 'src/app/shared/pipes/list-converter.pipe';

@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'tbody[app-add-document-table-rows]',
Expand Down Expand Up @@ -45,6 +44,7 @@ export class AddDocumentTableRowsComponent implements OnInit, TableComponent {
}

selectItem(item) {
if (!item.selectable) { return; }
item.checkbox = !item.checkbox;
if (item.checkbox) {
this.storageService.state.selectedDocumentsForCP.data.push(item);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { AddDocumentComponent } from './add-documents.component';
import { ConfigService } from 'src/app/services/config.service';
import { KeycloakService } from 'src/app/services/keycloak.service';
import { LoggingService } from 'src/app/services/logging.service';
import { StorageService } from 'src/app/services/storage.service';
import { ToastService } from 'src/app/services/toast.service';

describe('AddDocumentComponent', () => {
let http: HttpTestingController;
let storage: StorageService;
let toast: jasmine.SpyObj<ToastService>;

beforeEach(async () => {
toast = jasmine.createSpyObj('ToastService', ['error', 'success']);
await TestBed.configureTestingModule({
imports: [AddDocumentComponent],
providers: [
provideRouter([]),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
{
provide: ActivatedRoute, useValue: {
url: of([]),
params: of({}),
snapshot: { queryParams: {}, params: {} },
parent: { parent: { snapshot: { params: { projId: 'eagle-project-1' } } } }
}
},
{ provide: ConfigService, useValue: { getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } },
{ provide: KeycloakService, useValue: { keycloakEnabled: true } },
{ provide: ToastService, useValue: toast },
{ provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['debug', 'error', 'warn', 'info']) }
]
}).compileComponents();

http = TestBed.inject(HttpTestingController);
storage = TestBed.inject(StorageService);
storage.state = { type: 'selectedDocumentsForCP', data: { data: [] } };
});

afterEach(() => http.verify());

function setProject(data: any, type = 'currentProject') {
storage.state = { type: 'currentProject', data: { type, data } };
}

function render() {
const fixture = TestBed.createComponent(AddDocumentComponent);
fixture.detectChanges();
return fixture;
}

it('lists the project documents from DEMI by the DEMI project id', () => {
setProject({ _id: 'eagle-project-1', demiId: 'demi-project-1', name: 'Project' });
render();

const req = http.expectOne(r => r.url === '/demi/api/documents');
expect(req.request.params.get('project')).toBe('demi-project-1');
expect(req.request.params.getAll('documentSource')).toEqual(['PROJECT']);
expect(req.request.params.get('page')).toBe('1');
expect(req.request.params.get('pageSize')).toBe('10');
expect(req.request.params.get('count')).toBe('true');
req.flush([]);
});

it('lets select-all link only project documents with an Eagle id', () => {
setProject({ _id: 'eagle-project-1', demiId: 'demi-project-1', name: 'Project' });
const fixture = render();
http.expectOne(r => r.url === '/demi/api/documents').flush([
{ id: 'uuid-1', eagleId: 'eagle-doc-1', projectId: 'demi-project-1', documentSource: 'PROJECT', displayName: 'Report' },
{ id: 'uuid-2', eagleId: null, projectId: 'demi-project-1', documentSource: 'PROJECT', displayName: 'DEMI only' },
{ id: 'uuid-3', eagleId: 'eagle-doc-3', projectId: 'demi-project-1', documentSource: 'COMMENT', displayName: 'Attachment' }
], { headers: { 'x-total-count': '3' } });

fixture.componentInstance.selectAction('selectAll');

expect(fixture.componentInstance.documentTableData.data.length).toBe(3);
expect(storage.state.selectedDocumentsForCP.data.map(d => d._id)).toEqual(['eagle-doc-1']);
expect(fixture.componentInstance.selectedCount).toBe(1);
});

it('disables the checkbox of a comment attachment', async () => {
setProject({ _id: 'eagle-project-1', demiId: 'demi-project-1', name: 'Project' });
const fixture = render();
http.expectOne(r => r.url === '/demi/api/documents').flush([
{ id: 'uuid-1', eagleId: 'eagle-doc-1', projectId: 'demi-project-1', documentSource: 'PROJECT', displayName: 'Report' },
{ id: 'uuid-3', eagleId: 'eagle-doc-3', projectId: 'demi-project-1', documentSource: 'COMMENT', displayName: 'Attachment' }
], { headers: { 'x-total-count': '2' } });
fixture.detectChanges();
// NgModel applies `disabled` after a microtask.
await fixture.whenStable();

const boxes: HTMLInputElement[] = Array.from(fixture.nativeElement.querySelectorAll('input[type="checkbox"][aria-label="Checkbox for row item"]'));
expect(boxes.map(b => b.disabled)).toEqual([false, true]);
});

it('resolves the DEMI project id when the stored project has none', () => {
setProject({ _id: 'eagle-project-1', name: 'Project' });
render();

http.expectOne('/demi/api/projects/eagle-project-1').flush({ id: 'demi-project-1', eagleId: 'eagle-project-1' });
const req = http.expectOne(r => r.url === '/demi/api/documents');
expect(req.request.params.get('project')).toBe('demi-project-1');
req.flush([]);
});

it('tells the user when the DEMI project cannot be found', () => {
setProject({ _id: 'eagle-project-1', name: 'Project' });
render();

http.expectOne('/demi/api/projects/eagle-project-1').flush('gone', { status: 404, statusText: 'Not Found' });

expect(toast.error).toHaveBeenCalledWith('Could not load documents for this project.');
http.expectNone(r => r.url === '/demi/api/documents');
});

it('lists a notification\'s documents by the notification id and links only notification documents', () => {
setProject({ _id: 'notification-1', name: 'Notice' }, 'currentProjectNotification');
const fixture = render();

const req = http.expectOne(r => r.url === '/demi/api/documents');
expect(req.request.params.get('project')).toBe('notification-1');
expect(req.request.params.getAll('documentSource')).toEqual(['PROJECT-NOTIFICATION']);
req.flush([
{ id: 'n1', eagleId: 'n1', projectId: 'notification-1', documentSource: 'PROJECT-NOTIFICATION', displayName: 'Notice letter' },
{ id: 'c1', eagleId: 'c1', projectId: 'notification-1', documentSource: 'COMMENT', displayName: 'Attachment' }
]);
fixture.componentInstance.selectAction('selectAll');

expect(storage.state.selectedDocumentsForCP.data.map(d => d._id)).toEqual(['n1']);
});
});
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { Component, OnInit, ChangeDetectorRef, ChangeDetectionStrategy, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, catchError, map, of, switchMap } from 'rxjs';
import { PlatformLocation } from '@angular/common';
import { Router, ActivatedRoute } from '@angular/router';
import { AddDocumentTableRowsComponent } from './add-document-table-rows/add-document-table-rows.component';
import { SearchTerms } from 'src/app/models/search';
import { DocumentService } from 'src/app/services/document.service';
import { SearchService } from 'src/app/services/search.service';
import { ProjectService } from 'src/app/services/project.service';
import { StorageService } from 'src/app/services/storage.service';
import { TableObject, TableColumn } from 'src/app/shared/components/table-template/table-object';
import { TableParamsObject } from 'src/app/shared/components/table-template/table-params-object';
import { TableTemplateUtils, nextSortBy } from 'src/app/shared/utils/table-template-utils';
import { Document } from 'src/app/models/document';
import { isSelectableDocument, legacyDocumentRow, nativeDocumentSort, sourceFilters } from 'src/app/project/project-documents/document-edit/legacy-document-row';
import { ToastService } from 'src/app/services/toast.service';
import { DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
Expand All @@ -36,10 +39,11 @@ export class AddDocumentComponent implements OnInit {
private location = inject(PlatformLocation);
private route = inject(ActivatedRoute);
private router = inject(Router);
private searchService = inject(SearchService);
private projectService = inject(ProjectService);
storageService = inject(StorageService);
tableTemplateUtils = inject(TableTemplateUtils);
private logger = inject(LoggingService);
private toast = inject(ToastService);
private destroyRef = inject(DestroyRef);

public terms = new SearchTerms();
Expand Down Expand Up @@ -69,12 +73,14 @@ export class AddDocumentComponent implements OnInit {
{
name: 'Type',
value: 'type',
width: '15%'
width: '15%',
nosort: true
},
{
name: 'Milestone',
value: 'milestone',
width: '15%'
width: '15%',
nosort: true
}
];

Expand Down Expand Up @@ -129,40 +135,13 @@ export class AddDocumentComponent implements OnInit {

this.originalSelectedDocs = Object.assign([], this.storageService.state.selectedDocumentsForCP.data);

const currentProject = this.storageService.state.currentProject;
const projectId = currentProject ? currentProject.data._id : this.route.parent.parent.snapshot.params.projId;
const queryParams = this.route.snapshot.queryParams;
const pageNum = Number(queryParams['pageNum'] || 1);
const pageSize = Number(queryParams['pageSize'] || 10);
const sortBy = queryParams['sortBy'] || null;
const keywords = this.route.snapshot.params.keywords;
let queryModifier: any = {};
if (currentProject?.type === 'currentProjectNotification') {
queryModifier = { documentSource: 'PROJECT-NOTIFICATION' };
} else if (currentProject?.type === 'currentProject') {
queryModifier = { documentSource: 'PROJECT' };
}
this.searchService.getSearchResults(
keywords, 'Document',
[{ 'name': 'project', 'value': projectId }],
pageNum, pageSize, sortBy, queryModifier, false, {}, ''
).pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: any) => {
if (res) {
if (res[0].data.meta && res[0].data.meta.length > 0) {
this.tableParams.totalListItems = res[0].data.meta[0].searchResultsTotal;
this.documents = res[0].data.searchResults;
} else {
this.tableParams.totalListItems = 0;
this.documents = [];
}
this.setDocumentRowData();
this.loading = false;
this._changeDetectionRef.markForCheck();
} else {
alert('Uh-oh, couldn\'t load valued components');
this.router.navigate(['/search']);
}
});
this.loadDocuments(
Number(queryParams['pageNum'] || 1),
Number(queryParams['pageSize'] || 10),
queryParams['sortBy'] || null,
this.route.snapshot.params.keywords
);
}
}

Expand Down Expand Up @@ -194,7 +173,8 @@ export class AddDocumentComponent implements OnInit {
}
});

this.documentTableData.data.map((item) => {
const selectable = this.documentTableData.data.filter(item => item.selectable);
selectable.forEach((item) => {
item.checkbox = !someSelected;
if (item.checkbox) {
this.storageService.state.selectedDocumentsForCP.data.push(item);
Expand All @@ -203,7 +183,7 @@ export class AddDocumentComponent implements OnInit {
}
});

this.selectedCount = someSelected ? 0 : this.documentTableData.data.length;
this.selectedCount = someSelected ? 0 : selectable.length;
this._changeDetectionRef.markForCheck();
break;
case 'download':
Expand Down Expand Up @@ -267,12 +247,55 @@ export class AddDocumentComponent implements OnInit {
}
}

private get documentSource(): string {
return this.storageService.state.currentProject?.type === 'currentProjectNotification' ? 'PROJECT-NOTIFICATION' : 'PROJECT';
}

private loadDocuments(page: number, pageSize: number, sortBy: string, keywords: string, onLoaded?: () => void) {
this.documentsProjectId().pipe(
switchMap(project => {
if (!project) {
this.toast.error('Could not load documents for this project.');
return of({ rows: [], total: 0 });
}
return this.api.listNative({
page, pageSize, count: true, sort: nativeDocumentSort(sortBy), q: keywords || undefined,
filters: sourceFilters(project, this.documentSource)
});
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(res => {
this.tableParams.totalListItems = res.total ?? 0;
this.documents = res.rows.map(legacyDocumentRow);
onLoaded?.();
this.setDocumentRowData();
this.loading = false;
this._changeDetectionRef.markForCheck();
});
}

/** Documents store the DEMI project id, or the notification id for a notification's documents. */
private documentsProjectId(): Observable<string | null> {
const current = this.storageService.state.currentProject;
if (current?.type === 'currentProjectNotification') { return of(current.data._id); }
if (current?.data?.demiId) { return of(current.data.demiId); }
const eagleId = current ? current.data._id : this.route.parent.parent.snapshot.params.projId;
return this.projectService.getNative(eagleId).pipe(
map(row => row?.id ?? null),
catchError(error => {
this.logger.error('DEMI project lookup failed', 'AddDocumentComponent', error);
return of(null);
})
);
}

setDocumentRowData() {
const documentList = [];
if (this.documents && this.documents.length > 0) {
this.documents.forEach(document => {
documentList.push(
{
selectable: isSelectableDocument(document, this.documentSource),
displayName: document.displayName,
datePosted: document.datePosted,
type: document.type,
Expand Down Expand Up @@ -316,22 +339,9 @@ export class AddDocumentComponent implements OnInit {

this.tableParams = this.tableTemplateUtils.updateTableParams(this.tableParams, pageNumber, this.tableParams.sortBy);

this.searchService.getSearchResults(
this.tableParams.keywords || '',
'Document',
[{ 'name': 'project', 'value': this.currentProject.data._id }],
pageNumber,
this.tableParams.pageSize,
this.tableParams.sortBy,
{ documentSource: 'PROJECT' }, false, {}, ''
).pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: any) => {
this.tableParams.totalListItems = res[0].data.meta[0].searchResultsTotal;
this.documents = res[0].data.searchResults;
this.tableTemplateUtils.updateUrl(this.tableParams.sortBy, this.tableParams.currentPage, this.tableParams.pageSize, null, this.tableParams.keywords || '');
this.setDocumentRowData();
this.loading = false;
this._changeDetectionRef.markForCheck();
});
this.loadDocuments(pageNumber, this.tableParams.pageSize, this.tableParams.sortBy, this.tableParams.keywords, () =>
this.tableTemplateUtils.updateUrl(this.tableParams.sortBy, this.tableParams.currentPage, this.tableParams.pageSize, null, this.tableParams.keywords || '')
);
}

removeSelectedDoc(doc) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
<tr
class="clickable-row" [className]="'active'">
<td data-label="Select" (click)="selectItem(document)" scope="row" [style.width]="!useSmallTable && columns[0].width">
<input type="checkbox" aria-label="Checkbox for row item" [(ngModel)]='document.checkbox'>
<input type="checkbox" aria-label="Checkbox for row item" [(ngModel)]='document.checkbox' [disabled]="!document.selectable"
[title]="document.selectable ? '' : 'This document cannot be changed here'">
</td>
<td data-label="Name" scope="row" [style.width]="!useSmallTable && columns[1].width">{{document.displayName}}</td>
<td data-label="Date" (click)="goToItem(document)" scope="row" [style.width]="!useSmallTable && columns[2].width">
Expand All @@ -14,7 +15,7 @@
<td data-label="Status" scope="row" [style.width]="!useSmallTable && columns[4].width">
<p class="card-text publish-state">
<i class="material-icons">
{{document.status === 'unpublished' ? 'cloud_off' : 'cloud_done'}}
{{document.isPublished ? 'cloud_done' : 'cloud_off'}}
</i>
</p>
</td>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Component, OnInit, inject, ChangeDetectionStrategy, input, output } from '@angular/core';
import { DatePipe } from '@angular/common';
import { ListConverterPipe } from 'src/app/shared/pipes/list-converter.pipe';
import { RouterModule } from '@angular/router';
import { ListConverterPipe } from 'src/app/shared/pipes/list-converter.pipe';import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';

import { Router } from '@angular/router';
Expand Down Expand Up @@ -42,6 +41,7 @@ export class PnDocumentTableRowsComponent implements OnInit, TableComponent {
}

selectItem(item) {
if (!item.selectable) { return; }
item.checkbox = !item.checkbox;
let count = 0;
this.documents.forEach(doc => {
Expand Down
Loading
Loading