From c083dfda02041c3771711816bc725af9ea7d6f75 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Thu, 24 Sep 2026 15:57:40 +0300 Subject: [PATCH] Add status for preview files Issue: PER-10768 --- .../file-list-item.component.html | 39 ++++- .../file-list-item.component.scss | 83 ++++++++- .../file-list-item.component.spec.ts | 138 +++++++++++++++ .../file-list-item.component.ts | 58 +++++++ .../file-viewer/file-viewer.component.html | 95 ++++++----- .../file-viewer/file-viewer.component.spec.ts | 159 +++++++++++++++++- .../file-viewer/file-viewer.component.ts | 51 ++++++ .../record-preview-state.component.html | 33 ++++ .../record-preview-state.component.scss | 105 ++++++++++++ .../record-preview-state.component.spec.ts | 124 ++++++++++++++ .../record-preview-state.component.ts | 59 +++++++ .../file-browser-components.module.ts | 2 + src/app/models/file-vo.ts | 4 + src/app/models/generated-file-status.ts | 5 + src/app/models/record-preview-state.spec.ts | 146 ++++++++++++++++ src/app/models/record-preview-state.ts | 69 ++++++++ .../record-thumbnail-placeholder.spec.ts | 85 ++++++++++ .../models/record-thumbnail-placeholder.ts | 43 +++++ src/app/models/record-vo.ts | 10 +- .../shared/services/api/record.repo.spec.ts | 32 ++++ src/app/shared/services/api/record.repo.ts | 3 + src/assets/svg/access-copy/preview-failed.svg | 7 + .../svg/access-copy/preview-file-outline.svg | 12 ++ .../svg/access-copy/preview-preparing.svg | 19 +++ src/assets/svg/access-copy/row-failed.svg | 7 + .../svg/access-copy/row-file-outline.svg | 12 ++ src/assets/svg/access-copy/row-preparing.svg | 12 ++ 27 files changed, 1362 insertions(+), 50 deletions(-) create mode 100644 src/app/file-browser/components/record-preview-state/record-preview-state.component.html create mode 100644 src/app/file-browser/components/record-preview-state/record-preview-state.component.scss create mode 100644 src/app/file-browser/components/record-preview-state/record-preview-state.component.spec.ts create mode 100644 src/app/file-browser/components/record-preview-state/record-preview-state.component.ts create mode 100644 src/app/models/generated-file-status.ts create mode 100644 src/app/models/record-preview-state.spec.ts create mode 100644 src/app/models/record-preview-state.ts create mode 100644 src/app/models/record-thumbnail-placeholder.spec.ts create mode 100644 src/app/models/record-thumbnail-placeholder.ts create mode 100644 src/assets/svg/access-copy/preview-failed.svg create mode 100644 src/assets/svg/access-copy/preview-file-outline.svg create mode 100644 src/assets/svg/access-copy/preview-preparing.svg create mode 100644 src/assets/svg/access-copy/row-failed.svg create mode 100644 src/assets/svg/access-copy/row-file-outline.svg create mode 100644 src/assets/svg/access-copy/row-preparing.svg diff --git a/src/app/file-browser/components/file-list-item/file-list-item.component.html b/src/app/file-browser/components/file-list-item/file-list-item.component.html index 4e0d8e311..585455314 100644 --- a/src/app/file-browser/components/file-list-item/file-list-item.component.html +++ b/src/app/file-browser/components/file-list-item/file-list-item.component.html @@ -47,7 +47,37 @@ } @if (!item.isFolder && !isZip) { -
+
+ @if (thumbnailPlaceholder; as placeholder) { +
+ @switch (placeholder) { + @case (RecordThumbnailPlaceholder.Preparing) { + + } + @case (RecordThumbnailPlaceholder.Failed) { + + } + @case (RecordThumbnailPlaceholder.FileType) { + + + @if (originalFileExtension) { + {{ + originalFileExtension + }} + } + + } + } +
+ } } @if (inGridView && item.isFolder ? item.view : true) { @@ -87,6 +117,13 @@ > } + @if (previewStatusLabel; as statusLabel) { +
+ {{ statusLabel.text }} + • + Stored +
+ }
@if (!showAccess || !item.ShareArchiveVO) { diff --git a/src/app/file-browser/components/file-list-item/file-list-item.component.scss b/src/app/file-browser/components/file-list-item/file-list-item.component.scss index 528b4f05f..5c83116b1 100644 --- a/src/app/file-browser/components/file-list-item/file-list-item.component.scss +++ b/src/app/file-browser/components/file-list-item/file-list-item.component.scss @@ -167,6 +167,18 @@ $bg-transition-length: 0.075s; color: $text-muted; } + .preview-status { + display: none; + gap: 4px; + margin-top: 4px; + font-size: 12px; + line-height: 16px; + + @include after($tablet-horizontal) { + display: flex; + } + } + @include after($tablet-horizontal) { .second-row { display: none; @@ -267,7 +279,8 @@ $bg-transition-length: 0.075s; } [prBgImage], -.file-list-item-folder-icon { +.file-list-item-folder-icon, +.thumbnail-placeholder { height: $row-height - ($grid-unit * 0.5); width: $row-height - ($grid-unit * 0.5); flex: 0 0 $row-height - ($grid-unit * 0.5); @@ -435,3 +448,71 @@ $multi-select-radius: 30px; .public-archive { margin-bottom: 5rem; } + +.preview-status { + &[data-preview-status='preparing'] .preview-status-label { + color: $PR-yellow-500; + } + + &[data-preview-status='failed'] .preview-status-label { + color: $PR-red-500; + } + + .preview-status-stored { + color: $PR-blue-400; + } +} + +[prBgImage].replaced-by-thumbnail-placeholder { + @include after($tablet-horizontal) { + display: none; + } +} + +.thumbnail-placeholder { + display: none; + align-items: center; + justify-content: center; + overflow: hidden; + + @include after($tablet-horizontal) { + display: flex; + } + + &[data-placeholder='preparing'] img { + width: 100%; + height: 100%; + } + + &[data-placeholder='failed'], + &[data-placeholder='file-type'] { + background-color: $PR-blue-25; + border: 1px solid $PR-blue-50; + } +} + +.thumbnail-placeholder-file-type { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; +} + +.thumbnail-placeholder-file-extension { + position: absolute; + left: 8px; + bottom: 5px; + height: 10px; + padding: 0 3px; + display: flex; + align-items: center; + border-radius: 2px; + background-image: linear-gradient(117deg, $PR-purple 0%, $PR-orange 100%); + color: $PR-brand-white; + font-size: 7px; + line-height: 1; + letter-spacing: 1.12px; + text-transform: uppercase; +} diff --git a/src/app/file-browser/components/file-list-item/file-list-item.component.spec.ts b/src/app/file-browser/components/file-list-item/file-list-item.component.spec.ts index da9e3f0f6..eca714fb9 100644 --- a/src/app/file-browser/components/file-list-item/file-list-item.component.spec.ts +++ b/src/app/file-browser/components/file-list-item/file-list-item.component.spec.ts @@ -15,6 +15,10 @@ import { EditService } from '@core/services/edit/edit.service'; import { DeviceService } from '@shared/services/device/device.service'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { GetThumbnailPipe } from '@shared/pipes/get-thumbnail.pipe'; +import { RecordVO } from '@models/record-vo'; +import { FileFormat } from '@models/file-vo'; +import { GeneratedFileStatus } from '@models/generated-file-status'; +import { RecordThumbnailPlaceholder } from '@models/record-thumbnail-placeholder'; import { FileListItemComponent } from './file-list-item.component'; @Pipe({ name: 'itemTypeIcon' }) @@ -607,4 +611,138 @@ describe('FileListItemComponent', () => { expect(component.startDisplayTime).toBe('2020-06-10'); }); }); + + describe('access copy status', () => { + const makeRecord = (fields: Partial) => + new RecordVO({ + displayName: "Grandma Ruth's Recipes", + archiveNbr: '0001-0001', + folder_linkId: 1, + type: 'type.record.document', + FileVOs: [ + { + fileId: 1, + size: 1024, + format: FileFormat.Original, + fileURL: 'https://example.com/original', + downloadURL: 'https://example.com/download', + type: 'type.file.document.docx', + }, + ], + ...fields, + }); + + const showItem = async (item: RecordVO) => { + component.item = item; + await component.ngOnInit(); + fixture.detectChanges(); + }; + + const statusLine = (): HTMLElement | null => + fixture.nativeElement.querySelector('.preview-status'); + + const placeholderElement = (): HTMLElement | null => + fixture.nativeElement.querySelector('.thumbnail-placeholder'); + + it('shows that the viewable copy is being prepared', async () => { + await showItem( + makeRecord({ + accessCopyStatus: GeneratedFileStatus.Processing, + thumbnail256Status: GeneratedFileStatus.Processing, + }), + ); + + expect(component.thumbnailPlaceholder).toBe( + RecordThumbnailPlaceholder.Preparing, + ); + + expect( + placeholderElement().querySelector('img').getAttribute('src'), + ).toBe('assets/svg/access-copy/row-preparing.svg'); + + expect(statusLine().getAttribute('data-preview-status')).toBe( + 'preparing', + ); + + expect(statusLine().textContent).toContain('Preparing to view...'); + expect(statusLine().textContent).toContain('Stored'); + }); + + it('shows that the preview is unavailable when the copy failed', async () => { + await showItem( + makeRecord({ + accessCopyStatus: GeneratedFileStatus.Failed, + thumbnail256Status: GeneratedFileStatus.Failed, + }), + ); + + expect(component.thumbnailPlaceholder).toBe( + RecordThumbnailPlaceholder.Failed, + ); + + expect(statusLine().getAttribute('data-preview-status')).toBe('failed'); + expect(statusLine().textContent).toContain('Preview unavailable'); + }); + + it('shows the file type and no status line when no copy will be made', async () => { + await showItem( + makeRecord({ + type: 'type.record.image', + uploadFileName: 'family-history-graph.svg', + FileVOs: [], + accessCopyStatus: null, + thumbnail256Status: null, + }), + ); + + expect(component.thumbnailPlaceholder).toBe( + RecordThumbnailPlaceholder.FileType, + ); + + expect( + placeholderElement().querySelector( + '.thumbnail-placeholder-file-extension', + ).textContent, + ).toBe('svg'); + + expect(statusLine()).toBeNull(); + }); + + it('keeps an existing thumbnail and shows no placeholder', async () => { + await showItem( + makeRecord({ + thumbURL200: 'https://example.com/thumb200.jpg', + accessCopyStatus: GeneratedFileStatus.Failed, + thumbnail256Status: GeneratedFileStatus.Failed, + }), + ); + + expect(component.thumbnailPlaceholder).toBeUndefined(); + expect(placeholderElement()).toBeNull(); + }); + + it('shows nothing new in grid view', async () => { + component.inGridView = true; + component.item = makeRecord({ + accessCopyStatus: GeneratedFileStatus.Processing, + }); + + expect(component.thumbnailPlaceholder).toBeUndefined(); + expect(component.previewStatusLabel).toBeUndefined(); + }); + + it('shows nothing new behind the stock image of a listed share preview', async () => { + const router = TestBed.inject(Router); + (router.routerState.snapshot as any).url = '/share/test'; + + await showItem( + makeRecord({ accessCopyStatus: GeneratedFileStatus.Processing }), + ); + + expect(component.thumbnailPlaceholder).toBeUndefined(); + expect(component.previewStatusLabel).toBeUndefined(); + + (router.routerState.snapshot as any).url = '/'; + }); + }); }); diff --git a/src/app/file-browser/components/file-list-item/file-list-item.component.ts b/src/app/file-browser/components/file-list-item/file-list-item.component.ts index 376bf65a5..bf733bdaf 100644 --- a/src/app/file-browser/components/file-list-item/file-list-item.component.ts +++ b/src/app/file-browser/components/file-list-item/file-list-item.component.ts @@ -69,6 +69,15 @@ import { ngIfFadeInAnimation } from '@shared/animations'; import { RouteData } from '@root/app/app.routes'; import { ThumbnailCache } from '@shared/utilities/thumbnail-cache/thumbnail-cache'; import { GetThumbnail } from '@models/get-thumbnail'; +import { + getOriginalFileExtension, + getRecordPreviewState, + RecordPreviewState, +} from '@models/record-preview-state'; +import { + getRecordThumbnailPlaceholder, + RecordThumbnailPlaceholder, +} from '@models/record-thumbnail-placeholder'; import { ShareLinksService } from '@root/app/share-links/services/share-links.service'; import { ItemClickEvent } from '../file-list/file-list.component'; import { SharingComponent } from '../sharing/sharing.component'; @@ -155,6 +164,24 @@ const DOUBLE_CLICK_TIMEOUT = 350; const MOUSE_DOWN_DRAG_TIMEOUT = 500; const DRAG_MIN_Y = 1; +interface PreviewStatusLabel { + text: string; + tone: 'preparing' | 'failed'; +} + +const PREVIEW_STATUS_LABELS: Partial< + Record +> = { + [RecordPreviewState.Preparing]: { + text: 'Preparing to view...', + tone: 'preparing', + }, + [RecordPreviewState.Failed]: { + text: 'Preview unavailable', + tone: 'failed', + }, +}; + @Component({ selector: 'pr-file-list-item', templateUrl: './file-list-item.component.html', @@ -209,6 +236,8 @@ export class FileListItemComponent public isUnlistedShare = false; public recordThumbnailUrl: string | undefined; + public readonly RecordThumbnailPlaceholder = RecordThumbnailPlaceholder; + private folderThumb: string; private folderContentsType: FolderContentsType = FolderContentsType.NORMAL; @@ -269,6 +298,35 @@ export class FileListItemComponent return edtfStartDate || this.item.displayDT; } + get thumbnailPlaceholder(): RecordThumbnailPlaceholder | undefined { + if (!this.showsRecordPreviewStatus || this.recordThumbnailUrl) { + return undefined; + } + return getRecordThumbnailPlaceholder(this.item as RecordVO); + } + + get previewStatusLabel(): PreviewStatusLabel | undefined { + if (!this.showsRecordPreviewStatus) { + return undefined; + } + return PREVIEW_STATUS_LABELS[getRecordPreviewState(this.item as RecordVO)]; + } + + get originalFileExtension(): string | undefined { + return getOriginalFileExtension(this.item as RecordVO); + } + + private get showsRecordPreviewStatus(): boolean { + const showsStockPreviewImage = + this.isInSharePreview && !this.isUnlistedShare; + return ( + this.item.isRecord && + !this.isZip && + !this.inGridView && + !showsStockPreviewImage + ); + } + async ngOnInit() { this.isInSharePreview = this.router.routerState.snapshot.url.includes('/share/'); diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.html b/src/app/file-browser/components/file-viewer/file-viewer.component.html index ba24ee973..def9b6d48 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.html +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.html @@ -32,49 +32,58 @@ } @if (currentRecord === record) {
- @if ( - showThumbnail && - isZoomableImage && - !isDocument && - !isVideo && - !isAudio && - !isWebArchive - ) { - - } - @if (isWebArchive && replayUrl) { - - } - @if ( - showThumbnail && - !isZoomableImage && - !isDocument && - !isVideo && - !isAudio && - !isWebArchive - ) { - - } - @if (isVideo) { - - } - @if (isAudio) { - - } - @if (isDocument && documentUrl) { - + @if (previewState === RecordPreviewState.Ready) { + @if ( + showThumbnail && + isZoomableImage && + !isDocument && + !isVideo && + !isAudio && + !isWebArchive + ) { + + } + @if (isWebArchive && replayUrl) { + + } + @if ( + showThumbnail && + !isZoomableImage && + !isDocument && + !isVideo && + !isAudio && + !isWebArchive + ) { + + } + @if (isVideo) { + + } + @if (isAudio) { + + } + @if (isDocument && documentUrl) { + + } + } @else { + }
} diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts b/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts index fd4aa5dc3..7c0568324 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts @@ -1,5 +1,12 @@ import { CUSTOM_ELEMENTS_SCHEMA, Pipe, PipeTransform } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, + fakeAsync, + flush, + flushMicrotasks, + TestBed, + tick, +} from '@angular/core/testing'; import { Router, ActivatedRoute } from '@angular/router'; import { Subject } from 'rxjs'; import { HttpClientTestingModule } from '@angular/common/http/testing'; @@ -21,9 +28,15 @@ import { DateTimeModel, EdtfService, } from '@shared/services/edtf-service/edtf.service'; -import { TagsComponent } from '../../../shared/components/tags/tags.component'; +import { GeneratedFileStatus } from '@models/generated-file-status'; +import { RecordPreviewState } from '@models/record-preview-state'; +import { FileFormat } from '@models/file-vo'; import { EditDateTimeModalService } from '../edit-date-time-modal/edit-date-time-modal.service'; -import { FileViewerComponent } from './file-viewer.component'; +import { TagsComponent } from '../../../shared/components/tags/tags.component'; +import { + FileViewerComponent, + PREPARING_RECORD_REFRESH_INTERVAL, +} from './file-viewer.component'; @Pipe({ name: 'dsFileSize', standalone: false }) class MockFileSizePipe implements PipeTransform { @@ -880,4 +893,144 @@ describe('FileViewerComponent', () => { expect(displayValue).toBe('2024-01-01T00:00:00.000Z'); }); }); + + describe('access copy preview states', () => { + const originalDocx = { + fileId: 1, + size: 1024, + format: FileFormat.Original, + fileURL: 'https://example.com/original.docx', + downloadURL: 'https://example.com/original.docx?download', + type: 'type.file.document.docx', + }; + + const makeDocxRecord = ( + accessCopyStatus: GeneratedFileStatus | null, + extraFiles: RecordVO['FileVOs'] = [], + ) => + new RecordVO({ + displayName: "Grandma Ruth's Recipes", + type: 'type.record.document', + TagVOs: [], + archiveNbr: '0001-0001', + FileVOs: [originalDocx, ...extraFiles], + accessCopyStatus, + }); + + const showRecord = (record: RecordVO) => { + component.currentRecord = record; + component.records = [record]; + component.currentIndex = 0; + component.initRecord(); + fixture.detectChanges(); + }; + + const renderedPreviewState = (): HTMLElement | null => + fixture.nativeElement.querySelector('pr-record-preview-state'); + + it('shows the preparing state while the access copy is processing', () => { + showRecord(makeDocxRecord(GeneratedFileStatus.Processing)); + + expect(component.previewState).toBe(RecordPreviewState.Preparing); + expect(component.originalFileExtension).toBe('docx'); + expect(renderedPreviewState()).toBeTruthy(); + }); + + it('shows the failed state instead of the video player', () => { + showRecord( + new RecordVO({ + type: 'type.record.video', + TagVOs: [], + FileVOs: [{ ...originalDocx, type: 'type.file.video.mp4' }], + accessCopyStatus: GeneratedFileStatus.Failed, + }), + ); + + expect(renderedPreviewState()).toBeTruthy(); + expect(fixture.nativeElement.querySelector('pr-video')).toBeNull(); + }); + + it('keeps showing a legacy converted copy even when Stela says it failed', () => { + showRecord( + makeDocxRecord(GeneratedFileStatus.Failed, [ + { + ...originalDocx, + fileId: 2, + format: FileFormat.Converted, + type: 'type.file.pdf.pdf', + }, + ]), + ); + + expect(component.previewState).toBe(RecordPreviewState.Ready); + expect(renderedPreviewState()).toBeNull(); + }); + + it('refreshes a preparing record until its copy is ready', fakeAsync(() => { + const preparingRecord = makeDocxRecord(GeneratedFileStatus.Processing); + const readyRecord = makeDocxRecord(GeneratedFileStatus.Ok, [ + { + ...originalDocx, + fileId: 2, + format: FileFormat.ArchivematicaAccess, + type: 'type.file.pdf.pdf', + }, + ]); + const getRecords = spyOn( + TestBed.inject(ApiService).record, + 'get', + ).and.resolveTo({ getRecordVO: () => readyRecord } as any); + + showRecord(preparingRecord); + tick(PREPARING_RECORD_REFRESH_INTERVAL - 1); + + expect(getRecords).not.toHaveBeenCalled(); + + tick(1); + flushMicrotasks(); + + expect(getRecords).toHaveBeenCalledOnceWith([preparingRecord], null); + expect(component.previewState).toBe(RecordPreviewState.Ready); + + tick(PREPARING_RECORD_REFRESH_INTERVAL); + + expect(getRecords).toHaveBeenCalledTimes(1); + component.ngOnDestroy(); + flush(); + })); + + it('stops refreshing once the viewer is closed', fakeAsync(() => { + const getRecords = spyOn(TestBed.inject(ApiService).record, 'get'); + + showRecord(makeDocxRecord(GeneratedFileStatus.Processing)); + component.ngOnDestroy(); + tick(PREPARING_RECORD_REFRESH_INTERVAL); + flush(); + + expect(getRecords).not.toHaveBeenCalled(); + })); + + it('does not refresh a record that is already ready', fakeAsync(() => { + const getRecords = spyOn(TestBed.inject(ApiService).record, 'get'); + + showRecord(makeDocxRecord(null)); + tick(PREPARING_RECORD_REFRESH_INTERVAL); + + expect(component.previewState).toBe(RecordPreviewState.Unavailable); + expect(getRecords).not.toHaveBeenCalled(); + component.ngOnDestroy(); + flush(); + })); + + it('hides the download button in a public archive that disallows downloads', () => { + component.isPublicArchive = true; + component.allowDownloads = false; + + expect(component.canDownload()).toBeFalse(); + + component.allowDownloads = true; + + expect(component.canDownload()).toBeTrue(); + }); + }); }); diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.ts b/src/app/file-browser/components/file-viewer/file-viewer.component.ts index 82af4c5ee..4d1889e25 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.ts +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.ts @@ -27,6 +27,11 @@ import { SearchService } from '@search/services/search.service'; import { ZoomingImageViewerComponent } from '@shared/components/zooming-image-viewer/zooming-image-viewer.component'; import { FileFormat } from '@models/file-vo'; import { GetAccessFile } from '@models/get-access-file'; +import { + getOriginalFileExtension, + getRecordPreviewState, + RecordPreviewState, +} from '@models/record-preview-state'; import { ShareLinksService } from '@root/app/share-links/services/share-links.service'; import { ApiService } from '@shared/services/api/api.service'; import { environment } from '@root/environments/environment'; @@ -39,6 +44,8 @@ import { FeatureFlagService } from '@root/app/feature-flag/services/feature-flag import { TagsService } from '../../../core/services/tags/tags.service'; import { EditDateTimeModalService } from '../edit-date-time-modal/edit-date-time-modal.service'; +export const PREPARING_RECORD_REFRESH_INTERVAL = 10000; + @Component({ selector: 'pr-file-viewer', templateUrl: './file-viewer.component.html', @@ -58,6 +65,9 @@ export class FileViewerComponent implements OnInit, OnDestroy { public isAudio = false; public isDocument = false; public isWebArchive = false; + public previewState = RecordPreviewState.Ready; + public originalFileExtension: string | undefined; + public readonly RecordPreviewState = RecordPreviewState; public showThumbnail = true; public isPublicArchive: boolean = false; public allowDownloads: boolean = false; @@ -95,6 +105,8 @@ export class FileViewerComponent implements OnInit, OnDestroy { private tagsSubscription: Subscription; private dateModalSubscription?: Subscription; private isUnlistedShare = true; + private preparingRecordRefreshTimeout?: ReturnType; + private isClosed = false; constructor( private router: Router, @@ -205,6 +217,8 @@ export class FileViewerComponent implements OnInit, OnDestroy { this.itemTagsSubscription.unsubscribe(); this.tagsSubscription.unsubscribe(); this.dateModalSubscription?.unsubscribe(); + this.isClosed = true; + clearTimeout(this.preparingRecordRefreshTimeout); } private setRecordsToPreview(resolvedRecord: RecordVO) { @@ -260,8 +274,41 @@ export class FileViewerComponent implements OnInit, OnDestroy { this.isWebArchive = this.currentRecord.type.includes('web_archive'); this.documentUrl = this.getDocumentUrl(); this.replayUrl = this.getReplayUrl(); + this.previewState = getRecordPreviewState(this.currentRecord); + this.originalFileExtension = getOriginalFileExtension(this.currentRecord); this.setCurrentTags(); this.updateDisplayTimeObject(); + this.schedulePreparingRecordRefresh(); + } + + private schedulePreparingRecordRefresh(): void { + clearTimeout(this.preparingRecordRefreshTimeout); + if (this.isClosed || this.previewState !== RecordPreviewState.Preparing) { + return; + } + const recordBeingPrepared = this.currentRecord; + this.preparingRecordRefreshTimeout = setTimeout(() => { + void this.refreshPreparingRecord(recordBeingPrepared); + }, PREPARING_RECORD_REFRESH_INTERVAL); + } + + private async refreshPreparingRecord( + recordBeingPrepared: RecordVO, + ): Promise { + try { + const response = await this.api.record.get( + [recordBeingPrepared], + this.isUnlistedShare ? this.shareLinksService.currentShareToken : null, + ); + const refreshedRecord = response.getRecordVO(); + if (refreshedRecord) { + recordBeingPrepared.update(refreshedRecord); + } + } finally { + if (this.currentRecord === recordBeingPrepared) { + this.initRecord(); + } + } } toggleSwipe(value: boolean) { @@ -528,6 +575,10 @@ export class FileViewerComponent implements OnInit, OnDestroy { this.dataService.downloadFile(this.currentRecord); } + public canDownload(): boolean { + return !this.isPublicArchive || this.allowDownloads; + } + private setCurrentTags(): void { this.keywords = this.currentRecord.TagVOs.filter( (tag) => diff --git a/src/app/file-browser/components/record-preview-state/record-preview-state.component.html b/src/app/file-browser/components/record-preview-state/record-preview-state.component.html new file mode 100644 index 000000000..dc3188d6f --- /dev/null +++ b/src/app/file-browser/components/record-preview-state/record-preview-state.component.html @@ -0,0 +1,33 @@ +
+
+ + @if (content().showsFileExtensionOnIcon && fileExtension()) { + {{ + fileExtension() + }} + } +
+

{{ content().heading }}

+

{{ content().body }}

+ @if (canDownload()) { + + } + {{ content().helpLinkText }} +
diff --git a/src/app/file-browser/components/record-preview-state/record-preview-state.component.scss b/src/app/file-browser/components/record-preview-state/record-preview-state.component.scss new file mode 100644 index 000000000..3b37a48b7 --- /dev/null +++ b/src/app/file-browser/components/record-preview-state/record-preview-state.component.scss @@ -0,0 +1,105 @@ +@import 'colors'; +@import 'mixins'; + +$icon-size: 96px; +$content-width: 560px; + +:host { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background-color: $PR-blue-25; + z-index: 1; +} + +.record-preview-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + max-width: $content-width; + text-align: center; +} + +.record-preview-state-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: $icon-size; + height: $icon-size; +} + +.record-preview-state-icon-extension { + position: absolute; + right: 0; + bottom: 16px; + height: 28px; + padding: 0 10px; + display: flex; + align-items: center; + border-radius: 6px; + background-image: linear-gradient(115deg, $PR-purple 0%, $PR-orange 100%); + color: $PR-brand-white; + font-family: 'Usual', sans-serif; + font-weight: 600; + font-size: 16px; + line-height: 24px; + letter-spacing: 2.56px; + text-transform: uppercase; +} + +.record-preview-state-heading { + margin: 0; + color: $PR-blue-900; + font-family: 'Usual', sans-serif; + font-weight: 600; + font-size: 24px; + line-height: 32px; + letter-spacing: -0.48px; +} + +.record-preview-state-body { + @include usual-text; + margin: 0; + color: $PR-blue-600; +} + +.record-preview-state-download { + @include usual-text; + display: flex; + align-items: center; + gap: 24px; + height: 56px; + padding: 16px 24px; + border: none; + border-radius: 12px; + background-color: $PR-blue-900; + color: $PR-brand-white; + font-weight: 600; + cursor: pointer; +} + +.record-preview-state-download-extension { + display: flex; + align-items: center; + height: 24px; + padding: 0 8px; + border-radius: 4px; + background-color: $PR-brand-white; + color: $PR-blue-900; + font-size: 10px; + line-height: 16px; + letter-spacing: 1.6px; + text-transform: uppercase; + font-weight: 400; +} + +.record-preview-state-help-link { + @include usual-text; + color: $PR-blue-900; + text-decoration: underline; +} diff --git a/src/app/file-browser/components/record-preview-state/record-preview-state.component.spec.ts b/src/app/file-browser/components/record-preview-state/record-preview-state.component.spec.ts new file mode 100644 index 000000000..1de6371c8 --- /dev/null +++ b/src/app/file-browser/components/record-preview-state/record-preview-state.component.spec.ts @@ -0,0 +1,124 @@ +import { MockBuilder, MockRender, ngMocks } from 'ng-mocks'; +import { RecordPreviewState } from '@models/record-preview-state'; +import { + ACCESS_COPY_HELP_LINK, + RecordPreviewStateComponent, +} from './record-preview-state.component'; + +const renderPreviewState = (inputs: { + previewState: RecordPreviewState; + fileExtension?: string; + canDownload?: boolean; + downloadOriginal?: () => void; +}) => + MockRender( + RecordPreviewStateComponent, + { + fileExtension: undefined, + canDownload: true, + downloadOriginal: () => undefined, + ...inputs, + }, + { reset: true }, + ); + +const textOf = (selector: string): string => + ngMocks.formatText(ngMocks.find(selector)); + +describe('RecordPreviewStateComponent', () => { + beforeEach(async () => { + await MockBuilder(RecordPreviewStateComponent); + }); + + it('explains that a file is still being prepared', () => { + renderPreviewState({ + previewState: RecordPreviewState.Preparing, + fileExtension: 'docx', + }); + + expect(textOf('.record-preview-state-heading')).toBe( + "We're still preparing this file to view", + ); + + expect(textOf('.record-preview-state-help-link')).toBe( + 'What does it mean when my file is processing?', + ); + + expect( + ngMocks.find('.record-preview-state-icon img').nativeElement.src, + ).toContain('assets/svg/access-copy/preview-preparing.svg'); + }); + + it('shows the extension on the icon only when no copy can be made', () => { + renderPreviewState({ + previewState: RecordPreviewState.Unavailable, + fileExtension: 'svg', + }); + + expect(textOf('.record-preview-state-heading')).toBe( + "This file is stored, but we can't show it here", + ); + + expect(textOf('.record-preview-state-body')).toContain( + "This isn't a format we can make a viewable copy of.", + ); + + expect(textOf('.record-preview-state-icon-extension')).toBe('svg'); + }); + + it('explains that making the copy failed', () => { + renderPreviewState({ + previewState: RecordPreviewState.Failed, + fileExtension: 'docx', + }); + + expect(textOf('.record-preview-state-heading')).toBe( + "We couldn't make a copy of this file to view", + ); + + expect(ngMocks.findAll('.record-preview-state-icon-extension').length).toBe( + 0, + ); + }); + + it('labels the download button with the original extension', () => { + renderPreviewState({ + previewState: RecordPreviewState.Failed, + fileExtension: 'docx', + }); + + expect(textOf('.record-preview-state-download-extension')).toBe('docx'); + }); + + it('asks for the original to be downloaded when the button is clicked', () => { + const downloadOriginal = jasmine.createSpy('downloadOriginal'); + renderPreviewState({ + previewState: RecordPreviewState.Preparing, + downloadOriginal, + }); + + ngMocks.click('.record-preview-state-download'); + + expect(downloadOriginal).toHaveBeenCalledTimes(1); + }); + + it('hides the download button when downloads are not allowed', () => { + renderPreviewState({ + previewState: RecordPreviewState.Preparing, + canDownload: false, + }); + + expect(ngMocks.findAll('.record-preview-state-download').length).toBe(0); + }); + + it('opens the help link in a new tab', () => { + renderPreviewState({ previewState: RecordPreviewState.Unavailable }); + + const helpLink = ngMocks.find('.record-preview-state-help-link'); + + expect(helpLink.attributes.target).toBe('_blank'); + expect(helpLink.nativeElement.getAttribute('href')).toBe( + ACCESS_COPY_HELP_LINK, + ); + }); +}); diff --git a/src/app/file-browser/components/record-preview-state/record-preview-state.component.ts b/src/app/file-browser/components/record-preview-state/record-preview-state.component.ts new file mode 100644 index 000000000..c8b547426 --- /dev/null +++ b/src/app/file-browser/components/record-preview-state/record-preview-state.component.ts @@ -0,0 +1,59 @@ +import { Component, computed, input, output } from '@angular/core'; +import { RecordPreviewState } from '@models/record-preview-state'; + +export const ACCESS_COPY_HELP_LINK = 'about:blank'; + +type UnreadyPreviewState = Exclude< + RecordPreviewState, + RecordPreviewState.Ready +>; + +interface PreviewStateContent { + iconSource: string; + showsFileExtensionOnIcon: boolean; + heading: string; + body: string; + helpLinkText: string; +} + +const PREVIEW_STATE_CONTENT: Record = + { + [RecordPreviewState.Preparing]: { + iconSource: 'assets/svg/access-copy/preview-preparing.svg', + showsFileExtensionOnIcon: false, + heading: "We're still preparing this file to view", + body: "Your original file is stored and safe. We're making a copy you can read in the browser, which usually takes a few minutes and can take longer after a large upload.", + helpLinkText: 'What does it mean when my file is processing?', + }, + [RecordPreviewState.Unavailable]: { + iconSource: 'assets/svg/access-copy/preview-file-outline.svg', + showsFileExtensionOnIcon: true, + heading: "This file is stored, but we can't show it here", + body: "This isn't a format we can make a viewable copy of. Your original is kept exactly as you uploaded it and you can download it any time.", + helpLinkText: 'Which files can Permanent preview?', + }, + [RecordPreviewState.Failed]: { + iconSource: 'assets/svg/access-copy/preview-failed.svg', + showsFileExtensionOnIcon: false, + heading: "We couldn't make a copy of this file to view", + body: "Something went wrong while preparing it. This doesn't affect your original, which is stored exactly as you uploaded it. Download it to check it opens on your device.", + helpLinkText: 'Get help with this file', + }, + }; + +@Component({ + selector: 'pr-record-preview-state', + standalone: true, + templateUrl: './record-preview-state.component.html', + styleUrls: ['./record-preview-state.component.scss'], +}) +export class RecordPreviewStateComponent { + previewState = input.required(); + fileExtension = input(); + canDownload = input(true); + + downloadOriginal = output(); + + readonly helpLink = ACCESS_COPY_HELP_LINK; + readonly content = computed(() => PREVIEW_STATE_CONTENT[this.previewState()]); +} diff --git a/src/app/file-browser/file-browser-components.module.ts b/src/app/file-browser/file-browser-components.module.ts index 16921f1b4..dc00154fa 100644 --- a/src/app/file-browser/file-browser-components.module.ts +++ b/src/app/file-browser/file-browser-components.module.ts @@ -29,6 +29,7 @@ import { SharingDialogComponent } from './components/sharing-dialog/sharing-dial import { DownloadButtonComponent } from './components/download-button/download-button.component'; import { SidebarDatePickerComponent } from './components/sidebar-date-picker/sidebar-date-picker.component'; import { SidebarLocationComponent } from './components/sidebar-location/sidebar-location.component'; +import { RecordPreviewStateComponent } from './components/record-preview-state/record-preview-state.component'; @NgModule({ imports: [ @@ -40,6 +41,7 @@ import { SidebarLocationComponent } from './components/sidebar-location/sidebar- FontAwesomeModule, SidebarDatePickerComponent, SidebarLocationComponent, + RecordPreviewStateComponent, ], exports: [ FileListComponent, diff --git a/src/app/models/file-vo.ts b/src/app/models/file-vo.ts index 737b1d42c..d777b7ddc 100644 --- a/src/app/models/file-vo.ts +++ b/src/app/models/file-vo.ts @@ -13,3 +13,7 @@ export interface PermanentFile { downloadURL: string; type: string; } + +export function getFileExtension(file: PermanentFile): string { + return file.type.split('.').pop(); +} diff --git a/src/app/models/generated-file-status.ts b/src/app/models/generated-file-status.ts new file mode 100644 index 000000000..cee86eaee --- /dev/null +++ b/src/app/models/generated-file-status.ts @@ -0,0 +1,5 @@ +export enum GeneratedFileStatus { + Ok = 'ok', + Processing = 'processing', + Failed = 'failed', +} diff --git a/src/app/models/record-preview-state.spec.ts b/src/app/models/record-preview-state.spec.ts new file mode 100644 index 000000000..d32c0b5da --- /dev/null +++ b/src/app/models/record-preview-state.spec.ts @@ -0,0 +1,146 @@ +import { FileFormat, PermanentFile } from './file-vo'; +import { GeneratedFileStatus } from './generated-file-status'; +import { + getOriginalFileExtension, + getRecordPreviewState, + HasPreviewStatus, + RecordPreviewState, +} from './record-preview-state'; + +const makeFile = (params: Partial = {}): PermanentFile => ({ + fileId: 1, + size: 1024, + format: FileFormat.Original, + fileURL: 'https://example.com/file', + downloadURL: 'https://example.com/download', + type: 'type.file.document.docx', + ...params, +}); + +const originalDocx = makeFile(); + +describe('getRecordPreviewState', () => { + it('is ready when an Archivematica access copy exists, whatever the status says', () => { + expect( + getRecordPreviewState({ + FileVOs: [ + originalDocx, + makeFile({ + format: FileFormat.ArchivematicaAccess, + type: 'type.file.pdf.pdf', + }), + ], + accessCopyStatus: GeneratedFileStatus.Failed, + }), + ).toBe(RecordPreviewState.Ready); + }); + + it('is ready when only a legacy converted copy exists, even though Stela says failed', () => { + expect( + getRecordPreviewState({ + FileVOs: [ + originalDocx, + makeFile({ format: FileFormat.Converted, type: 'type.file.pdf.pdf' }), + ], + accessCopyStatus: GeneratedFileStatus.Failed, + }), + ).toBe(RecordPreviewState.Ready); + }); + + it('does not count an Archivematica copy with no identified file type as viewable', () => { + expect( + getRecordPreviewState({ + FileVOs: [ + originalDocx, + makeFile({ + format: FileFormat.ArchivematicaAccess, + type: 'type.file.unknown.null', + }), + ], + accessCopyStatus: GeneratedFileStatus.Failed, + }), + ).toBe(RecordPreviewState.Failed); + }); + + it('is preparing while the access copy is processing', () => { + expect( + getRecordPreviewState({ + FileVOs: [originalDocx], + accessCopyStatus: GeneratedFileStatus.Processing, + }), + ).toBe(RecordPreviewState.Preparing); + }); + + it('is preparing for an original the browser could show on its own', () => { + expect( + getRecordPreviewState({ + FileVOs: [makeFile({ type: 'type.file.image.jpg' })], + accessCopyStatus: GeneratedFileStatus.Processing, + }), + ).toBe(RecordPreviewState.Preparing); + }); + + it('is failed when the access copy failed', () => { + expect( + getRecordPreviewState({ + FileVOs: [originalDocx], + accessCopyStatus: GeneratedFileStatus.Failed, + }), + ).toBe(RecordPreviewState.Failed); + }); + + it('is unavailable when no access copy will ever be made', () => { + expect( + getRecordPreviewState({ + type: 'type.record.image', + FileVOs: [makeFile({ type: 'type.file.image.svg' })], + accessCopyStatus: null, + }), + ).toBe(RecordPreviewState.Unavailable); + }); + + it('keeps web archives ready, since they replay from the original', () => { + expect( + getRecordPreviewState({ + type: 'type.record.web_archive', + FileVOs: [makeFile({ type: 'type.file.archive.wacz' })], + accessCopyStatus: null, + }), + ).toBe(RecordPreviewState.Ready); + }); + + it('is ready when the record has no status from Stela', () => { + expect(getRecordPreviewState({ FileVOs: [originalDocx] })).toBe( + RecordPreviewState.Ready, + ); + }); + + it('is ready for a record that has not loaded its files yet', () => { + expect(getRecordPreviewState({})).toBe(RecordPreviewState.Ready); + }); +}); + +describe('getOriginalFileExtension', () => { + it("uses the original file's type", () => { + const record: HasPreviewStatus = { + FileVOs: [ + makeFile({ format: FileFormat.Converted, type: 'type.file.pdf.pdf' }), + originalDocx, + ], + }; + + expect(getOriginalFileExtension(record)).toBe('docx'); + }); + + it('falls back to the uploaded file name', () => { + expect( + getOriginalFileExtension({ uploadFileName: 'family-tree.svg' }), + ).toBe('svg'); + }); + + it('has no extension when neither is known', () => { + expect(getOriginalFileExtension({ uploadFileName: 'README' })).toBe( + undefined, + ); + }); +}); diff --git a/src/app/models/record-preview-state.ts b/src/app/models/record-preview-state.ts new file mode 100644 index 000000000..0ffa628ef --- /dev/null +++ b/src/app/models/record-preview-state.ts @@ -0,0 +1,69 @@ +import { FileFormat, getFileExtension, PermanentFile } from './file-vo'; +import { GeneratedFileStatus } from './generated-file-status'; +import { HasThumbnails } from './get-thumbnail'; + +export enum RecordPreviewState { + Ready = 'ready', + Preparing = 'preparing', + Unavailable = 'unavailable', + Failed = 'failed', +} + +export interface HasPreviewStatus extends HasThumbnails { + type?: string; + uploadFileName?: string; + FileVOs?: PermanentFile[]; + accessCopyStatus?: GeneratedFileStatus | null; + thumbnail256Status?: GeneratedFileStatus | null; +} + +const UNIDENTIFIED_FILE_TYPE = 'type.file.unknown.null'; + +const isViewableCopy = (file: PermanentFile): boolean => + file.format === FileFormat.Converted || + (file.format === FileFormat.ArchivematicaAccess && + file.type !== UNIDENTIFIED_FILE_TYPE); + +const isWebArchive = (record: HasPreviewStatus): boolean => + !!record.type?.includes('web_archive'); + +/** + * A viewable copy already on the record wins over the status, because Stela + * only counts Archivematica copies and would call legacy conversions failed. + * An undefined status means the record didn't come from Stela, so the viewer + * keeps rendering it the way it always has. + */ +export function getRecordPreviewState( + record: HasPreviewStatus, +): RecordPreviewState { + if ((record.FileVOs ?? []).some(isViewableCopy)) { + return RecordPreviewState.Ready; + } + + switch (record.accessCopyStatus) { + case GeneratedFileStatus.Processing: + return RecordPreviewState.Preparing; + case GeneratedFileStatus.Failed: + return RecordPreviewState.Failed; + case null: + return isWebArchive(record) + ? RecordPreviewState.Ready + : RecordPreviewState.Unavailable; + default: + return RecordPreviewState.Ready; + } +} + +export function getOriginalFileExtension( + record: HasPreviewStatus, +): string | undefined { + const originalFile = record.FileVOs?.find( + (file) => file.format === FileFormat.Original, + ); + if (originalFile) { + return getFileExtension(originalFile); + } + return record.uploadFileName?.includes('.') + ? record.uploadFileName.split('.').pop() + : undefined; +} diff --git a/src/app/models/record-thumbnail-placeholder.spec.ts b/src/app/models/record-thumbnail-placeholder.spec.ts new file mode 100644 index 000000000..4a8661eb1 --- /dev/null +++ b/src/app/models/record-thumbnail-placeholder.spec.ts @@ -0,0 +1,85 @@ +import { FileFormat, PermanentFile } from './file-vo'; +import { GeneratedFileStatus } from './generated-file-status'; +import { + getRecordThumbnailPlaceholder, + RecordThumbnailPlaceholder, +} from './record-thumbnail-placeholder'; + +const makeFile = (params: Partial = {}): PermanentFile => ({ + fileId: 1, + size: 1024, + format: FileFormat.Original, + fileURL: 'https://example.com/file', + downloadURL: 'https://example.com/download', + type: 'type.file.document.docx', + ...params, +}); + +const originalDocx = makeFile(); + +describe('getRecordThumbnailPlaceholder', () => { + it('shows no placeholder when the record has a thumbnail', () => { + expect( + getRecordThumbnailPlaceholder({ + thumbURL200: 'https://example.com/thumb200.jpg', + thumbnail256Status: GeneratedFileStatus.Failed, + accessCopyStatus: GeneratedFileStatus.Failed, + FileVOs: [originalDocx], + }), + ).toBeUndefined(); + }); + + it('shows preparing while the thumbnail is processing', () => { + expect( + getRecordThumbnailPlaceholder({ + thumbnail256Status: GeneratedFileStatus.Processing, + accessCopyStatus: GeneratedFileStatus.Ok, + }), + ).toBe(RecordThumbnailPlaceholder.Preparing); + }); + + it('shows preparing for a video whose access copy is processing, since videos never get a 256px thumbnail status', () => { + expect( + getRecordThumbnailPlaceholder({ + FileVOs: [makeFile({ type: 'type.file.video.mp4' })], + thumbnail256Status: null, + accessCopyStatus: GeneratedFileStatus.Processing, + }), + ).toBe(RecordThumbnailPlaceholder.Preparing); + }); + + it('shows failed when the thumbnail failed', () => { + expect( + getRecordThumbnailPlaceholder({ + thumbnail256Status: GeneratedFileStatus.Failed, + accessCopyStatus: null, + }), + ).toBe(RecordThumbnailPlaceholder.Failed); + }); + + it('shows failed when the access copy failed', () => { + expect( + getRecordThumbnailPlaceholder({ + FileVOs: [originalDocx], + thumbnail256Status: null, + accessCopyStatus: GeneratedFileStatus.Failed, + }), + ).toBe(RecordThumbnailPlaceholder.Failed); + }); + + it('shows the file type when neither copy will ever be made', () => { + expect( + getRecordThumbnailPlaceholder({ + FileVOs: [makeFile({ type: 'type.file.image.svg' })], + thumbnail256Status: null, + accessCopyStatus: null, + }), + ).toBe(RecordThumbnailPlaceholder.FileType); + }); + + it('keeps the old empty thumbnail when the record has no status from Stela', () => { + expect( + getRecordThumbnailPlaceholder({ FileVOs: [originalDocx] }), + ).toBeUndefined(); + }); +}); diff --git a/src/app/models/record-thumbnail-placeholder.ts b/src/app/models/record-thumbnail-placeholder.ts new file mode 100644 index 000000000..2c3821d26 --- /dev/null +++ b/src/app/models/record-thumbnail-placeholder.ts @@ -0,0 +1,43 @@ +import { GeneratedFileStatus } from './generated-file-status'; +import { GetThumbnail } from './get-thumbnail'; +import { + getRecordPreviewState, + HasPreviewStatus, + RecordPreviewState, +} from './record-preview-state'; + +export enum RecordThumbnailPlaceholder { + Preparing = 'preparing', + Failed = 'failed', + FileType = 'file-type', +} + +export function getRecordThumbnailPlaceholder( + record: HasPreviewStatus, +): RecordThumbnailPlaceholder | undefined { + if (GetThumbnail(record)) { + return undefined; + } + + const previewState = getRecordPreviewState(record); + + if ( + record.thumbnail256Status === GeneratedFileStatus.Processing || + previewState === RecordPreviewState.Preparing + ) { + return RecordThumbnailPlaceholder.Preparing; + } + + if ( + record.thumbnail256Status === GeneratedFileStatus.Failed || + previewState === RecordPreviewState.Failed + ) { + return RecordThumbnailPlaceholder.Failed; + } + + const hasNoStatusFromStela = + record.thumbnail256Status === undefined && + record.accessCopyStatus === undefined; + + return hasNoStatusFromStela ? undefined : RecordThumbnailPlaceholder.FileType; +} diff --git a/src/app/models/record-vo.ts b/src/app/models/record-vo.ts index aec8eb891..cc5fd1dac 100644 --- a/src/app/models/record-vo.ts +++ b/src/app/models/record-vo.ts @@ -11,7 +11,8 @@ import { LocnVOData } from './locn-vo'; import { TagVOData } from './tag-vo'; import { ArchiveVO } from './archive-vo'; import { HasThumbnails } from './get-thumbnail'; -import { FileFormat, PermanentFile } from './file-vo'; +import { FileFormat, getFileExtension, PermanentFile } from './file-vo'; +import { GeneratedFileStatus } from './generated-file-status'; interface RecordVoOptions { dataStatus: DataStatus; @@ -81,6 +82,9 @@ export class RecordVO // New thumbnails public thumbnail256: string; public thumbnail256CloudPath: string; + public thumbnail256Status?: GeneratedFileStatus | null; + + public accessCopyStatus?: GeneratedFileStatus | null; // Statuses public fileStatus; @@ -168,7 +172,7 @@ export class RecordVO (file) => file.format === FileFormat.Original, ).map((file) => ({ name: file.type, - extension: file.type.split('.').pop(), + extension: getFileExtension(file), })); } } @@ -206,6 +210,8 @@ export interface RecordVOData extends BaseVOData { thumbURL2000?: string; thumbnail256?: string; thumbnail256CloudPath?: string; + thumbnail256Status?: GeneratedFileStatus | null; + accessCopyStatus?: GeneratedFileStatus | null; thumbDT?: any; fileStatus?: any; status?: any; diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index 3cd8feceb..5703377bd 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -14,6 +14,7 @@ import { StelaLocation, } from '@shared/services/api/record.repo'; import { RecordVO } from '@root/app/models'; +import { GeneratedFileStatus } from '@models/generated-file-status'; import { provideHttpClient, withInterceptorsFromDi, @@ -467,6 +468,37 @@ describe('RecordRepo', () => { expect(record.thumbnail256).toBe('https://example.com/256'); }); + it('maps the thumbnail and access copy statuses', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + accessCopyStatus: 'processing', + thumbnailUrls: { width256Status: 'failed' }, + } as any); + + expect(record.accessCopyStatus).toBe(GeneratedFileStatus.Processing); + expect(record.thumbnail256Status).toBe(GeneratedFileStatus.Failed); + }); + + it('keeps a null status, which means no copy will ever be made', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + accessCopyStatus: null, + thumbnailUrls: { width256Status: null }, + } as any); + + expect(record.accessCopyStatus).toBeNull(); + expect(record.thumbnail256Status).toBeNull(); + }); + + it('leaves the statuses undefined when Stela does not send them', () => { + const record = convertStelaRecordToRecordVO({ + ...baseStelaRecord, + } as any); + + expect(record.accessCopyStatus).toBeUndefined(); + expect(record.thumbnail256Status).toBeUndefined(); + }); + it("should translate Stela's role into ours", () => { const record = convertStelaRecordToRecordVO({ ...baseStelaRecord, diff --git a/src/app/shared/services/api/record.repo.ts b/src/app/shared/services/api/record.repo.ts index 5bd28a213..6fc153ade 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -18,6 +18,7 @@ import { ThumbnailCache } from '@shared/utilities/thumbnail-cache/thumbnail-cach import { firstValueFrom } from 'rxjs'; import { FileFormat, PermanentFile } from '@models/file-vo'; import { ShareStatus } from '@models/share-vo'; +import { GeneratedFileStatus } from '@models/generated-file-status'; import { AccessRoleType, getOptionalAccessRoleField, @@ -120,6 +121,7 @@ export type StelaRecord = Omit & { folderLinkType: FolderLinkType; parentFolderLinkId: string; thumbnailUrls?: { + width256Status?: GeneratedFileStatus | null; '200'?: string; '256'?: string; '500'?: string; @@ -218,6 +220,7 @@ export const convertStelaRecordToRecordVO = ( stelaRecord.thumbnailUrls?.['2000'] ?? stelaRecord.thumbURL2000, thumbnail256: stelaRecord.thumbnailUrls?.['256'] ?? stelaRecord.thumbnail256, + thumbnail256Status: stelaRecord.thumbnailUrls?.width256Status, TagVOs: (stelaRecord.tags ?? []).map((stelaTag) => convertStelaTagToTagVO(stelaTag, stelaRecord.archiveId), ), diff --git a/src/assets/svg/access-copy/preview-failed.svg b/src/assets/svg/access-copy/preview-failed.svg new file mode 100644 index 000000000..4cd4d5bbc --- /dev/null +++ b/src/assets/svg/access-copy/preview-failed.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/svg/access-copy/preview-file-outline.svg b/src/assets/svg/access-copy/preview-file-outline.svg new file mode 100644 index 000000000..3963df2f8 --- /dev/null +++ b/src/assets/svg/access-copy/preview-file-outline.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/svg/access-copy/preview-preparing.svg b/src/assets/svg/access-copy/preview-preparing.svg new file mode 100644 index 000000000..9d970cff4 --- /dev/null +++ b/src/assets/svg/access-copy/preview-preparing.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svg/access-copy/row-failed.svg b/src/assets/svg/access-copy/row-failed.svg new file mode 100644 index 000000000..a01e95ba0 --- /dev/null +++ b/src/assets/svg/access-copy/row-failed.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/svg/access-copy/row-file-outline.svg b/src/assets/svg/access-copy/row-file-outline.svg new file mode 100644 index 000000000..057648642 --- /dev/null +++ b/src/assets/svg/access-copy/row-file-outline.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/svg/access-copy/row-preparing.svg b/src/assets/svg/access-copy/row-preparing.svg new file mode 100644 index 000000000..0d01c3690 --- /dev/null +++ b/src/assets/svg/access-copy/row-preparing.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + +