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 new file mode 100644 index 000000000..00e1d3049 --- /dev/null +++ b/src/app/file-browser/components/file-list-item/file-list-item.component.spec.ts @@ -0,0 +1,315 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ElementRef, Pipe, PipeTransform } from '@angular/core'; +import { of } from 'rxjs'; +import { ActivatedRoute, provideRouter } from '@angular/router'; +import { Deferred } from '@root/vendor/deferred'; + +import { DataService } from '@shared/services/data/data.service'; +import { PromptService } from '@shared/services/prompt/prompt.service'; +import { ApiService } from '@shared/services/api/api.service'; +import { MessageService } from '@shared/services/message/message.service'; +import { AccountService } from '@shared/services/account/account.service'; +import { DragService } from '@shared/services/drag/drag.service'; +import { ShareLinksService } from '@root/app/share-links/services/share-links.service'; +import { EditService } from '@core/services/edit/edit.service'; +import { DeviceService } from '@shared/services/device/device.service'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { FileListItemComponent } from './file-list-item.component'; + +@Pipe({ name: 'itemTypeIcon' }) +class MockItemTypeIconPipe implements PipeTransform { + transform(value: any): any { + return value; + } +} + +@Pipe({ name: 'prDate' }) +export class MockPrDatePipe implements PipeTransform { + transform(value: any, format?: string): string { + return `mocked-date${format ? `-${format}` : ''}`; + } +} + +@Pipe({ name: 'prConstants' }) +export class MockPrConstantsPipe implements PipeTransform { + transform(value: string): string { + return `mocked-${value}`; + } +} + +describe('FileListItemComponent', () => { + let component: FileListItemComponent; + let fixture: ComponentFixture; + let editService: EditService; + + const activatedRouteMock = { + snapshot: { + data: {}, + }, + parent: { + snapshot: { + data: { + sharePreviewVO: { + previewToggle: 1, + }, + }, + }, + }, + }; + + const mockEditService = { + moveItems: jasmine.createSpy().and.returnValue(Promise.resolve()), + updateItems: jasmine.createSpy().and.returnValue(Promise.resolve()), + }; + + const mockDeviceService = { + isMobileWidth: jasmine.createSpy().and.returnValue(false), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MockItemTypeIconPipe, MockPrDatePipe, MockPrConstantsPipe], + declarations: [FileListItemComponent], + providers: [ + provideNoopAnimations(), + provideRouter([]), + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: ElementRef, useValue: { nativeElement: {} } }, + { + provide: DataService, + useValue: { + registerItem: jasmine.createSpy(), + unregisterItem: jasmine.createSpy(), + getSelectedItems: () => new Map(), + beginPreparingForNavigate: jasmine.createSpy(), + fetchLeanItems: jasmine.createSpy(), + setItemMultiSelectStatus: jasmine.createSpy(), + currentFolder: { type: '' }, + }, + }, + { + provide: PromptService, + useValue: { + prompt: jasmine + .createSpy() + .and.returnValue( + Promise.resolve({ displayName: 'Updated Name' }), + ), + confirm: jasmine.createSpy().and.returnValue(Promise.resolve()), + }, + }, + { + provide: ApiService, + useValue: { folder: { getWithChildren: jasmine.createSpy() } }, + }, + { + provide: MessageService, + useValue: { showError: jasmine.createSpy() }, + }, + { + provide: AccountService, + useValue: { + getArchive: () => ({ archiveId: '123' }), + checkMinimumAccess: () => true, + }, + }, + { + provide: DragService, + useValue: { + events: () => of(), + dispatch: jasmine.createSpy(), + getDestinationFromDropTarget: () => ({ + displayName: 'Target Folder', + }), + }, + }, + { + provide: ShareLinksService, + useValue: { + isUnlistedShare: async () => await Promise.resolve(false), + }, + }, + { provide: EditService, useValue: mockEditService }, + { provide: DeviceService, useValue: mockDeviceService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FileListItemComponent); + component = fixture.componentInstance; + editService = TestBed.inject(EditService); + + component.item = { + displayDT: new Date().toISOString(), + displayName: 'Test Item', + archiveNbr: '123', + folder_linkId: '456', + type: '', + isFolder: false, + isRecord: false, + dataStatus: 0, + isFetching: false, + update: jasmine.createSpy(), + fetched: Promise.resolve(true), + } as any; + + component.folderView = '' as any; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should register and unregister item', async () => { + await component.ngOnInit(); + + expect(TestBed.inject(DataService).registerItem).toHaveBeenCalled(); + component.ngOnDestroy(); + + expect(TestBed.inject(DataService).unregisterItem).toHaveBeenCalledWith( + component.item, + ); + }); + + it('should handle drag events', () => { + const dragEvent = { + type: 'start', + srcComponent: {}, + targetTypes: ['folder'], + } as any; + component.item.isFolder = true; + component.onDragServiceEvent(dragEvent); + + expect(component.isDragTarget).toBeTrue(); + }); + + it('should handle drop and confirm move', async () => { + await component.onDrop({} as any); + + expect(editService.moveItems).toHaveBeenCalled(); + }); + + it('should reject drop and show error', async () => { + editService.moveItems = jasmine + .createSpy() + .and.returnValue(Promise.reject({ getMessage: () => 'Error' })); + await component.onDrop({} as any).catch(() => { + expect(TestBed.inject(MessageService).showError).toHaveBeenCalledWith({ + message: 'Error occurred', + }); + }); + }); + + it('should preview unlisted record', () => { + component.isUnlistedShare = true; + component.item.isFolder = false; + spyOn(component, 'goToItem'); + component.onItemClick({} as MouseEvent); + + expect(component.goToItem).toHaveBeenCalled(); + }); + + it('should emit itemClicked on mobile or non-selectable', () => { + component.isUnlistedShare = false; + component.canSelect = false; + mockDeviceService.isMobileWidth.and.returnValue(true); + spyOn(component.itemClicked, 'emit'); + spyOn(component, 'goToItem'); + component.onItemClick(new MouseEvent('click')); + + expect(component.goToItem).toHaveBeenCalled(); + expect(component.itemClicked.emit).toHaveBeenCalled(); + }); + + it('should handle double click and clear timeout', () => { + (component as any).singleClickTimeout = setTimeout(() => {}, 100); + spyOn(component, 'goToItem'); + component.onItemDoubleClick(); + + expect((component as any).singleClickTimeout).toBeNull(); + expect(component.goToItem).toHaveBeenCalled(); + }); + + it('should emit itemClicked on single click', (done) => { + spyOn(component.itemClicked, 'emit'); + component.onItemSingleClick(new MouseEvent('click')); + setTimeout(() => { + expect(component.itemClicked.emit).toHaveBeenCalled(); + done(); + }, 150); + }); + + it('should handle touch click', () => { + const mockTouch = { clientX: 100, clientY: 100 }; + const touchStartEvent = { touches: { item: () => mockTouch } }; + const touchEndEvent = { + changedTouches: { item: () => mockTouch }, + preventDefault: () => {}, + target: { + classList: { + contains: () => false, + }, + }, + }; + spyOn(component, 'onItemClick'); + component.onItemTouchStart(touchStartEvent); + component.onItemTouchEnd(touchEndEvent as any); + + expect(component.onItemClick).toHaveBeenCalled(); + }); + + it('should prompt for update and save changes', async () => { + await component.promptForUpdate(); + + expect(component.item.update).toHaveBeenCalled(); + expect(editService.updateItems).toHaveBeenCalled(); + }); + + it('should resolve update if no changes', () => { + const deferred = new Deferred(); + component.item.displayName = 'Same'; + component.saveUpdates({ displayName: 'Same' }, deferred); + deferred.promise.then(() => { + expect(component.item.update).not.toHaveBeenCalled(); + }); + }); + + it('should reject update and restore original data', async () => { + const deferred = new Deferred(); + editService.updateItems = jasmine + .createSpy() + .and.returnValue(Promise.reject({ getMessage: () => 'Error' })); + await component.saveUpdates({ displayName: 'New' }, deferred).catch(() => { + expect(component.item.update).toHaveBeenCalledWith({ + displayName: 'Test Item', + }); + + expect(TestBed.inject(MessageService).showError).toHaveBeenCalled(); + }); + }); + + it('should update multi-select status', () => { + component.isMultiSelected = true; + component.onMultiSelectChange(); + + expect( + TestBed.inject(DataService).setItemMultiSelectStatus, + ).toHaveBeenCalledWith(component.item, true); + }); + + it('should emit itemVisible on intersection', () => { + spyOn(component.itemVisible, 'emit'); + component.onIntersection({ target: {} as Element, visible: true }); + + expect(component.itemVisible.emit).toHaveBeenCalled(); + }); + + it('should toggle hover flags', () => { + component.onMouseOverName(); + + expect(component.isNameHovered).toBeTrue(); + component.onMouseLeaveName(); + + expect(component.isNameHovered).toBeFalse(); + }); +}); 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 8fa0f30f4..042ea950a 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 @@ -67,6 +67,7 @@ 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 { ShareLinksService } from '@root/app/share-links/services/share-links.service'; import { ItemClickEvent } from '../file-list/file-list.component'; import { SharingComponent } from '../sharing/sharing.component'; import { PublishComponent } from '../publish/publish.component'; @@ -203,6 +204,7 @@ export class FileListItemComponent public canEdit = true; public isZip = false; public date: string = ''; + public isUnlistedShare = false; private folderThumb200: string; private folderThumb500: string; @@ -239,12 +241,15 @@ export class FileListItemComponent @Optional() private drag: DragService, private storage: StorageService, @Inject(DOCUMENT) private document: Document, + private shareLinksService: ShareLinksService, ) {} - ngOnInit() { + async ngOnInit() { const date = new Date(this.item.displayDT); this.date = getFormattedDate(date); + this.isUnlistedShare = await this.shareLinksService.isUnlistedShare(); + this.dataService.registerItem(this.item); if (this.item.type.includes('app')) { this.allowActions = false; @@ -487,6 +492,14 @@ export class FileListItemComponent } onItemClick(event: MouseEvent) { + if (this.isUnlistedShare) { + //TO DO: make preview for folder --> story PER-10314 + if (this.item.isFolder) { + return; + } + this.goToItem(); + return; + } if (this.device.isMobileWidth() || !this.canSelect) { this.goToItem(); this.itemClicked.emit({ @@ -509,7 +522,7 @@ export class FileListItemComponent } async goToItem() { - if (!this.allowNavigation) { + if (!this.allowNavigation && !this.isUnlistedShare) { return false; } @@ -519,7 +532,7 @@ export class FileListItemComponent return; } - if (this.item.dataStatus < DataStatus.Lean) { + if (this.item.dataStatus < DataStatus.Lean && !this.isUnlistedShare) { this.dataService.beginPreparingForNavigate(); if (!this.item.isFetching) { this.dataService.fetchLeanItems([this.item]); @@ -577,6 +590,10 @@ export class FileListItemComponent this.dataService.currentFolder.type === 'type.folder.root.share' ) { this.router.navigate(['/shares/record', this.item.archiveNbr]); + } else if (this.isUnlistedShare) { + this.router.navigate(['view/record', this.item.archiveNbr], { + relativeTo: this.route, + }); } else { this.router.navigate(['record', this.item.archiveNbr], { relativeTo: this.route, diff --git a/src/app/file-browser/components/file-list/file-list.component.spec.ts b/src/app/file-browser/components/file-list/file-list.component.spec.ts index 7f2062b95..e8f32504d 100644 --- a/src/app/file-browser/components/file-list/file-list.component.spec.ts +++ b/src/app/file-browser/components/file-list/file-list.component.spec.ts @@ -1,94 +1,125 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DataService } from '@shared/services/data/data.service'; -import { AccountService } from '@shared/services/account/account.service'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; import { ActivatedRoute, Router } from '@angular/router'; import { Location } from '@angular/common'; -import { DragService } from '@shared/services/drag/drag.service'; +import { of, Subject, Subscription } from 'rxjs'; +import { DOCUMENT } from '@angular/common'; +import { UP_ARROW } from '@angular/cdk/keycodes'; +import { RecordVO } from '@root/app/models'; +import { FileListItemComponent } from '@fileBrowser/components/file-list-item/file-list-item.component'; +import { DataService } from '@shared/services/data/data.service'; import { FolderViewService } from '@shared/services/folder-view/folder-view.service'; +import { AccountService } from '@shared/services/account/account.service'; +import { DragService } from '@shared/services/drag/drag.service'; import { DeviceService } from '@shared/services/device/device.service'; import { EventService } from '@shared/services/event/event.service'; -import { of, Subject } from 'rxjs'; -import { FolderVO } from '@models/folder-vo'; +import { ShareLinksService } from '@root/app/share-links/services/share-links.service'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; -import { CdkPortal } from '@angular/cdk/portal'; -import { FileListItemComponent } from '@fileBrowser/components/file-list-item/file-list-item.component'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Component } from '@angular/core'; import { FileListComponent } from './file-list.component'; +@Component({ template: '' }) +class DummyComponent {} + describe('FileListComponent', () => { let component: FileListComponent; let fixture: ComponentFixture; - const folderViewServiceMock = { - folderView: 'List', - viewChange: new Subject(), + const mockFolder: any = { + type: 'type.folder.root.private', + ChildItemVOs: [{ folder_linkId: 123 } as RecordVO], }; - const dragServiceMock = { - events: jasmine.createSpy('events').and.returnValue(of({ type: 'start' })), + const routerEvents$ = new Subject(); + + const mockActivatedRoute = { + snapshot: { + data: { + currentFolder: mockFolder, + showSidebar: true, + fileListCentered: false, + noFileListNavigation: false, + isPublicArchive: false, + showFolderDescription: true, + }, + queryParamMap: { + has: () => true, + get: () => '123', + }, + }, }; - const deviceServiceMock = { - isMobileWidth: jasmine.createSpy('isMobileWidth').and.returnValue(false), + const mockRouter = { + events: routerEvents$, + url: '/folder', + navigateByUrl: jasmine.createSpy(), + navigate: jasmine.createSpy(), }; - const accountServiceMock = { - archiveChange: new Subject(), + const mockDataService = { + setCurrentFolder: jasmine.createSpy(), + onSelectEvent: jasmine.createSpy(), + folderUpdate: of(mockFolder), + multiSelectChange: of(true), + selectedItems$: () => of(new Set()), + itemToShow$: () => of(mockFolder.ChildItemVOs[0]), + fetchLeanItems: jasmine.createSpy().and.returnValue(Promise.resolve()), }; - const eventServiceMock = { - dispatch: jasmine.createSpy('dispatch'), + const mockDeviceService = { + isMobileWidth: jasmine.createSpy().and.returnValue(false), }; - const dataServiceMock = { - setCurrentFolder: jasmine.createSpy('setCurrentFolder'), - folderUpdate: of(new FolderVO({})), - selectedItems$: jasmine - .createSpy('selectedItems$') - .and.returnValue(of(new Set())), - multiSelectChange: of(true), - itemToShow$: jasmine.createSpy('itemToShow$').and.returnValue(of(null)), - fetchLeanItems: jasmine.createSpy('fetchLeanItems'), - onSelectEvent: jasmine.createSpy('onSelectEvent'), + const mockFolderViewService = { + folderView: 'List', + viewChange: of('Grid'), }; - const routerMock = { - navigate: jasmine.createSpy('navigate'), - events: new Subject(), - url: '/folder', + const mockAccountService = { + archiveChange: of({}), }; - const routeMock = { - snapshot: { - data: { - currentFolder: new FolderVO({ - type: 'type.folder.root', - }), - showSidebar: true, - fileListCentered: false, - isPublicArchive: false, - }, - queryParamMap: { - has: jasmine.createSpy('has').and.returnValue(false), - get: jasmine.createSpy('get').and.returnValue(null), - }, - }, + const mockEventService = { + dispatch: jasmine.createSpy(), + }; + + const mockShareLinksService = { + isUnlistedShare: jasmine + .createSpy() + .and.returnValue(Promise.resolve(false)), + }; + + const mockDragService = { + events: () => of({ type: 'start' }), }; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [RouterTestingModule, NoopAnimationsModule, CdkPortal], - declarations: [FileListComponent, FileListItemComponent], + imports: [ + RouterTestingModule.withRoutes([ + { path: '', component: DummyComponent }, + { path: 'view', component: DummyComponent }, + ]), + BrowserAnimationsModule, + ], + declarations: [FileListComponent], providers: [ - { provide: DataService, useValue: dataServiceMock }, - { provide: AccountService, useValue: accountServiceMock }, - { provide: ActivatedRoute, useValue: routeMock }, - { provide: Router, useValue: routerMock }, - { provide: Location, useValue: {} }, // Use empty mock for Location - { provide: FolderViewService, useValue: folderViewServiceMock }, - { provide: DragService, useValue: dragServiceMock }, - { provide: DeviceService, useValue: deviceServiceMock }, - { provide: EventService, useValue: eventServiceMock }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: Router, useValue: mockRouter }, + { provide: DataService, useValue: mockDataService }, + { provide: FolderViewService, useValue: mockFolderViewService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: DragService, useValue: mockDragService }, + { provide: DeviceService, useValue: mockDeviceService }, + { provide: EventService, useValue: mockEventService }, + { provide: ShareLinksService, useValue: mockShareLinksService }, + { provide: Location, useValue: { replaceState: jasmine.createSpy() } }, + { provide: DOCUMENT, useValue: document }, ], }).compileComponents(); @@ -97,7 +128,102 @@ describe('FileListComponent', () => { fixture.detectChanges(); }); - it('should create the component', () => { - expect(component).toBeTruthy(); + it('should initialize and dispatch workspace event', () => { + expect(component.currentFolder).toEqual(mockFolder); + expect(mockEventService.dispatch).toHaveBeenCalledWith({ + action: 'open_private_workspace', + entity: 'account', + }); + }); + + it('should handle ngAfterViewInit and scroll to item', fakeAsync(() => { + component = fixture.componentInstance; + spyOn(component, 'scrollToItem'); + component.listItemsQuery = { + toArray: () => [], + } as any; + component.ngAfterViewInit(); + + expect(component.scrollToItem).toHaveBeenCalledWith( + mockFolder.ChildItemVOs[0], + ); + + tick(1000); + + expect(mockDataService.onSelectEvent).toHaveBeenCalledWith({ + type: 'click', + item: mockFolder.ChildItemVOs[0], + }); + })); + + it('should clean up on ngOnDestroy', () => { + const sub = new Subscription(); + spyOn(sub, 'unsubscribe'); + component.subscriptions = [sub]; + + component.ngOnDestroy(); + + expect(mockDataService.setCurrentFolder).toHaveBeenCalled(); + expect(sub.unsubscribe).toHaveBeenCalled(); + }); + + it('should emit itemClicked and dispatch select event', () => { + const item = mockFolder.ChildItemVOs[0]; + const clickEvent: any = { + item, + selectable: true, + event: { ctrlKey: true }, + }; + + spyOn(component.itemClicked, 'emit'); + component.showSidebar = true; + + component.onItemClick(clickEvent); + + expect(component.itemClicked.emit).toHaveBeenCalledWith(clickEvent); + expect(mockDataService.onSelectEvent).toHaveBeenCalledWith({ + type: 'click', + item, + modifierKey: 'ctrl', + }); + }); + + it('should handle keyboard arrow selection', () => { + const event = new KeyboardEvent('keydown', { keyCode: UP_ARROW }); + spyOn(component, 'checkKeyEvent').and.returnValue(true); + + component.onWindowKeydown(event); + + expect(mockDataService.onSelectEvent).toHaveBeenCalledWith({ + type: 'key', + key: 'up', + }); + }); + + it('should handle ctrl+a selection', () => { + const event = new KeyboardEvent('keydown', { ctrlKey: true }); + spyOn(component, 'checkKeyEvent').and.returnValue(true); + + component.onSelectAllKeypress(event); + + expect(mockDataService.onSelectEvent).toHaveBeenCalledWith({ + type: 'key', + key: 'a', + modifierKey: 'ctrl', + }); + }); + + it('should add/remove visible items on onItemVisible', () => { + const mockComponent = {} as FileListItemComponent; + const visibleEvent: any = { visible: true, component: mockComponent }; + const hiddenEvent: any = { visible: false, component: mockComponent }; + + component.onItemVisible(visibleEvent); + + expect(component.visibleItems.has(mockComponent)).toBeTrue(); + + component.onItemVisible(hiddenEvent); + + expect(component.visibleItems.has(mockComponent)).toBeFalse(); }); }); diff --git a/src/app/file-browser/components/file-list/file-list.component.ts b/src/app/file-browser/components/file-list/file-list.component.ts index b7d5e6adf..fba439025 100644 --- a/src/app/file-browser/components/file-list/file-list.component.ts +++ b/src/app/file-browser/components/file-list/file-list.component.ts @@ -57,6 +57,7 @@ import { AccountService } from '@shared/services/account/account.service'; import { routeHasDialog } from '@shared/utilities/router'; import { RouteHistoryService } from '@root/app/route-history/route-history.service'; import { EventService } from '@shared/services/event/event.service'; +import { ShareLinksService } from '@root/app/share-links/services/share-links.service'; export interface ItemClickEvent { event?: MouseEvent; @@ -148,6 +149,7 @@ export class FileListComponent public device: DeviceService, private ngZone: NgZone, private event: EventService, + private shareLinksService: ShareLinksService, ) { this.currentFolder = this.route.snapshot.data.currentFolder; // this.noFileListPadding = this.route.snapshot.data.noFileListPadding; @@ -526,7 +528,6 @@ export class FileListComponent } const itemsToFetch = visibleListItems.map((c) => c.item); - if (itemsToFetch.length) { await this.dataService.fetchLeanItems(itemsToFetch); } 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 84a05960c..eeb1f59b7 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 @@ -9,6 +9,7 @@ import { DataService } from '@shared/services/data/data.service'; import { EditService } from '@core/services/edit/edit.service'; import { TagsService } from '@core/services/tags/tags.service'; import { PublicProfileService } from '@public/services/public-profile/public-profile.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { FileBrowserComponentsModule } from '../../file-browser-components.module'; import { TagsComponent } from '../../../shared/components/tags/tags.component'; import { FileViewerComponent } from './file-viewer.component'; @@ -94,6 +95,7 @@ describe('FileViewerComponent', () => { openedDialogs = []; downloaded = false; shallow = new Shallow(FileViewerComponent, FileBrowserComponentsModule) + .import(HttpClientTestingModule) .dontMock(TagsService) .dontMock(PublicProfileService) .mock(Router, { @@ -415,6 +417,7 @@ describe('FileViewerComponent', () => { it('can open the location dialog with edit permissions', async () => { setAccess(true); const { fixture, instance } = await defaultRender(); + instance.canEdit = true; instance.onLocationClick(); await fixture.whenStable(); @@ -433,6 +436,7 @@ describe('FileViewerComponent', () => { it('can open the tags dialog with edit permissions', async () => { setAccess(true); const { fixture, instance } = await defaultRender(); + instance.canEdit = true; instance.onTagsClick('keyword'); await fixture.whenStable(); 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 97c6aedad..6aa219dbd 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,8 @@ 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 { ShareLinksService } from '@root/app/share-links/services/share-links.service'; +import { ApiService } from '@shared/services/api/api.service'; import { TagsService } from '../../../core/services/tags/tags.service'; @Component({ @@ -74,6 +76,7 @@ export class FileViewerComponent implements OnInit, OnDestroy { public editingDate: boolean = false; private bodyScrollTop: number; private tagSubscription: Subscription; + private isUnlistedShare = true; constructor( private router: Router, @@ -85,7 +88,9 @@ export class FileViewerComponent implements OnInit, OnDestroy { private accountService: AccountService, private editService: EditService, private tagsService: TagsService, - @Optional() private publicProfile: PublicProfileService, + @Optional() publicProfile: PublicProfileService, + private shareLinksService: ShareLinksService, + private api: ApiService, ) { // store current scroll position in file list this.bodyScrollTop = window.scrollY; @@ -97,19 +102,7 @@ export class FileViewerComponent implements OnInit, OnDestroy { this.records = [this.currentRecord]; this.currentIndex = 0; } else { - this.records = filter( - this.dataService.currentFolder.ChildItemVOs, - 'isRecord', - ) as RecordVO[]; - this.currentIndex = findIndex(this.records, { - folder_linkId: resolvedRecord.folder_linkId, - }); - this.currentRecord = this.records[this.currentIndex]; - if (resolvedRecord !== this.currentRecord) { - this.currentRecord.update(resolvedRecord); - } - - this.loadQueuedItems(); + this.setRecordsToPreview(resolvedRecord); } if (route.snapshot.data?.isPublicArchive) { @@ -122,12 +115,6 @@ export class FileViewerComponent implements OnInit, OnDestroy { }); } - this.canEdit = - this.accountService.checkMinimumAccess( - this.currentRecord.accessRole, - AccessRole.Editor, - ) && !route.snapshot.data?.isPublicArchive; - this.tagSubscription = this.tagsService .getItemTags$() ?.subscribe((tags) => { @@ -140,7 +127,24 @@ export class FileViewerComponent implements OnInit, OnDestroy { }); } - ngOnInit() { + async ngOnInit() { + this.isUnlistedShare = await this.shareLinksService.isUnlistedShare(); + + this.canEdit = this.isUnlistedShare + ? false + : this.accountService.checkMinimumAccess( + this.currentRecord.accessRole, + AccessRole.Editor, + ) && !this.route.snapshot.data?.isPublicArchive; + + if (this.isUnlistedShare) { + const response = await this.api.record.get( + [this.currentRecord], + this.shareLinksService.currentShareToken, + ); + this.setRecordsToPreview(response.getRecordVO()); + } + this.initRecord(); // disable scrolling file list in background @@ -170,6 +174,22 @@ export class FileViewerComponent implements OnInit, OnDestroy { this.tagSubscription.unsubscribe(); } + private setRecordsToPreview(resolvedRecord: RecordVO) { + this.records = filter( + this.dataService.currentFolder.ChildItemVOs, + 'isRecord', + ) as RecordVO[]; + this.currentIndex = findIndex(this.records, { + folder_linkId: resolvedRecord.folder_linkId, + }); + this.currentRecord = this.records[this.currentIndex]; + if (resolvedRecord !== this.currentRecord) { + this.currentRecord.update(resolvedRecord); + } + + this.loadQueuedItems(); + } + @HostListener('window:resize', []) onViewportResize(event) { this.screenWidth = this.touchElement.clientWidth; @@ -367,7 +387,13 @@ export class FileViewerComponent implements OnInit, OnDestroy { } close() { - this.router.navigate(['.'], { relativeTo: this.route.parent }); + if (this.isUnlistedShare) { + this.router.navigate([ + `/share/${this.shareLinksService.currentShareToken}`, + ]); + } else { + this.router.navigate(['.'], { relativeTo: this.route.parent }); + } } public async onFinishEditing( diff --git a/src/app/file-browser/file-browser.module.spec.ts b/src/app/file-browser/file-browser.module.spec.ts index 92e3ecb91..645b91ea7 100644 --- a/src/app/file-browser/file-browser.module.spec.ts +++ b/src/app/file-browser/file-browser.module.spec.ts @@ -1,11 +1,13 @@ -// xdescribe('FileBrowserModule', () => { -// let fileBrowserModule: FileBrowserModule; +import { FileBrowserModule } from './file-browser.module'; -// beforeEach(() => { -// fileBrowserModule = new FileBrowserModule(); -// }); +describe('FileBrowserModule', () => { + let fileBrowserModule: FileBrowserModule; -// it('should create an instance', () => { -// expect(fileBrowserModule).toBeTruthy(); -// }); -// }); + beforeEach(() => { + fileBrowserModule = new FileBrowserModule(); + }); + + it('should create an instance', () => { + expect(fileBrowserModule).toBeTruthy(); + }); +}); diff --git a/src/app/file-browser/file-browser.module.ts b/src/app/file-browser/file-browser.module.ts index bdd3612d6..809f6aca0 100644 --- a/src/app/file-browser/file-browser.module.ts +++ b/src/app/file-browser/file-browser.module.ts @@ -10,9 +10,13 @@ import { FileListItemComponent } from '@fileBrowser/components/file-list-item/fi import { FileViewerComponent } from '@fileBrowser/components/file-viewer/file-viewer.component'; import { VideoComponent } from '@shared/components/video/video.component'; import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { EditService } from '@core/services/edit/edit.service'; +import { FolderPickerService } from '@core/services/folder-picker/folder-picker.service'; +import { ShareLinksApiService } from '../share-links/services/share-links-api.service'; import { FileBrowserComponentsModule } from './file-browser-components.module'; @NgModule({ + providers: [EditService, FolderPickerService, ShareLinksApiService], imports: [ CommonModule, RouterModule, diff --git a/src/app/filesystem/filesystem-api.service.spec.ts b/src/app/filesystem/filesystem-api.service.spec.ts index c57f3c1ce..1ee86709e 100644 --- a/src/app/filesystem/filesystem-api.service.spec.ts +++ b/src/app/filesystem/filesystem-api.service.spec.ts @@ -1,91 +1,121 @@ import { TestBed } from '@angular/core/testing'; -import { - HttpTestingController, - provideHttpClientTesting, -} from '@angular/common/http/testing'; -import { environment } from '@root/environments/environment'; import { FolderResponse } from '@shared/services/api/folder.repo'; -import { - provideHttpClient, - withInterceptorsFromDi, -} from '@angular/common/http'; +import { FolderVO } from '@models/index'; +import { DataStatus } from '@models/data-status.enum'; +import { ApiService } from '@shared/services/api/api.service'; +import { of } from 'rxjs'; +import { ShareLinksService } from '../share-links/services/share-links.service'; import { FilesystemApiService } from './filesystem-api.service'; +const folderId = 42; + +const mockFolderVO = { + folderId, + displayName: 'Unlisted Folder', + ChildItemVOs: [], + dataStatus: DataStatus.Lean, +}; +const mockResponse = new FolderResponse({ + isSuccessful: true, + Results: [ + { + data: [ + { + FolderVO: mockFolderVO, + }, + ], + }, + ], +}); + +const mockApiService = { + folder: { + getWithChildren: jasmine + .createSpy('getWithChildren') + .and.returnValue(Promise.resolve(mockResponse)), + navigateLean: jasmine.createSpy('navigateLean').and.returnValue( + of({ + isSuccessful: true, + getFolderVO: () => mockFolderVO, + }), + ), + }, +}; + describe('FilesystemApiService', () => { let service: FilesystemApiService; - let http: HttpTestingController; + let shareLinksServiceSpy: jasmine.SpyObj; beforeEach(() => { + shareLinksServiceSpy = jasmine.createSpyObj('ShareLinksService', [ + 'isUnlistedShare', + 'currentShareToken', + ]); + TestBed.configureTestingModule({ - imports: [], providers: [ - provideHttpClient(withInterceptorsFromDi()), - provideHttpClientTesting(), + FilesystemApiService, + { provide: ShareLinksService, useValue: shareLinksServiceSpy }, + { provide: ApiService, useValue: mockApiService }, ], }); + service = TestBed.inject(FilesystemApiService); - http = TestBed.inject(HttpTestingController); }); it('should be created', () => { expect(service).toBeTruthy(); }); - it('should be able to navigate', (done) => { - service - .navigate({ folderId: 0 }) - .then((folder) => { - expect(folder.displayName).toBe('Unit Test'); - }) - .catch((response) => { - fail(response); - }) - .finally(() => { - done(); - }); - - const req = http.expectOne(`${environment.apiUrl}/folder/navigateLean`); - - req.flush({ - isSuccessful: true, - Results: [ - { - data: [ - { - FolderVO: { - folderId: 0, - displayName: 'Unit Test', - ChildItemVOs: [], - }, - }, - ], - }, - ], - }); + it('should navigate using navigateLean', async () => { + shareLinksServiceSpy.isUnlistedShare.and.resolveTo(false); + mockApiService.folder.navigateLean.and.returnValue( + of({ + isSuccessful: true, + getFolderVO: () => mockFolderVO, + }), + ); + + const folder = await service.navigate({ folderId }); - http.verify(); + expect(mockApiService.folder.navigateLean).toHaveBeenCalledWith( + jasmine.any(FolderVO), + ); + + expect(folder.folderId).toBe(folderId); + expect(folder.displayName).toBe('Unlisted Folder'); + expect(folder.dataStatus).toBe(DataStatus.Lean); }); - it('will throw the invalid folder response for a failed request', (done) => { - service - .navigate({ folderId: 0 }) - .then(() => { - fail('Expected a rejected promise, but it resolved instead'); - }) - .catch((response: FolderResponse) => { - expect(response.getMessage()).toBe('Unit Test Error'); - }) - .finally(() => { - done(); - }); - - const req = http.expectOne(`${environment.apiUrl}/folder/navigateLean`); - - req.flush({ - isSuccessful: false, - message: 'Unit Test Error', - }); + it('should navigate using getWithChildren when in unlisted share', async () => { + shareLinksServiceSpy.isUnlistedShare.and.resolveTo(true); + shareLinksServiceSpy.currentShareToken = 'mock-token'; + + const folder = await service.navigate({ folderId }); + + expect(mockApiService.folder.getWithChildren).toHaveBeenCalledWith( + [jasmine.any(FolderVO)], + 'mock-token', + ); + + expect(folder.folderId).toBe(folderId); + expect(folder.displayName).toBe('Unlisted Folder'); + expect(folder.dataStatus).toBe(DataStatus.Lean); + }); + + it('should throw FolderResponse error if response is unsuccessful', async () => { + shareLinksServiceSpy.isUnlistedShare.and.resolveTo(false); + mockApiService.folder.navigateLean.and.resolveTo( + of({ isSuccessful: false }), + ); + + const promise = service.navigate({ folderId: 0 }); - http.verify(); + try { + await promise; + fail('Expected promise to reject'); + } catch (error) { + expect(error).toBeDefined(); + } }); }); diff --git a/src/app/filesystem/filesystem-api.service.ts b/src/app/filesystem/filesystem-api.service.ts index 32d9d80ff..2026554df 100644 --- a/src/app/filesystem/filesystem-api.service.ts +++ b/src/app/filesystem/filesystem-api.service.ts @@ -4,6 +4,8 @@ import { firstValueFrom } from 'rxjs'; import { FolderVO, RecordVO } from '@models/index'; import { ApiService } from '@shared/services/api/api.service'; import { DataStatus } from '@models/data-status.enum'; +import { FolderResponse } from '@shared/services/api/folder.repo'; +import { ShareLinksService } from '../share-links/services/share-links.service'; import { FilesystemApi } from './types/filesystem-api'; import { FolderIdentifier, @@ -15,12 +17,24 @@ import { ArchiveIdentifier } from './types/archive-identifier'; providedIn: 'root', }) export class FilesystemApiService implements FilesystemApi { - constructor(private api: ApiService) {} + constructor( + private api: ApiService, + private shareLinksService: ShareLinksService, + ) {} public async navigate(folder: FolderIdentifier): Promise { - const response = await firstValueFrom( - this.api.folder.navigateLean(new FolderVO(folder)), - ); + const isUnlistedShare = await this.shareLinksService.isUnlistedShare(); + let response: FolderResponse = null; + if (isUnlistedShare) { + response = await this.api.folder.getWithChildren( + [new FolderVO(folder)], + this.shareLinksService.currentShareToken, + ); + } else { + response = await firstValueFrom( + this.api.folder.navigateLean(new FolderVO(folder)), + ); + } if (!response.isSuccessful) { throw response; } diff --git a/src/app/share-links/services/share-links-api.service.spec.ts b/src/app/share-links/services/share-links-api.service.spec.ts index d135c4e76..95a0777d0 100644 --- a/src/app/share-links/services/share-links-api.service.spec.ts +++ b/src/app/share-links/services/share-links-api.service.spec.ts @@ -34,7 +34,6 @@ describe('ShareLinksApiService', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [], providers: [ HttpV2Service, provideHttpClient(withInterceptorsFromDi()), @@ -45,24 +44,17 @@ describe('ShareLinksApiService', () => { http = TestBed.inject(HttpTestingController); }); + afterEach(() => { + http.verify(); + }); + it('should be created', () => { expect(service).toBeTruthy(); }); - it('should be able to get multiple share links by id', (done) => { - const expectedShareLink: StelaItems = { - items: makeShareLinks(3), - }; - - service - .getShareLinksById([123, 456, 789]) - .then((links) => { - expect(links).toEqual(expectedShareLink.items); - done(); - }) - .catch(() => { - done.fail('Rejection in promise.'); - }); + it('should get multiple share links by ID', async () => { + const expected: StelaItems = { items: makeShareLinks(3) }; + const promise = service.getShareLinksById([123, 456, 789]); const req = http.expectOne( `${environment.apiUrl}/v2/share-links?shareLinkIds[]=123&shareLinkIds[]=456&shareLinkIds[]=789`, @@ -70,68 +62,58 @@ describe('ShareLinksApiService', () => { expect(req.request.method).toBe('GET'); expect(req.request.headers.get('Request-Version')).toBe('2'); - req.flush(expectedShareLink); + + req.flush(expected); + const result = await promise; + + expect(result).toEqual(expected.items); }); - it('should handle a share link by id GET request error', (done) => { - service - .getShareLinksById([123]) - .then(() => { - done.fail('This promise should have been rejected'); - }) - .catch(() => { - done(); - }); + it('should handle error when getting share links by ID', async () => { + const promise = service.getShareLinksById([123]); const req = http.expectOne( `${environment.apiUrl}/v2/share-links?shareLinkIds[]=123`, ); - req.flush({}, { status: 400, statusText: 'Bad Request' }); + + await expectAsync(promise).toBeRejected(); }); - it('should get a share link by token', (done) => { - const items: StelaItems = { items: makeShareLinks(1) }; - service - .getShareLinksByToken(['token-1', 'token-2', 'token-3']) - .then((links) => { - expect(links).toEqual(items.items); - done(); - }) - .catch(() => done.fail); + it('should get share links by token', async () => { + const expected: StelaItems = { items: makeShareLinks(1) }; + const promise = service.getShareLinksByToken(['token-1', 'token-2']); const req = http.expectOne( - `${environment.apiUrl}/v2/share-links?shareTokens[]=token-1&shareTokens[]=token-2&shareTokens[]=token-3`, + `${environment.apiUrl}/v2/share-links?shareTokens[]=token-1&shareTokens[]=token-2`, ); expect(req.request.method).toBe('GET'); expect(req.request.headers.get('Request-Version')).toBe('2'); - req.flush(items); + + req.flush(expected); + const result = await promise; + + expect(result).toEqual(expected.items); }); - it('should handle a share link by token GET request error', (done) => { - service - .getShareLinksByToken(['token']) - .then(() => { - done.fail('This promise should have been rejected'); - }) - .catch(() => { - done(); - }); + it('should handle error when getting share links by token', async () => { + const promise = service.getShareLinksByToken(['token']); const req = http.expectOne( `${environment.apiUrl}/v2/share-links?shareTokens[]=token`, ); - req.flush({}, { status: 400, statusText: 'Bad Request' }); + + await expectAsync(promise).toBeRejected(); }); - it('should generate a share link', (done) => { - const expectedResponse: ShareLink = { + it('should generate a share link', async () => { + const expected: ShareLink = { id: '7', itemId: '4', itemType: 'record', - token: '971299ea-6732-4699-8629-34186a624e07', + token: 'abc-token', permissionsLevel: 'viewer', accessRestrictions: 'none', maxUses: null, @@ -141,96 +123,61 @@ describe('ShareLinksApiService', () => { updatedAt: new Date('2025-04-09T13:09:07.755Z'), }; - const mockApiResponse = { - data: expectedResponse, - }; - - service - .generateShareLink({ itemId: '1', itemType: 'record' }) - .then((res) => { - expect(res).toEqual(expectedResponse); - done(); - }) - .catch(() => { - done.fail('Rejection in promise.'); - }); + const promise = service.generateShareLink({ + itemId: '4', + itemType: 'record', + }); const req = http.expectOne(`${environment.apiUrl}/v2/share-links`); expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual({ itemId: '1', itemType: 'record' }); + expect(req.request.body).toEqual({ itemId: '4', itemType: 'record' }); + + req.flush({ data: expected }); + const result = await promise; - req.flush(mockApiResponse); + expect(result).toEqual(expected); }); - it('should handle a share link POST error', (done) => { - service - .generateShareLink({ itemId: '1', itemType: 'record' }) - .then(() => { - done.fail('This promise should have been rejected'); - }) - .catch(() => { - done(); - }); + it('should handle error when generating share link', async () => { + const promise = service.generateShareLink({ + itemId: '4', + itemType: 'record', + }); const req = http.expectOne(`${environment.apiUrl}/v2/share-links`); + req.flush({}, { status: 400, statusText: 'Bad Request' }); - req.flush( - {}, - { - status: 400, - statusText: 'Bad Request', - }, - ); + await expectAsync(promise).toBeRejected(); }); - it('should handle a share link DELETE call', (done) => { - service - .deleteShareLink('7') - .then(() => { - done(); - }) - .catch(() => { - done.fail('Rejection in promise.'); - }); + it('should delete a share link', async () => { + const promise = service.deleteShareLink('7'); + const req = http.expectOne(`${environment.apiUrl}/v2/share-links/7`); expect(req.request.method).toBe('DELETE'); expect(req.request.headers.get('Request-Version')).toBe('2'); req.flush({}, { status: 204, statusText: 'No Content' }); + await expectAsync(promise).toBeResolved(); }); - it('should handle a share link DELETE error', (done) => { - service - .deleteShareLink('7') - .then(() => { - done.fail('This promise should have been rejected'); - }) - .catch(() => { - done(); - }); + it('should handle error when deleting share link', async () => { + const promise = service.deleteShareLink('7'); const req = http.expectOne(`${environment.apiUrl}/v2/share-links/7`); + req.flush({}, { status: 400, statusText: 'Bad Request' }); - expect(req.request.method).toBe('DELETE'); - expect(req.request.headers.get('Request-Version')).toBe('2'); - - req.flush( - {}, - { - status: 400, - statusText: 'Bad Request', - }, - ); + await expectAsync(promise).toBeRejected(); }); - it('should update a share link', (done) => { - const expectedResponse: ShareLink = { + it('should update a share link', async () => { + const expected: ShareLink = { id: '7', itemId: '4', itemType: 'record', - token: '971299ea-6732-4699-8629-34186a624e07', + token: 'abc-token', permissionsLevel: 'viewer', accessRestrictions: 'account', maxUses: null, @@ -240,48 +187,29 @@ describe('ShareLinksApiService', () => { updatedAt: new Date('2025-04-09T13:09:07.755Z'), }; - const mockApiResponse = { - data: expectedResponse, - }; + const promise = service.updateShareLink('7', { + accessRestrictions: 'account', + }); - service - .updateShareLink('1', { accessRestrictions: 'account' }) - .then((res) => { - expect(res).toEqual(expectedResponse); - done(); - }) - .catch(() => { - done.fail('Rejection in promise.'); - }); - const req = http.expectOne(`${environment.apiUrl}/v2/share-links/1`); + const req = http.expectOne(`${environment.apiUrl}/v2/share-links/7`); expect(req.request.method).toBe('PATCH'); expect(req.request.body).toEqual({ accessRestrictions: 'account' }); - req.flush(mockApiResponse); + req.flush({ data: expected }); + const result = await promise; + + expect(result).toEqual(expected); }); - it('should handle a share link PATCH error', (done) => { - service - .updateShareLink('7', { accessRestrictions: 'account' }) - .then(() => { - done.fail('This promise should have been rejected'); - }) - .catch(() => { - done(); - }); + it('should handle error when updating share link', async () => { + const promise = service.updateShareLink('7', { + accessRestrictions: 'account', + }); const req = http.expectOne(`${environment.apiUrl}/v2/share-links/7`); + req.flush({}, { status: 400, statusText: 'Bad Request' }); - expect(req.request.method).toBe('PATCH'); - expect(req.request.headers.get('Request-Version')).toBe('2'); - - req.flush( - {}, - { - status: 400, - statusText: 'Bad Request', - }, - ); + await expectAsync(promise).toBeRejected(); }); }); diff --git a/src/app/share-links/services/share-links-api.service.ts b/src/app/share-links/services/share-links-api.service.ts index 33709828b..ff7caba74 100644 --- a/src/app/share-links/services/share-links-api.service.ts +++ b/src/app/share-links/services/share-links-api.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpV2Service } from '@shared/services/http-v2/http-v2.service'; import { firstValueFrom } from 'rxjs'; -import { StelaItems } from '@root/utils/stela-items'; import { ShareLink, ShareLinkPayload } from '../models/share-link'; @Injectable({ @@ -12,7 +11,7 @@ export class ShareLinksApiService { public async getShareLinksById(shareLinkIds: number[]): Promise { const response = await firstValueFrom( - this.http.get>( + this.http.get<{ items: ShareLink[] }>( 'v2/share-links', { shareLinkIds }, null, @@ -25,10 +24,13 @@ export class ShareLinksApiService { shareTokens: string[], ): Promise { const response = await firstValueFrom( - this.http.get>( + this.http.get<{ items: ShareLink[] }>( 'v2/share-links', - { shareTokens }, + { shareTokens: shareTokens }, null, + { + authToken: false, + }, ), ); return response[0].items; diff --git a/src/app/share-links/services/share-links.service.spec.ts b/src/app/share-links/services/share-links.service.spec.ts new file mode 100644 index 000000000..deaaffe73 --- /dev/null +++ b/src/app/share-links/services/share-links.service.spec.ts @@ -0,0 +1,87 @@ +import { TestBed } from '@angular/core/testing'; +import { ShareLink } from '../models/share-link'; +import { ShareLinksService } from './share-links.service'; +import { ShareLinksApiService } from './share-links-api.service'; + +describe('ShareLinksService', () => { + let service: ShareLinksService; + let apiSpy: jasmine.SpyObj; + + beforeEach(() => { + apiSpy = jasmine.createSpyObj('ShareLinksApiService', [ + 'getShareLinksByToken', + ]); + + TestBed.configureTestingModule({ + providers: [ + ShareLinksService, + { provide: ShareLinksApiService, useValue: apiSpy }, + ], + }); + + service = TestBed.inject(ShareLinksService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should get and set currentShareToken', () => { + service.currentShareToken = 'abc123'; + + expect(service.currentShareToken).toBe('abc123'); + }); + + it('should return false if no token is set', async () => { + service.currentShareToken = ''; + const result = await service.isUnlistedShare(); + + expect(result).toBeFalse(); + expect(apiSpy.getShareLinksByToken).not.toHaveBeenCalled(); + }); + + it('should return false if no share links are returned', async () => { + service.currentShareToken = 'abc123'; + apiSpy.getShareLinksByToken.and.resolveTo([]); + const result = await service.isUnlistedShare(); + + expect(result).toBeFalse(); + }); + + it('should return true if accessRestrictions is "none"', async () => { + service.currentShareToken = 'abc123'; + apiSpy.getShareLinksByToken.and.resolveTo([ + { accessRestrictions: 'none' } as ShareLink, + ]); + const result = await service.isUnlistedShare(); + + expect(result).toBeTrue(); + }); + + it('should return false if accessRestrictions is not "none"', async () => { + service.currentShareToken = 'abc123'; + apiSpy.getShareLinksByToken.and.resolveTo([ + { accessRestrictions: 'read-only' } as unknown as ShareLink, + ]); + const result = await service.isUnlistedShare(); + + expect(result).toBeFalse(); + }); + + it('should cache shareLinks after first fetch', async () => { + service.currentShareToken = 'abc123'; + apiSpy.getShareLinksByToken.and.resolveTo([ + { accessRestrictions: 'none' } as ShareLink, + ]); + + const firstCall = await service.isUnlistedShare(); + + expect(firstCall).toBeTrue(); + expect(apiSpy.getShareLinksByToken).toHaveBeenCalledTimes(1); + + const secondCall = await service.isUnlistedShare(); + + expect(secondCall).toBeTrue(); + expect(apiSpy.getShareLinksByToken).toHaveBeenCalledTimes(1); // no second fetch + }); +}); diff --git a/src/app/share-links/services/share-links.service.ts b/src/app/share-links/services/share-links.service.ts new file mode 100644 index 000000000..db29a0a71 --- /dev/null +++ b/src/app/share-links/services/share-links.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { ShareLink } from '../models/share-link'; +import { ShareLinksApiService } from './share-links-api.service'; + +@Injectable({ + providedIn: 'root', +}) +export class ShareLinksService { + private _currentShareToken = ''; + private _shareLinks: ShareLink[] = undefined; + + constructor(private shareLinksApiService: ShareLinksApiService) {} + + public get currentShareToken() { + return this._currentShareToken; + } + + public set currentShareToken(token: string) { + this._currentShareToken = token; + } + + public async isUnlistedShare() { + if (!this._currentShareToken) { + return false; + } + if (!this._shareLinks) { + this._shareLinks = await this.shareLinksApiService.getShareLinksByToken([ + this._currentShareToken, + ]); + } + if (this._shareLinks && this._shareLinks.length) { + return this._shareLinks[0].accessRestrictions === 'none'; + } else { + return false; + } + } +} diff --git a/src/app/share-links/share-links.module.ts b/src/app/share-links/share-links.module.ts index 4c281aa81..b1ce9bd51 100644 --- a/src/app/share-links/share-links.module.ts +++ b/src/app/share-links/share-links.module.ts @@ -2,10 +2,11 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SharedModule } from '@shared/shared.module'; import { ShareLinksApiService } from './services/share-links-api.service'; +import { ShareLinksService } from './services/share-links.service'; @NgModule({ declarations: [], - providers: [ShareLinksApiService], + providers: [ShareLinksApiService, ShareLinksService], imports: [CommonModule, SharedModule], }) export class ShareLinksModule {} diff --git a/src/app/share-preview/components/share-preview/share-preview.component.html b/src/app/share-preview/components/share-preview/share-preview.component.html index 31d1658dc..3c71b8c15 100644 --- a/src/app/share-preview/components/share-preview/share-preview.component.html +++ b/src/app/share-preview/components/share-preview/share-preview.component.html @@ -81,7 +81,7 @@ class="share-preview-cover from-bottom" [ngClass]="{ 'share-preview-cover-visible': showCover }" (click)="toggleCover()" - *ngIf="!hasAccess && isLoggedIn" + *ngIf="!hasAccess && isLoggedIn && !isUnlistedShare" >