diff --git a/src/app/comment-periods/add-edit-comment-period/add-documents/add-document-table-rows/add-document-table-rows.component.html b/src/app/comment-periods/add-edit-comment-period/add-documents/add-document-table-rows/add-document-table-rows.component.html
index f2a38f8a4..a41d9dee3 100644
--- a/src/app/comment-periods/add-edit-comment-period/add-documents/add-document-table-rows/add-document-table-rows.component.html
+++ b/src/app/comment-periods/add-edit-comment-period/add-documents/add-document-table-rows/add-document-table-rows.component.html
@@ -2,7 +2,8 @@
|
-
+
|
{{document.displayName}} |
{{document.datePosted | date:'longDate'}}
diff --git a/src/app/comment-periods/add-edit-comment-period/add-documents/add-document-table-rows/add-document-table-rows.component.ts b/src/app/comment-periods/add-edit-comment-period/add-documents/add-document-table-rows/add-document-table-rows.component.ts
index 100827711..8fe7105d9 100644
--- a/src/app/comment-periods/add-edit-comment-period/add-documents/add-document-table-rows/add-document-table-rows.component.ts
+++ b/src/app/comment-periods/add-edit-comment-period/add-documents/add-document-table-rows/add-document-table-rows.component.ts
@@ -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]',
@@ -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);
diff --git a/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.spec.ts b/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.spec.ts
new file mode 100644
index 000000000..183346c5a
--- /dev/null
+++ b/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.spec.ts
@@ -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;
+
+ 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']);
+ });
+});
diff --git a/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.ts b/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.ts
index 2c9377b01..f7d4ea163 100644
--- a/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.ts
+++ b/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.ts
@@ -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';
@@ -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();
@@ -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
}
];
@@ -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
+ );
}
}
@@ -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);
@@ -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':
@@ -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 {
+ 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,
@@ -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) {
diff --git a/src/app/project-notification/documents/project-notification-document-table-rows/project-notification-document-table-rows.component.html b/src/app/project-notification/documents/project-notification-document-table-rows/project-notification-document-table-rows.component.html
index cd0fea2a6..73ad677a9 100644
--- a/src/app/project-notification/documents/project-notification-document-table-rows/project-notification-document-table-rows.component.html
+++ b/src/app/project-notification/documents/project-notification-document-table-rows/project-notification-document-table-rows.component.html
@@ -2,7 +2,8 @@
|
-
+
|
{{document.displayName}} |
@@ -14,7 +15,7 @@
|
- {{document.status === 'unpublished' ? 'cloud_off' : 'cloud_done'}}
+ {{document.isPublished ? 'cloud_done' : 'cloud_off'}}
|
diff --git a/src/app/project-notification/documents/project-notification-document-table-rows/project-notification-document-table-rows.component.ts b/src/app/project-notification/documents/project-notification-document-table-rows/project-notification-document-table-rows.component.ts
index 565ca84e6..b1279bca0 100644
--- a/src/app/project-notification/documents/project-notification-document-table-rows/project-notification-document-table-rows.component.ts
+++ b/src/app/project-notification/documents/project-notification-document-table-rows/project-notification-document-table-rows.component.ts
@@ -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';
@@ -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 => {
diff --git a/src/app/project-notification/documents/project-notification-documents.component.spec.ts b/src/app/project-notification/documents/project-notification-documents.component.spec.ts
new file mode 100644
index 000000000..3c87ecc60
--- /dev/null
+++ b/src/app/project-notification/documents/project-notification-documents.component.spec.ts
@@ -0,0 +1,72 @@
+import { TestBed } from '@angular/core/testing';
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { provideRouter } from '@angular/router';
+import { ProjectNotificationDocumentsComponent } from './project-notification-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 { SearchService } from 'src/app/services/search.service';
+import { StorageService } from 'src/app/services/storage.service';
+import { ToastService } from 'src/app/services/toast.service';
+
+describe('ProjectNotificationDocumentsComponent', () => {
+ let http: HttpTestingController;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ProjectNotificationDocumentsComponent],
+ providers: [
+ provideRouter([]),
+ provideHttpClient(withInterceptorsFromDi()),
+ provideHttpClientTesting(),
+ { provide: ConfigService, useValue: { getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } },
+ { provide: KeycloakService, useValue: { keycloakEnabled: true } },
+ { provide: SearchService, useValue: {} },
+ { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error', 'success', 'info']) },
+ { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['debug', 'error', 'warn', 'info']) }
+ ]
+ }).compileComponents();
+
+ http = TestBed.inject(HttpTestingController);
+ TestBed.inject(StorageService).state = {
+ type: 'currentProject',
+ data: { type: 'currentProjectNotification', data: { _id: 'notification-1', name: 'Notice' } }
+ };
+ });
+
+ afterEach(() => http.verify());
+
+ it('lists the notification documents from DEMI, newest first', () => {
+ const fixture = TestBed.createComponent(ProjectNotificationDocumentsComponent);
+ fixture.detectChanges();
+
+ const req = http.expectOne(r => r.url === '/demi/api/documents');
+ expect(req.request.params.get('project')).toBe('notification-1');
+ expect(req.request.params.get('sort')).toBe('-datePosted');
+ expect(req.request.params.get('count')).toBe('true');
+ expect(req.request.params.getAll('documentSource')).toEqual(['PROJECT-NOTIFICATION']);
+ req.flush([
+ { id: 'uuid-1', eagleId: 'eagle-doc-1', projectId: 'notification-1', documentSource: 'PROJECT-NOTIFICATION', displayName: 'Notice letter', isPublished: true }
+ ], { headers: { 'x-total-count': '1' } });
+
+ const rows = fixture.componentInstance.documentTableData().data;
+ expect(rows.map(d => d._id)).toEqual(['eagle-doc-1']);
+ expect(rows[0].isPublished).toBeTrue();
+ });
+
+ it('lets select-all pick only notification documents, so comment attachments cannot be published', () => {
+ const fixture = TestBed.createComponent(ProjectNotificationDocumentsComponent);
+ fixture.detectChanges();
+ http.expectOne(r => r.url === '/demi/api/documents').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');
+
+ const rows = fixture.componentInstance.documentTableData().data;
+ expect(rows.filter(d => d.checkbox).map(d => d._id)).toEqual(['n1']);
+ expect(fixture.componentInstance.selectedCount().total).toBe(1);
+ });
+});
diff --git a/src/app/project-notification/documents/project-notification-documents.component.ts b/src/app/project-notification/documents/project-notification-documents.component.ts
index e2e6af30f..4cf30e9b4 100644
--- a/src/app/project-notification/documents/project-notification-documents.component.ts
+++ b/src/app/project-notification/documents/project-notification-documents.component.ts
@@ -13,9 +13,8 @@ import { TableObject, TableColumn } from 'src/app/shared/components/table-templa
import { TableParamsObject } from 'src/app/shared/components/table-template/table-params-object';
import { Constants } from 'src/app/shared/utils/constants';
import { encodeString } from 'src/app/shared/utils/utils';
-import { HttpCacheService } from 'src/app/interceptors/http-cache.interceptor';
import { PnDocumentTableRowsComponent } from './project-notification-document-table-rows/project-notification-document-table-rows.component';
-import { Document } from 'src/app/models/document';
+import { isSelectableDocument, legacyDocumentRow, nativeDocumentSort, sourceFilters } from 'src/app/project/project-documents/document-edit/legacy-document-row';
import { TableTemplateComponent } from 'src/app/shared/components/table-template/table-template.component';
import { LoggingService } from 'src/app/services/logging.service';
import { nextSortBy } from 'src/app/shared/utils/table-template-utils';
@@ -46,7 +45,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit {
public readonly constants = Constants;
- private docs: Document[] = [];
+ private docs: any[] = [];
// Signals — OnPush tracks these automatically, no detectChanges() needed
public loading = signal(true);
@@ -62,8 +61,8 @@ export class ProjectNotificationDocumentsComponent implements OnInit {
{ name: 'select_all_box', value: 'select_all_box', width: '5%', nosort: true },
{ name: 'Name', value: 'displayName', width: '30%' },
{ name: 'Date', value: 'datePosted', width: '26%' },
- { name: 'Document Author', value: 'documentAuthor', width: '29%' },
- { name: 'status', value: 'status', width: '10%' },
+ { name: 'Document Author', value: 'documentAuthor', width: '29%', nosort: true },
+ { name: 'status', value: 'status', width: '10%', nosort: true },
];
ngOnInit(): void {
@@ -137,12 +136,12 @@ export class ProjectNotificationDocumentsComponent implements OnInit {
tableData.data.forEach(item => {
if (item.checkbox === true) { someSelected = true; }
});
- tableData.data.forEach(item => {
+ tableData.data.filter(item => item.selectable).forEach(item => {
item.checkbox = !someSelected;
});
}
- this.selectedCount.set({ ...this.selectedCount(), total: someSelected ? 0 : (tableData?.data.length ?? 0) });
+ this.selectedCount.set({ ...this.selectedCount(), total: someSelected ? 0 : (tableData?.data.filter(item => item.selectable).length ?? 0) });
this.setPublishUnpublish();
break;
case 'delete':
@@ -198,7 +197,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit {
const tableData = this.documentTableData();
if (tableData) {
tableData.data.forEach(item => {
- if (item.checkbox && !item.read.includes('public')) {
+ if (item.checkbox && !item.isPublished) {
observables.push(this.documentService.publish(item._id));
}
});
@@ -248,7 +247,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit {
const tableData = this.documentTableData();
if (tableData) {
tableData.data.forEach(item => {
- if (item.checkbox && item.read.includes('public')) {
+ if (item.checkbox && item.isPublished) {
observables.push(this.documentService.unPublish(item._id));
}
});
@@ -321,39 +320,32 @@ export class ProjectNotificationDocumentsComponent implements OnInit {
}
private refreshDocuments(): void {
- HttpCacheService.clearByResource('search');
this.loading.set(true);
const project = this.currentProject();
if (!project?._id) { return; }
- const sortBy = this.tableParams.sortBy
- ? (this.tableParams.sortBy.substr(1) === 'displayName'
- ? this.tableParams.sortBy
- : `${this.tableParams.sortBy},+displayName`)
- : '-datePosted,+displayName';
-
- this.searchService.getSearchResults(
- null,
- 'Document',
- [],
- this.tableParams.currentPage,
- this.tableParams.pageSize,
- sortBy,
- { documentSource: 'PROJECT-NOTIFICATION', project: project._id })
+ this.loadDocuments(project._id, () => {
+ this.selectedCount.set({ categorized: 0, total: 0 });
+ this.canPublish.set(false);
+ this.canUnpublish.set(false);
+ });
+ }
+
+ /** Notification documents store the notification id as their project. */
+ private loadDocuments(notificationId: string, onLoaded?: () => void): void {
+ this.documentService.listNative({
+ page: this.tableParams.currentPage,
+ pageSize: this.tableParams.pageSize,
+ count: true,
+ sort: nativeDocumentSort(this.tableParams.sortBy) ?? '-datePosted',
+ filters: sourceFilters(notificationId, 'PROJECT-NOTIFICATION')
+ })
.pipe(takeUntilDestroyed(this.destroyRef))
- .subscribe((res: any) => {
- if (res[0].data && res[0].data.meta.length > 0) {
- this.docs = res[0].data.searchResults;
- this.tableParams.totalListItems = res[0].data.meta[0].searchResultsTotal;
- } else {
- this.docs = [];
- this.tableParams.totalListItems = 0;
- this.documentTableData.set(null);
- }
+ .subscribe(res => {
+ this.docs = res.rows.map(legacyDocumentRow);
+ this.tableParams.totalListItems = res.total ?? 0;
this.setRowData();
- this.selectedCount.set({ categorized: 0, total: 0 });
- this.canPublish.set(false);
- this.canUnpublish.set(false);
+ onLoaded?.();
this.loading.set(false);
});
}
@@ -362,21 +354,19 @@ export class ProjectNotificationDocumentsComponent implements OnInit {
if (this.docs && this.docs.length > 0) {
const documentList = this.docs.map((document) => {
return {
+ selectable: isSelectableDocument(document, 'PROJECT-NOTIFICATION'),
displayName: document.displayName,
documentFileName: document.documentFileName,
documentAuthor: document.documentAuthor,
datePosted: document.datePosted,
- status: document['status'],
+ isPublished: !!document.isPublished,
type: '',
milestone: '',
legislation: 2018,
_id: document._id,
project: document.project,
- read: document.read,
isFeatured: false,
sortOrder: 0,
- publicHitCount: document.publicHitCount,
- secureHitCount: document.secureHitCount,
};
});
@@ -402,25 +392,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit {
const project = this.currentProject();
if (!project?._id) { return; }
- const sortBy = this.tableParams.sortBy.substr(1) === 'displayName'
- ? this.tableParams.sortBy
- : `${this.tableParams.sortBy},+displayName`;
-
- this.searchService.getSearchResults(
- null,
- 'Document',
- [],
- this.tableParams.currentPage,
- this.tableParams.pageSize,
- sortBy,
- { documentSource: 'PROJECT-NOTIFICATION', project: project._id })
- .pipe(takeUntilDestroyed(this.destroyRef))
- .subscribe((res: any) => {
- this.docs = res[0].data.searchResults;
- this.tableParams.totalListItems = res[0].data.meta[0].searchResultsTotal;
- this.setRowData();
- this.loading.set(false);
- });
+ this.loadDocuments(project._id);
}
isEnabled(button) {
@@ -450,7 +422,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit {
if (tableData) {
for (const document of tableData.data) {
if (document.checkbox) {
- if (document.read.includes('public')) {
+ if (document.isPublished) {
canUnpub = true;
} else {
canPub = true;
diff --git a/src/app/project/project-documents/document-edit/document-edit.component.html b/src/app/project/project-documents/document-edit/document-edit.component.html
index 4694e3b72..169fc4c68 100644
--- a/src/app/project/project-documents/document-edit/document-edit.component.html
+++ b/src/app/project/project-documents/document-edit/document-edit.component.html
@@ -193,8 +193,12 @@
}
+ @if (readOnlyReason) {
+ {{readOnlyReason}}
+ }
+ @if (!readOnlyReason) {
@@ -207,6 +211,7 @@
}
+ }
}
\ No newline at end of file
diff --git a/src/app/project/project-documents/document-edit/document-edit.component.spec.ts b/src/app/project/project-documents/document-edit/document-edit.component.spec.ts
new file mode 100644
index 000000000..274ae20d1
--- /dev/null
+++ b/src/app/project/project-documents/document-edit/document-edit.component.spec.ts
@@ -0,0 +1,134 @@
+import { TestBed } from '@angular/core/testing';
+import { signal } from '@angular/core';
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { Router, provideRouter } from '@angular/router';
+import { DocumentEditComponent } from './document-edit.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';
+import { DemiDocument } from 'src/app/models/demi/document';
+
+// The row shape the document list and detail put in `selectedDocs`.
+const nativeRow: DemiDocument = {
+ id: '3f2a9c1e-uuid',
+ projectId: 'demi-project-1',
+ eagleId: 'eagle-doc-1',
+ projectEagleId: 'eagle-project-1',
+ displayName: 'Report',
+ documentFileName: 'report.pdf',
+ type: 'Letter',
+ typeId: 'type-1',
+ milestoneId: 'milestone-1',
+ projectPhaseId: 'phase-1',
+ documentAuthorTypeId: 'author-1',
+ datePosted: '2020-01-02T00:00:00.000Z',
+ legislation: 2018,
+ isPublished: false
+};
+
+const legacyRow = {
+ _id: 'legacy-doc-1',
+ project: 'eagle-project-1',
+ displayName: 'Letter',
+ documentFileName: 'letter.pdf',
+ type: 'type-1',
+ milestone: 'milestone-1',
+ projectPhase: 'phase-1',
+ documentAuthorType: 'author-1',
+ datePosted: '2020-01-02T00:00:00.000Z',
+ legislation: 2018,
+ read: ['staff', 'public']
+};
+
+describe('DocumentEditComponent', () => {
+ let http: HttpTestingController;
+ let storage: StorageService;
+
+ beforeEach(async () => {
+ const configService = {
+ listsSignal: signal([]),
+ ensureListsLoaded: () => Promise.resolve(),
+ getApiPath: () => '/api',
+ getDemiApiPath: () => '/demi/api'
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [DocumentEditComponent],
+ providers: [
+ provideRouter([]),
+ provideHttpClient(withInterceptorsFromDi()),
+ provideHttpClientTesting(),
+ { provide: ConfigService, useValue: configService },
+ { provide: KeycloakService, useValue: { keycloakEnabled: true } },
+ { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error', 'success']) },
+ { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['debug', 'error', 'warn', 'info']) }
+ ]
+ }).compileComponents();
+
+ http = TestBed.inject(HttpTestingController);
+ storage = TestBed.inject(StorageService);
+ storage.state = { type: 'currentProject', data: { type: 'currentProject', data: { _id: 'route-project', name: 'Project' } } };
+ spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true);
+ });
+
+ afterEach(() => http.verify());
+
+ function render(rows: any[]) {
+ storage.state = { type: 'selectedDocs', data: rows };
+ const fixture = TestBed.createComponent(DocumentEditComponent);
+ fixture.detectChanges();
+ return fixture;
+ }
+
+ function button(fixture, label: string): HTMLButtonElement | undefined {
+ return Array.from(fixture.nativeElement.querySelectorAll('button'))
+ .find(b => b.textContent.trim() === label);
+ }
+
+ it('saves a native row to eagle-api by its Eagle id and Eagle project id', () => {
+ const fixture = render([nativeRow]);
+
+ button(fixture, 'Save').click();
+
+ const req = http.expectOne('/api/document/eagle-doc-1');
+ expect(req.request.method).toBe('PUT');
+ const body = req.request.body as FormData;
+ expect(body.get('project')).toBe('eagle-project-1');
+ expect(body.get('type')).toBe('type-1');
+ expect(body.get('milestone')).toBe('milestone-1');
+ req.flush({});
+ });
+
+ it('saves a legacy row by its _id', () => {
+ const fixture = render([legacyRow]);
+
+ button(fixture, 'Save').click();
+
+ const req = http.expectOne('/api/document/legacy-doc-1');
+ expect(req.request.method).toBe('PUT');
+ expect((req.request.body as FormData).get('project')).toBe('eagle-project-1');
+ req.flush({});
+ });
+
+ it('unpublishes a published native row by its Eagle id', () => {
+ const fixture = render([{ ...nativeRow, isPublished: true }]);
+
+ button(fixture, 'Save & Unpublish').click();
+
+ const req = http.expectOne('/api/document/eagle-doc-1/unpublish');
+ expect(req.request.method).toBe('PUT');
+ req.flush({});
+ http.expectOne('/api/document/eagle-doc-1').flush({});
+ });
+
+ it('shows no save for a row with no Eagle id, and says why', () => {
+ const fixture = render([{ ...nativeRow, eagleId: null }]);
+
+ expect(button(fixture, 'Save')).toBeUndefined();
+ expect(button(fixture, 'Save & Publish')).toBeUndefined();
+ expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('no Eagle record');
+ });
+});
diff --git a/src/app/project/project-documents/document-edit/document-edit.component.ts b/src/app/project/project-documents/document-edit/document-edit.component.ts
index 9fe7d7436..6e6f63154 100644
--- a/src/app/project/project-documents/document-edit/document-edit.component.ts
+++ b/src/app/project/project-documents/document-edit/document-edit.component.ts
@@ -12,6 +12,8 @@ import { StorageService } from 'src/app/services/storage.service';
import { convertJSDateToNGBDate, convertFormGroupNGBDateToJSDate } from 'src/app/shared/utils/utils';
import { LoggingService } from 'src/app/services/logging.service';
import { List } from 'src/app/models/demi/list';
+import { eagleWriteId, eagleWriteProjectId } from 'src/app/models/demi/document-write';
+import { legacyDocumentRow } from './legacy-document-row';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -77,15 +79,20 @@ export class DocumentEditComponent implements OnInit {
public docNameInvalid = false;
public dateInvalid = false;
public legislationYear = '1996';
+ /** Set when a selected row has no Eagle record: eagle-api cannot write it, so saving is hidden. */
+ public readOnlyReason: string = null;
ngOnInit() {
- this.documents = this.storageService.state.selectedDocs;
+ this.documents = (this.storageService.state.selectedDocs ?? []).map(legacyDocumentRow);
this.currentProject = this.storageService.currentProjectData;
this.logger.debug('documents loaded', 'DocumentEditComponent', this.documents);
// Check if documents are null (nav straight to this page)
if (!this.documents || this.documents.length === 0) {
this.router.navigate(['p', this.currentProject._id, 'project-documents']);
} else {
+ if (this.documents.some(doc => !eagleWriteId(doc))) {
+ this.readOnlyReason = 'A selected document exists only in DEMI and has no Eagle record, so it cannot be edited here.';
+ }
this.legislationYear = this.documents[0].legislation ? this.documents[0].legislation.toString() : this.legislationYear;
this.buildForm();
this.configService.ensureListsLoaded();
@@ -109,7 +116,9 @@ export class DocumentEditComponent implements OnInit {
populateForm() {
if (this.documents.length === 1) {
// todo: figure out publish see barakas code
- this.isPublished = this.documents[0].read.includes('public');
+ const doc = this.documents[0];
+ // Native rows hide `read` and carry `isPublished` instead.
+ this.isPublished = doc.read ? doc.read.includes('public') : !!doc.isPublished;
// Set the old data in there if it exists.
if (this.documents[0].type) { this.myForm.controls.doctypesel.setValue(this.documents[0].type); }
@@ -186,6 +195,7 @@ export class DocumentEditComponent implements OnInit {
}
save() {
+ if (this.readOnlyReason) { return; }
this.loading = true;
// Save all the elements to all the documents.
@@ -199,7 +209,7 @@ export class DocumentEditComponent implements OnInit {
this.documents.map(doc => {
const formData = new FormData();
- formData.append('project', this.currentProject._id);
+ formData.append('project', eagleWriteProjectId(doc) || this.currentProject._id);
formData.append('documentSource', 'PROJECT');
if (!this.multiEdit) {
@@ -226,7 +236,7 @@ export class DocumentEditComponent implements OnInit {
// TODO
formData.append('labels', JSON.stringify(theLabels));
formData.append('legislation', this.legislationYear);
- observables.push(this.documentService.update(formData, doc._id));
+ observables.push(this.documentService.update(formData, eagleWriteId(doc)));
});
// multi edit should not be set to false
@@ -264,32 +274,28 @@ export class DocumentEditComponent implements OnInit {
}
public togglePublish() {
+ if (this.readOnlyReason) { return; }
this.isPublished = !this.isPublished;
- const observables = [];
- this.documents.map(doc => {
- if (this.isPublished) {
- observables.push(this.documentService.publish(doc._id));
- } else {
- observables.push(this.documentService.unPublish(doc._id));
- }
- forkJoin(observables)
- .pipe(takeUntilDestroyed(this.destroyRef))
- .subscribe(
- () => {},
- error => {
- this.logger.error('update document publish status failed', 'DocumentEditComponent', error);
- alert('Uh-oh, couldn\'t update document\'s publish status');
- },
- () => {
- this.save();
- if (this.isPublished) {
- this.toastService.success('This document has been published.');
- } else {
- this.toastService.success('This document has been unpublished.');
- }
+ const observables = this.documents.map(doc => this.isPublished
+ ? this.documentService.publish(eagleWriteId(doc))
+ : this.documentService.unPublish(eagleWriteId(doc)));
+ forkJoin(observables)
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe(
+ () => {},
+ error => {
+ this.logger.error('update document publish status failed', 'DocumentEditComponent', error);
+ alert('Uh-oh, couldn\'t update document\'s publish status');
+ },
+ () => {
+ this.save();
+ if (this.isPublished) {
+ this.toastService.success('This document has been published.');
+ } else {
+ this.toastService.success('This document has been unpublished.');
}
- );
- });
+ }
+ );
}
register(myForm: UntypedFormGroup) {
diff --git a/src/app/project/project-documents/document-edit/legacy-document-row.spec.ts b/src/app/project/project-documents/document-edit/legacy-document-row.spec.ts
new file mode 100644
index 000000000..27bf55f0d
--- /dev/null
+++ b/src/app/project/project-documents/document-edit/legacy-document-row.spec.ts
@@ -0,0 +1,53 @@
+import { isSelectableDocument, legacyDocumentRow, nativeDocumentSort } from './legacy-document-row';
+
+describe('isSelectableDocument', () => {
+ it('accepts a row of the screen\'s source with an Eagle id', () => {
+ expect(isSelectableDocument({ _id: 'e1', documentSource: 'PROJECT' }, 'PROJECT')).toBeTrue();
+ });
+
+ it('refuses a comment attachment on a project screen', () => {
+ expect(isSelectableDocument({ _id: 'e1', documentSource: 'COMMENT' }, 'PROJECT')).toBeFalse();
+ });
+
+ it('refuses a row with no Eagle id', () => {
+ expect(isSelectableDocument({ _id: null, documentSource: 'PROJECT' }, 'PROJECT')).toBeFalse();
+ });
+});
+
+describe('legacyDocumentRow', () => {
+ it('returns a legacy row unchanged', () => {
+ const legacy = { _id: 'e1', id: 'ignored', project: 'p1', type: 'type-1' };
+
+ expect(legacyDocumentRow(legacy)).toBe(legacy);
+ });
+
+ it('maps a native row to Eagle ids and list ids', () => {
+ const row = legacyDocumentRow({
+ id: 'uuid', eagleId: 'e1', projectId: 'demi-p', projectEagleId: 'p1',
+ type: 'Letter', typeId: 'type-1', milestoneId: 'm1', projectPhaseId: 'ph1', documentAuthorTypeId: 'a1'
+ });
+
+ expect(row._id).toBe('e1');
+ expect(row.project).toBe('p1');
+ expect(row.type).toBe('type-1');
+ expect(row.milestone).toBe('m1');
+ expect(row.projectPhase).toBe('ph1');
+ expect(row.documentAuthorType).toBe('a1');
+ });
+
+ it('leaves _id null for a native row with no Eagle record', () => {
+ expect(legacyDocumentRow({ id: 'uuid', projectId: 'demi-p' })._id).toBeNull();
+ });
+});
+
+describe('nativeDocumentSort', () => {
+ it('keeps the sort keys DEMI allows, without a + prefix', () => {
+ expect(nativeDocumentSort('-datePosted')).toBe('-datePosted');
+ expect(nativeDocumentSort('+displayName')).toBe('displayName');
+ });
+
+ it('drops keys DEMI cannot sort by', () => {
+ expect(nativeDocumentSort('+type')).toBeUndefined();
+ expect(nativeDocumentSort(null)).toBeUndefined();
+ });
+});
diff --git a/src/app/project/project-documents/document-edit/legacy-document-row.ts b/src/app/project/project-documents/document-edit/legacy-document-row.ts
new file mode 100644
index 000000000..64fded29e
--- /dev/null
+++ b/src/app/project/project-documents/document-edit/legacy-document-row.ts
@@ -0,0 +1,42 @@
+import { DemiDocument, DemiDocumentFilters } from 'src/app/models/demi/document';
+import { eagleWriteId, eagleWriteProjectId } from 'src/app/models/demi/document-write';
+
+/**
+ * Document row in the Eagle field names the edit form and document pickers read.
+ * A row with `_id` is legacy and comes back unchanged; a native row maps its list ids and Eagle ids,
+ * with `_id` null when it has no Eagle record.
+ */
+export function legacyDocumentRow(row: any): any {
+ if (row._id || !row.id) { return row; }
+ const doc = row as DemiDocument;
+ return {
+ ...doc,
+ _id: eagleWriteId(doc),
+ project: eagleWriteProjectId(doc),
+ type: doc.typeId ?? null,
+ milestone: doc.milestoneId ?? null,
+ projectPhase: doc.projectPhaseId ?? null,
+ documentAuthorType: doc.documentAuthorTypeId ?? null
+ };
+}
+
+/**
+ * Whether a mapped row may be linked, published or deleted on a screen for `documentSource` documents.
+ * Those writes go to eagle-api by Eagle id, and `GET /documents` also returns other sources, such as comment attachments.
+ */
+export function isSelectableDocument(row: { _id?: string | null; documentSource?: string }, documentSource: string): boolean {
+ return !!row._id && row.documentSource === documentSource;
+}
+
+/** `GET /documents` filters for one parent's documents of one `documentSource`. */
+export function sourceFilters(project: string, documentSource: string): DemiDocumentFilters {
+ // Typed, not cast: `documentSource` is not in DemiDocumentFilters on this branch yet.
+ const filters: DemiDocumentFilters & { documentSource: string } = { project, documentSource };
+ return filters;
+}
+
+/** Table sort (`-datePosted`, `+displayName`) as a native documents `sort`; keys DEMI cannot sort by are dropped. */
+export function nativeDocumentSort(sortBy: string | null | undefined): string | undefined {
+ const key = sortBy?.replace(/^\+/, '');
+ return key && /^-?(datePosted|displayName)$/.test(key) ? key : undefined;
+}
|