From f029a7a475938efbf61ae99b5727727d1369031c Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 03:14:07 +0000 Subject: [PATCH 1/4] feat(documents): edit and pick native document rows - Document edit maps native rows to Eagle field names and writes by Eagle document and project id; a row with no Eagle record shows no save and says why. - Comment period and notification document pickers read DEMI /documents by the DEMI project id or the notification id. Rows with no Eagle id are left out, since links and publish go to eagle-api. - Columns DEMI cannot sort by are no longer sortable. --- .../add-documents.component.spec.ts | 93 ++++++++++++ .../add-documents/add-documents.component.ts | 99 ++++++------- ...ication-document-table-rows.component.html | 2 +- ...t-notification-documents.component.spec.ts | 57 ++++++++ ...roject-notification-documents.component.ts | 88 ++++-------- .../document-edit.component.html | 5 + .../document-edit.component.spec.ts | 132 ++++++++++++++++++ .../document-edit/document-edit.component.ts | 62 ++++---- .../document-edit/legacy-document-row.spec.ts | 39 ++++++ .../document-edit/legacy-document-row.ts | 27 ++++ 10 files changed, 464 insertions(+), 140 deletions(-) create mode 100644 src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.spec.ts create mode 100644 src/app/project-notification/documents/project-notification-documents.component.spec.ts create mode 100644 src/app/project/project-documents/document-edit/document-edit.component.spec.ts create mode 100644 src/app/project/project-documents/document-edit/legacy-document-row.spec.ts create mode 100644 src/app/project/project-documents/document-edit/legacy-document-row.ts 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..1c2f9c7cf --- /dev/null +++ b/src/app/comment-periods/add-edit-comment-period/add-documents/add-documents.component.spec.ts @@ -0,0 +1,93 @@ +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; + + beforeEach(async () => { + 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: 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: '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' }); + const fixture = 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.get('page')).toBe('1'); + expect(req.request.params.get('pageSize')).toBe('10'); + expect(req.request.params.get('count')).toBe('true'); + expect(req.request.params.has('documentSource')).toBeFalse(); + req.flush([ + { id: 'uuid-1', eagleId: 'eagle-doc-1', projectId: 'demi-project-1', displayName: 'Report' }, + { id: 'uuid-2', eagleId: null, projectId: 'demi-project-1', displayName: 'DEMI only' } + ], { headers: { 'x-total-count': '2' } }); + + // DEMI-only rows cannot be linked to an eagle-api comment period. + expect(fixture.componentInstance.documentTableData.data.map(d => d._id)).toEqual(['eagle-doc-1']); + }); + + 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('lists a notification\'s documents by the notification id', () => { + setProject({ _id: 'notification-1', name: 'Notice' }, 'currentProjectNotification'); + render(); + + const req = http.expectOne(r => r.url === '/demi/api/documents'); + expect(req.request.params.get('project')).toBe('notification-1'); + req.flush([]); + }); +}); 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..fdd6bbe2f 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,18 @@ 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 { legacyDocumentRow, nativeDocumentSort } from 'src/app/project/project-documents/document-edit/legacy-document-row'; import { DatePipe } from '@angular/common'; import { RouterModule } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -36,7 +38,7 @@ 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); @@ -69,12 +71,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 +133,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 + ); } } @@ -267,6 +244,35 @@ export class AddDocumentComponent implements OnInit { } } + private loadDocuments(page: number, pageSize: number, sortBy: string, keywords: string, onLoaded?: () => void) { + this.documentsProjectId().pipe( + switchMap(project => project + ? this.api.listNative({ page, pageSize, count: true, sort: nativeDocumentSort(sortBy), q: keywords || undefined, filters: { project } }) + : of({ rows: [], total: 0 })), + takeUntilDestroyed(this.destroyRef) + ).subscribe(res => { + this.tableParams.totalListItems = res.total ?? 0; + // A comment period links documents by Eagle id, so DEMI-only rows cannot be attached. + this.documents = res.rows.map(legacyDocumentRow).filter(doc => doc._id); + 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(() => of(null)) + ); + } + setDocumentRowData() { const documentList = []; if (this.documents && this.documents.length > 0) { @@ -316,22 +322,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..7bf44165f 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 @@ -14,7 +14,7 @@

- {{document.status === 'unpublished' ? 'cloud_off' : 'cloud_done'}} + {{document.isPublished ? 'cloud_done' : 'cloud_off'}}

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..dfdcf60fb --- /dev/null +++ b/src/app/project-notification/documents/project-notification-documents.component.spec.ts @@ -0,0 +1,57 @@ +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.has('documentSource')).toBeFalse(); + req.flush([ + { id: 'uuid-1', eagleId: 'eagle-doc-1', projectId: 'notification-1', 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(); + }); +}); 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..0e9454de1 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 { legacyDocumentRow, nativeDocumentSort } 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 { @@ -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,33 @@ 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: { project: notificationId } + }) .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 => { + // Publish, unpublish and delete go to eagle-api by Eagle id, so DEMI-only rows are left out. + this.docs = res.rows.map(legacyDocumentRow).filter(doc => doc._id); + 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); }); } @@ -366,17 +359,14 @@ export class ProjectNotificationDocumentsComponent implements OnInit { 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..262149006 --- /dev/null +++ b/src/app/project/project-documents/document-edit/document-edit.component.spec.ts @@ -0,0 +1,132 @@ +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'; + +const nativeRow = { + 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..bbeb0f836 --- /dev/null +++ b/src/app/project/project-documents/document-edit/legacy-document-row.spec.ts @@ -0,0 +1,39 @@ +import { legacyDocumentRow, nativeDocumentSort } from './legacy-document-row'; + +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..38c6e06c5 --- /dev/null +++ b/src/app/project/project-documents/document-edit/legacy-document-row.ts @@ -0,0 +1,27 @@ +import { DemiDocument } 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 + }; +} + +/** 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; +} From b91a32dc198911d49e4a23a547185804a1237258 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 03:54:02 +0000 Subject: [PATCH 2/4] fix(documents): filter pickers by documentSource - Send documentSource (PROJECT, or PROJECT-NOTIFICATION) so comment attachments stay out of the comment period picker and the notification document list. - Keep rows with no Eagle id on the page so paging and totals match DEMI; their checkbox is disabled and select-all skips them. - Toast and log when the DEMI project lookup fails. --- .../add-document-table-rows.component.html | 3 +- .../add-document-table-rows.component.ts | 4 +++ .../add-documents.component.spec.ts | 34 +++++++++++++++---- .../add-documents/add-documents.component.ts | 32 ++++++++++++----- ...ication-document-table-rows.component.html | 3 +- ...ification-document-table-rows.component.ts | 4 +++ ...t-notification-documents.component.spec.ts | 2 +- ...roject-notification-documents.component.ts | 13 +++---- .../document-edit.component.spec.ts | 4 ++- .../document-edit/legacy-document-row.ts | 5 +++ 10 files changed, 79 insertions(+), 25 deletions(-) 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..6e07bc99e 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..b2817d527 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,6 +5,7 @@ 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'; +import { isSelectableDocument } from 'src/app/project/project-documents/document-edit/legacy-document-row'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -44,7 +45,10 @@ export class AddDocumentTableRowsComponent implements OnInit, TableComponent { } } + readonly isSelectable = isSelectableDocument; + selectItem(item) { + if (!isSelectableDocument(item)) { 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 index 1c2f9c7cf..b220f88ae 100644 --- 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 @@ -13,8 +13,10 @@ 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: [ @@ -31,7 +33,7 @@ describe('AddDocumentComponent', () => { }, { provide: ConfigService, useValue: { getApiPath: () => '/api', getDemiApiPath: () => '/demi/api' } }, { provide: KeycloakService, useValue: { keycloakEnabled: true } }, - { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error', 'success']) }, + { provide: ToastService, useValue: toast }, { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['debug', 'error', 'warn', 'info']) } ] }).compileComponents(); @@ -55,21 +57,30 @@ describe('AddDocumentComponent', () => { it('lists the project documents from DEMI by the DEMI project id', () => { setProject({ _id: 'eagle-project-1', demiId: 'demi-project-1', name: 'Project' }); - const fixture = render(); + 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'); - expect(req.request.params.has('documentSource')).toBeFalse(); - req.flush([ + req.flush([]); + }); + + it('keeps DEMI-only rows on the page but lets select-all link only rows 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', displayName: 'Report' }, { id: 'uuid-2', eagleId: null, projectId: 'demi-project-1', displayName: 'DEMI only' } ], { headers: { 'x-total-count': '2' } }); - // DEMI-only rows cannot be linked to an eagle-api comment period. - expect(fixture.componentInstance.documentTableData.data.map(d => d._id)).toEqual(['eagle-doc-1']); + fixture.componentInstance.selectAction('selectAll'); + + expect(fixture.componentInstance.documentTableData.data.length).toBe(2); + expect(storage.state.selectedDocumentsForCP.data.map(d => d._id)).toEqual(['eagle-doc-1']); + expect(fixture.componentInstance.selectedCount).toBe(1); }); it('resolves the DEMI project id when the stored project has none', () => { @@ -82,12 +93,23 @@ describe('AddDocumentComponent', () => { 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', () => { setProject({ _id: 'notification-1', name: 'Notice' }, 'currentProjectNotification'); 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([]); }); }); 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 fdd6bbe2f..7ebb5bba5 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 @@ -12,7 +12,9 @@ import { TableObject, TableColumn } from 'src/app/shared/components/table-templa 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 { legacyDocumentRow, nativeDocumentSort } from 'src/app/project/project-documents/document-edit/legacy-document-row'; +import { DemiDocumentFilters } from 'src/app/models/demi/document'; +import { isSelectableDocument, legacyDocumentRow, nativeDocumentSort } 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'; @@ -42,6 +44,7 @@ export class AddDocumentComponent implements OnInit { storageService = inject(StorageService); tableTemplateUtils = inject(TableTemplateUtils); private logger = inject(LoggingService); + private toast = inject(ToastService); private destroyRef = inject(DestroyRef); public terms = new SearchTerms(); @@ -171,7 +174,8 @@ export class AddDocumentComponent implements OnInit { } }); - this.documentTableData.data.map((item) => { + const selectable = this.documentTableData.data.filter(isSelectableDocument); + selectable.forEach((item) => { item.checkbox = !someSelected; if (item.checkbox) { this.storageService.state.selectedDocumentsForCP.data.push(item); @@ -180,7 +184,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': @@ -245,15 +249,22 @@ export class AddDocumentComponent implements OnInit { } private loadDocuments(page: number, pageSize: number, sortBy: string, keywords: string, onLoaded?: () => void) { + const notification = this.storageService.state.currentProject?.type === 'currentProjectNotification'; + // DEMI PR #592 adds this filter; the shared filter type does not list it yet. + const documentSource = notification ? 'PROJECT-NOTIFICATION' : 'PROJECT'; this.documentsProjectId().pipe( - switchMap(project => project - ? this.api.listNative({ page, pageSize, count: true, sort: nativeDocumentSort(sortBy), q: keywords || undefined, filters: { project } }) - : of({ rows: [], total: 0 })), + switchMap(project => { + if (!project) { + this.toast.error('Could not load documents for this project.'); + return of({ rows: [], total: 0 }); + } + const filters = { project, documentSource } as DemiDocumentFilters; + return this.api.listNative({ page, pageSize, count: true, sort: nativeDocumentSort(sortBy), q: keywords || undefined, filters }); + }), takeUntilDestroyed(this.destroyRef) ).subscribe(res => { this.tableParams.totalListItems = res.total ?? 0; - // A comment period links documents by Eagle id, so DEMI-only rows cannot be attached. - this.documents = res.rows.map(legacyDocumentRow).filter(doc => doc._id); + this.documents = res.rows.map(legacyDocumentRow); onLoaded?.(); this.setDocumentRowData(); this.loading = false; @@ -269,7 +280,10 @@ export class AddDocumentComponent implements OnInit { 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(() => of(null)) + catchError(error => { + this.logger.error('DEMI project lookup failed', 'AddDocumentComponent', error); + return of(null); + }) ); } 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 7bf44165f..a6e9e26af 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}} 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..788484757 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,6 +1,7 @@ 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 { isSelectableDocument } from 'src/app/project/project-documents/document-edit/legacy-document-row'; import { RouterModule } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -41,7 +42,10 @@ export class PnDocumentTableRowsComponent implements OnInit, TableComponent { this.useSmallTable = this.smallTable(); } + readonly isSelectable = isSelectableDocument; + selectItem(item) { + if (!isSelectableDocument(item)) { 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 index dfdcf60fb..fcda428f1 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.spec.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.spec.ts @@ -45,7 +45,7 @@ describe('ProjectNotificationDocumentsComponent', () => { 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.has('documentSource')).toBeFalse(); + expect(req.request.params.getAll('documentSource')).toEqual(['PROJECT-NOTIFICATION']); req.flush([ { id: 'uuid-1', eagleId: 'eagle-doc-1', projectId: 'notification-1', displayName: 'Notice letter', isPublished: true } ], { headers: { 'x-total-count': '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 0e9454de1..7bf777248 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.ts @@ -14,7 +14,8 @@ import { TableParamsObject } from 'src/app/shared/components/table-template/tabl import { Constants } from 'src/app/shared/utils/constants'; import { encodeString } from 'src/app/shared/utils/utils'; import { PnDocumentTableRowsComponent } from './project-notification-document-table-rows/project-notification-document-table-rows.component'; -import { legacyDocumentRow, nativeDocumentSort } from 'src/app/project/project-documents/document-edit/legacy-document-row'; +import { DemiDocumentFilters } from 'src/app/models/demi/document'; +import { isSelectableDocument, legacyDocumentRow, nativeDocumentSort } 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'; @@ -136,12 +137,12 @@ export class ProjectNotificationDocumentsComponent implements OnInit { tableData.data.forEach(item => { if (item.checkbox === true) { someSelected = true; } }); - tableData.data.forEach(item => { + tableData.data.filter(isSelectableDocument).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(isSelectableDocument).length ?? 0) }); this.setPublishUnpublish(); break; case 'delete': @@ -338,12 +339,12 @@ export class ProjectNotificationDocumentsComponent implements OnInit { pageSize: this.tableParams.pageSize, count: true, sort: nativeDocumentSort(this.tableParams.sortBy) ?? '-datePosted', - filters: { project: notificationId } + // DEMI PR #592 adds `documentSource`; the shared filter type does not list it yet. + filters: { project: notificationId, documentSource: 'PROJECT-NOTIFICATION' } as DemiDocumentFilters }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(res => { - // Publish, unpublish and delete go to eagle-api by Eagle id, so DEMI-only rows are left out. - this.docs = res.rows.map(legacyDocumentRow).filter(doc => doc._id); + this.docs = res.rows.map(legacyDocumentRow); this.tableParams.totalListItems = res.total ?? 0; this.setRowData(); onLoaded?.(); 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 index 262149006..274ae20d1 100644 --- 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 @@ -9,8 +9,10 @@ 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'; -const nativeRow = { +// 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', 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 index 38c6e06c5..924b6fb07 100644 --- a/src/app/project/project-documents/document-edit/legacy-document-row.ts +++ b/src/app/project/project-documents/document-edit/legacy-document-row.ts @@ -20,6 +20,11 @@ export function legacyDocumentRow(row: any): any { }; } +/** A mapped row can be linked, published or deleted only when it has an Eagle id: those writes go to eagle-api. */ +export function isSelectableDocument(row: { _id?: string | null }): boolean { + return !!row._id; +} + /** 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(/^\+/, ''); From 6ad40be081648f2ab04de123fdae6c4779adcc51 Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 04:10:20 +0000 Subject: [PATCH 3/4] fix(documents): gate picker rows by documentSource DEMI main answers 400 to a documentSource filter, so the pickers stop sending it. Rows of another source, such as comment attachments, and rows with no Eagle id stay on the page with their checkbox disabled, and select-all skips them. --- .../add-document-table-rows.component.html | 4 +- .../add-document-table-rows.component.ts | 6 +-- .../add-documents.component.spec.ts | 42 ++++++++++++++----- .../add-documents/add-documents.component.ts | 11 ++--- ...ication-document-table-rows.component.html | 4 +- ...ification-document-table-rows.component.ts | 8 +--- ...t-notification-documents.component.spec.ts | 20 ++++++++- ...roject-notification-documents.component.ts | 9 ++-- .../document-edit/legacy-document-row.spec.ts | 16 ++++++- .../document-edit/legacy-document-row.ts | 9 ++-- 10 files changed, 86 insertions(+), 43 deletions(-) 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 6e07bc99e..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,8 +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 b2817d527..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,8 +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'; -import { isSelectableDocument } from 'src/app/project/project-documents/document-edit/legacy-document-row'; - @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'tbody[app-add-document-table-rows]', @@ -45,10 +43,8 @@ export class AddDocumentTableRowsComponent implements OnInit, TableComponent { } } - readonly isSelectable = isSelectableDocument; - selectItem(item) { - if (!isSelectableDocument(item)) { return; } + 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 index b220f88ae..6df094e07 100644 --- 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 @@ -61,28 +61,45 @@ describe('AddDocumentComponent', () => { 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']); + // DEMI main answers 400 to a `documentSource` filter. + expect(req.request.params.has('documentSource')).toBeFalse(); 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('keeps DEMI-only rows on the page but lets select-all link only rows with an Eagle id', () => { + it('keeps every row on the page but 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', displayName: 'Report' }, - { id: 'uuid-2', eagleId: null, projectId: 'demi-project-1', displayName: 'DEMI only' } - ], { headers: { 'x-total-count': '2' } }); + { 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(2); + 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(); @@ -103,13 +120,18 @@ describe('AddDocumentComponent', () => { http.expectNone(r => r.url === '/demi/api/documents'); }); - it('lists a notification\'s documents by the notification id', () => { + it('lists a notification\'s documents by the notification id and links only notification documents', () => { setProject({ _id: 'notification-1', name: 'Notice' }, 'currentProjectNotification'); - render(); + 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([]); + 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 7ebb5bba5..79cfe211f 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 @@ -12,7 +12,6 @@ import { TableObject, TableColumn } from 'src/app/shared/components/table-templa 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 { DemiDocumentFilters } from 'src/app/models/demi/document'; import { isSelectableDocument, legacyDocumentRow, nativeDocumentSort } from 'src/app/project/project-documents/document-edit/legacy-document-row'; import { ToastService } from 'src/app/services/toast.service'; import { DatePipe } from '@angular/common'; @@ -174,7 +173,7 @@ export class AddDocumentComponent implements OnInit { } }); - const selectable = this.documentTableData.data.filter(isSelectableDocument); + const selectable = this.documentTableData.data.filter(item => item.selectable); selectable.forEach((item) => { item.checkbox = !someSelected; if (item.checkbox) { @@ -249,17 +248,13 @@ export class AddDocumentComponent implements OnInit { } private loadDocuments(page: number, pageSize: number, sortBy: string, keywords: string, onLoaded?: () => void) { - const notification = this.storageService.state.currentProject?.type === 'currentProjectNotification'; - // DEMI PR #592 adds this filter; the shared filter type does not list it yet. - const documentSource = notification ? 'PROJECT-NOTIFICATION' : 'PROJECT'; this.documentsProjectId().pipe( switchMap(project => { if (!project) { this.toast.error('Could not load documents for this project.'); return of({ rows: [], total: 0 }); } - const filters = { project, documentSource } as DemiDocumentFilters; - return this.api.listNative({ page, pageSize, count: true, sort: nativeDocumentSort(sortBy), q: keywords || undefined, filters }); + return this.api.listNative({ page, pageSize, count: true, sort: nativeDocumentSort(sortBy), q: keywords || undefined, filters: { project } }); }), takeUntilDestroyed(this.destroyRef) ).subscribe(res => { @@ -289,10 +284,12 @@ export class AddDocumentComponent implements OnInit { setDocumentRowData() { const documentList = []; + const documentSource = this.currentProject?.type === 'currentProjectNotification' ? 'PROJECT-NOTIFICATION' : 'PROJECT'; if (this.documents && this.documents.length > 0) { this.documents.forEach(document => { documentList.push( { + selectable: isSelectableDocument(document, documentSource), displayName: document.displayName, datePosted: document.datePosted, type: document.type, 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 a6e9e26af..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,8 +2,8 @@ - + {{document.displayName}} 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 788484757..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,8 +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 { isSelectableDocument } from 'src/app/project/project-documents/document-edit/legacy-document-row'; -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,10 +40,8 @@ export class PnDocumentTableRowsComponent implements OnInit, TableComponent { this.useSmallTable = this.smallTable(); } - readonly isSelectable = isSelectableDocument; - selectItem(item) { - if (!isSelectableDocument(item)) { return; } + 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 index fcda428f1..d9048891e 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.spec.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.spec.ts @@ -45,13 +45,29 @@ describe('ProjectNotificationDocumentsComponent', () => { 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']); + // DEMI main answers 400 to a `documentSource` filter. + expect(req.request.params.has('documentSource')).toBeFalse(); req.flush([ - { id: 'uuid-1', eagleId: 'eagle-doc-1', projectId: 'notification-1', displayName: 'Notice letter', isPublished: true } + { 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 7bf777248..8aa2e9f3b 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.ts @@ -14,7 +14,6 @@ import { TableParamsObject } from 'src/app/shared/components/table-template/tabl import { Constants } from 'src/app/shared/utils/constants'; import { encodeString } from 'src/app/shared/utils/utils'; import { PnDocumentTableRowsComponent } from './project-notification-document-table-rows/project-notification-document-table-rows.component'; -import { DemiDocumentFilters } from 'src/app/models/demi/document'; import { isSelectableDocument, legacyDocumentRow, nativeDocumentSort } 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'; @@ -137,12 +136,12 @@ export class ProjectNotificationDocumentsComponent implements OnInit { tableData.data.forEach(item => { if (item.checkbox === true) { someSelected = true; } }); - tableData.data.filter(isSelectableDocument).forEach(item => { + tableData.data.filter(item => item.selectable).forEach(item => { item.checkbox = !someSelected; }); } - this.selectedCount.set({ ...this.selectedCount(), total: someSelected ? 0 : (tableData?.data.filter(isSelectableDocument).length ?? 0) }); + this.selectedCount.set({ ...this.selectedCount(), total: someSelected ? 0 : (tableData?.data.filter(item => item.selectable).length ?? 0) }); this.setPublishUnpublish(); break; case 'delete': @@ -339,8 +338,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit { pageSize: this.tableParams.pageSize, count: true, sort: nativeDocumentSort(this.tableParams.sortBy) ?? '-datePosted', - // DEMI PR #592 adds `documentSource`; the shared filter type does not list it yet. - filters: { project: notificationId, documentSource: 'PROJECT-NOTIFICATION' } as DemiDocumentFilters + filters: { project: notificationId } }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(res => { @@ -356,6 +354,7 @@ 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, 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 index bbeb0f836..27bf55f0d 100644 --- 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 @@ -1,4 +1,18 @@ -import { legacyDocumentRow, nativeDocumentSort } from './legacy-document-row'; +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', () => { 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 index 924b6fb07..45a26e742 100644 --- a/src/app/project/project-documents/document-edit/legacy-document-row.ts +++ b/src/app/project/project-documents/document-edit/legacy-document-row.ts @@ -20,9 +20,12 @@ export function legacyDocumentRow(row: any): any { }; } -/** A mapped row can be linked, published or deleted only when it has an Eagle id: those writes go to eagle-api. */ -export function isSelectableDocument(row: { _id?: string | null }): boolean { - return !!row._id; +/** + * 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; } /** Table sort (`-datePosted`, `+displayName`) as a native documents `sort`; keys DEMI cannot sort by are dropped. */ From 831661742360a7ff881719607e8bfbf2d185527f Mon Sep 17 00:00:00 2001 From: Daniel Truong Date: Wed, 14 Oct 2026 04:12:15 +0000 Subject: [PATCH 4/4] fix(documents): filter pickers by documentSource again DEMI accepts the documentSource filter now, so pages and totals hold only the screen's own source. The per-row source check stays as a second guard. --- .../add-documents/add-documents.component.spec.ts | 6 +++--- .../add-documents/add-documents.component.ts | 14 ++++++++++---- ...roject-notification-documents.component.spec.ts | 3 +-- .../project-notification-documents.component.ts | 4 ++-- .../document-edit/legacy-document-row.ts | 9 ++++++++- 5 files changed, 24 insertions(+), 12 deletions(-) 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 index 6df094e07..183346c5a 100644 --- 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 @@ -61,15 +61,14 @@ describe('AddDocumentComponent', () => { const req = http.expectOne(r => r.url === '/demi/api/documents'); expect(req.request.params.get('project')).toBe('demi-project-1'); - // DEMI main answers 400 to a `documentSource` filter. - expect(req.request.params.has('documentSource')).toBeFalse(); + 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('keeps every row on the page but lets select-all link only project documents with an Eagle id', () => { + 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([ @@ -126,6 +125,7 @@ describe('AddDocumentComponent', () => { 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' } 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 79cfe211f..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 @@ -12,7 +12,7 @@ import { TableObject, TableColumn } from 'src/app/shared/components/table-templa 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 } from 'src/app/project/project-documents/document-edit/legacy-document-row'; +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'; @@ -247,6 +247,10 @@ 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 => { @@ -254,7 +258,10 @@ export class AddDocumentComponent implements OnInit { 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: { project } }); + return this.api.listNative({ + page, pageSize, count: true, sort: nativeDocumentSort(sortBy), q: keywords || undefined, + filters: sourceFilters(project, this.documentSource) + }); }), takeUntilDestroyed(this.destroyRef) ).subscribe(res => { @@ -284,12 +291,11 @@ export class AddDocumentComponent implements OnInit { setDocumentRowData() { const documentList = []; - const documentSource = this.currentProject?.type === 'currentProjectNotification' ? 'PROJECT-NOTIFICATION' : 'PROJECT'; if (this.documents && this.documents.length > 0) { this.documents.forEach(document => { documentList.push( { - selectable: isSelectableDocument(document, documentSource), + selectable: isSelectableDocument(document, this.documentSource), displayName: document.displayName, datePosted: document.datePosted, type: document.type, 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 index d9048891e..3c87ecc60 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.spec.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.spec.ts @@ -45,8 +45,7 @@ describe('ProjectNotificationDocumentsComponent', () => { 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'); - // DEMI main answers 400 to a `documentSource` filter. - expect(req.request.params.has('documentSource')).toBeFalse(); + 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' } }); 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 8aa2e9f3b..4cf30e9b4 100644 --- a/src/app/project-notification/documents/project-notification-documents.component.ts +++ b/src/app/project-notification/documents/project-notification-documents.component.ts @@ -14,7 +14,7 @@ import { TableParamsObject } from 'src/app/shared/components/table-template/tabl import { Constants } from 'src/app/shared/utils/constants'; import { encodeString } from 'src/app/shared/utils/utils'; import { PnDocumentTableRowsComponent } from './project-notification-document-table-rows/project-notification-document-table-rows.component'; -import { isSelectableDocument, legacyDocumentRow, nativeDocumentSort } from 'src/app/project/project-documents/document-edit/legacy-document-row'; +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'; @@ -338,7 +338,7 @@ export class ProjectNotificationDocumentsComponent implements OnInit { pageSize: this.tableParams.pageSize, count: true, sort: nativeDocumentSort(this.tableParams.sortBy) ?? '-datePosted', - filters: { project: notificationId } + filters: sourceFilters(notificationId, 'PROJECT-NOTIFICATION') }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(res => { 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 index 45a26e742..64fded29e 100644 --- a/src/app/project/project-documents/document-edit/legacy-document-row.ts +++ b/src/app/project/project-documents/document-edit/legacy-document-row.ts @@ -1,4 +1,4 @@ -import { DemiDocument } from 'src/app/models/demi/document'; +import { DemiDocument, DemiDocumentFilters } from 'src/app/models/demi/document'; import { eagleWriteId, eagleWriteProjectId } from 'src/app/models/demi/document-write'; /** @@ -28,6 +28,13 @@ export function isSelectableDocument(row: { _id?: string | null; documentSource? 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(/^\+/, '');