From a16a271516253edb4baf3cd7b013244e28ef227a Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 9 Sep 2026 14:32:19 +0300 Subject: [PATCH 1/6] Keep the order of folders/records stela sends In the new converter from stela records/folders, when the items were coming as children of a folder, they would be separated into folders and records for mapping, so the sort order the backend sent was lost. We do not do any kind of sorting on the FE, so this actually fixes the scrambled sort issue we were having before. Issue: PER-10678 --- .../shared/services/api/folder.repo.spec.ts | 57 +++++++++++++++++++ src/app/shared/services/api/folder.repo.ts | 24 +++++--- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/app/shared/services/api/folder.repo.spec.ts b/src/app/shared/services/api/folder.repo.spec.ts index e9ac56675..32bc25ad0 100644 --- a/src/app/shared/services/api/folder.repo.spec.ts +++ b/src/app/shared/services/api/folder.repo.spec.ts @@ -508,6 +508,63 @@ describe('Folder repo', () => { expect(child.thumbnail256).toBe('thumb256'); }); + // Stela's children endpoint ranks folders and records together by the + // parent folder's sort setting, so the response order is the render order. + it('should keep children in the order the response sent them', async () => { + httpV2Spy.get.and.returnValues( + of([{ items: [mockStelaFolder] }]), + of([ + { + items: [ + { recordId: '77', displayName: 'Apple', folderLinkId: '900' }, + { folderId: '78', displayName: 'Banana', folderLinkId: '901' }, + { recordId: '79', displayName: 'Cherry', folderLinkId: '902' }, + { folderId: '80', displayName: 'Date', folderLinkId: '903' }, + ], + }, + ]), + ); + + const result = await folderRepo.getWithChildren([ + new FolderVO({ folderId: 123 }), + ]); + const childNames = result + .getFolderVO(true) + .ChildItemVOs.map((child) => child.displayName); + + expect(childNames).toEqual(['Apple', 'Banana', 'Cherry', 'Date']); + }); + + it('should still expose children split by kind, each in response order', async () => { + httpV2Spy.get.and.returnValues( + of([{ items: [mockStelaFolder] }]), + of([ + { + items: [ + { recordId: '77', displayName: 'Apple', folderLinkId: '900' }, + { folderId: '78', displayName: 'Banana', folderLinkId: '901' }, + { recordId: '79', displayName: 'Cherry', folderLinkId: '902' }, + { folderId: '80', displayName: 'Date', folderLinkId: '903' }, + ], + }, + ]), + ); + + const folder = ( + await folderRepo.getWithChildren([new FolderVO({ folderId: 123 })]) + ).getFolderVO(); + + expect(folder.ChildFolderVOs.map((child) => child.displayName)).toEqual([ + 'Banana', + 'Date', + ]); + + expect(folder.RecordVOs.map((child) => child.displayName)).toEqual([ + 'Apple', + 'Cherry', + ]); + }); + it('should leave link ids undefined rather than NaN when absent', async () => { const folder = await convertFolder({ folderLinkId: undefined }); diff --git a/src/app/shared/services/api/folder.repo.ts b/src/app/shared/services/api/folder.repo.ts index b2571f56f..6e073d8b3 100644 --- a/src/app/shared/services/api/folder.repo.ts +++ b/src/app/shared/services/api/folder.repo.ts @@ -1,4 +1,4 @@ -import { FolderVO, FolderVOData, ItemVO } from '@root/app/models'; +import { FolderVO, FolderVOData, ItemVO, RecordVO } from '@root/app/models'; import { BaseResponse, BaseRepo } from '@shared/services/api/base'; import { firstValueFrom, Observable } from 'rxjs'; import { DataStatus } from '@models/data-status.enum'; @@ -155,12 +155,20 @@ const convertStelaPathsToBreadcrumbPaths = ( const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { stelaFolder.children ??= []; - const childFolderVOs = stelaFolder.children - .filter((child): child is StelaFolder => !isStelaRecord(child)) - .map(convertStelaFolderToFolderVO); - const childRecordVOs = stelaFolder.children - .filter(isStelaRecord) - .map(convertStelaRecordToRecordVO); + // Stela's children endpoint already ranks folders and records together by the + // parent folder's sort setting, so the incoming order is the order to render. + // Splitting the children by kind and concatenating them would discard it. + const childItemVOs: ItemVO[] = stelaFolder.children.map((child) => + isStelaRecord(child) + ? convertStelaRecordToRecordVO(child) + : convertStelaFolderToFolderVO(child), + ); + const childFolderVOs = childItemVOs.filter( + (childItemVO): childItemVO is FolderVO => childItemVO instanceof FolderVO, + ); + const childRecordVOs = childItemVOs.filter( + (childItemVO): childItemVO is RecordVO => childItemVO instanceof RecordVO, + ); const { accessRole: stelaAccessRole, ...stelaFolderWithoutAccessRole } = stelaFolder; return new FolderVO({ @@ -212,7 +220,7 @@ const convertStelaFolderToFolderVO = (stelaFolder: StelaFolder): FolderVO => { TagVOs: (stelaFolder.tags ?? []).map((stelaTag) => convertStelaTagToTagVO(stelaTag, stelaFolder.archive?.id), ), - ChildItemVOs: [...childRecordVOs, ...childFolderVOs], + ChildItemVOs: childItemVOs, ShareVOs: (stelaFolder.shares ?? []).map(convertStelaSharetoShareVO), isFolder: true, }); From bdf0f2d680e2fa2df6e80ace13d4edc89cdd7161 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 9 Sep 2026 14:37:36 +0300 Subject: [PATCH 2/6] Make the error handler more generic to also accept stela errors The folder resolver's catch block would expect a FolderResponse type of error, which we are trying to move away from. So instead of converting the stela error to a FolderResponse, we will just make the expected error type more generic. Issue: PER-10678 --- .../resolves/folder-resolve.service.spec.ts | 281 ++++++++++++++++++ .../core/resolves/folder-resolve.service.ts | 12 +- 2 files changed, 287 insertions(+), 6 deletions(-) diff --git a/src/app/core/resolves/folder-resolve.service.spec.ts b/src/app/core/resolves/folder-resolve.service.spec.ts index abd82f723..1b05893cc 100644 --- a/src/app/core/resolves/folder-resolve.service.spec.ts +++ b/src/app/core/resolves/folder-resolve.service.spec.ts @@ -1,20 +1,301 @@ import { TestBed } from '@angular/core/testing'; import * as Testing from '@root/test/testbedConfig'; import { cloneDeep } from 'lodash'; +import { Router } from '@angular/router'; import { FolderResolveService } from '@core/resolves/folder-resolve.service'; +import { AccountService } from '@shared/services/account/account.service'; +import { FilesystemService } from '@root/app/filesystem/filesystem.service'; +import { FolderResponse } from '@shared/services/api/folder.repo'; +import { FolderVO } from '@models/index'; +import { + MessageDisplayOptions, + MessageService, +} from '@shared/services/message/message.service'; + +const buildFolder = (folderData: Record) => + new FolderVO({ + ChildItemVOs: [], + type: 'type.folder.private', + view: 'folder.view.grid', + ...folderData, + }); describe('FolderResolveService', () => { let service: FolderResolveService; + let accountService: AccountService; + let filesystem: FilesystemService; + let message: MessageService; + let router: Router; beforeEach(() => { const config = cloneDeep(Testing.BASE_TEST_CONFIG); config.providers.push(FolderResolveService); TestBed.configureTestingModule(config); + service = TestBed.inject(FolderResolveService); + accountService = TestBed.inject(AccountService); + filesystem = TestBed.inject(FilesystemService); + message = TestBed.inject(MessageService); + router = TestBed.inject(Router); + + spyOn(accountService, 'getRootFolder').and.returnValue( + new FolderVO({ + ChildItemVOs: [ + new FolderVO({ + folderId: '11', + type: 'type.folder.root.private', + archiveNbr: '0001-0001', + }), + new FolderVO({ + folderId: '22', + type: 'type.folder.root.app', + archiveNbr: '0001-0002', + }), + new FolderVO({ + folderId: '33', + type: 'type.folder.root.public', + archiveNbr: '0001-0003', + }), + ], + }), + ); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should load My Files by default', async () => { + const getFolderSpy = spyOn(filesystem, 'getFolder').and.resolveTo( + buildFolder({ + displayName: 'My Files', + type: 'type.folder.root.private', + }), + ); + + const result = await service.resolve( + { params: {}, data: {} } as any, + { url: '/private' } as any, + ); + + const requestedFolder = getFolderSpy.calls.mostRecent().args[0] as FolderVO; + + expect(getFolderSpy).toHaveBeenCalled(); + expect(requestedFolder.folderId).toBe('11'); + expect(result.displayName).toBe('My Files'); + }); + + it('should load the apps folder on /apps', async () => { + const getFolderSpy = spyOn(filesystem, 'getFolder').and.resolveTo( + buildFolder({ displayName: 'Apps', type: 'type.folder.root.app' }), + ); + + await service.resolve( + { params: {}, data: {} } as any, + { url: '/apps' } as any, + ); + + expect((getFolderSpy.calls.mostRecent().args[0] as FolderVO).folderId).toBe( + '22', + ); + }); + + it('should load the public root on /public', async () => { + const getFolderSpy = spyOn(filesystem, 'getFolder').and.resolveTo( + buildFolder({ displayName: 'Public', type: 'type.folder.root.public' }), + ); + + await service.resolve( + { params: {}, data: {} } as any, + { url: '/public' } as any, + ); + + expect((getFolderSpy.calls.mostRecent().args[0] as FolderVO).folderId).toBe( + '33', + ); + }); + + it('should pass the route identifiers through for a deep link, coercing the link id', async () => { + const getFolderSpy = spyOn(filesystem, 'getFolder').and.resolveTo( + buildFolder({ displayName: 'Deep Linked' }), + ); + + const result = await service.resolve( + { + params: { archiveNbr: '0001-0005', folderLinkId: '99' }, + data: {}, + } as any, + { url: '/private/0001-0005/99' } as any, + ); + + const requestedFolder = getFolderSpy.calls.mostRecent().args[0] as FolderVO; + + expect(requestedFolder.archiveNbr).toBe('0001-0005'); + expect(requestedFolder.folder_linkId).toBe(99); + expect(requestedFolder.folderId).toBeUndefined(); + expect(result.displayName).toBe('Deep Linked'); + }); + + it('should splice share crumbs onto a shared record without loading a folder', async () => { + const getFolderSpy = spyOn(filesystem, 'getFolder'); + const sharedRecord = { displayName: 'A shared photo' }; + + const result = await service.resolve( + { + params: {}, + data: {}, + parent: { + data: { + sharePreviewVO: { FolderVO: null, RecordVO: sharedRecord }, + currentFolder: new FolderVO({ + pathAsText: ['My Files'], + pathAsArchiveNbr: ['0001-0001'], + pathAsFolder_linkId: [11], + }), + }, + }, + } as any, + { url: '/share/abc123' } as any, + ); + + expect(getFolderSpy).not.toHaveBeenCalled(); + expect(result.pathAsText).toEqual(['Shares', 'Record', 'My Files']); + expect(result.pathAsArchiveNbr).toEqual([ + '0000-0000', + '0000-0000', + '0001-0001', + ]); + + expect(result.pathAsFolder_linkId).toEqual([0, 0, 11]); + expect(result.ChildItemVOs).toEqual([sharedRecord] as any); + }); + + it('should redirect a timeline folder to the public timeline route', async () => { + spyOn(filesystem, 'getFolder').and.resolveTo( + buildFolder({ displayName: 'Trip', view: 'folder.view.timeline' }), + ); + const navigateSpy = spyOn(router, 'navigate'); + + await service.resolve( + { + params: { + archiveNbr: '0001-0005', + folderLinkId: '99', + publicArchiveNbr: '0002-0000', + }, + data: {}, + } as any, + { url: '/p/archive/0002-0000/0001-0005/99' } as any, + ); + + expect(navigateSpy).toHaveBeenCalledWith([ + 'p', + 'archive', + '0002-0000', + 'view', + 'timeline', + '0001-0005', + '99', + ]); + }); + + it('should not redirect when the route already declares a folder view', async () => { + spyOn(filesystem, 'getFolder').and.resolveTo( + buildFolder({ displayName: 'Trip', view: 'folder.view.timeline' }), + ); + const navigateSpy = spyOn(router, 'navigate'); + + const result = await service.resolve( + { + params: { + archiveNbr: '0001-0005', + folderLinkId: '99', + publicArchiveNbr: '0002-0000', + }, + data: { folderView: 'folder.view.timeline' }, + } as any, + { url: '/p/archive/0002-0000/view/timeline/0001-0005/99' } as any, + ); + + expect(navigateSpy).not.toHaveBeenCalled(); + expect(result.displayName).toBe('Trip'); + }); + + it('should surface the server message when the load fails', async () => { + spyOn(filesystem, 'getFolder').and.rejectWith( + new FolderResponse({ + isSuccessful: false, + Results: [{ message: ['Test Error'] }], + }), + ); + spyOn(accountService, 'logOut').and.resolveTo(null); + spyOn(router, 'navigate'); + let displayedErrorMessage: string; + spyOn(message, 'showError').and.callFake((data: MessageDisplayOptions) => { + displayedErrorMessage = data.message; + }); + + await expectAsync( + service.resolve( + { params: {}, data: {} } as any, + { url: '/private' } as any, + ), + ).toBeRejected(); + + expect(displayedErrorMessage).toBe('Test Error'); + }); + + it('should fall back to a generic message for a raw error', async () => { + spyOn(filesystem, 'getFolder').and.rejectWith(new Error('Network down')); + spyOn(accountService, 'logOut').and.resolveTo(null); + spyOn(router, 'navigate'); + let displayedErrorMessage: string; + spyOn(message, 'showError').and.callFake((data: MessageDisplayOptions) => { + displayedErrorMessage = data.message; + }); + + await expectAsync( + service.resolve( + { params: {}, data: {} } as any, + { url: '/private' } as any, + ), + ).toBeRejected(); + + expect(displayedErrorMessage).toBe('error.generic.internal'); + }); + + it('should log out when a root folder fails to load', async () => { + spyOn(filesystem, 'getFolder').and.rejectWith(new Error('Network down')); + const logOutSpy = spyOn(accountService, 'logOut').and.resolveTo(null); + spyOn(router, 'navigate'); + spyOn(message, 'showError'); + + await expectAsync( + service.resolve( + { params: {}, data: {} } as any, + { url: '/private' } as any, + ), + ).toBeRejected(); + + expect(logOutSpy).toHaveBeenCalled(); + }); + + it('should redirect rather than throw when a deep link fails', async () => { + spyOn(filesystem, 'getFolder').and.rejectWith(new Error('Network down')); + const navigateSpy = spyOn(router, 'navigate'); + spyOn(message, 'showError'); + + await expectAsync( + service.resolve( + { + params: { archiveNbr: '0001-0005', folderLinkId: '99' }, + data: {}, + } as any, + { url: '/private/0001-0005/99' } as any, + ), + ).toBeRejectedWith(false); + + expect(navigateSpy).toHaveBeenCalledWith(['/private']); + }); }); diff --git a/src/app/core/resolves/folder-resolve.service.ts b/src/app/core/resolves/folder-resolve.service.ts index ab54c126b..991e09600 100644 --- a/src/app/core/resolves/folder-resolve.service.ts +++ b/src/app/core/resolves/folder-resolve.service.ts @@ -9,11 +9,11 @@ import { find, cloneDeep } from 'lodash'; import { AccountService } from '@shared/services/account/account.service'; import { MessageService } from '@shared/services/message/message.service'; -import { FolderResponse } from '@shared/services/api/index.repo'; - import { FolderVO } from '@root/app/models'; import { FolderView } from '@shared/services/folder-view/folder-view.enum'; import { findRouteData } from '@shared/utilities/router'; +import { getFolderErrorMessage } from '@shared/utilities/folder-error-message'; +import { toFolderLinkId } from '@shared/services/api/folder.repo'; import { FilesystemService } from '@root/app/filesystem/filesystem.service'; @Injectable() @@ -34,7 +34,7 @@ export class FolderResolveService { if (route.params.archiveNbr && route.params.folderLinkId) { targetFolder = new FolderVO({ archiveNbr: route.params.archiveNbr, - folder_linkId: route.params.folderLinkId, + folder_linkId: toFolderLinkId(route.params.folderLinkId), }); } else if (state.url === '/apps') { const apps = find(this.accountService.getRootFolder().ChildItemVOs, { @@ -92,12 +92,12 @@ export class FolderResolveService { } return folder; }) - .catch(async (response: FolderResponse) => { + .catch(async (error: unknown) => { this.message.showError({ - message: response.getMessage(), + message: getFolderErrorMessage(error), translate: true, }); - if (targetFolder.type.includes('root')) { + if (targetFolder.type?.includes('root')) { this.accountService .logOut() .then(() => { From 13df6657eb67f85f46f9c03fc1dccf4bef809f2b Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 9 Sep 2026 14:40:58 +0300 Subject: [PATCH 3/6] Replace navigateLean with getWithChildren for the fileSystem service The fileSystem service, that is used by the folder resolver has quite a wide reach, so this will instantly replace a lot of the navigateLean calls from route level. Issue: PER-10678 --- .../filesystem/filesystem-api.service.spec.ts | 40 ++++++++++++++----- src/app/filesystem/filesystem-api.service.ts | 8 ++-- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/app/filesystem/filesystem-api.service.spec.ts b/src/app/filesystem/filesystem-api.service.spec.ts index 1716643bf..24181419d 100644 --- a/src/app/filesystem/filesystem-api.service.spec.ts +++ b/src/app/filesystem/filesystem-api.service.spec.ts @@ -3,7 +3,6 @@ import { FolderResponse } from '@shared/services/api/folder.repo'; 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'; @@ -46,9 +45,9 @@ describe('FilesystemApiService', () => { getWithChildren: jasmine .createSpy('getWithChildren') .and.returnValue(Promise.resolve(mockSuccessResponse)), - navigateLean: jasmine - .createSpy('navigateLean') - .and.returnValue(of(mockSuccessResponse)), + getWithChildrenByIdentifier: jasmine + .createSpy('getWithChildrenByIdentifier') + .and.returnValue(Promise.resolve(mockSuccessResponse)), }, }; @@ -72,20 +71,32 @@ describe('FilesystemApiService', () => { expect(service).toBeTruthy(); }); - it('should navigate using navigateLean', async () => { + it('should navigate using getWithChildrenByIdentifier', async () => { shareLinksServiceSpy.isUnlistedShare.and.resolveTo(false); const folder = await service.navigate({ folderId }); - expect(mockApiService.folder.navigateLean).toHaveBeenCalledWith( - jasmine.any(FolderVO), - ); + expect( + mockApiService.folder.getWithChildrenByIdentifier, + ).toHaveBeenCalledWith(jasmine.any(FolderVO)); expect(folder.folderId).toBe(folderId); expect(folder.displayName).toBe('Unlisted Folder'); expect(folder.dataStatus).toBe(DataStatus.Lean); }); + it('should navigate by archiveNbr and folder_linkId without a folder id', async () => { + shareLinksServiceSpy.isUnlistedShare.and.resolveTo(false); + + await service.navigate({ archiveNbr: '0001-0000' }); + + const [requestedFolder] = + mockApiService.folder.getWithChildrenByIdentifier.calls.mostRecent().args; + + expect(requestedFolder.archiveNbr).toBe('0001-0000'); + expect(requestedFolder.folderId).toBeUndefined(); + }); + it('should navigate using getWithChildren when in unlisted share', async () => { shareLinksServiceSpy.isUnlistedShare.and.resolveTo(true); shareLinksServiceSpy.currentShareToken = 'mock-token'; @@ -104,8 +115,8 @@ describe('FilesystemApiService', () => { it('should throw FolderResponse error if response is unsuccessful', async () => { shareLinksServiceSpy.isUnlistedShare.and.resolveTo(false); - mockApiService.folder.navigateLean.and.returnValue( - of(mockUnsuccessfulResponse), + mockApiService.folder.getWithChildrenByIdentifier.and.resolveTo( + mockUnsuccessfulResponse, ); try { @@ -115,4 +126,13 @@ describe('FilesystemApiService', () => { expect(error).toBeDefined(); } }); + + it('should surface a rejection from getWithChildrenByIdentifier', async () => { + shareLinksServiceSpy.isUnlistedShare.and.resolveTo(false); + mockApiService.folder.getWithChildrenByIdentifier.and.rejectWith( + new Error('500 Internal Server Error'), + ); + + await expectAsync(service.navigate({ folderId })).toBeRejected(); + }); }); diff --git a/src/app/filesystem/filesystem-api.service.ts b/src/app/filesystem/filesystem-api.service.ts index 2026554df..8e1ff5fed 100644 --- a/src/app/filesystem/filesystem-api.service.ts +++ b/src/app/filesystem/filesystem-api.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; import { FolderVO, RecordVO } from '@models/index'; import { ApiService } from '@shared/services/api/api.service'; @@ -26,13 +25,16 @@ export class FilesystemApiService implements FilesystemApi { const isUnlistedShare = await this.shareLinksService.isUnlistedShare(); let response: FolderResponse = null; if (isUnlistedShare) { + // A share-token visitor has no auth token, so the folder id cannot be + // resolved through the v1 endpoint the way getWithChildrenByIdentifier + // does -- these routes always carry a real folder id already. response = await this.api.folder.getWithChildren( [new FolderVO(folder)], this.shareLinksService.currentShareToken, ); } else { - response = await firstValueFrom( - this.api.folder.navigateLean(new FolderVO(folder)), + response = await this.api.folder.getWithChildrenByIdentifier( + new FolderVO(folder), ); } if (!response.isSuccessful) { From ffd226a184446505596e6948596e6c0196d7472b Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 9 Sep 2026 14:46:18 +0300 Subject: [PATCH 4/6] Replace lean folder resolve with the updated folder resolve Folder resolve is already using getWithChildren and it serves the same data the lean folder resolve did, so keeping it became redundant. Issue: PER-10678 --- .../lean-folder-resolve.service.spec.ts | 211 ------------------ .../resolves/lean-folder-resolve.service.ts | 93 -------- src/app/views/views.routes.ts | 15 +- 3 files changed, 3 insertions(+), 316 deletions(-) delete mode 100644 src/app/core/resolves/lean-folder-resolve.service.spec.ts delete mode 100644 src/app/core/resolves/lean-folder-resolve.service.ts diff --git a/src/app/core/resolves/lean-folder-resolve.service.spec.ts b/src/app/core/resolves/lean-folder-resolve.service.spec.ts deleted file mode 100644 index dee541767..000000000 --- a/src/app/core/resolves/lean-folder-resolve.service.spec.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import * as Testing from '@root/test/testbedConfig'; -import { cloneDeep } from 'lodash'; -import { Router } from '@angular/router'; - -import { LeanFolderResolveService } from '@core/resolves/lean-folder-resolve.service'; -import { ApiService } from '@shared/services/api/api.service'; -import { AccountService } from '@shared/services/account/account.service'; -import { FolderResponse } from '@shared/services/api/folder.repo'; -import { FolderVO } from '@models/index'; -import { - MessageDisplayOptions, - MessageService, -} from '@shared/services/message/message.service'; - -const buildFolderResponse = (folderData: Record) => - new FolderResponse({ - isSuccessful: true, - Results: [{ data: [{ FolderVO: { ChildItemVOs: [], ...folderData } }] }], - }); - -describe('LeanFolderResolveService', () => { - let service: LeanFolderResolveService; - let api: ApiService; - let accountService: AccountService; - let message: MessageService; - let router: Router; - - beforeEach(() => { - const config = cloneDeep(Testing.BASE_TEST_CONFIG); - config.providers.push(LeanFolderResolveService); - TestBed.configureTestingModule(config); - - service = TestBed.inject(LeanFolderResolveService); - api = TestBed.inject(ApiService); - accountService = TestBed.inject(AccountService); - message = TestBed.inject(MessageService); - router = TestBed.inject(Router); - - spyOn(accountService, 'getRootFolder').and.returnValue( - new FolderVO({ - ChildItemVOs: [ - new FolderVO({ - folderId: '11', - type: 'type.folder.root.private', - archiveNbr: '0001-0001', - }), - new FolderVO({ - folderId: '22', - type: 'type.folder.root.app', - archiveNbr: '0001-0002', - }), - ], - }), - ); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should load My Files by default', async () => { - const getSpy = spyOn( - api.folder, - 'getWithChildrenByIdentifier', - ).and.resolveTo(buildFolderResponse({ displayName: 'My Files' })); - - const result = await service.resolve( - { params: {} } as any, - { url: '/private' } as any, - ); - - expect(getSpy).toHaveBeenCalled(); - expect(getSpy.calls.mostRecent().args[0].folderId).toBe('11'); - expect(result.displayName).toBe('My Files'); - }); - - it('should load the apps folder on /apps', async () => { - const getSpy = spyOn( - api.folder, - 'getWithChildrenByIdentifier', - ).and.resolveTo(buildFolderResponse({ displayName: 'Apps' })); - - await service.resolve({ params: {} } as any, { url: '/apps' } as any); - - expect(getSpy.calls.mostRecent().args[0].folderId).toBe('22'); - }); - - it('should pass the route identifiers through for a deep link', async () => { - const getSpy = spyOn( - api.folder, - 'getWithChildrenByIdentifier', - ).and.resolveTo(buildFolderResponse({ displayName: 'Deep Linked' })); - - const result = await service.resolve( - { params: { archiveNbr: '0001-0005', folderLinkId: '99' } } as any, - { url: '/view/timeline/0001-0005/99' } as any, - ); - - const requestedFolder = getSpy.calls.mostRecent().args[0]; - - expect(requestedFolder.archiveNbr).toBe('0001-0005'); - expect(requestedFolder.folder_linkId).toBe(99); - expect(requestedFolder.folderId).toBeUndefined(); - expect(result.displayName).toBe('Deep Linked'); - }); - - it('should splice share crumbs onto a shared record without calling the API', async () => { - const getSpy = spyOn(api.folder, 'getWithChildrenByIdentifier'); - const sharedRecord = { displayName: 'A shared photo' }; - - const result = await service.resolve( - { - params: {}, - parent: { - data: { - sharePreviewVO: { FolderVO: null, RecordVO: sharedRecord }, - currentFolder: new FolderVO({ - pathAsText: ['My Files'], - pathAsArchiveNbr: ['0001-0001'], - pathAsFolder_linkId: [11], - }), - }, - }, - } as any, - { url: '/share/abc123/view/timeline' } as any, - ); - - expect(getSpy).not.toHaveBeenCalled(); - expect(result.pathAsText).toEqual(['Shares', 'Record', 'My Files']); - expect(result.pathAsArchiveNbr).toEqual([ - '0000-0000', - '0000-0000', - '0001-0001', - ]); - - expect(result.pathAsFolder_linkId).toEqual([0, 0, 11]); - expect(result.ChildItemVOs).toEqual([sharedRecord] as any); - }); - - it('should surface the server message when the load fails', async () => { - spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( - new FolderResponse({ - isSuccessful: false, - Results: [{ message: ['Test Error'] }], - }), - ); - spyOn(accountService, 'logOut').and.resolveTo(null); - spyOn(router, 'navigate'); - let displayedErrorMessage: string; - spyOn(message, 'showError').and.callFake((data: MessageDisplayOptions) => { - displayedErrorMessage = data.message; - }); - - await expectAsync( - service.resolve({ params: {} } as any, { url: '/private' } as any), - ).toBeRejected(); - - expect(displayedErrorMessage).toBe('Test Error'); - }); - - it('should log out when a root folder fails to load', async () => { - spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( - new Error('Network down'), - ); - const logOutSpy = spyOn(accountService, 'logOut').and.resolveTo(null); - spyOn(router, 'navigate'); - spyOn(message, 'showError'); - - await expectAsync( - service.resolve({ params: {} } as any, { url: '/private' } as any), - ).toBeRejected(); - - expect(logOutSpy).toHaveBeenCalled(); - }); - - it('should fall back to a generic message for a raw error', async () => { - spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( - new Error('Network down'), - ); - spyOn(accountService, 'logOut').and.resolveTo(null); - spyOn(router, 'navigate'); - let displayedErrorMessage: string; - spyOn(message, 'showError').and.callFake((data: MessageDisplayOptions) => { - displayedErrorMessage = data.message; - }); - - await expectAsync( - service.resolve({ params: {} } as any, { url: '/private' } as any), - ).toBeRejected(); - - expect(displayedErrorMessage).toBe('error.generic.internal'); - }); - - it('should redirect rather than throw when a deep link fails', async () => { - spyOn(api.folder, 'getWithChildrenByIdentifier').and.rejectWith( - new Error('Network down'), - ); - const navigateSpy = spyOn(router, 'navigate'); - spyOn(message, 'showError'); - - await expectAsync( - service.resolve( - { params: { archiveNbr: '0001-0005', folderLinkId: '99' } } as any, - { url: '/view/timeline/0001-0005/99' } as any, - ), - ).toBeRejectedWith(false); - - expect(navigateSpy).toHaveBeenCalledWith(['/private']); - }); -}); diff --git a/src/app/core/resolves/lean-folder-resolve.service.ts b/src/app/core/resolves/lean-folder-resolve.service.ts deleted file mode 100644 index ef6b4b601..000000000 --- a/src/app/core/resolves/lean-folder-resolve.service.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Injectable } from '@angular/core'; -import { - ActivatedRouteSnapshot, - RouterStateSnapshot, - Router, -} from '@angular/router'; -import { find, cloneDeep } from 'lodash'; -import { ApiService } from '@shared/services/api/api.service'; -import { AccountService } from '@shared/services/account/account.service'; -import { MessageService } from '@shared/services/message/message.service'; - -import { getFolderErrorMessage } from '@shared/utilities/folder-error-message'; - -import { FolderVO } from '@root/app/models'; -import { toFolderLinkId } from '@shared/services/api/folder.repo'; - -@Injectable() -export class LeanFolderResolveService { - constructor( - private api: ApiService, - private accountService: AccountService, - private message: MessageService, - private router: Router, - ) {} - - async resolve( - route: ActivatedRouteSnapshot, - state: RouterStateSnapshot, - ): Promise { - let targetFolder; - - if (route.params.archiveNbr && route.params.folderLinkId) { - targetFolder = new FolderVO({ - archiveNbr: route.params.archiveNbr, - folder_linkId: toFolderLinkId(route.params.folderLinkId), - }); - } else if (state.url === '/apps') { - const apps = find(this.accountService.getRootFolder().ChildItemVOs, { - type: 'type.folder.root.app', - }); - targetFolder = new FolderVO(apps); - } else if (state.url.includes('/share/')) { - const sharedFolder = route.parent.data.sharePreviewVO.FolderVO; - const sharedRecord = route.parent.data.sharePreviewVO.RecordVO; - if (sharedFolder) { - targetFolder = new FolderVO(sharedFolder); - } else { - const folder = new FolderVO(cloneDeep(route.parent.data.currentFolder)); - folder.pathAsArchiveNbr.unshift('0000-0000', '0000-0000'); - folder.pathAsText.unshift('Shares', 'Record'); - folder.pathAsFolder_linkId.unshift(0, 0); - folder.ChildItemVOs = [sharedRecord]; - return folder; - } - } else { - const myFiles = find(this.accountService.getRootFolder().ChildItemVOs, { - type: 'type.folder.root.private', - }); - targetFolder = new FolderVO(myFiles); - } - - try { - const folderResponse = - await this.api.folder.getWithChildrenByIdentifier(targetFolder); - - if (!folderResponse.isSuccessful) { - throw folderResponse; - } - - return folderResponse.getFolderVO(true); - } catch (error) { - this.message.showError({ - message: getFolderErrorMessage(error), - translate: true, - }); - if (targetFolder.type?.includes('root')) { - this.accountService - .logOut() - .then(() => { - this.router.navigate(['/login']); - }) - .catch(() => { - this.router.navigate(['/login']); - }); - } else if (state.url.includes('apps')) { - this.router.navigate(['/apps']); - } else { - this.router.navigate(['/private']); - } - return await Promise.reject(false); - } - } -} diff --git a/src/app/views/views.routes.ts b/src/app/views/views.routes.ts index e935a98bc..aed86bafb 100644 --- a/src/app/views/views.routes.ts +++ b/src/app/views/views.routes.ts @@ -1,7 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { FileViewerComponent } from '@fileBrowser/components/file-viewer/file-viewer.component'; -import { LeanFolderResolveService } from '@core/resolves/lean-folder-resolve.service'; import { RecordResolveService } from '@core/resolves/record-resolve.service'; import { FileBrowserComponentsModule } from '@fileBrowser/file-browser-components.module'; import { fileListChildRoutes } from '@fileBrowser/file-browser.routes'; @@ -16,10 +15,6 @@ const folderResolve = { currentFolder: FolderResolveService, }; -const leanFolderResolve = { - currentFolder: LeanFolderResolveService, -}; - const recordResolve = { currentRecord: RecordResolveService, }; @@ -34,13 +29,13 @@ export const routes: RoutesWithData = [ { path: '', component: TimelineViewComponent, - resolve: leanFolderResolve, + resolve: folderResolve, children: fileListChildRoutes, }, { path: ':archiveNbr/:folderLinkId', component: TimelineViewComponent, - resolve: leanFolderResolve, + resolve: folderResolve, children: [ { path: 'record/:recArchiveNbr', @@ -79,11 +74,7 @@ export const routes: RoutesWithData = [ FileBrowserComponentsModule, ], exports: [], - providers: [ - LeanFolderResolveService, - RecordResolveService, - FolderResolveService, - ], + providers: [RecordResolveService, FolderResolveService], declarations: [], }) export class ViewsRoutingModule {} From f7f93ae7b74bd8649634854a81851bc41b7796e1 Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 9 Sep 2026 15:43:22 +0300 Subject: [PATCH 5/6] Make sure the shares workspace starts with a Shares breadcrumb Stela sends the real sharers hierarchy of folders, so when we build the breadcrumbs, the root will be set as Private instead of Shares. In order to bypass that, we'll check if the route we are is shared and show the correct route. Issue: PER-10678 --- .../breadcrumbs/breadcrumbs.component.spec.ts | 16 ++++++++++++++++ .../breadcrumbs/breadcrumbs.component.ts | 15 ++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/app/shared/components/breadcrumbs/breadcrumbs.component.spec.ts b/src/app/shared/components/breadcrumbs/breadcrumbs.component.spec.ts index 140a2264b..ff1002807 100644 --- a/src/app/shared/components/breadcrumbs/breadcrumbs.component.spec.ts +++ b/src/app/shared/components/breadcrumbs/breadcrumbs.component.spec.ts @@ -127,4 +127,20 @@ describe('BreadcrumbsComponent', () => { expect(component.breadcrumbs[0].text).toEqual('Shares'); expect(component.breadcrumbs[1].routerPath).toContain('/shares/test2'); }); + + it('should keep the Shares root even when the folder path starts in the sharing archive', async () => { + await init('/shares/test2/2'); + const sharedFolderFromAnotherArchive = new FolderVO({ + pathAsArchiveNbr: ['test1', 'test2', 'test3'], + pathAsText: ['My Files', 'shared folder', 'shared inner folder'], + pathAsFolder_linkId: [1, 2, 3], + }); + TestBed.inject(DataService).setCurrentFolder( + sharedFolderFromAnotherArchive, + ); + + expect(component.breadcrumbs[0].text).toEqual('Shares'); + expect(component.breadcrumbs[0].routerPath).toEqual('/shares'); + expect(component.breadcrumbs[1].routerPath).toEqual('/shares/test2/2'); + }); }); diff --git a/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts b/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts index 8598bbbce..2c7172140 100644 --- a/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts +++ b/src/app/shared/components/breadcrumbs/breadcrumbs.component.ts @@ -186,9 +186,18 @@ export class BreadcrumbsComponent implements OnInit, OnDestroy { } if (showRootBreadcrumb) { - this.breadcrumbs.push(new Breadcrumb(0, rootUrl, folder.pathAsText[0])); - if (this.breadcrumbs[0].routerPath === '/private') - this.breadcrumbs[0].text = 'Private'; + // A shared folder's path is rooted in the sharing archive's own tree, + // so its first path name ("My Files") would link back to this user's + // private workspace. The root crumb must be the workspace, not the path. + if (rootUrl === '/shares') { + this.breadcrumbs.push( + new Breadcrumb(0, rootUrl, 'Shares', null, null, true), + ); + } else { + this.breadcrumbs.push(new Breadcrumb(0, rootUrl, folder.pathAsText[0])); + if (this.breadcrumbs[0].routerPath === '/private') + this.breadcrumbs[0].text = 'Private'; + } } if (isInPublicArchive) { From 7b92066274932f1c2194a527019a1be8ec6bc10d Mon Sep 17 00:00:00 2001 From: aasandei-vsp Date: Wed, 9 Sep 2026 17:13:02 +0300 Subject: [PATCH 6/6] Add a guard for showing the Publish dialog title In the publish dialog, the title was decided by a property from the folder or record, so if that property would ever be undefined, the dialog would crash. Moved the property check in the component instead of the view and also check if the item includes a type, which is a stela property. Issue: PER-10678 --- .../components/publish/publish.component.html | 6 +-- .../publish/publish.component.spec.ts | 48 ++++++++++++++++++- .../components/publish/publish.component.ts | 10 +++- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/app/file-browser/components/publish/publish.component.html b/src/app/file-browser/components/publish/publish.component.html index 18d18fce7..b8c2c7065 100644 --- a/src/app/file-browser/components/publish/publish.component.html +++ b/src/app/file-browser/components/publish/publish.component.html @@ -1,11 +1,7 @@
- {{ - this.sourceItem.folder_linkType.includes('public') - ? 'Get public link for' - : 'Publish' - }} + {{ isPublicSourceItem ? 'Get public link for' : 'Publish' }} {{ sourceItem.displayName }}
@if (publicLink) { diff --git a/src/app/file-browser/components/publish/publish.component.spec.ts b/src/app/file-browser/components/publish/publish.component.spec.ts index f31fa0bfe..a457d5526 100644 --- a/src/app/file-browser/components/publish/publish.component.spec.ts +++ b/src/app/file-browser/components/publish/publish.component.spec.ts @@ -86,6 +86,11 @@ describe('PublishComponent', () => { showErrorSpy = jasmine.createSpy('showError'); + await init({ folder_linkType: 'linkType' }); + }); + + async function init(dialogItem: unknown) { + TestBed.resetTestingModule(); await TestBed.configureTestingModule({ declarations: [PublishComponent], providers: [ @@ -94,7 +99,7 @@ describe('PublishComponent', () => { { provide: DIALOG_DATA, useValue: { - item: { folder_linkType: 'linkType' }, + item: dialogItem, }, }, { provide: DialogRef, useClass: MockDialogRef }, @@ -118,12 +123,51 @@ describe('PublishComponent', () => { fixture = TestBed.createComponent(PublishComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + } it('should create', () => { expect(component).toBeTruthy(); }); + describe('Stela-shaped items with no folder_linkType', () => { + it('should render the Publish title for a private folder without throwing', async () => { + await init( + new FolderVO({ + folderId: '900', + archiveNbr: '0002-0001', + folder_linkId: 12, + displayName: 'Trip to Iceland', + type: 'type.folder.private', + }), + ); + + expect(component.isPublicSourceItem).toBeFalse(); + + const pageTitle = fixture.nativeElement.querySelector('.page-title'); + + expect(pageTitle.textContent).toContain('Publish'); + expect(pageTitle.textContent).not.toContain('Get public link for'); + }); + + it('should treat a public folder as already published', async () => { + await init( + new FolderVO({ + folderId: '901', + archiveNbr: '0001-0002', + folder_linkId: 71, + displayName: 'Trip to Iceland', + type: 'type.folder.public', + }), + ); + + expect(component.isPublicSourceItem).toBeTrue(); + expect(component.publicItem).toBe(component.sourceItem); + expect(component.publicLink).toContain( + '/p/archive/0001-0000/0001-0002/71', + ); + }); + }); + it('should disaple the public to internet archive button if the user does not have the correct access role', () => { mockAccountService.getArchive = () => new ArchiveVO({ accessRole: 'access.role.viewer' }); diff --git a/src/app/file-browser/components/publish/publish.component.ts b/src/app/file-browser/components/publish/publish.component.ts index 1c3b4aec1..a8b6e2bf7 100644 --- a/src/app/file-browser/components/publish/publish.component.ts +++ b/src/app/file-browser/components/publish/publish.component.ts @@ -32,6 +32,7 @@ export class PublishComponent { public linkCopied = false; public iaLinkCopied = false; public isAtleastManager = false; + public isPublicSourceItem = false; @ViewChild('publicLinkInput', { static: false }) publicLinkInput: ElementRef; @ViewChild('iaLinkInput', { static: false }) iaLinkInput: ElementRef; @@ -54,7 +55,14 @@ export class PublishComponent { this.isAtleastManager = this.getRole().includes('manager') || this.getRole().includes('owner'); - if (this.sourceItem?.folder_linkType?.includes('public')) { + // Stela folders carry no folder_linkType, so public-ness also has to be + // read off the folder type, the same way the sidebar decides it. + this.isPublicSourceItem = !!( + this.sourceItem?.folder_linkType?.includes('public') || + this.sourceItem?.type?.includes('public') + ); + + if (this.isPublicSourceItem) { this.publicItem = this.sourceItem; this.publicLink = this.linkPipe.transform(this.publicItem); this.checkInternetArchiveLink();