Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/models/demi/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
46 changes: 17 additions & 29 deletions src/app/project/project-documents/detail/detail.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ <h1>
<button class="btn btn-primary" id="actionDropdown" ngbDropdownToggle>Actions</button>
<div class="dropdown-menu" aria-labelledby="actionDropdown" ngbDropdownMenu>
<button ngbDropdownItem (click)="onEdit()">Edit</button>
<button ngbDropdownItem (click)="openDocument()">Open</button>
@if (published()) {
<button ngbDropdownItem (click)="openDocument()">Open</button>
} @else {
<span class="dropdown-item-text small text-muted" id="open-unavailable">Open in a tab needs a published file. Use Download.</span>
}
<button ngbDropdownItem (click)="downloadDocument()">Download</button>
<button ngbDropdownItem (click)="togglePublish()">{{publishText()}}</button>
@if (writeId()) {
<button ngbDropdownItem (click)="togglePublish()">{{published() ? 'Unpublish' : 'Publish'}}</button>
}
</div>
</div>
</div>
Expand All @@ -36,20 +42,12 @@ <h1>
<h3 for="displayName">Display Name: </h3>
<p id="displayName">{{resolvedDocument().displayName || '-'}}</p>
</div>
<div class="label-pair">
<h3 for="publicHitCount">Public Views: </h3>
<p id="publicHitCount">{{resolvedDocument().publicHitCount || '-'}}</p>
</div>
<div class="label-pair">
<h3 for="secureHitCount">Secure Views: </h3>
<p id="secureHitCount">{{resolvedDocument().secureHitCount || '-'}}</p>
</div>
<div class="label-pair">
<p id="status publish-state">
<i class="material-icons">
{{publishText() === 'Publish' ? 'cloud_off' : 'cloud_done'}}
{{published() ? 'cloud_done' : 'cloud_off'}}
</i>
{{publishText() === 'Publish' ? 'Not Published' : 'Published'}}
{{published() ? 'Published' : 'Not Published'}}
</p>
</div>
<div class="label-pair">
Expand All @@ -60,29 +58,19 @@ <h3 for="documentFileName">Filename: </h3>
<h3 for="description">Description: </h3>
<p id="description">{{resolvedDocument().description || '-'}}</p>
</div>
@if (resolvedDocument().labels) {
<div class="label-pair">
<h3 for="tags">Labels: </h3>
@for (label of resolvedDocument().labels; track label) {
<div>
<div class="me-2 badge badge-pill badge-info">{{label}}</div>
</div>
}
</div>
}
</section>
<section>
<div class="label-pair">
<h3 for="type">Type: </h3>
<p id="type">{{resolvedType()}}</p>
<p id="type">{{resolvedDocument().type || '-'}}</p>
</div>
<div class="label-pair">
<h3 for="dateUploaded">Upload Date: </h3>
<p id="dateUploaded">{{resolvedDocument().dateUploaded | date}}</p>
</div>
<div class="label-pair">
<h3 for="datePosted">Document Date: </h3>
@if ($any(resolvedDocument().datePosted) !== '1900-01-01T08:00:00.000Z') {
@if (resolvedDocument().datePosted !== '1900-01-01T08:00:00.000Z') {
<p id="datePosted">{{resolvedDocument().datePosted | date}}</p>
}
</div>
Expand All @@ -92,19 +80,19 @@ <h3 for="legislation">Legislation:</h3>
</div>
<div class="label-pair">
<h3 for="documentAuthor">Author: </h3>
<p id="documentAuthor">{{resolvedAuthor()}}</p>
<p id="documentAuthor">{{resolvedDocument().documentAuthorType || '-'}}</p>
</div>
<div class="label-pair">
<h3 for="milestone">Milestone: </h3>
<p id="milestone">{{resolvedMilestone()}}</p>
<p id="milestone">{{resolvedDocument().milestone || '-'}}</p>
</div>
<div class="label-pair">
<h3 for="projectPhase">Project Phase: </h3>
<p id="projectPhase">{{resolvedProjectPhase()}}</p>
<p id="projectPhase">{{resolvedDocument().projectPhase || '-'}}</p>
</div>
<div class="label-pair">
<h3 for="internalSize">Filesize: </h3>
<p id="internalSize">{{formatBytes(resolvedDocument().internalSize)}}</p>
<h3 for="fileSize">Filesize: </h3>
<p id="fileSize">{{fileSize()}}</p>
</div>
</section>
</main>
Expand Down
129 changes: 129 additions & 0 deletions src/app/project/project-documents/detail/detail.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<DocumentDetailComponent>;
let documentService: jasmine.SpyObj<DocumentService>;
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<HTMLElement> {
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']);
});
});
86 changes: 34 additions & 52 deletions src/app/project/project-documents/detail/detail.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<any>(this.storageService.currentProjectData);
resolvedDocument = input.required<Document>();
resolvedDocument = input.required<DemiDocument>();

public readonly publishText = signal<string>('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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@
<input type="checkbox" aria-label="Checkbox for row item" [(ngModel)]='document.checkbox' [disabled]="(activeLegislationYear && document.legislation !== activeLegislationYear)">
</td>
<td data-label="Name" scope="row" [style.width]="!useSmallTable && columns[1].width">
@if (document.project) {
<a [routerLink]="['/p', document.project?._id || document.project, 'project-documents', 'detail', document._id]"> {{document.displayName}} </a>
} @else {
<span>{{document.displayName}}</span>
}
<a [routerLink]="['/p', document.projectEagleId, 'project-documents', 'detail', document.id]"> {{document.displayName}} </a>
</td>
<td data-label="Status" (click)="goToItem(document)" scope="row" [style.width]="!useSmallTable && columns[2].width">
<p class="card-text publish-state">
Expand All @@ -23,9 +19,9 @@
<div>{{document.datePosted | date:'longDate'}}</div>
}
</td>
<td data-label="Type" (click)="goToItem(document)" scope="row" [style.width]="!useSmallTable && columns[4].width">{{document.type | listConverter}}</td>
<td data-label="Type" (click)="goToItem(document)" scope="row" [style.width]="!useSmallTable && columns[4].width">{{document.type}}</td>
<td data-label="Milestone" (click)="goToItem(document)" scope="row" [style.width]="!useSmallTable && columns[5].width">
{{document.milestone | listConverter}}</td>
{{document.milestone}}</td>
<td data-label="Legislation" (click)="goToItem(document)" scope="row" [style.width]="!useSmallTable && columns[6].width">
{{document.legislation}}</td>
<td data-label="Feature" (click)="favoriteDocument(document)" scope="row" [style.width]="!useSmallTable && columns[7].width">
Expand Down
Loading
Loading