Skip to content

Commit f369ead

Browse files
[DURACOM-470] implement detailed error handling for custom url conflicts
1 parent 993c89a commit f369ead

9 files changed

Lines changed: 347 additions & 5 deletions

File tree

src/app/core/router/utils/dso-route.utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ export function getCommunityPageRoute(communityId: string) {
2929
* Depending on the item's entity type, the route will either start with /items or /entities
3030
* @param item The item to retrieve the route for
3131
*/
32-
export function getItemPageRoute(item: Item) {
32+
export function getItemPageRoute(item: Item, ignoreCustomUrl = false) {
3333
const type = item.firstMetadataValue('dspace.entity.type');
3434
let url = item.uuid;
3535

36-
if (isNotEmpty(item.metadata) && item.hasMetadata('dspace.customurl')) {
36+
if (isNotEmpty(item.metadata) && item.hasMetadata('dspace.customurl') && !ignoreCustomUrl) {
3737
url = item.firstMetadataValue('dspace.customurl');
3838
}
3939

src/app/item-page/item-page-routing-paths.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import {
66
import { Item } from '@dspace/core/shared/item.model';
77
import { URLCombiner } from '@dspace/core/url-combiner/url-combiner';
88

9-
export function getItemEditRoute(item: Item) {
10-
return new URLCombiner(getItemPageRoute(item), ITEM_EDIT_PATH).toString();
9+
export function getItemEditRoute(item: Item, ignoreCustomUrl = false) {
10+
return new URLCombiner(getItemPageRoute(item, ignoreCustomUrl), ITEM_EDIT_PATH).toString();
1111
}
1212

1313
export function getItemEditVersionhistoryRoute(item: Item) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<ds-alert [type]="AlertTypeEnum.Error">
2+
<h1 class="h4">{{ 'error.custom-url-conflict.title' | translate }}</h1>
3+
<p>{{ 'error.custom-url-conflict.description' | translate: { customUrl: customUrl } }}</p>
4+
<p>{{ 'error.custom-url-conflict.admin-action' | translate }}</p>
5+
@let items = (conflictingItems$ | async);
6+
@if (items) {
7+
@if (items.length > 0) {
8+
<ul class="mb-2">
9+
@for (item of items; track item.uuid) {
10+
<li>
11+
<a class="btn btn-link" [routerLink]="item.editLink">
12+
{{ 'error.custom-url-conflict.edit-link' | translate: { name: item.name, uuid: item.uuid } }}
13+
</a>
14+
</li>
15+
}
16+
</ul>
17+
} @else {
18+
<p class="text-muted small">{{ 'error.custom-url-conflict.no-items-found' | translate }}</p>
19+
}
20+
} @else {
21+
<ds-loading [showMessage]="false"></ds-loading>
22+
}
23+
</ds-alert>
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { Component } from '@angular/core';
2+
import {
3+
ComponentFixture,
4+
TestBed,
5+
waitForAsync,
6+
} from '@angular/core/testing';
7+
import { By } from '@angular/platform-browser';
8+
import { provideNoopAnimations } from '@angular/platform-browser/animations';
9+
import { provideRouter } from '@angular/router';
10+
import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service';
11+
import { Item } from '@dspace/core/shared/item.model';
12+
import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model';
13+
import { SearchResult } from '@dspace/core/shared/search/models/search-result.model';
14+
import { DSONameServiceMock } from '@dspace/core/testing/dso-name.service.mock';
15+
import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock';
16+
import { URLCombiner } from '@dspace/core/url-combiner/url-combiner';
17+
import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils';
18+
import {
19+
TranslateLoader,
20+
TranslateModule,
21+
} from '@ngx-translate/core';
22+
import { throwError } from 'rxjs';
23+
24+
import { AlertComponent } from '../../../shared/alert/alert.component';
25+
import { SearchService } from '../../../shared/search/search.service';
26+
import { getItemEditRoute } from '../../item-page-routing-paths';
27+
import { CustomUrlConflictErrorComponent } from './custom-url-conflict-error.component';
28+
29+
@Component({
30+
selector: 'ds-alert',
31+
template: '<ng-content></ng-content>',
32+
})
33+
class AlertComponentStub {}
34+
35+
describe('CustomUrlConflictErrorComponent', () => {
36+
let component: CustomUrlConflictErrorComponent;
37+
let fixture: ComponentFixture<CustomUrlConflictErrorComponent>;
38+
let searchServiceSpy: jasmine.SpyObj<SearchService>;
39+
40+
const customUrl = 'my-conflicting-url';
41+
42+
const mockItem1 = Object.assign(new Item(), {
43+
uuid: 'item-uuid-1',
44+
name: 'Item One',
45+
metadata: {},
46+
_links: { self: { href: 'item-1-selflink' } },
47+
});
48+
49+
const mockItem2 = Object.assign(new Item(), {
50+
uuid: 'item-uuid-2',
51+
name: 'Item Two',
52+
metadata: {},
53+
_links: { self: { href: 'item-2-selflink' } },
54+
});
55+
56+
const buildSearchResult = (item: Item): SearchResult<Item> =>
57+
Object.assign(new SearchResult<Item>(), { indexableObject: item });
58+
59+
const buildSearchObjects = (items: Item[]): SearchObjects<Item> =>
60+
Object.assign(new SearchObjects<Item>(), { page: items.map(buildSearchResult) });
61+
62+
const expectedEditLink = (item: Item) =>
63+
new URLCombiner(getItemEditRoute(item, true), 'metadata').toString();
64+
65+
const createComponent = () => {
66+
fixture = TestBed.createComponent(CustomUrlConflictErrorComponent);
67+
component = fixture.componentInstance;
68+
component.customUrl = customUrl;
69+
fixture.detectChanges();
70+
};
71+
72+
beforeEach(waitForAsync(() => {
73+
searchServiceSpy = jasmine.createSpyObj('SearchService', ['search']);
74+
// Default: return empty results — overridden per suite below
75+
searchServiceSpy.search.and.returnValue(
76+
createSuccessfulRemoteDataObject$(buildSearchObjects([])),
77+
);
78+
79+
TestBed.configureTestingModule({
80+
imports: [
81+
CustomUrlConflictErrorComponent,
82+
TranslateModule.forRoot({
83+
loader: { provide: TranslateLoader, useClass: TranslateLoaderMock },
84+
}),
85+
],
86+
providers: [
87+
provideNoopAnimations(),
88+
provideRouter([]),
89+
{ provide: SearchService, useValue: searchServiceSpy },
90+
{ provide: DSONameService, useValue: new DSONameServiceMock() },
91+
],
92+
})
93+
.overrideComponent(CustomUrlConflictErrorComponent, {
94+
remove: { imports: [AlertComponent] },
95+
add: { imports: [AlertComponentStub] },
96+
})
97+
.compileComponents();
98+
}));
99+
100+
describe('when search returns matching items', () => {
101+
beforeEach(() => {
102+
searchServiceSpy.search.and.returnValue(
103+
createSuccessfulRemoteDataObject$(buildSearchObjects([mockItem1, mockItem2])),
104+
);
105+
createComponent();
106+
});
107+
108+
it('should create', () => {
109+
expect(component).toBeTruthy();
110+
});
111+
112+
it('should call SearchService.search with a query containing the custom URL', () => {
113+
expect(searchServiceSpy.search).toHaveBeenCalledOnceWith(
114+
jasmine.objectContaining({ query: `dspace.customurl:${customUrl}` }),
115+
);
116+
});
117+
118+
it('should emit one entry per conflicting item', (done) => {
119+
component.conflictingItems$.subscribe((items) => {
120+
expect(items.length).toBe(2);
121+
done();
122+
});
123+
});
124+
125+
it('should build the correct metadata edit link for each item', (done) => {
126+
component.conflictingItems$.subscribe((items) => {
127+
expect(items[0].editLink).toBe(expectedEditLink(mockItem1));
128+
expect(items[1].editLink).toBe(expectedEditLink(mockItem2));
129+
done();
130+
});
131+
});
132+
133+
it('should use DSONameService to resolve item names', (done) => {
134+
component.conflictingItems$.subscribe((items) => {
135+
expect(items[0].name).toBe(mockItem1.name);
136+
expect(items[1].name).toBe(mockItem2.name);
137+
done();
138+
});
139+
});
140+
141+
it('should render one edit link per item in the template', () => {
142+
fixture.detectChanges();
143+
const links = fixture.debugElement.queryAll(By.css('ul li a'));
144+
expect(links.length).toBe(2);
145+
});
146+
});
147+
148+
describe('when search returns no items', () => {
149+
beforeEach(() => {
150+
searchServiceSpy.search.and.returnValue(
151+
createSuccessfulRemoteDataObject$(buildSearchObjects([])),
152+
);
153+
createComponent();
154+
});
155+
156+
it('should emit an empty array', (done) => {
157+
component.conflictingItems$.subscribe((items) => {
158+
expect(items.length).toBe(0);
159+
done();
160+
});
161+
});
162+
163+
it('should show the no-items-found message', () => {
164+
fixture.detectChanges();
165+
const compiled = fixture.nativeElement as HTMLElement;
166+
expect(compiled.textContent).toContain('error.custom-url-conflict.no-items-found');
167+
});
168+
});
169+
170+
describe('when search throws an error', () => {
171+
beforeEach(() => {
172+
searchServiceSpy.search.and.returnValue(throwError(() => new Error('Network error')));
173+
createComponent();
174+
});
175+
176+
it('should emit an empty array on error', (done) => {
177+
component.conflictingItems$.subscribe((items) => {
178+
expect(items).toEqual([]);
179+
done();
180+
});
181+
});
182+
});
183+
});
184+
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { AsyncPipe } from '@angular/common';
2+
import {
3+
Component,
4+
Input,
5+
OnInit,
6+
} from '@angular/core';
7+
import { RouterLink } from '@angular/router';
8+
import { DSONameService } from '@dspace/core/breadcrumbs/dso-name.service';
9+
import { RemoteData } from '@dspace/core/data/remote-data';
10+
import { DSpaceObjectType } from '@dspace/core/shared/dspace-object-type.model';
11+
import { Item } from '@dspace/core/shared/item.model';
12+
import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators';
13+
import { PaginatedSearchOptions } from '@dspace/core/shared/search/models/paginated-search-options.model';
14+
import { SearchObjects } from '@dspace/core/shared/search/models/search-objects.model';
15+
import { SearchResult } from '@dspace/core/shared/search/models/search-result.model';
16+
import { URLCombiner } from '@dspace/core/url-combiner/url-combiner';
17+
import { TranslateModule } from '@ngx-translate/core';
18+
import {
19+
Observable,
20+
of,
21+
} from 'rxjs';
22+
import {
23+
catchError,
24+
map,
25+
} from 'rxjs/operators';
26+
27+
import { AlertComponent } from '../../../shared/alert/alert.component';
28+
import { AlertType } from '../../../shared/alert/alert-type';
29+
import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component';
30+
import { SearchService } from '../../../shared/search/search.service';
31+
import { getItemEditRoute } from '../../item-page-routing-paths';
32+
33+
/**
34+
* Component shown on the item page when a custom URL lookup returns a 500 error,
35+
* which indicates that multiple items share the same `dspace.customurl` value.
36+
*
37+
* Uses `SearchService.search()` with a `dspace.customurl` filter to find all items
38+
* with the conflicting value, then renders a direct edit link (`/edit-items/<uuid>:FULL`)
39+
* for each result so administrators can immediately navigate to and fix the affected items.
40+
*
41+
* Usage: placed inside the item page template when `itemRD.statusCode === 500`
42+
* and the resolved route param is not a UUID (i.e. it was a custom URL lookup).
43+
*/
44+
@Component({
45+
selector: 'ds-custom-url-conflict-error',
46+
templateUrl: './custom-url-conflict-error.component.html',
47+
imports: [
48+
AlertComponent,
49+
AsyncPipe,
50+
RouterLink,
51+
ThemedLoadingComponent,
52+
TranslateModule,
53+
],
54+
})
55+
export class CustomUrlConflictErrorComponent implements OnInit {
56+
57+
/** The custom URL value that caused the conflict (the route param). */
58+
@Input() customUrl: string;
59+
60+
/** AlertType enum reference for the template */
61+
readonly AlertTypeEnum = AlertType;
62+
63+
/**
64+
* Observable emitting the list of items that share the conflicting custom URL.
65+
* Each entry contains the item's uuid, display name, and a direct edit link
66+
* pointing to `/edit-items/<uuid>:FULL`.
67+
*/
68+
conflictingItems$: Observable<{ uuid: string; name: string; editLink: string }[]>;
69+
70+
constructor(
71+
private searchService: SearchService,
72+
private dsoNameService: DSONameService,
73+
) {}
74+
75+
ngOnInit(): void {
76+
const searchOptions = new PaginatedSearchOptions({
77+
dsoTypes: [DSpaceObjectType.ITEM],
78+
query: `dspace.customurl:${this.customUrl}`,
79+
});
80+
81+
this.conflictingItems$ = this.searchService.search<Item>(searchOptions).pipe(
82+
getFirstCompletedRemoteData(),
83+
map((rd: RemoteData<SearchObjects<Item>>) => {
84+
if (rd.hasSucceeded && rd.payload?.page?.length > 0) {
85+
return rd.payload.page.map((result: SearchResult<Item>) => {
86+
const item = result.indexableObject;
87+
return {
88+
uuid: item.uuid,
89+
name: this.dsoNameService.getName(item),
90+
editLink: new URLCombiner(getItemEditRoute(item, true), 'metadata').toString(),
91+
};
92+
});
93+
}
94+
return [];
95+
}),
96+
catchError(() => of([])),
97+
);
98+
}
99+
}

src/app/item-page/simple/item-page.component.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
</div>
1919
}
2020
@if (itemRD?.hasFailed) {
21-
<ds-error message="{{'error.item' | translate}}"></ds-error>
21+
@if ((customUrlConflict$ | async); as conflictUrl) {
22+
<ds-custom-url-conflict-error [customUrl]="conflictUrl"></ds-custom-url-conflict-error>
23+
} @else {
24+
<ds-error message="{{'error.item' | translate}}"></ds-error>
25+
}
2226
}
2327
@if (itemRD?.isLoading) {
2428
<ds-loading message="{{'loading.item' | translate}}"></ds-loading>

0 commit comments

Comments
 (0)