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) { + + }
+ @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; +}