('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'))))
);
};
|