From 33b173e51974624b4a3c08c4b66e2576f72cfe7d Mon Sep 17 00:00:00 2001 From: crisnicandrei <62384997+crisnicandrei@users.noreply.github.com> Date: Tue, 3 Jun 2025 15:17:33 +0300 Subject: [PATCH] PER-10096-archive-invitations PER-10096-archive-invitations -Create new route for archives dialog -Redirect that new route based on the url to the pending archives dialog --- src/app/auth/guards/auth.guard.spec.ts | 116 +++++++++++++ src/app/auth/guards/auth.guard.ts | 14 ++ .../my-archives-dialog.component.html | 8 +- .../my-archives-dialog.component.spec.ts | 152 ++++++++++++++++++ .../my-archives-dialog.component.ts | 28 +++- src/app/core/core.routes.ts | 19 +++ .../account-dropdown.component.ts | 9 +- 7 files changed, 334 insertions(+), 12 deletions(-) create mode 100644 src/app/auth/guards/auth.guard.spec.ts diff --git a/src/app/auth/guards/auth.guard.spec.ts b/src/app/auth/guards/auth.guard.spec.ts new file mode 100644 index 000000000..ad9989312 --- /dev/null +++ b/src/app/auth/guards/auth.guard.spec.ts @@ -0,0 +1,116 @@ +import { TestBed } from '@angular/core/testing'; +import { + Router, + UrlTree, + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; +import { AccountService } from '@shared/services/account/account.service'; +import { AccountVO } from '@models/account-vo'; +import { AuthGuard } from './auth.guard'; + +describe('AuthGuard', () => { + let guard: AuthGuard; + let accountServiceSpy: jasmine.SpyObj; + let routerSpy: jasmine.SpyObj; + + beforeEach(() => { + accountServiceSpy = jasmine.createSpyObj('AccountService', [ + 'getAccount', + 'hasOwnArchives', + ]); + + routerSpy = jasmine.createSpyObj('Router', ['createUrlTree', 'parseUrl']); + + TestBed.configureTestingModule({ + providers: [ + AuthGuard, + { provide: AccountService, useValue: accountServiceSpy }, + { provide: Router, useValue: routerSpy }, + ], + }); + + guard = TestBed.inject(AuthGuard); + }); + + function createMockRouteSnapshot( + query: Record = {}, + ): ActivatedRouteSnapshot { + return { + queryParamMap: { + get: (key: string) => query[key] ?? null, + }, + } as any; + } + + it('should allow access if no account is found', () => { + accountServiceSpy.getAccount.and.returnValue(null); + const result = guard.canActivate(createMockRouteSnapshot(), { + url: '/signup', + } as RouterStateSnapshot); + + expect(result).toBeTrue(); + }); + + it('should redirect to /app/dialog:archives/pending if on /signup with invite params', () => { + accountServiceSpy.getAccount.and.returnValue( + new AccountVO({ accountId: 1 }), + ); + + const query = { + inviteCode: 'xyz', + fullName: 'Test User', + primaryEmail: 'test@example.com', + }; + + const expectedTree = {} as UrlTree; + routerSpy.createUrlTree.and.returnValue(expectedTree); + + const result = guard.canActivate(createMockRouteSnapshot(query), { + url: '/signup', + } as RouterStateSnapshot); + + expect(routerSpy.createUrlTree).toHaveBeenCalledWith([ + '/app', + { outlets: { primary: 'private', dialog: 'archives/pending' } }, + ]); + + expect(result).toBe(expectedTree); + }); + + it('should redirect to /app/private if account has own archives', async () => { + accountServiceSpy.getAccount.and.returnValue( + new AccountVO({ accountId: 1 }), + ); + accountServiceSpy.hasOwnArchives.and.returnValue(Promise.resolve(true)); + + const expectedUrl = {} as UrlTree; + routerSpy.parseUrl.and.returnValue(expectedUrl); + + const result = await guard.canActivate(createMockRouteSnapshot(), { + url: '/app/anything', + } as RouterStateSnapshot); + + expect(accountServiceSpy.hasOwnArchives).toHaveBeenCalled(); + expect(result).toBe(expectedUrl); + expect(routerSpy.parseUrl).toHaveBeenCalledWith('/app/private'); + }); + + it('should redirect to /app/onboarding if account has no own archives', async () => { + accountServiceSpy.getAccount.and.returnValue( + new AccountVO({ accountId: 1 }), + ); + accountServiceSpy.hasOwnArchives.and.returnValue(Promise.resolve(false)); + + const expectedUrl = {} as UrlTree; + routerSpy.parseUrl.and.returnValue(expectedUrl); + + const result = await guard.canActivate(createMockRouteSnapshot(), { + url: '/app/anything', + } as RouterStateSnapshot); + + expect(accountServiceSpy.hasOwnArchives).toHaveBeenCalled(); + expect(result).toBe(expectedUrl); + expect(routerSpy.parseUrl).toHaveBeenCalledWith('/app/onboarding'); + }); +}); diff --git a/src/app/auth/guards/auth.guard.ts b/src/app/auth/guards/auth.guard.ts index 529bc7297..6e3695344 100644 --- a/src/app/auth/guards/auth.guard.ts +++ b/src/app/auth/guards/auth.guard.ts @@ -26,7 +26,21 @@ export class AuthGuard { | Promise | boolean | UrlTree { + const account = this.account.getAccount(); + const queryParams = route.queryParamMap; + + const inviteCode = queryParams.get('inviteCode'); + const fullName = queryParams.get('fullName'); + const primaryEmail = queryParams.get('primaryEmail'); + const isInviteSignup = inviteCode && fullName && primaryEmail; + if (this.account.getAccount()?.accountId) { + if (state.url.includes('/signup') && isInviteSignup) { + return this.router.createUrlTree([ + '/app', + { outlets: { primary: 'private', dialog: 'archives/pending' } }, + ]); + } return this.account.hasOwnArchives().then((hasArchives) => { if (hasArchives) { return this.router.parseUrl('/app/private'); diff --git a/src/app/core/components/my-archives-dialog/my-archives-dialog.component.html b/src/app/core/components/my-archives-dialog/my-archives-dialog.component.html index 3593d434a..5bb919d64 100644 --- a/src/app/core/components/my-archives-dialog/my-archives-dialog.component.html +++ b/src/app/core/components/my-archives-dialog/my-archives-dialog.component.html @@ -32,7 +32,7 @@
-
({{ archives.length }}) Archives
+
({{ archives?.length }}) Archives
1 + archives?.length > 1 " removeText="Delete Archive" (removeClick)="onArchiveDeleteClick(archive)" @@ -58,9 +58,9 @@
- ({{ pendingArchives.length }}) Pending Archives + ({{ pendingArchives?.length }}) Pending Archives
-
No pending archives
+
No pending archives
{ + let component: MyArchivesDialogComponent; + let fixture: ComponentFixture; + let accountServiceSpy: jasmine.SpyObj; + let apiServiceSpy: jasmine.SpyObj; + let promptServiceSpy: jasmine.SpyObj; + let messageServiceSpy: jasmine.SpyObj; + let dialogRefSpy: jasmine.SpyObj; + let dialogData; + + beforeEach(async () => { + accountServiceSpy = jasmine.createSpyObj('AccountService', [ + 'refreshArchives', + 'getAccount', + 'getArchive', + 'getArchives', + 'changeArchive', + 'updateAccount', + ]); + + apiServiceSpy = jasmine.createSpyObj('ApiService', ['archive'], { + archive: jasmine.createSpyObj('archive', ['delete', 'accept', 'decline']), + }); + + promptServiceSpy = jasmine.createSpyObj('PromptService', ['confirm']); + messageServiceSpy = jasmine.createSpyObj('MessageService', ['showError']); + dialogRefSpy = jasmine.createSpyObj('DialogRef', ['close']); + + await TestBed.configureTestingModule({ + declarations: [MyArchivesDialogComponent], + providers: [ + { provide: AccountService, useValue: accountServiceSpy }, + { provide: ApiService, useValue: apiServiceSpy }, + { provide: PromptService, useValue: promptServiceSpy }, + { provide: MessageService, useValue: messageServiceSpy }, + { provide: DialogRef, useValue: dialogRefSpy }, + { + provide: ActivatedRoute, + useValue: { snapshot: { root: { params: {}, children: [] } } }, + }, + { + provide: Router, + useValue: { routerState: { snapshot: { root: {} } } }, + }, + { + provide: DIALOG_DATA, + useValue: {}, + }, + + UntypedFormBuilder, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MyArchivesDialogComponent); + component = fixture.componentInstance; + + const mockAccount: AccountVO = new AccountVO({ defaultArchiveId: '123' }); + const mockCurrentArchive: ArchiveVO = new ArchiveVO({ + archiveId: '123', + fullName: 'Test Archive', + accessRole: 'access.role.owner', + }); + + accountServiceSpy.getAccount.and.returnValue(mockAccount); + accountServiceSpy.getArchive.and.returnValue(mockCurrentArchive); + accountServiceSpy.getArchives.and.returnValue([ + new ArchiveVO({ + archiveId: '123', + fullName: 'Test Archive', + status: 'status.generic.ok', + accessRole: 'access.role.owner', + }), + ]); + accountServiceSpy.refreshArchives.and.returnValue( + Promise.resolve([new ArchiveVO({ archiveId: 1 })]), + ); + + fixture.detectChanges(); + }); + + it('should initialize and categorize archives correctly', async () => { + await component.ngOnInit(); + + expect(component.archives?.length).toBe(1); + expect(component.pendingArchives?.length).toBe(0); + }); + + it('should switch tabs when setTab is called', () => { + component.setTab('pending'); + + expect(component.activeTab).toBe('pending'); + }); + + it('should close dialog onDoneClick', () => { + component.onDoneClick(); + + expect(dialogRefSpy.close).toHaveBeenCalled(); + }); + + it('should initialize and separate pending/regular archives correctly', async () => { + const mockAccount = new AccountVO({ defaultArchiveId: '111' }); + const mockCurrentArchive = new ArchiveVO({ + archiveId: '111', + fullName: 'Current Archive', + }); + + const pending = new ArchiveVO({ + archiveId: '222', + fullName: 'Pending Archive', + status: 'status.generic.pending', + }); + + const active = new ArchiveVO({ + archiveId: '333', + fullName: 'Active Archive', + status: 'status.generic.ok', + }); + + accountServiceSpy.refreshArchives.and.returnValue( + Promise.resolve([new ArchiveVO({})]), + ); + accountServiceSpy.getAccount.and.returnValue(mockAccount); + accountServiceSpy.getArchive.and.returnValue(mockCurrentArchive); + accountServiceSpy.getArchives.and.returnValue([pending, active]); + + await component.ngOnInit(); + + expect(accountServiceSpy.refreshArchives).toHaveBeenCalled(); + expect(component.account).toEqual(mockAccount); + expect(component.currentArchive).toEqual(mockCurrentArchive); + expect(component.pendingArchives.length).toBe(1); + expect(component.archives.length).toBe(1); + expect(component.pendingArchives[0].archiveId).toBe('222'); + expect(component.archives[0].archiveId).toBe('333'); + }); +}); diff --git a/src/app/core/components/my-archives-dialog/my-archives-dialog.component.ts b/src/app/core/components/my-archives-dialog/my-archives-dialog.component.ts index c7b6a8cad..6846236d4 100644 --- a/src/app/core/components/my-archives-dialog/my-archives-dialog.component.ts +++ b/src/app/core/components/my-archives-dialog/my-archives-dialog.component.ts @@ -6,10 +6,11 @@ import { ViewChildren, QueryList, Inject, + inject, } from '@angular/core'; import { ArchiveVO, AccountVO } from '@models'; import { AccountService } from '@shared/services/account/account.service'; -import { Router } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { partition, remove, find, orderBy } from 'lodash'; import { ApiService } from '@shared/services/api/api.service'; import { ArchiveResponse } from '@shared/services/api/archive.repo'; @@ -63,8 +64,8 @@ const ARCHIVE_TYPES: { text: string; value: ArchiveType }[] = [ export class MyArchivesDialogComponent implements OnInit { account: AccountVO; currentArchive: ArchiveVO; - archives: ArchiveVO[]; - pendingArchives: ArchiveVO[]; + archives: ArchiveVO[] = []; + pendingArchives: ArchiveVO[] = []; waiting = false; archiveTypes = ARCHIVE_TYPES; @@ -77,6 +78,8 @@ export class MyArchivesDialogComponent implements OnInit { @ViewChildren(ArchiveSmallComponent) archiveComponents: QueryList; + private tabs = ['switch', 'pending', 'new']; + constructor( private dialogRef: DialogRef, @Inject(DIALOG_DATA) public data: any, @@ -85,6 +88,8 @@ export class MyArchivesDialogComponent implements OnInit { private prompt: PromptService, private message: MessageService, private fb: UntypedFormBuilder, + private route: ActivatedRoute, + private router: Router, ) { this.newArchiveForm = this.fb.group({ fullName: ['', [Validators.required]], @@ -97,7 +102,9 @@ export class MyArchivesDialogComponent implements OnInit { } } - ngOnInit(): void { + async ngOnInit(): Promise { + await this.accountService.refreshArchives(); + this.account = this.accountService.getAccount(); this.currentArchive = this.accountService.getArchive(); [this.pendingArchives, this.archives] = partition( @@ -106,6 +113,11 @@ export class MyArchivesDialogComponent implements OnInit { ), { status: 'status.generic.pending' }, ); + + const tab = this.getParams(this.router.routerState.snapshot.root); + if (tab.path) { + this.setTab(tab.path); + } } setTab(tab: MyArchivesTab) { @@ -117,6 +129,14 @@ export class MyArchivesDialogComponent implements OnInit { this.dialogRef.close(); } + getParams = (route) => ({ + ...route.params, + ...route.children?.reduce( + (acc, child) => ({ ...this.getParams(child), ...acc }), + {}, + ), + }); + scrollToArchive(archive: ArchiveVO) { setTimeout(() => { const component = find( diff --git a/src/app/core/core.routes.ts b/src/app/core/core.routes.ts index 107cfdfba..3c7624568 100644 --- a/src/app/core/core.routes.ts +++ b/src/app/core/core.routes.ts @@ -32,6 +32,7 @@ import { ArchiveSettingsDialogComponent } from './components/archive-settings-di import { MembersDialogComponent } from './components/members-dialog/members-dialog.component'; import { AccountSettingsDialogComponent } from './components/account-settings-dialog/account-settings-dialog.component'; import { InvitationsDialogComponent } from './components/invitations-dialog/invitations-dialog.component'; +import { MyArchivesDialogComponent } from './components/my-archives-dialog/my-archives-dialog.component'; const rootFolderResolve = { rootFolder: RootFolderResolveService, @@ -282,6 +283,24 @@ export const routes: RoutesWithData = [ path: 'welcome-invitation', redirectTo: '/app/(private//dialog:welcomeinvitation)', }, + { + path: 'archives/:path', + component: RoutedDialogWrapperComponent, + outlet: 'dialog', + data: { + title: 'Archives', + component: MyArchivesDialogComponent, + dialogOptions: { width: '1000px' }, + }, + }, + { + path: 'archives', + redirectTo: '/app/(private//dialog:archives/)', + }, + { + path: 'connections', + redirectTo: '/app/(private//dialog:connections)', + }, { path: 'storage/:path', component: RoutedDialogWrapperComponent, diff --git a/src/app/shared/components/account-dropdown/account-dropdown.component.ts b/src/app/shared/components/account-dropdown/account-dropdown.component.ts index 917c4647d..26763114a 100644 --- a/src/app/shared/components/account-dropdown/account-dropdown.component.ts +++ b/src/app/shared/components/account-dropdown/account-dropdown.component.ts @@ -13,7 +13,7 @@ import { unsubscribeAll, } from '@shared/utilities/hasSubscriptions'; import { Subscription } from 'rxjs'; -import { Router, NavigationStart } from '@angular/router'; +import { Router, NavigationStart, ActivatedRoute } from '@angular/router'; import { ngIfFadeInAnimationSlow, TWEAKED } from '@shared/animations'; import { trigger, @@ -77,6 +77,7 @@ export class AccountDropdownComponent private dialog: DialogCdkService, private guidedTour: GuidedTourService, private event: EventService, + private route: ActivatedRoute, ) {} ngOnInit() { @@ -159,9 +160,9 @@ export class AccountDropdownComponent async openArchivesDialog() { await this.accountService.refreshArchives(); - this.dialog.open(MyArchivesDialogComponent, { - panelClass: 'dialog', - width: '1000px', + + this.router.navigate([{ outlets: { dialog: ['archives', 'switch'] } }], { + relativeTo: this.route, }); }