diff --git a/src/app/models/demi/document.ts b/src/app/models/demi/document.ts index 65d3783f0..69f94bfbf 100644 --- a/src/app/models/demi/document.ts +++ b/src/app/models/demi/document.ts @@ -47,6 +47,8 @@ export type DemiDocumentFilters = { milestoneId?: string | string[]; projectPhaseId?: string | string[]; documentAuthorTypeId?: string | string[]; + /** `PROJECT` leaves comment attachments out. */ + documentSource?: string | string[]; isFeatured?: boolean; legislation?: number | number[]; /** ISO date, inclusive. */ diff --git a/src/app/project/project-documents/detail/detail.component.html b/src/app/project/project-documents/detail/detail.component.html index 49fc6cec5..6b6d8fcba 100644 --- a/src/app/project/project-documents/detail/detail.component.html +++ b/src/app/project/project-documents/detail/detail.component.html @@ -24,9 +24,15 @@

@@ -36,20 +42,12 @@

Display Name:

{{resolvedDocument().displayName || '-'}}

-
-

Public Views:

-

{{resolvedDocument().publicHitCount || '-'}}

-
-
-

Secure Views:

-

{{resolvedDocument().secureHitCount || '-'}}

-

- {{publishText() === 'Publish' ? 'cloud_off' : 'cloud_done'}} + {{published() ? 'cloud_done' : 'cloud_off'}} - {{publishText() === 'Publish' ? 'Not Published' : 'Published'}} + {{published() ? 'Published' : 'Not Published'}}

@@ -60,21 +58,11 @@

Filename:

Description:

{{resolvedDocument().description || '-'}}

- @if (resolvedDocument().labels) { -
-

Labels:

- @for (label of resolvedDocument().labels; track label) { -
-
{{label}}
-
- } -
- }

Type:

-

{{resolvedType()}}

+

{{resolvedDocument().type || '-'}}

Upload Date:

@@ -82,7 +70,7 @@

Upload Date:

Document Date:

- @if ($any(resolvedDocument().datePosted) !== '1900-01-01T08:00:00.000Z') { + @if (resolvedDocument().datePosted !== '1900-01-01T08:00:00.000Z') {

{{resolvedDocument().datePosted | date}}

}
@@ -92,19 +80,19 @@

Legislation:

Author:

-

{{resolvedAuthor()}}

+

{{resolvedDocument().documentAuthorType || '-'}}

Milestone:

-

{{resolvedMilestone()}}

+

{{resolvedDocument().milestone || '-'}}

Project Phase:

-

{{resolvedProjectPhase()}}

+

{{resolvedDocument().projectPhase || '-'}}

-

Filesize:

-

{{formatBytes(resolvedDocument().internalSize)}}

+

Filesize:

+

{{fileSize()}}

diff --git a/src/app/project/project-documents/detail/detail.component.spec.ts b/src/app/project/project-documents/detail/detail.component.spec.ts new file mode 100644 index 000000000..3d369677e --- /dev/null +++ b/src/app/project/project-documents/detail/detail.component.spec.ts @@ -0,0 +1,129 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { DocumentDetailComponent } from './detail.component'; +import { DemiDocument } from 'src/app/models/demi/document'; +import { ConfigService } from 'src/app/services/config.service'; +import { DocumentService } from 'src/app/services/document.service'; +import { LoggingService } from 'src/app/services/logging.service'; +import { StorageService } from 'src/app/services/storage.service'; +import { ToastService } from 'src/app/services/toast.service'; + +const nativeDoc: DemiDocument = { + id: 'uuid-1', + eagleId: 'e1', + projectId: '7', + projectEagleId: 'p1', + displayName: 'Appendix D', + documentFileName: 'Appendix D.pdf', + description: 'Decision appendix', + type: 'Decision Materials', + typeId: 't1', + milestone: 'Decision', + projectPhase: 'Referral', + documentAuthorType: 'EAO', + datePosted: '2011-09-09T19:00:00.000Z', + dateUploaded: '2011-09-09T13:06:00.000Z', + legislation: 2002, + fileSize: '411449', + isPublished: false +}; + +describe('DocumentDetailComponent', () => { + let fixture: ComponentFixture; + let documentService: jasmine.SpyObj; + let storage: any; + + function render(doc: DemiDocument) { + documentService = jasmine.createSpyObj('DocumentService', ['publish', 'unPublish', 'downloadNative']); + documentService.publish.and.returnValue(of({} as any)); + storage = { currentProjectData: { _id: 'p1', demiId: '7', name: 'Ashcroft' }, state: {} }; + TestBed.configureTestingModule({ + imports: [DocumentDetailComponent], + providers: [ + provideRouter([]), + { provide: DocumentService, useValue: documentService }, + { provide: ConfigService, useValue: { getDemiApiPath: () => '/demi/api' } }, + { provide: StorageService, useValue: storage }, + { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['success', 'error']) }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error']) } + ] + }); + fixture = TestBed.createComponent(DocumentDetailComponent); + fixture.componentRef.setInput('resolvedDocument', doc); + fixture.detectChanges(); + } + + const text = (id: string) => fixture.nativeElement.querySelector(`#${id}`)?.textContent.trim(); + + async function openActions(): Promise { + fixture.nativeElement.querySelector('#actionDropdown').click(); + fixture.detectChanges(); + await fixture.whenStable(); + return document.querySelector('[aria-labelledby="actionDropdown"]'); + } + + const menuItem = (menu: HTMLElement, label: string) => + Array.from(menu.querySelectorAll('button')).find(b => b.textContent.trim() === label); + + afterEach(() => fixture.destroy()); + + it('renders the native fields, list names included', () => { + render(nativeDoc); + + expect(text('displayName')).toBe('Appendix D'); + expect(text('documentFileName')).toBe('Appendix D.pdf'); + expect(text('type')).toBe('Decision Materials'); + expect(text('milestone')).toBe('Decision'); + expect(text('projectPhase')).toBe('Referral'); + expect(text('documentAuthor')).toBe('EAO'); + expect(text('legislation')).toBe('2002'); + expect(text('fileSize')).toBe('401.81 KB'); + expect(fixture.nativeElement.textContent).toContain('Not Published'); + }); + + it('offers Open only for a published file and says why otherwise', async () => { + render(nativeDoc); + const menu = await openActions(); + + expect(menuItem(menu, 'Open')).toBeUndefined(); + expect(menu.querySelector('#open-unavailable')).not.toBeNull(); + }); + + it('opens a published file inline through DEMI', async () => { + render({ ...nativeDoc, isPublished: true }); + const open = spyOn(window, 'open'); + const menu = await openActions(); + + menuItem(menu, 'Open').click(); + + expect(open).toHaveBeenCalledWith('/demi/api/documents/uuid-1/download?inline=1', '_blank', 'noopener'); + }); + + it('publishes by the Eagle id', async () => { + render(nativeDoc); + const menu = await openActions(); + + menuItem(menu, 'Publish').click(); + + expect(documentService.publish).toHaveBeenCalledOnceWith('e1'); + }); + + it('offers no publish toggle for a row DEMI holds alone', async () => { + render({ ...nativeDoc, eagleId: null }); + const menu = await openActions(); + + expect(menuItem(menu, 'Publish')).toBeUndefined(); + }); + + // The edit screen maps native rows itself (legacyDocumentRow), so the row goes over unchanged. + it('hands the native row to the edit screen under the Eagle project id', () => { + render(nativeDoc); + const navigate = spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true); + + fixture.componentInstance.onEdit(); + + expect(storage.state.selectedDocs).toEqual([nativeDoc]); + expect(navigate).toHaveBeenCalledOnceWith(['p', 'p1', 'project-documents', 'edit']); + }); +}); diff --git a/src/app/project/project-documents/detail/detail.component.ts b/src/app/project/project-documents/detail/detail.component.ts index 5c6a69bc4..ab937247d 100644 --- a/src/app/project/project-documents/detail/detail.component.ts +++ b/src/app/project/project-documents/detail/detail.component.ts @@ -5,7 +5,8 @@ import { ToastService } from 'src/app/services/toast.service'; import { DocumentService } from 'src/app/services/document.service'; import { StorageService } from 'src/app/services/storage.service'; import { formatBytes } from 'src/app/shared/utils/utils'; -import { Document } from 'src/app/models/document'; +import { DemiDocument } from 'src/app/models/demi/document'; +import { eagleWriteId } from 'src/app/models/demi/document-write'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { LoggingService } from 'src/app/services/logging.service'; import { ConfigService } from 'src/app/services/config.service'; @@ -28,74 +29,55 @@ export class DocumentDetailComponent implements OnInit { // Parent route (p/:projId) resolver populates StorageService before this component initializes, // for both direct URL loads and in-app navigation. Signal captures the value at construction time. public readonly project = signal(this.storageService.currentProjectData); - resolvedDocument = input.required(); + resolvedDocument = input.required(); - public readonly publishText = signal('Publish'); - public readonly formatBytes = formatBytes; - - // Lists signal from ConfigService — updates reactively when lists load - private readonly lists = this.configService.listsSignal; - - // Resolved metadata fields — recompute whenever document or lists change - public readonly resolvedType = computed(() => this.resolveListItem(this.resolvedDocument()?.type)); - public readonly resolvedAuthor = computed(() => this.resolveListItem(this.resolvedDocument()?.documentAuthorType)); - public readonly resolvedMilestone = computed(() => this.resolveListItem(this.resolvedDocument()?.milestone)); - public readonly resolvedProjectPhase = computed(() => this.resolveListItem(this.resolvedDocument()?.projectPhase)); - - private resolveListItem(id: string | null | undefined): string { - if (!id) return '-'; - const item = this.lists().find(i => i.id === id); - return item ? item.name : '-'; - } + public readonly published = signal(false); + /** Eagle id the publish and edit writes need; null for a DEMI-only row. */ + public readonly writeId = computed(() => eagleWriteId(this.resolvedDocument())); + public readonly fileSize = computed(() => { + const size = this.resolvedDocument().fileSize; + return formatBytes(size == null || size === '' ? null : Number(size)); + }); ngOnInit() { - this.publishText.set(this.resolvedDocument()?.read?.includes('public') ? 'Unpublish' : 'Publish'); - this.configService.ensureListsLoaded(); + this.published.set(this.resolvedDocument().isPublished === true); } onEdit() { const doc = this.resolvedDocument(); + const projId = this.project()._id; this.storageService.state.selectedDocs = [doc]; - this.storageService.state.labels = doc.labels; - this.storageService.state.back = { url: ['/p', doc.project, 'project-documents', 'detail', doc._id], label: 'View Document' }; - this.router.navigate(['p', doc.project, 'project-documents', 'edit']); + this.storageService.state.back = { url: ['/p', projId, 'project-documents', 'detail', doc.id], label: 'View Document' }; + this.router.navigate(['p', projId, 'project-documents', 'edit']); } + /** Opens a published file in a new tab. The navigation carries no bearer, so DEMI serves it only when public. */ openDocument() { - this.documentService.openDocument(this.resolvedDocument()); + const id = encodeURIComponent(this.resolvedDocument().id); + window.open(`${this.configService.getDemiApiPath()}/documents/${id}/download?inline=1`, '_blank', 'noopener'); } downloadDocument() { - this.documentService.downloadDocument(this.resolvedDocument()); + this.documentService.downloadNative(this.resolvedDocument().id); } togglePublish() { - const doc = this.resolvedDocument(); - if (this.publishText() === 'Publish') { - this.documentService.publish(doc._id).subscribe( - null, - error => { - this.logger.error('publish document failed', 'DocumentDetailComponent', error); - alert('Uh-oh, couldn\'t update document'); - }, - () => { - this.toastService.success('This document has been published.'); - } - ); - this.publishText.set('Unpublish'); - } else { - this.documentService.unPublish(doc._id).subscribe( - null, - error => { - this.logger.error('unpublish document failed', 'DocumentDetailComponent', error); - alert('Uh-oh, couldn\'t update document'); - }, - () => { - this.toastService.success('This document has been unpublished.'); - } - ); - this.publishText.set('Publish'); + const id = this.writeId(); + if (!id) { + return; } + const publish = !this.published(); + const write = publish ? this.documentService.publish(id) : this.documentService.unPublish(id); + write.subscribe({ + error: error => { + this.logger.error(`${publish ? 'publish' : 'unpublish'} document failed`, 'DocumentDetailComponent', error); + this.published.set(!publish); + this.toastService.error('Could not update the document.'); + }, + complete: () => { + this.toastService.success(`This document has been ${publish ? 'published' : 'unpublished'}.`); + } + }); + this.published.set(publish); } - } diff --git a/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.html b/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.html index e8027a6a7..76efb8146 100644 --- a/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.html +++ b/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.html @@ -5,11 +5,7 @@ - @if (document.project) { - {{document.displayName}} - } @else { - {{document.displayName}} - } + {{document.displayName}}

@@ -23,9 +19,9 @@

{{document.datePosted | date:'longDate'}}
} - {{document.type | listConverter}} + {{document.type}} - {{document.milestone | listConverter}} + {{document.milestone}} {{document.legislation}} diff --git a/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.spec.ts b/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.spec.ts new file mode 100644 index 000000000..9f1d8c004 --- /dev/null +++ b/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.spec.ts @@ -0,0 +1,58 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { DocumentTableRowsComponent } from './project-document-table-rows.component'; +import { DocumentService } from 'src/app/services/document.service'; +import { LoggingService } from 'src/app/services/logging.service'; +import { ToastService } from 'src/app/services/toast.service'; + +describe('DocumentTableRowsComponent', () => { + let component: DocumentTableRowsComponent; + let documentService: jasmine.SpyObj; + let toast: jasmine.SpyObj; + + // A table row as ProjectDocumentsComponent.setRowData builds it. + const row = (eagleId: string | null) => ({ id: 'uuid-1', eagleId, projectEagleId: 'p1', isFeatured: false }); + + beforeEach(() => { + documentService = jasmine.createSpyObj('DocumentService', ['feature', 'unFeature']); + documentService.feature.and.returnValue(of({} as any)); + toast = jasmine.createSpyObj('ToastService', ['error']); + TestBed.configureTestingModule({ + imports: [DocumentTableRowsComponent], + providers: [ + provideRouter([]), + { provide: DocumentService, useValue: documentService }, + { provide: ToastService, useValue: toast }, + { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error']) } + ] + }); + // No detectChanges: the handlers under test need no rendered template. + component = TestBed.createComponent(DocumentTableRowsComponent).componentInstance; + }); + + it('opens the detail screen by the row DEMI id under the Eagle project id', () => { + const navigate = spyOn(TestBed.inject(Router), 'navigate').and.resolveTo(true); + + component.goToItem(row('e1')); + + expect(navigate).toHaveBeenCalledOnceWith(['/p', 'p1', 'project-documents', 'detail', 'uuid-1']); + }); + + it('features a row by its Eagle id', () => { + const item = row('e1'); + + component.favoriteDocument(item); + + expect(documentService.feature).toHaveBeenCalledOnceWith('e1'); + expect(item.isFeatured).toBeTrue(); + }); + + it('refuses to feature a row DEMI holds alone', () => { + component.favoriteDocument(row(null)); + + expect(documentService.feature).not.toHaveBeenCalled(); + expect(documentService.unFeature).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalled(); + }); +}); diff --git a/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.ts b/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.ts index 9e8079acf..006ba688c 100644 --- a/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.ts +++ b/src/app/project/project-documents/project-document-table-rows/project-document-table-rows.component.ts @@ -5,16 +5,16 @@ import { DocumentService } from 'src/app/services/document.service'; import { TableObject, TableColumn } from 'src/app/shared/components/table-template/table-object'; import { TableComponent } from 'src/app/shared/components/table-template/table.component'; import { DatePipe } from '@angular/common'; -import { ListConverterPipe } from 'src/app/shared/pipes/list-converter.pipe'; import { FormsModule } from '@angular/forms'; import { LoggingService } from 'src/app/services/logging.service'; +import { eagleWriteId } from 'src/app/models/demi/document-write'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'tbody[app-document-table-rows]', templateUrl: './project-document-table-rows.component.html', styleUrl: './project-document-table-rows.component.css', - imports: [DatePipe, ListConverterPipe, FormsModule, RouterModule], + imports: [DatePipe, FormsModule, RouterModule], }) export class DocumentTableRowsComponent implements OnInit, TableComponent { @@ -66,20 +66,15 @@ export class DocumentTableRowsComponent implements OnInit, TableComponent { goToItem(item) { this.activeLegislationYear = 0; - // project may be a populated object or a bare string ID - const projId = item.project && typeof item.project === 'object' - ? item.project._id - : item.project; - if (projId) { - this.router.navigate(['/p', projId, 'project-documents', 'detail', item._id]); - } else { - this.toastService.error('Uh-oh, couldn\'t open document'); - } + this.router.navigate(['/p', item.projectEagleId, 'project-documents', 'detail', item.id]); } favoriteDocument(document) { - if (document.isFeatured) { - this.documentService.unFeature(document._id).subscribe( + const id = eagleWriteId(document); + if (!id) { + this.toastService.error('This document exists only in DEMI and cannot be featured here.'); + } else if (document.isFeatured) { + this.documentService.unFeature(id).subscribe( () => { document.isFeatured = false; this._changeDetectionRef.markForCheck(); @@ -90,7 +85,7 @@ export class DocumentTableRowsComponent implements OnInit, TableComponent { } ); } else { - this.documentService.feature(document._id).subscribe( + this.documentService.feature(id).subscribe( () => { document.isFeatured = true; this._changeDetectionRef.markForCheck(); diff --git a/src/app/project/project-documents/project-documents.component.spec.ts b/src/app/project/project-documents/project-documents.component.spec.ts index 7be6250f2..7d3efcb7d 100644 --- a/src/app/project/project-documents/project-documents.component.spec.ts +++ b/src/app/project/project-documents/project-documents.component.spec.ts @@ -6,7 +6,6 @@ import { ProjectDocumentsComponent } from './project-documents.component'; import { ConfigService } from 'src/app/services/config.service'; import { DocumentService } from 'src/app/services/document.service'; import { LoggingService } from 'src/app/services/logging.service'; -import { SearchService } from 'src/app/services/search.service'; import { StorageService } from 'src/app/services/storage.service'; import { ToastService } from 'src/app/services/toast.service'; import { Constants } from 'src/app/shared/utils/constants'; @@ -18,17 +17,14 @@ describe('ProjectDocumentsComponent Actions menu', () => { target.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); beforeEach(async () => { - const searchService = jasmine.createSpyObj('SearchService', ['getSearchResults']); - searchService.getSearchResults.and.returnValue(of([{ data: { meta: [], searchResults: [] } }])); await TestBed.configureTestingModule({ imports: [ProjectDocumentsComponent], providers: [ provideRouter([]), - { provide: SearchService, useValue: searchService }, { provide: ConfigService, useValue: { lists: [], ensureListsLoaded: () => Promise.resolve() } }, - { provide: StorageService, useValue: { currentProjectData: { _id: 'proj123', name: 'Test Project' }, state: {} } }, - { provide: DocumentService, useValue: {} }, + { provide: StorageService, useValue: { currentProjectData: { _id: 'proj123', demiId: '7', name: 'Test Project' }, state: {} } }, + { provide: DocumentService, useValue: { listNative: () => of({ rows: [], total: 0 }) } }, { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['log', 'warn', 'error']) } ] }).compileComponents(); @@ -79,30 +75,44 @@ describe('ProjectDocumentsComponent Actions menu', () => { // URL state: page 3 of a 10-per-page list sorted by name. const urlParams = { currentPageCategorized: '3', pageSizeCategorized: '10', sortByCategorized: '+displayName' }; -function doc(id: string, read: string[]) { - return { _id: id, displayName: id, read }; +const lists = [ + { id: 'm1', type: 'label', legislation: 2002 }, + { id: 'm2', type: 'label', legislation: 2002 }, + { id: 't1', type: 'doctype', legislation: 2002 }, + { id: 'a1', type: 'author', legislation: 2002 }, + { id: 'ph1', type: 'projectPhase', legislation: 2002 } +]; + +// Legacy project from the route resolver: `_id` is the Eagle id, `demiId` the DEMI one. +let storage: any; + +function doc(id: string, isPublished: boolean, eagleId: string | null = id) { + return { id, eagleId, projectId: '7', displayName: id, isPublished }; } describe('ProjectDocumentsComponent paging and sort', () => { let component: ProjectDocumentsComponent; let navigate: jasmine.Spy; + let documentService: jasmine.SpyObj; // Loads the page with the given rows on page 3 and total match count. - function load(rows: any[], total: number) { + function load(rows: any[], total: number, params: object = urlParams) { + storage = { currentProjectData: { _id: 'p1', demiId: '7', legislationYear: 2002 }, state: {} }; + documentService = jasmine.createSpyObj('DocumentService', ['listNative', 'publish', 'unPublish', 'delete', 'downloadNative']); + documentService.listNative.and.returnValue(of({ rows, total })); + documentService.publish.and.returnValue(of({} as any)); + documentService.unPublish.and.returnValue(of({} as any)); + documentService.delete.and.returnValue(of({} as any)); spyOn(window.history, 'replaceState'); TestBed.configureTestingModule({ imports: [ProjectDocumentsComponent], providers: [ provideRouter([]), - { provide: ActivatedRoute, useValue: { params: of(urlParams) } }, - { - provide: SearchService, - useValue: { getSearchResults: () => of([{ data: { meta: [{ searchResultsTotal: total }], searchResults: rows } }]) } - }, - { provide: ConfigService, useValue: { lists: [], ensureListsLoaded: () => Promise.resolve() } }, - { provide: StorageService, useValue: { currentProjectData: { _id: 'p1' }, state: {} } }, + { provide: ActivatedRoute, useValue: { params: of(params) } }, + { provide: ConfigService, useValue: { lists, ensureListsLoaded: () => Promise.resolve() } }, + { provide: StorageService, useValue: storage }, { provide: NgbModal, useValue: { open: () => ({ componentInstance: {}, result: Promise.resolve(true) }) } }, - { provide: DocumentService, useValue: { publish: () => of({}), unPublish: () => of({}), delete: () => of({}) } }, + { provide: DocumentService, useValue: documentService }, { provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['success', 'error']) }, { provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['error', 'debug']) } ] @@ -115,7 +125,7 @@ describe('ProjectDocumentsComponent paging and sort', () => { } function checkRow(id: string) { - component.categorizedDocumentTableData.data.find(row => row._id === id).checkbox = true; + component.categorizedDocumentTableData.data.find(row => row.id === id).checkbox = true; } function navigatedParams(): any { @@ -123,7 +133,7 @@ describe('ProjectDocumentsComponent paging and sort', () => { } it('keeps the page after publishing', fakeAsync(() => { - load([doc('d21', ['staff'])], 21); + load([doc('d21', false)], 21); checkRow('d21'); component.publishDocument(); @@ -133,7 +143,7 @@ describe('ProjectDocumentsComponent paging and sort', () => { })); it('keeps the page after unpublishing', fakeAsync(() => { - load([doc('d21', ['staff', 'public'])], 21); + load([doc('d21', true)], 21); checkRow('d21'); component.unpublishDocument(); @@ -143,7 +153,7 @@ describe('ProjectDocumentsComponent paging and sort', () => { })); it('steps back a page when a delete empties the last page', fakeAsync(() => { - load([doc('d21', ['staff'])], 21); + load([doc('d21', false)], 21); checkRow('d21'); component.deleteDocument(); @@ -153,7 +163,7 @@ describe('ProjectDocumentsComponent paging and sort', () => { })); it('keeps the page when a delete leaves rows on the last page', fakeAsync(() => { - load([doc('d21', ['staff']), doc('d22', ['staff']), doc('d23', ['staff'])], 23); + load([doc('d21', false), doc('d22', false), doc('d23', false)], 23); checkRow('d22'); component.deleteDocument(); @@ -163,7 +173,7 @@ describe('ProjectDocumentsComponent paging and sort', () => { })); it('keeps the sort from the URL when a keyword search is submitted', fakeAsync(() => { - load([doc('d21', ['staff'])], 21); + load([doc('d21', false)], 21); component.tableParams.keywords = 'mine'; component.onSubmit(); @@ -172,10 +182,100 @@ describe('ProjectDocumentsComponent paging and sort', () => { })); it('goes to page 1 when a column sort is clicked', fakeAsync(() => { - load([doc('d21', ['staff'])], 21); + load([doc('d21', false)], 21); component.setColumnSort(Constants.documentTypes.CATEGORIZED, 'displayName'); expect(navigatedParams().currentPageCategorized).toBe(1); })); + + const listQuery = () => documentService.listNative.calls.mostRecent().args[0]; + + it('lists documents by the DEMI project id with page, size, count and sort', fakeAsync(() => { + load([doc('d21', false)], 21); + + expect(documentService.listNative).toHaveBeenCalledTimes(1); + expect(listQuery()).toEqual({ + page: 3, pageSize: 10, count: true, sort: 'displayName', q: undefined, + filters: { + project: '7', documentSource: 'PROJECT', milestoneId: undefined, typeId: undefined, documentAuthorTypeId: undefined, + projectPhaseId: undefined, datePostedStart: undefined, datePostedEnd: undefined + } + }); + })); + + it('sends URL filters and keywords under their native names', fakeAsync(() => { + load([], 0, { + ...urlParams, keywords: 'tailings%20dam', milestone: 'm1,m2', type: 't1', documentAuthorType: 'a1', + projectPhase: 'ph1', datePostedStart: '2020-01-01', datePostedEnd: '2020-12-31' + }); + + expect(listQuery()).toEqual(jasmine.objectContaining({ + q: 'tailings dam', + filters: { + project: '7', documentSource: 'PROJECT', milestoneId: ['m1', 'm2'], typeId: ['t1'], documentAuthorTypeId: ['a1'], + projectPhaseId: ['ph1'], datePostedStart: '2020-01-01', datePostedEnd: '2020-12-31' + } + })); + })); + + it('sorts newest first when the URL sort is one DEMI cannot sort on', fakeAsync(() => { + load([], 0, { ...urlParams, sortByCategorized: '+type' }); + expect(documentService.listNative.calls.mostRecent().args[0].sort).toBe('-datePosted'); + })); + + it('keeps only the first key of an old two-key URL sort', fakeAsync(() => { + load([], 0, { ...urlParams, sortByCategorized: '-datePosted,+displayName' }); + expect(documentService.listNative.calls.mostRecent().args[0].sort).toBe('-datePosted'); + })); + + it('publishes by Eagle id and skips a row DEMI holds alone', fakeAsync(() => { + load([doc('uuid-1', false, 'e1'), doc('uuid-2', false, null)], 21); + checkRow('uuid-1'); + checkRow('uuid-2'); + + component.publishDocument(); + flushMicrotasks(); + + expect(documentService.publish).toHaveBeenCalledOnceWith('e1'); + })); + + it('hands the selected native rows to the edit screen', fakeAsync(() => { + const row = doc('uuid-1', false, 'e1'); + load([row, doc('d22', false)], 21); + checkRow('uuid-1'); + + component.selectAction('edit'); + + expect(storage.state.selectedDocs).toEqual([row]); + expect(navigate).toHaveBeenCalledWith(['p', 'p1', 'project-documents', 'edit']); + })); + + it('downloads each checked row through DEMI by its DEMI id', fakeAsync(() => { + load([doc('uuid-1', true, 'e1')], 21); + documentService.downloadNative.and.resolveTo(); + checkRow('uuid-1'); + + component.selectAction('download'); + + expect(documentService.downloadNative).toHaveBeenCalledOnceWith('uuid-1'); + })); + it('leaves comment attachments out with documentSource=PROJECT', fakeAsync(() => { + load([], 0); + expect(listQuery()).toEqual(jasmine.objectContaining({ filters: jasmine.objectContaining({ documentSource: 'PROJECT' }) })); + })); + + it('links a row to the route project when DEMI leaves out projectEagleId', fakeAsync(() => { + load([doc('uuid-1', false, 'e1')], 21); + expect(component.categorizedDocumentTableData.data[0].projectEagleId).toBe('p1'); + })); + + it('keeps Publish disabled when the only checked row has no Eagle id', fakeAsync(() => { + load([doc('uuid-2', false, null)], 21); + checkRow('uuid-2'); + + component.updateSelectedRow(Constants.documentTypes.CATEGORIZED, { count: 1, activeLegislationYear: null }); + + expect(component.isEnabled('publish')).toBeFalse(); + })); }); diff --git a/src/app/project/project-documents/project-documents.component.ts b/src/app/project/project-documents/project-documents.component.ts index d2d99aecd..be3048767 100644 --- a/src/app/project/project-documents/project-documents.component.ts +++ b/src/app/project/project-documents/project-documents.component.ts @@ -4,13 +4,12 @@ import { Router, ActivatedRoute } from '@angular/router'; import { ToastService } from 'src/app/services/toast.service'; import { NgbModal, NgbDropdownModule, NgbTooltipModule, NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap'; import { forkJoin, firstValueFrom } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { finalize, switchMap } from 'rxjs/operators'; import { DateTime } from 'luxon'; import { ConfirmComponent } from 'src/app/confirm/confirm.component'; import { SearchTerms } from 'src/app/models/search'; import { DocumentService } from 'src/app/services/document.service'; import { ConfigService } from 'src/app/services/config.service'; -import { SearchService } from 'src/app/services/search.service'; import { StorageService } from 'src/app/services/storage.service'; import { TableDocumentParamsObject } from 'src/app/shared/components/table-template/table-document-params-object'; import { TableObject, TableColumn } from 'src/app/shared/components/table-template/table-object'; @@ -20,6 +19,8 @@ import { TableDocumentTemplateUtils } from 'src/app/shared/utils/table-document- import { encodeParams, encodeString, filterCompareWith } from 'src/app/shared/utils/utils'; import { DocumentTableRowsComponent } from './project-document-table-rows/project-document-table-rows.component'; import { Document } from 'src/app/models/document'; +import { DemiDocument, DemiDocumentFilters, DemiDocumentListParams } from 'src/app/models/demi/document'; +import { eagleWriteId } from 'src/app/models/demi/document-write'; import { FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { TableTemplateComponent } from 'src/app/shared/components/table-template/table-template.component'; @@ -29,6 +30,18 @@ import { NgSelectModule } from '@ng-select/ng-select'; import { nextSortBy } from 'src/app/shared/utils/table-template-utils'; +const NATIVE_SORT_KEYS = ['datePosted', 'displayName']; + +/** First sort key of a URL `sortBy`, when DEMI can sort on it; else newest first. Keeps the `+`/`-` the table shows. */ +function toNativeSort(sortBy: string | null | undefined): string { + const first = (sortBy || '').split(',')[0]; + const field = first.replace(/^[+-]/, ''); + if (!NATIVE_SORT_KEYS.includes(field)) { + return '-datePosted'; + } + return first.startsWith('-') ? first : `+${field}`; +} + class DocumentFilterObject { constructor( public milestone: Array = [], @@ -63,7 +76,6 @@ export class ProjectDocumentsComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private toastService = inject(ToastService); - private searchService = inject(SearchService); private configService = inject(ConfigService); private storageService = inject(StorageService); private tableDocumentTemplateUtils = inject(TableDocumentTemplateUtils); @@ -76,7 +88,7 @@ export class ProjectDocumentsComponent implements OnInit { private activeLegislationYear: number; public categorizedDocsCount = 0; - public categorizedDocs: Document[] = []; + public categorizedDocs: DemiDocument[] = []; public milestones: any[] = []; public authors: any[] = []; @@ -85,9 +97,9 @@ export class ProjectDocumentsComponent implements OnInit { public legislations: any[] = []; private listsLoading = this.loadingState.getOperationState('lists'); - private searchLoading = this.loadingState.getOperationState('search-results'); + private listLoading = signal(false); private actionLoading = signal(false); - public loading = computed(() => this.listsLoading() || this.searchLoading() || this.actionLoading()); + public loading = computed(() => this.listsLoading() || this.listLoading() || this.actionLoading()); public tableParams: TableDocumentParamsObject = new TableDocumentParamsObject(); public terms = new SearchTerms(); @@ -133,6 +145,7 @@ export class ProjectDocumentsComponent implements OnInit { { name: 'Status', value: 'status', + nosort: true, width: '7%' }, { @@ -143,21 +156,25 @@ export class ProjectDocumentsComponent implements OnInit { { name: 'Type', value: 'type', + nosort: true, width: '14%' }, { name: 'Milestone', value: 'milestone', + nosort: true, width: '14%' }, { name: 'Legislation', value: 'legislation', + nosort: true, width: '10%' }, { name: 'Feature', value: 'isFeatured', + nosort: true, width: '6%' } ]; @@ -221,9 +238,7 @@ export class ProjectDocumentsComponent implements OnInit { this.filterForURL ); - if (this.tableParams.sortByCategorized === '') { - this.tableParams.sortByCategorized = '-datePosted,+displayName'; - } + this.tableParams.sortByCategorized = toNativeSort(this.tableParams.sortByCategorized); this.tableParams.keywords = params.keywords ? decodeURIComponent(params.keywords) @@ -232,48 +247,43 @@ export class ProjectDocumentsComponent implements OnInit { this.currentProject = this.storageService.currentProjectData; this.storageService.state.labels = null; - const projectId = this.currentProject._id; - const sortBy = this.tableParams.sortByCategorized || '-datePosted,+displayName'; - const pageNum = this.tableParams.currentPageCategorized || 1; - const pageSize = this.tableParams.pageSizeCategorized || 10; - const keywords = this.tableParams.keywords || ''; - - return this.searchService.getSearchResults( - keywords, - 'Document', - [ - { name: 'project', value: projectId }, - { name: 'categorized', value: true } - ], - pageNum, - pageSize, - sortBy, - { documentSource: 'PROJECT' }, - true, - this.filterForAPI, - '' + this.listLoading.set(true); + return this.documentService.listNative(this.listParams()).pipe( + finalize(() => this.listLoading.set(false)) ); }) ) - .subscribe((res: any) => { - if (res && res[0]) { - if (res[0].data && res[0].data.meta.length > 0) { - this.tableParams.totalListItemsCategorized = res[0].data.meta[0].searchResultsTotal; - this.categorizedDocs = res[0].data.searchResults; - } else { - this.tableParams.totalListItemsCategorized = 0; - this.categorizedDocs = []; - } - - this.setRowData(); - } else { - alert('Uh-oh, couldn\'t load valued components'); - // project not found --> navigate back to search - this.router.navigate(['/search']); - } + .subscribe(res => { + this.tableParams.totalListItemsCategorized = res.total ?? 0; + this.categorizedDocs = res.rows; + this.setRowData(); }); } + /** Native list query for the current URL state. Documents carry the DEMI project id, not the Eagle one. */ + listParams(): DemiDocumentListParams { + const ids = (name: string) => this.filterForAPI[name]?.split(','); + // Comment attachments stay out, as the old search did. + const filters: DemiDocumentFilters = { + project: this.currentProject.demiId, + documentSource: 'PROJECT', + milestoneId: ids('milestone'), + typeId: ids('type'), + documentAuthorTypeId: ids('documentAuthorType'), + projectPhaseId: ids('projectPhase'), + datePostedStart: this.filterForAPI['datePostedStart'], + datePostedEnd: this.filterForAPI['datePostedEnd'] + }; + return { + page: this.tableParams.currentPageCategorized || 1, + pageSize: this.tableParams.pageSizeCategorized || 10, + count: true, + sort: this.tableParams.sortByCategorized.replace(/^\+/, ''), + q: this.tableParams.keywords || undefined, + filters + }; + } + public selectAction(action) { const promises = []; @@ -316,18 +326,12 @@ export class ProjectDocumentsComponent implements OnInit { if (this.categorizedDocumentTableData) { this.categorizedDocumentTableData.data.map(item => { if (item.checkbox === true) { - selectedDocs.push( - this.categorizedDocs.filter(d => d._id === item._id)[0] - ); + selectedDocs.push(this.categorizedDocs.find(d => d.id === item.id)); } }); } // Store and send to the edit page. this.storageService.state.selectedDocs = selectedDocs; - // Set labels if doc size === 1 - if (selectedDocs.length === 1) { - this.storageService.state.labels = selectedDocs[0].labels; - } this.router.navigate([ 'p', @@ -343,11 +347,7 @@ export class ProjectDocumentsComponent implements OnInit { if (this.categorizedDocumentTableData && this.currentTab === Constants.documentTypes.CATEGORIZED) { this.categorizedDocumentTableData.data.map(item => { if (item.checkbox === true) { - promises.push( - this.documentService.downloadDocument( - this.categorizedDocs.filter(d => d._id === item._id)[0] - ) - ); + promises.push(this.documentService.downloadNative(item.id)); } }); } @@ -365,10 +365,6 @@ export class ProjectDocumentsComponent implements OnInit { } } - navSearchHelp() { - this.router.navigate(['/search-help']); - } - public publishDocument() { const modalRef = this.modalService.open(ConfirmComponent, { backdrop: 'static', // Prevent closing when clicking outside the modal @@ -386,8 +382,8 @@ export class ProjectDocumentsComponent implements OnInit { if (this.categorizedDocumentTableData) { this.categorizedDocumentTableData.data.map(item => { - if (item.checkbox && !item.read.includes('public')) { - observables.push(this.documentService.publish(item._id)); + if (item.checkbox && !item.isPublished && eagleWriteId(item)) { + observables.push(this.documentService.publish(eagleWriteId(item))); } }); } @@ -429,8 +425,8 @@ export class ProjectDocumentsComponent implements OnInit { if (this.categorizedDocumentTableData) { this.categorizedDocumentTableData.data.map(item => { - if (item.checkbox && item.read.includes('public')) { - observables.push(this.documentService.unPublish(item._id)); + if (item.checkbox && item.isPublished && eagleWriteId(item)) { + observables.push(this.documentService.unPublish(eagleWriteId(item))); } }); } @@ -472,8 +468,8 @@ export class ProjectDocumentsComponent implements OnInit { if (this.categorizedDocumentTableData) { this.categorizedDocumentTableData.data.map(item => { - if (item.checkbox === true) { - itemsToDelete.push(firstValueFrom(this.documentService.delete(item))); + if (item.checkbox === true && eagleWriteId(item)) { + itemsToDelete.push(firstValueFrom(this.documentService.delete({ _id: eagleWriteId(item) } as Document))); } }); } @@ -497,35 +493,6 @@ export class ProjectDocumentsComponent implements OnInit { }); } - public onNumItems(numItems) { - // dismiss any open snackbar - // if (this.snackBarRef) { this.snackBarRef.dismiss(); } - - // NOTE: Angular Router doesn't reload page on same URL - // REF: https://stackoverflow.com/questions/40983055/how-to-reload-the-current-route-with-the-angular-2-router - // WORKAROUND: add timestamp to force URL to be different than last time - - const params = this.terms.getParams(); - params['ms'] = new Date().getMilliseconds(); - params['dataset'] = this.terms.dataset; - params['currentPageCategorized'] = this.tableParams.currentPageCategorized = 1; - params['sortByCategorized'] = this.tableParams.sortByCategorized; - params['keywords'] = this.tableParams.keywords; - - if (numItems === 'max') { - params['pageSizeCategorized'] = this.tableParams.totalListItemsCategorized; - } else { - params['pageSizeCategorized'] = this.tableParams.pageSizeCategorized = numItems; - } - - this.router.navigate([ - 'p', - this.currentProject._id, - 'project-documents', - params - ]); - } - public onSubmit(pageNumber = 1) { // NOTE: Angular Router doesn't reload page on same URL // REF: https://stackoverflow.com/questions/40983055/how-to-reload-the-current-route-with-the-angular-2-router @@ -561,22 +528,18 @@ export class ProjectDocumentsComponent implements OnInit { this.categorizedDocs.forEach(document => { documentList.push({ + id: document.id, + eagleId: document.eagleId, + projectEagleId: document.projectEagleId || this.currentProject._id, displayName: document.displayName, documentFileName: document.documentFileName, datePosted: document.datePosted, - status: document.read.includes('public') - ? 'Published' - : 'Not Published', + status: document.isPublished ? 'Published' : 'Not Published', + isPublished: document.isPublished, type: document.type, milestone: document.milestone, legislation: document.legislation, - _id: document._id, - project: document.project, - read: document.read, - isFeatured: document.isFeatured, - sortOrder: document.sortOrder, - publicHitCount: document.publicHitCount, - secureHitCount: document.secureHitCount + isFeatured: document.isFeatured }); }); @@ -632,8 +595,9 @@ export class ProjectDocumentsComponent implements OnInit { this.canUnpublish = false; for (const document of this.categorizedDocumentTableData.data) { - if (document.checkbox) { - if (document.read.includes('public')) { + // A row with no Eagle id cannot be written through eagle-api. + if (document.checkbox && eagleWriteId(document)) { + if (document.isPublished) { this.canUnpublish = true; } else { this.canPublish = true; @@ -807,15 +771,6 @@ export class ProjectDocumentsComponent implements OnInit { this.router.navigate(['p', this.currentProject._id, 'project-documents', params]); } - // Compares selected options when a dropdown is grouped by legislation. - compareDropdownOptions(optionA: any, optionB: any) { - if ((optionA.name === optionB.name) && (optionA.legislation === optionB.legislation)) { - return true; - } - - return false; - } - clearSelectedItem(filter: string, item: any) { this.filterForUI[filter] = this.filterForUI[filter].filter(option => option.id !== item.id); } @@ -832,7 +787,7 @@ export class ProjectDocumentsComponent implements OnInit { ); selBox.value = window.location.origin + - `/api/document/${item._id}/fetch/${safeName}`; + `/api/document/${item.eagleId || item.id}/fetch/${safeName}`; document.body.appendChild(selBox); selBox.focus(); selBox.select(); @@ -840,36 +795,6 @@ export class ProjectDocumentsComponent implements OnInit { document.body.removeChild(selBox); } - /** - * Groups documents into categorized. - * - * @param documents Array of documents. - */ - public groupDocuments(documents: any[]): void { - documents.forEach(document => { - if (document.milestone && document.documentType && document.documentAuthorType) { - this.categorizedDocs.push(document); - } - }); - } - public readonly filterCompareWith = filterCompareWith; - public onPageLimitClick(pageLimit: number | string) { - if (pageLimit === 'all') { - this.tableParams.pageSizeCategorized = this.tableParams.totalListItemsCategorized; - } - - this.onSubmit(); - } - - - public getResultTerm(count) { - if (count === 1) { - return 'result'; - } else { - return 'results'; - } - } - } diff --git a/src/app/resolvers/document.resolver.spec.ts b/src/app/resolvers/document.resolver.spec.ts new file mode 100644 index 000000000..82914e373 --- /dev/null +++ b/src/app/resolvers/document.resolver.spec.ts @@ -0,0 +1,38 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, RedirectCommand, RouterStateSnapshot, convertToParamMap, provideRouter } from '@angular/router'; +import { Observable, firstValueFrom, of } from 'rxjs'; +import { documentResolver } from './document.resolver'; +import { DemiDocument } from '../models/demi/document'; +import { DocumentService } from '../services/document.service'; + +describe('documentResolver', () => { + let documentService: jasmine.SpyObj; + + function resolve(docId: string) { + const route = { paramMap: convertToParamMap({ docId }) } as ActivatedRouteSnapshot; + return firstValueFrom(TestBed.runInInjectionContext( + () => documentResolver(route, {} as RouterStateSnapshot) as Observable + )); + } + + beforeEach(() => { + documentService = jasmine.createSpyObj('DocumentService', ['getNative']); + TestBed.configureTestingModule({ + providers: [provideRouter([]), { provide: DocumentService, useValue: documentService }] + }); + }); + + it('returns the native row the detail screen takes', async () => { + const row = { id: 'uuid-1', eagleId: 'e1', projectId: '7', displayName: 'Appendix D', isPublished: true } as DemiDocument; + documentService.getNative.and.returnValue(of(row)); + + expect(await resolve('e1')).toBe(row); + expect(documentService.getNative).toHaveBeenCalledOnceWith('e1'); + }); + + it('redirects to search when the document cannot be read', async () => { + documentService.getNative.and.returnValue(of(null)); + + expect(await resolve('missing')).toEqual(jasmine.any(RedirectCommand)); + }); +}); diff --git a/src/app/resolvers/document.resolver.ts b/src/app/resolvers/document.resolver.ts index eac3c27ad..a483c4a98 100644 --- a/src/app/resolvers/document.resolver.ts +++ b/src/app/resolvers/document.resolver.ts @@ -3,20 +3,16 @@ import { RedirectCommand, Router } from '@angular/router'; import { catchError, map, of } from 'rxjs'; import type { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; import { DocumentService } from '../services/document.service'; -import type { Document } from '../models/document'; +import type { DemiDocument } from '../models/demi/document'; -export const documentResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { +/** Loads the DEMI document for `:docId` (DEMI or Eagle id) for the detail screen. */ +export const documentResolver: ResolveFn = (route: ActivatedRouteSnapshot) => { const documentService = inject(DocumentService); const router = inject(Router); const docId = route.paramMap.get('docId')!; - return documentService.getById(docId, true).pipe( - map((doc: Document | null) => { - if (!doc) { - return new RedirectCommand(router.parseUrl('/search')); - } - return doc; - }), + return documentService.getNative(docId).pipe( + map(doc => doc ?? new RedirectCommand(router.parseUrl('/search'))), catchError(() => of(new RedirectCommand(router.parseUrl('/search')))) ); };