Skip to content

Commit 0e9772a

Browse files
Clarin9/Fix "latest version" notice link ignoring the /repository base href (#876) (#1428)
* Clarin9/Fix "latest version" notice link ignoring the base href (#876) The `item.version.notice` translation embeds a raw `<a href='{{destination}}'>` anchor which the alert renders through `[innerHTML]`, so the interpolated value is resolved by the browser, not by the Angular router. `getItemPage()` returned the bare router path `/items/<uuid>`, and `<base href>` does not apply to root-relative URLs, so on a sub-path deployment (`<base href="/repository/">`) the link pointed at `/items/<uuid>` and 404'd. Resolve the route through `Location.prepareExternalUrl()` - the same transform `RouterLink` applies to its own `href` - which is a no-op when the base href is `/`, so vanilla deployments are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Review feedback: cover the vanilla base href and tighten the spy assertions - getItemPage now declares the contract it always had: the template calls it while the latest version is still loading, so item and the returned url may be undefined (Copilot). - Reset the shared Location spy before acting, so the "the plain router path is what gets handed to Location" assertion cannot be satisfied by the call the template already made during initial rendering, and assert the hasValue guard really short-circuits (Copilot). - Add a describe that drops the Location stub and exercises the real PathLocationStrategy against APP_BASE_HREF '/', '/repository/' and '/repository' (the form express passes on the server), plus an entity-typed item. Without it the "no-op when the base href is /" claim - the case every vanilla install runs - lived only in a code comment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Shorten the item page url comments Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4d72c11 commit 0e9772a

2 files changed

Lines changed: 109 additions & 6 deletions

File tree

src/app/item-page/versions/notice/item-versions-notice.component.spec.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
APP_BASE_HREF,
3+
Location,
4+
} from '@angular/common';
15
import { NO_ERRORS_SCHEMA } from '@angular/core';
26
import {
37
ComponentFixture,
@@ -7,7 +11,10 @@ import {
711
import { By } from '@angular/platform-browser';
812
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
913
import { RouterTestingModule } from '@angular/router/testing';
10-
import { TranslateModule } from '@ngx-translate/core';
14+
import {
15+
TranslateModule,
16+
TranslateService,
17+
} from '@ngx-translate/core';
1118
import { of } from 'rxjs';
1219
import { take } from 'rxjs/operators';
1320

@@ -61,6 +68,7 @@ describe('ItemVersionsNoticeComponent', () => {
6168
const versionHistoryServiceSpy = jasmine.createSpyObj('versionHistoryService',
6269
['getVersions', 'getLatestVersionFromHistory$', 'isLatest$' ],
6370
);
71+
const locationStub = jasmine.createSpyObj('location', ['prepareExternalUrl']);
6472

6573
beforeEach(waitForAsync(() => {
6674

@@ -73,6 +81,7 @@ describe('ItemVersionsNoticeComponent', () => {
7381
],
7482
providers: [
7583
{ provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy },
84+
{ provide: Location, useValue: locationStub },
7685
],
7786
schemas: [NO_ERRORS_SCHEMA],
7887
}).compileComponents();
@@ -84,6 +93,8 @@ describe('ItemVersionsNoticeComponent', () => {
8493
versionHistoryServiceSpy.getVersions.and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList(versions)));
8594
versionHistoryServiceSpy.getLatestVersionFromHistory$.and.returnValue(of(latestVersion));
8695
versionHistoryServiceSpy.isLatest$.and.callFake(isLatestFcn);
96+
// Simulate a UI deployed under a sub-path namespace, i.e. <base href="/repository/">
97+
locationStub.prepareExternalUrl.and.callFake((url: string) => `/repository${url}`);
8798
}));
8899

89100
describe('when the item is the latest version', () => {
@@ -121,6 +132,87 @@ describe('ItemVersionsNoticeComponent', () => {
121132
});
122133
});
123134

135+
describe('getItemPage', () => {
136+
beforeEach(() => {
137+
initComponentWithItem(firstItem);
138+
});
139+
140+
it('should resolve the latest version item page url against the base href', () => {
141+
locationStub.prepareExternalUrl.calls.reset();
142+
143+
expect(component.getItemPage(latestItem)).toEqual('/repository/items/latest_item_id');
144+
// The plain router path must be what is handed to Location, otherwise the prefix would be applied twice
145+
expect(locationStub.prepareExternalUrl).toHaveBeenCalledOnceWith('/items/latest_item_id');
146+
});
147+
148+
it('should not resolve a url when no item is provided', () => {
149+
locationStub.prepareExternalUrl.calls.reset();
150+
151+
expect(component.getItemPage(undefined)).toBeUndefined();
152+
expect(locationStub.prepareExternalUrl).not.toHaveBeenCalled();
153+
});
154+
155+
it('should render the notice anchor with the base href applied', () => {
156+
const translate = TestBed.inject(TranslateService);
157+
translate.setTranslation('en', {
158+
'item.version.notice': 'The latest version can be found <a href=\'{{destination}}\'>here</a>.',
159+
}, true);
160+
translate.use('en');
161+
fixture.detectChanges();
162+
163+
const anchor = fixture.debugElement.query(By.css('ds-alert a'));
164+
expect(anchor).not.toBeNull();
165+
expect(anchor.nativeElement.getAttribute('href')).toEqual('/repository/items/latest_item_id');
166+
});
167+
});
168+
169+
describe('getItemPage with the real Location', () => {
170+
// the real PathLocationStrategy, so the "no-op for NAMESPACE=/" claim is actually covered
171+
[
172+
{ baseHref: '/', expected: '/items/latest_item_id' },
173+
{ baseHref: '/repository/', expected: '/repository/items/latest_item_id' },
174+
// the form express hands to the SSR platform injector (req.baseUrl, no trailing slash)
175+
{ baseHref: '/repository', expected: '/repository/items/latest_item_id' },
176+
].forEach(({ baseHref, expected }) => {
177+
it(`should resolve the item page url against base href '${baseHref}'`, () => {
178+
expect(createComponentWithBaseHref(baseHref).getItemPage(latestItem)).toEqual(expected);
179+
});
180+
});
181+
182+
it('should keep the entity route shape and only add the prefix', () => {
183+
const entityItem = Object.assign(new Item(), {
184+
id: 'entity_item_id',
185+
uuid: 'entity_item_id',
186+
metadata: { 'dspace.entity.type': [{ value: 'Publication' }] },
187+
});
188+
189+
expect(createComponentWithBaseHref('/repository/').getItemPage(entityItem))
190+
.toEqual('/repository/entities/publication/entity_item_id');
191+
});
192+
193+
function createComponentWithBaseHref(baseHref: string): ItemVersionsNoticeComponent {
194+
TestBed.resetTestingModule();
195+
TestBed.configureTestingModule({
196+
// no RouterTestingModule on purpose: its SpyLocation/MockLocationStrategy ignore APP_BASE_HREF
197+
imports: [
198+
TranslateModule.forRoot(),
199+
ItemVersionsNoticeComponent,
200+
NoopAnimationsModule,
201+
],
202+
providers: [
203+
{ provide: VersionHistoryDataService, useValue: versionHistoryServiceSpy },
204+
{ provide: APP_BASE_HREF, useValue: baseHref },
205+
],
206+
schemas: [NO_ERRORS_SCHEMA],
207+
});
208+
209+
const realLocationFixture = TestBed.createComponent(ItemVersionsNoticeComponent);
210+
realLocationFixture.componentInstance.item = firstItem;
211+
realLocationFixture.detectChanges();
212+
return realLocationFixture.componentInstance;
213+
}
214+
});
215+
124216
function initComponentWithItem(item: Item) {
125217
fixture = TestBed.createComponent(ItemVersionsNoticeComponent);
126218
component = fixture.componentInstance;

src/app/item-page/versions/notice/item-versions-notice.component.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { AsyncPipe } from '@angular/common';
1+
import {
2+
AsyncPipe,
3+
Location,
4+
} from '@angular/common';
25
import {
36
Component,
47
Input,
@@ -84,7 +87,10 @@ export class ItemVersionsNoticeComponent implements OnInit {
8487
*/
8588
public AlertTypeEnum = AlertType;
8689

87-
constructor(private versionHistoryService: VersionHistoryDataService) {
90+
constructor(
91+
private versionHistoryService: VersionHistoryDataService,
92+
private location: Location,
93+
) {
8894
}
8995

9096
/**
@@ -128,12 +134,17 @@ export class ItemVersionsNoticeComponent implements OnInit {
128134
}
129135

130136
/**
131-
* Get the item page url
137+
* Get the item page url, resolved against the base href. The url lands in the raw `<a href>` of the
138+
* `item.version.notice` translation, so the browser resolves it and not the router - a plain
139+
* `/items/<uuid>` would ignore `<base href="/repository/">`. No-op when the base href is `/`.
140+
*
141+
* Undefined while the latest version is still loading.
142+
*
132143
* @param item The item for which the url is requested
133144
*/
134-
getItemPage(item: Item): string {
145+
getItemPage(item: Item | undefined): string | undefined {
135146
if (hasValue(item)) {
136-
return getItemPageRoute(item);
147+
return this.location.prepareExternalUrl(getItemPageRoute(item));
137148
}
138149
}
139150
}

0 commit comments

Comments
 (0)