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
1 change: 0 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"rxjs",
"xlsx",
"tinymce",
"jszip",
"js-sha256",
"dlv"
],
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
"@popperjs/core": "^2.11.8",
"@tinymce/tinymce-angular": "^9",
"bootstrap": "^5.3.8",
"jszip": "^3.10.1",
"keycloak-angular": "^22.0.0",
"keycloak-js": "^26.2.4",
"luxon": "^3.7.2",
Expand Down
3 changes: 2 additions & 1 deletion src/app/activity/activity.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { switchMap } from 'rxjs/operators';

import { ActivityTableRowsComponent } from './activity-table-rows/activity-table-rows.component';
import { DEMI_MAX_PAGE_SIZE } from '../models/demi/demi-list';
import { DemiUpdate, nativeUpdateSort } from '../models/demi/update';
import { LegacyProject, toLegacyProject } from '../models/demi/project-legacy';
import { SearchTerms } from '../models/search';
Expand Down Expand Up @@ -109,7 +110,7 @@ export class ActivityComponent {
];

constructor() {
this.projectService.list({ pageSize: 1000 })
this.projectService.list({ pageSize: DEMI_MAX_PAGE_SIZE })
.pipe(
switchMap(page => {
this.projects = page.rows.map(toLegacyProject).filter(project => project._id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('AddEditActivityComponent', () => {

// Test rows use Eagle field names; DEMI answers with `id`, `eagleId` and the parent's Eagle id in `projectId`.
const nativeOf = ({ _id, project, ...rest }: any) => ({ ...rest, id: _id, eagleId: _id, projectId: project ?? null });
const demiProject = (eagleId: string) => ({ id: `track-${eagleId}`, eagleId, name: eagleId, location: 'Kamloops' });
const demiProject = (eagleId: string) => ({ id: `track-${eagleId}`, eagleId, name: eagleId, address: 'Kamloops' });
const docs = (rows: any[]) => of({ rows } as any);
const documentSearches = () => demiApi.list.calls.allArgs().filter(args => args[0] === 'documents').map(args => args[1]!.filters as any);
const searchedSources = () => documentSearches().map(filters => filters.documentSource);
Expand All @@ -43,8 +43,8 @@ describe('AddEditActivityComponent', () => {
projectService.getNative.and.callFake((id: string) => of(demiProject(id)));
commentPeriodService = jasmine.createSpyObj('CommentPeriodService', ['listByProject']);
commentPeriodService.listByProject.and.returnValue(of({ rows: periods }));
documentService = jasmine.createSpyObj('DocumentService', ['addWithProgress', 'downloadResource']);
documentService.downloadResource.and.resolveTo(new Blob());
documentService = jasmine.createSpyObj('DocumentService', ['addWithProgress', 'downloadUrlNative']);
documentService.downloadUrlNative.and.returnValue(of('https://files.example/plan.jpg'));

TestBed.configureTestingModule({
imports: [AddEditActivityComponent],
Expand Down Expand Up @@ -491,8 +491,9 @@ describe('AddEditActivityComponent', () => {
});

describe('photos and featured image', () => {
const publicDoc = (_id: string, name: string) => ({ _id, displayName: name, documentFileName: name, documentSource: 'PROJECT', read: ['public'] });
const privateDoc = (_id: string, name: string) => ({ _id, displayName: name, documentFileName: name, documentSource: 'PROJECT', read: ['staff'] });
// DEMI rows leave out `read`; `isPublished` says whether the public can read them.
const publicDoc = (_id: string, name: string) => ({ _id, displayName: name, documentFileName: name, documentSource: 'PROJECT', isPublished: true });
const privateDoc = (_id: string, name: string) => ({ _id, displayName: name, documentFileName: name, documentSource: 'PROJECT', isPublished: false });
const photoIds = (component: AddEditActivityComponent) => component.photoRows.map(row => row.document.value);
const statusMessage = () => (query('[role=status] #altNeeded')?.textContent ?? '').trim();

Expand Down Expand Up @@ -623,6 +624,16 @@ describe('AddEditActivityComponent', () => {
});
});

it('shows a photo the public cannot see from its presigned DEMI URL', () => {
const component = create(null);
demiApi.list.and.returnValue(docs([privateDoc('i3', 'plan.jpg')]));
component.addPhotos([{ doc: privateDoc('i3', 'plan.jpg') }]);
fillNews(component);

expect(documentService.downloadUrlNative).toHaveBeenCalledOnceWith('i3');
expect(component.imageSrcs().get('i3')).toBe('https://files.example/plan.jpg');
});

it('previews public photos from the fetch URL and new uploads from their local copy, caption and credit on one line', () => {
const component = render(null, null, true);
TestBed.inject(NgbConfig).animation = false;
Expand Down
36 changes: 18 additions & 18 deletions src/app/activity/add-edit-activity/add-edit-activity.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, DestroyRef, TemplateRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, Subject, catchError, distinctUntilChanged, forkJoin, map, of, shareReplay, switchMap } from 'rxjs';
import { Observable, Subject, catchError, distinctUntilChanged, finalize, forkJoin, map, of, shareReplay, switchMap } from 'rxjs';
import { DatePipe } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Router, RouterModule, ActivatedRoute } from '@angular/router';
Expand All @@ -17,11 +17,12 @@ import { ConfigService } from 'src/app/services/config.service';
import { LoggingService } from 'src/app/services/logging.service';
import { ProjectService } from 'src/app/services/project.service';
import { RecentActivityService } from 'src/app/services/recent-activity';
import { DEMI_MAX_PAGE_SIZE } from 'src/app/models/demi/demi-list';
import { DemiDocument } from 'src/app/models/demi/document';
import { DemiCommentPeriod } from 'src/app/models/demi/comment-period';
import { isEagleId } from 'src/app/models/demi/demi-ids';
import { eagleWriteId } from 'src/app/models/demi/demi-ids';
import { LegacyProject, toLegacyProject } from 'src/app/models/demi/project-legacy';
import { DemiUpdate, updateEagleId, updateParentId } from 'src/app/models/demi/update';
import { DemiUpdate, updateParentId } from 'src/app/models/demi/update';
import { legacyDocumentRow, sourceFilters } from 'src/app/project/project-documents/document-edit/legacy-document-row';
import { Constants } from 'src/app/shared/utils/constants';
import { convertJSDateToNGBDate, convertFormGroupNGBDateToJSDate } from 'src/app/shared/utils/utils';
Expand Down Expand Up @@ -176,7 +177,7 @@ export class AddEditActivityComponent implements OnInit {
this.recentActivityService.getNative(activityId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe((row: DemiUpdate) => {
this.isEditing = true;
// Eagle ids, as the form and eagle-api writes use them.
const data = { ...row, _id: updateEagleId(row), project: updateParentId(row) };
const data = { ...row, _id: eagleWriteId(row), project: updateParentId(row) };
this.buildForm(data);
this.activity = data;
this.statusText = statusLabel(data);
Expand All @@ -203,7 +204,7 @@ export class AddEditActivityComponent implements OnInit {
});
}

this.projectService.list({ pageSize: 1000 }).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
this.projectService.list({ pageSize: DEMI_MAX_PAGE_SIZE }).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: page => {
this.projects = page.rows.map(toLegacyProject).filter(project => project._id);
this.loading = false;
Expand All @@ -213,7 +214,7 @@ export class AddEditActivityComponent implements OnInit {
});

// A project notification's DEMI id is its Eagle id.
this.demiApi.list<{ id: string; name: string }>('notifications', { pageSize: 1000, sort: 'name' })
this.demiApi.list<{ id: string; name: string }>('notifications', { pageSize: DEMI_MAX_PAGE_SIZE, sort: 'name' })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: page => { this.projectNotifications = page.rows.map(row => ({ _id: row.id, name: row.name })); },
Expand Down Expand Up @@ -485,22 +486,21 @@ export class AddEditActivityComponent implements OnInit {
}
}

/** The admin fetch URL needs no token only for public documents; load the rest with the staff token. */
/** The admin fetch URL needs no token only for public documents; the rest show from a presigned DEMI URL. */
private loadPrivateThumbnails() {
this.pickedImageIds()
.filter(id => !this.imageSrcs().has(id) && !this.srcsLoading.has(id) && !isPublicDocument(this.imageDocs().get(id)))
.forEach(id => {
this.srcsLoading.add(id);
this.documentService.downloadResource(id)
.then(blob => {
// The form may be gone or the image removed while the file downloaded.
if (blob?.size && !this.destroyed && this.pickedImageIds().includes(id)) {
this.setImageSrc(id, URL.createObjectURL(blob));
this.documentService.downloadUrlNative(id)
.pipe(finalize(() => this.srcsLoading.delete(id)))
.subscribe(url => {
// The form may be gone or the image removed while the URL loaded; with no URL the row shows the placeholder.
if (url && !this.destroyed && this.pickedImageIds().includes(id)) {
this.setImageSrc(id, url);
this._cdr.markForCheck();
}
})
.catch(() => { /* the row shows the placeholder */ })
.finally(() => this.srcsLoading.delete(id));
});
});
}

Expand Down Expand Up @@ -599,7 +599,7 @@ export class AddEditActivityComponent implements OnInit {
private watchProjectDocuments() {
// First 1000 of the parent's documents of one source, newest first. Null marks a failed search.
const search = (parentId: string | null, source: string): Observable<any[] | null> => parentId
? this.demiApi.list<DemiDocument>('documents', { pageSize: 1000, sort: '-datePosted', filters: sourceFilters(parentId, source) })
? this.demiApi.list<DemiDocument>('documents', { pageSize: DEMI_MAX_PAGE_SIZE, sort: '-datePosted', filters: sourceFilters(parentId, source) })
.pipe(
map(page => page.rows.map(legacyDocumentRow)),
catchError(error => {
Expand Down Expand Up @@ -677,13 +677,13 @@ export class AddEditActivityComponent implements OnInit {
public loadPcpsForProject(projectId: string) {
this.demiProject(projectId)
.pipe(
switchMap(project => project ? this.commentPeriodService.listByProject(project.demiId, { pageSize: 1000 }) : of({ rows: [] })),
switchMap(project => project ? this.commentPeriodService.listByProject(project.demiId, { pageSize: DEMI_MAX_PAGE_SIZE }) : of({ rows: [] })),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(page => {
// An update's pcp is the period's Eagle id; a period eagle-api does not hold cannot be picked.
this.periods = page.rows
.map((period: DemiCommentPeriod) => ({ ...period, _id: period.eagleId ?? (isEagleId(period.id) ? period.id : null) }))
.map((period: DemiCommentPeriod) => ({ ...period, _id: eagleWriteId(period) }))
.filter(period => period._id);
// Preserve the current PCP selection if it exists in the loaded periods.
// Only reset when the current value is no longer valid (e.g. user switched projects).
Expand Down
28 changes: 27 additions & 1 deletion src/app/activity/update-image-field/update-images.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { blocksPublish, captionLine, imageSrc, imageType, toUpdateImage } from './update-images';
import { blocksPublish, captionLine, imageSrc, imageType, isPublicDocument, toUpdateImage } from './update-images';

describe('update images', () => {
describe('blocksPublish', () => {
Expand All @@ -21,6 +21,32 @@ describe('update images', () => {
it('does not block a missing document', () => {
expect(blocksPublish(undefined)).toBeFalse();
});

it('blocks an unpublished DEMI row, which has no read list', () => {
expect(blocksPublish({ documentSource: 'PROJECT', isPublished: false })).toBeTrue();
});

it('lets a published DEMI row through', () => {
expect(blocksPublish({ documentSource: 'PROJECT', isPublished: true })).toBeFalse();
});
});

describe('isPublicDocument', () => {
it('reads a published DEMI row as public', () => {
expect(isPublicDocument({ _id: 'd1', isPublished: true })).toBeTrue();
});

it('reads an unpublished DEMI row as not public', () => {
expect(isPublicDocument({ _id: 'd1', isPublished: false })).toBeFalse();
});

it('takes an Eagle read list over isPublished', () => {
expect(isPublicDocument({ _id: 'd1', read: ['staff'], isPublished: true })).toBeFalse();
});

it('treats a row with neither field as not public', () => {
expect(isPublicDocument({ _id: 'd1' })).toBeFalse();
});
});

describe('imageSrc', () => {
Expand Down
15 changes: 13 additions & 2 deletions src/app/activity/update-image-field/update-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,22 @@ export const IMAGE_MAX_MB = 10;
/** MIME type the API expects for an image file name, or undefined when it is not an image name. */
export const imageType = (fileName: string): string | undefined => IMAGE_TYPES.get(/\.([^.]+)$/.exec(fileName)?.[1].toLowerCase() ?? '');

export const isPublicDocument = (doc: any): boolean => !!doc?.read?.includes('public');
/**
* Whether the public can read a document; undefined when the row says nothing.
* Eagle upload answers carry `read`; DEMI rows leave `read` out and carry `isPublished`.
*/
function publicState(doc: any): boolean | undefined {
if (Array.isArray(doc?.read)) {
return doc.read.includes('public');
}
return typeof doc?.isPublished === 'boolean' ? doc.isPublished : undefined;
}

export const isPublicDocument = (doc: any): boolean => publicState(doc) === true;

/** A picked project document the public cannot read. Update uploads do not count: they go public with the Update. */
export const blocksPublish = (doc: any): boolean =>
Array.isArray(doc?.read) && doc.documentSource !== UPDATE_IMAGE_SOURCE && !doc.read.includes('public');
publicState(doc) === false && doc.documentSource !== UPDATE_IMAGE_SOURCE;

/** Name staff see for a document. */
export const documentName = (doc: any): string => doc?.displayName || doc?.documentFileName || '';
Expand Down
6 changes: 3 additions & 3 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import { AnalyticsService } from './services/analytics/analytics.service';
import { KeycloakService } from './services/keycloak.service';
import { SideBarService } from './services/sidebar.service';

// A body-container dropdown whose toggle sits inside a small scroll area,
// like the Actions menus inside .app-body.
// A body-container dropdown whose toggle sits inside a small scroll area, like the Actions menus inside .app-body.
// Fixed to the viewport: karma's frame is ~440px tall, so content above the fixture (reporter failure text) hid the toggle.
@Component({
imports: [NgbDropdownModule],
template: `
<div id="scroller" style="height: 100px; overflow: auto">
<div id="scroller" style="position: fixed; top: 0; left: 0; height: 100px; overflow: auto">
<div ngbDropdown container="body">
<button id="toggle" ngbDropdownToggle>Actions</button>
<div id="menu" ngbDropdownMenu><button ngbDropdownItem>Edit</button></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AddCommentComponent } from './add-comment.component';
import { CommentService } from 'src/app/services/comment.service';
import { DocumentService } from 'src/app/services/document.service';
import { LoggingService } from 'src/app/services/logging.service';
import { ToastService } from 'src/app/services/toast.service';

Expand All @@ -13,7 +12,6 @@ describe('AddCommentComponent', () => {
providers: [
provideRouter([]),
{ provide: CommentService, useValue: {} },
{ provide: DocumentService, useValue: {} },
{ provide: ToastService, useValue: jasmine.createSpyObj('ToastService', ['error', 'success']) },
{ provide: LoggingService, useValue: jasmine.createSpyObj('LoggingService', ['debug', 'error']) }
]
Expand Down
8 changes: 0 additions & 8 deletions src/app/comment-period/add-comment/add-comment.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { RouterModule } from '@angular/router';
import { NgbDatepickerModule } from '@ng-bootstrap/ng-bootstrap';
import { FileUploadComponent } from '../../file-upload/file-upload.component';
import { CommentPeriod } from 'src/app/models/commentPeriod';
import { DocumentService } from 'src/app/services/document.service';
import { CommentService } from 'src/app/services/comment.service';
import { convertJSDateToNGBDate, convertFormGroupNGBDateToJSDate } from 'src/app/shared/utils/utils';
import { Comment } from 'src/app/models/comment';
Expand All @@ -33,7 +32,6 @@ import { LoggingService } from 'src/app/services/logging.service';
})

export class AddCommentComponent implements OnInit {
private api = inject(DocumentService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private commentService = inject(CommentService);
Expand Down Expand Up @@ -212,12 +210,6 @@ export class AddCommentComponent implements OnInit {
}
}

public downloadFile(document: Document) {
return this.api.downloadDocument(document).then(() => {
this.logger.debug('Download initiated for file(s)', 'AddCommentComponent');
});
}

public toggleDocumentPublish(document: any, action: string) {
if (action === 'Published' && !this.addCommentForm.get('isRejected').value) {
document.eaoStatus = 'Published';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ <h3>
<span class="cell">
<p class="card-text publish-state">
<i class="material-icons">
{{doc.read.includes("public") ? 'cloud_done' : 'cloud_off'}}
{{doc.isPublished ? 'cloud_done' : 'cloud_off'}}
</i>
</p>
</span>
Expand Down
Loading
Loading