Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/app/core/services/route.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,34 @@ export class RouteService {
});
}

/**
* Store a URL in session storage for later retrieval
* Generic method that can be used by any component
* @param key The session storage key
* @param url The URL to store
*/
public storeUrlInSession(key: string, url: string): void {
if (typeof window !== 'undefined' && hasValue(window.sessionStorage)) {
// Only write if the value is different to avoid unnecessary writes
const currentValue = window.sessionStorage.getItem(key);
if (currentValue !== url) {
window.sessionStorage.setItem(key, url);
}
}
}

/**
* Retrieve a URL from session storage
* Generic method that can be used by any component
* @param key The session storage key
*/
public getUrlFromSession(key: string): string | null {
if (typeof window !== 'undefined' && hasValue(window.sessionStorage)) {
return window.sessionStorage.getItem(key);
}
return null;
}

private getRouteParams(): Observable<Params> {
let active = this.route;
while (active.firstChild) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ describe('PublicationComponent', () => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/search?query=test%20query&fakeParam=true');
},
storeUrlInSession(key: string, url: string): void {
// no-op
},
getUrlFromSession(key: string): string | null {
return null;
}
};
beforeEach(waitForAsync(() => {
Expand Down Expand Up @@ -186,6 +192,12 @@ describe('PublicationComponent', () => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/item');
},
storeUrlInSession(key: string, url: string): void {
// no-op
},
getUrlFromSession(key: string): string | null {
return null;
}
};
beforeEach(waitForAsync(() => {
Expand Down
34 changes: 34 additions & 0 deletions src/app/item-page/simple/item-types/shared/item.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export function getIIIFEnabled(enabled: boolean): MetadataValue {
export const mockRouteService = {
getPreviousUrl(): Observable<string> {
return observableOf('');
},
storeUrlInSession(key: string, url: string): void {
// no-op
},
getUrlFromSession(key: string): string | null {
return null;
}
};

Expand Down Expand Up @@ -425,6 +431,7 @@ describe('ItemComponent', () => {

const searchUrl = '/search?query=test&spc.page=2';
const browseUrl = '/browse/title?scope=0cc&bbm.page=3';
const homeUrl = '/home';
const recentSubmissionsUrl = '/collections/be7b8430-77a5-4016-91c9-90863e50583a?cp.page=3';

beforeEach(waitForAsync(() => {
Expand Down Expand Up @@ -485,6 +492,7 @@ describe('ItemComponent', () => {

it('should hide back button',() => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf('/item'));
comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
expect(val).toBeFalse();
});
Expand All @@ -510,6 +518,32 @@ describe('ItemComponent', () => {
expect(val).toBeTrue();
});
});

it('should show back button for home', () => {
spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf(homeUrl));
comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
expect(val).toBeTrue();
});
});

it('should prioritize home previous url over session fallback', () => {
const staleSessionUrl = searchUrl;
const getPreviousUrlSpy = spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf(homeUrl));
const getUrlFromSessionSpy = spyOn(mockRouteService, 'getUrlFromSession').and.returnValue(staleSessionUrl);
const storeUrlInSessionSpy = spyOn(mockRouteService, 'storeUrlInSession');

comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
expect(val).toBeTrue();
expect(getPreviousUrlSpy).toHaveBeenCalled();
expect(getUrlFromSessionSpy).not.toHaveBeenCalled();
expect(storeUrlInSessionSpy).toHaveBeenCalledWith('item-previous-url', homeUrl);

comp.back();
expect(router.navigateByUrl).toHaveBeenCalledWith(homeUrl);
});
});
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test case for the scenario where getPreviousUrl() returns an invalid URL (e.g., '/item') and getUrlFromSession() returns a valid stored URL. This is a key part of the new logic that should be tested to ensure the session storage fallback mechanism works correctly.

Suggested change
});
});
it('should fallback to session stored url when previous url is invalid', () => {
const invalidPreviousUrl = '/item';
const sessionUrl = searchUrl;
const getPreviousUrlSpy = spyOn(mockRouteService, 'getPreviousUrl').and.returnValue(observableOf(invalidPreviousUrl));
const getUrlFromSessionSpy = spyOn(mockRouteService, 'getUrlFromSession').and.returnValue(sessionUrl);
const storeUrlInSessionSpy = spyOn(mockRouteService, 'storeUrlInSession');
comp.ngOnInit();
comp.showBackButton.subscribe((val) => {
expect(val).toBeTrue();
expect(getPreviousUrlSpy).toHaveBeenCalled();
expect(getUrlFromSessionSpy).toHaveBeenCalledWith('item-previous-url');
// Depending on implementation, the component may or may not store the session URL again.
// The critical behavior is that navigation uses the fallback URL from session storage.
comp.back();
expect(router.navigateByUrl).toHaveBeenCalledWith(sessionUrl);
});
});

Copilot uses AI. Check for mistakes.
});

});
51 changes: 39 additions & 12 deletions src/app/item-page/simple/item-types/shared/item.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getItemPageRoute } from '../../../item-page-routing-paths';
import { RouteService } from '../../../../core/services/route.service';
import { Observable } from 'rxjs';
import { getDSpaceQuery, isIiifEnabled, isIiifSearchEnabled } from './item-iiif-utils';
import { filter, map, take } from 'rxjs/operators';
import { map, take } from 'rxjs/operators';
import { Router } from '@angular/router';
import { select, Store } from '@ngrx/store';
import { AppState } from 'src/app/app.reducer';
Expand All @@ -22,11 +22,16 @@ import { APP_CONFIG, AppConfig } from 'src/config/app-config.interface';
export class ItemComponent implements OnInit {
@Input() object: Item;

/**
* Session storage key for storing the previous URL before entering item page
*/
private readonly ITEM_PREVIOUS_URL_SESSION_KEY = 'item-previous-url';

/**
* This regex matches previous routes. The button is shown
* for matching paths and hidden in other cases.
*/
previousRoute = /^(\/search|\/browse|\/collections|\/admin\/search|\/mydspace)/;
previousRoute = /^(\/home|\/search|\/browse|\/collections|\/admin\/search|\/mydspace)/;

/**
* Used to show or hide the back to results button in the view.
Expand Down Expand Up @@ -57,6 +62,11 @@ export class ItemComponent implements OnInit {

isAuthenticated$: Observable<boolean>;

/**
* Stores the previous URL retrieved either from RouteService or sessionStorage
*/
private storedPreviousUrl: string;

constructor(protected routeService: RouteService,
protected router: Router,
private store: Store<AppState>,
Expand All @@ -66,26 +76,36 @@ export class ItemComponent implements OnInit {

/**
* The function used to return to list from the item.
* Uses stored previous URL if available, otherwise falls back to browser history.
*/
back = () => {
this.routeService.getPreviousUrl().pipe(
take(1)
).subscribe(
(url => {
this.router.navigateByUrl(url);
})
);
this.router.navigateByUrl(this.storedPreviousUrl);
};

ngOnInit(): void {

this.itemPageRoute = getItemPageRoute(this.object);
// hide/show the back button
this.showBackButton = this.routeService.getPreviousUrl().pipe(
filter(url => this.previousRoute.test(url)),
take(1),
map(() => true)
map(url => {
const fromRoute = this.pickAllowedPrevious(url);

if (fromRoute) {
this.routeService.storeUrlInSession(this.ITEM_PREVIOUS_URL_SESSION_KEY, fromRoute);
this.storedPreviousUrl = fromRoute;
return true;
}

const storedUrl = this.routeService.getUrlFromSession(this.ITEM_PREVIOUS_URL_SESSION_KEY);
if (this.pickAllowedPrevious(storedUrl)) {
this.storedPreviousUrl = storedUrl;
return true;
}

return false;
})
);
Comment on lines 88 to 107
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a JSDoc comment explaining why session storage is used as a fallback and what scenarios it handles (e.g., browser back/forward navigation, page refresh). This would help future developers understand the design decision behind this two-tier approach.

Copilot uses AI. Check for mistakes.

// check to see if iiif viewer is required.
this.iiifEnabled = isIiifEnabled(this.object);
this.iiifSearchEnabled = isIiifSearchEnabled(this.object);
Expand All @@ -95,6 +115,13 @@ export class ItemComponent implements OnInit {
this.isAuthenticated$ = this.store.pipe(select(isAuthenticated));
}

/**
* Helper to check if a URL is from an allowed previous route and return it, otherwise null
*/
private pickAllowedPrevious(url?: string | null): string | null {
return url && this.previousRoute.test(url) ? url : null;
}

get hasConfiguredStatistics(): boolean {
return !!this.appConfig.statistics?.baseUrl && !!this.appConfig.statistics?.endpoint;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ describe('UntypedItemComponent', () => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/search?query=test%20query&fakeParam=true');
},
storeUrlInSession(key: string, url: string): void {
// no-op
},
getUrlFromSession(key: string): string | null {
return null;
}
};
beforeEach(waitForAsync(() => {
Expand Down Expand Up @@ -193,6 +199,12 @@ describe('UntypedItemComponent', () => {
const localMockRouteService = {
getPreviousUrl(): Observable<string> {
return of('/item');
},
storeUrlInSession(key: string, url: string): void {
// no-op
},
getUrlFromSession(key: string): string | null {
return null;
}
};
beforeEach(waitForAsync(() => {
Expand Down
10 changes: 10 additions & 0 deletions src/app/shared/testing/route-service.stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export const routeServiceStub: any = {
},
getPreviousUrl: () => {
return observableOf('/home');
},
// Added generic session helpers used by ItemComponent
storeUrlInSession: (key: string, url: string) => {
// no-op for tests
},
getUrlFromSession: (key: string): string | null => {
return null;
},
clearUrlFromSession: (key: string) => {
// no-op for tests
Comment on lines +47 to +49
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clearUrlFromSession method is defined in the test stub but is not implemented in the actual RouteService and is not used anywhere in the codebase. This method should be removed from the stub to avoid confusion and maintain consistency with the actual service implementation.

Suggested change
},
clearUrlFromSession: (key: string) => {
// no-op for tests

Copilot uses AI. Check for mistakes.
}
/* eslint-enable no-empty, @typescript-eslint/no-empty-function */
};