Skip to content

Commit 3e3df1a

Browse files
authored
Merge pull request DSpace#4814 from 4Science/task/main/DURACOM-413
[DSpace-CRIS] Creation of a custom URL for Items (Frontend)
2 parents 346bdb8 + 46fdb55 commit 3e3df1a

46 files changed

Lines changed: 1388 additions & 88 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/app/audit-page/object-audit-overview/object-audit-logs.component.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ describe('ObjectAuditLogsComponent', () => {
6060
{ findOwningCollectionFor: createSuccessfulRemoteDataObject$(createPaginatedList([{ id : 'collectionId' }])) },
6161
);
6262
activatedRoute = new MockActivatedRoute({ objectId: mockItemId });
63-
activatedRoute.paramMap = of({
64-
get: () => mockItemId,
65-
});
63+
activatedRoute.data = of({ dso: {
64+
payload: mockItem,
65+
} });
6666
locationStub = jasmine.createSpyObj('location', {
6767
back: jasmine.createSpy('back'),
6868
});

src/app/audit-page/object-audit-overview/object-audit-logs.component.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@angular/core';
99
import {
1010
ActivatedRoute,
11-
ParamMap,
11+
Data,
1212
Router,
1313
RouterLink,
1414
} from '@angular/router';
@@ -111,9 +111,8 @@ export class ObjectAuditLogsComponent implements OnInit {
111111
) {}
112112

113113
ngOnInit(): void {
114-
this.objectId$ = this.route.paramMap.pipe(
115-
map((paramMap: ParamMap) => paramMap.get('id')),
116-
switchMap((id: string) => this.dSpaceObjectDataService.findById(id, true, true)),
114+
this.objectId$ = this.route.data.pipe(
115+
switchMap((data: Data) => this.dSpaceObjectDataService.findById(data.dso.payload.id, true, true)),
117116
getFirstSucceededRemoteDataPayload(),
118117
tap((object) => {
119118
this.objectRoute = getDSORoute(object);

src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Observable } from 'rxjs';
77
import { map } from 'rxjs/operators';
88

99
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
10+
import { ItemDataService } from '../data/item-data.service';
1011
import { getDSORoute } from '../router/utils/dso-route.utils';
1112
import { DSpaceObject } from '../shared/dspace-object.model';
1213
import { FollowLinkConfig } from '../shared/follow-link-config.model';
@@ -55,7 +56,9 @@ export const DSOBreadcrumbResolverByUuid: (route: ActivatedRouteSnapshot, state:
5556
dataService: IdentifiableDataService<DSpaceObject>,
5657
...linksToFollow: FollowLinkConfig<DSpaceObject>[]
5758
): Observable<BreadcrumbConfig<DSpaceObject>> => {
58-
return dataService.findById(uuid, true, false, ...linksToFollow).pipe(
59+
const isItemDataService = dataService instanceof ItemDataService;
60+
const findMethod = isItemDataService ? dataService.findByIdOrCustomUrl.bind(dataService) : dataService.findById.bind(dataService);
61+
return findMethod(uuid, true, false, ...linksToFollow).pipe(
5962
getFirstCompletedRemoteData(),
6063
getRemoteDataPayload(),
6164
map((object: DSpaceObject) => {

src/app/core/data/item-data.service.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { HttpClient } from '@angular/common/http';
2+
import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils';
23
import { Store } from '@ngrx/store';
34
import {
45
cold,
@@ -13,6 +14,7 @@ import { RestResponse } from '../cache/response.models';
1314
import { CoreState } from '../core-state.model';
1415
import { NotificationsService } from '../notification-system/notifications.service';
1516
import { ExternalSourceEntry } from '../shared/external-source-entry.model';
17+
import { Item } from '../shared/item.model';
1618
import { HALEndpointServiceStub } from '../testing/hal-endpoint-service.stub';
1719
import { getMockRemoteDataBuildService } from '../testing/remote-data-build.service.mock';
1820
import { getMockRequestService } from '../testing/request.service.mock';
@@ -209,4 +211,93 @@ describe('ItemDataService', () => {
209211
});
210212
});
211213

214+
describe('findByCustomUrl', () => {
215+
let itemDataService: ItemDataService;
216+
let searchData: any;
217+
let findByHrefSpy: jasmine.Spy;
218+
let getSearchByHrefSpy: jasmine.Spy;
219+
const id = 'custom-id';
220+
const fakeHrefObs = of('https://rest.api/core/items/search/findByCustomURL?q=custom-id');
221+
const linksToFollow = [];
222+
const projections = ['full', 'detailed'];
223+
224+
beforeEach(() => {
225+
searchData = jasmine.createSpyObj('searchData', ['getSearchByHref']);
226+
getSearchByHrefSpy = searchData.getSearchByHref.and.returnValue(fakeHrefObs);
227+
itemDataService = new ItemDataService(
228+
requestService,
229+
rdbService,
230+
objectCache,
231+
halEndpointService,
232+
notificationsService,
233+
comparator,
234+
browseService,
235+
bundleService,
236+
);
237+
238+
(itemDataService as any).searchData = searchData;
239+
findByHrefSpy = spyOn(itemDataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(new Item()));
240+
});
241+
242+
it('should call searchData.getSearchByHref with correct parameters', () => {
243+
itemDataService.findByCustomUrl(id, true, true, linksToFollow, projections).subscribe();
244+
245+
expect(getSearchByHrefSpy).toHaveBeenCalledWith(
246+
'findByCustomURL',
247+
jasmine.objectContaining({
248+
searchParams: jasmine.arrayContaining([
249+
jasmine.objectContaining({ fieldName: 'q', fieldValue: id }),
250+
jasmine.objectContaining({ fieldName: 'projection', fieldValue: 'full' }),
251+
jasmine.objectContaining({ fieldName: 'projection', fieldValue: 'detailed' }),
252+
]),
253+
}),
254+
...linksToFollow,
255+
);
256+
});
257+
258+
it('should call findByHref with the href observable returned from getSearchByHref', () => {
259+
itemDataService.findByCustomUrl(id, true, false, linksToFollow, projections).subscribe();
260+
261+
expect(findByHrefSpy).toHaveBeenCalledWith(fakeHrefObs, true, false, ...linksToFollow);
262+
});
263+
});
264+
265+
describe('findById', () => {
266+
let itemDataService: ItemDataService;
267+
268+
beforeEach(() => {
269+
itemDataService = new ItemDataService(
270+
requestService,
271+
rdbService,
272+
objectCache,
273+
halEndpointService,
274+
notificationsService,
275+
comparator,
276+
browseService,
277+
bundleService,
278+
);
279+
spyOn(itemDataService, 'findByCustomUrl').and.returnValue(createSuccessfulRemoteDataObject$(new Item()));
280+
spyOn(itemDataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(new Item()));
281+
spyOn(itemDataService as any, 'getIDHrefObs').and.returnValue(of('uuid-href'));
282+
});
283+
284+
it('should call findByHref when given a valid UUID', () => {
285+
const validUuid = '4af28e99-6a9c-4036-a199-e1b587046d39';
286+
itemDataService.findById(validUuid).subscribe();
287+
288+
expect((itemDataService as any).getIDHrefObs).toHaveBeenCalledWith(encodeURIComponent(validUuid));
289+
expect(itemDataService.findByHref).toHaveBeenCalled();
290+
expect(itemDataService.findByCustomUrl).not.toHaveBeenCalled();
291+
});
292+
293+
it('should call findByCustomUrl when given a non-UUID id', () => {
294+
const nonUuid = 'custom-url';
295+
itemDataService.findByIdOrCustomUrl(nonUuid).subscribe();
296+
297+
expect(itemDataService.findByCustomUrl).toHaveBeenCalledWith(nonUuid, true, true, []);
298+
expect(itemDataService.findByHref).not.toHaveBeenCalled();
299+
});
300+
});
301+
302+
212303
});

src/app/core/data/item-data.service.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
switchMap,
2525
take,
2626
} from 'rxjs/operators';
27+
import { validate as uuidValidate } from 'uuid';
2728

2829
import { BrowseService } from '../browse/browse.service';
2930
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
@@ -34,6 +35,7 @@ import { NotificationsService } from '../notification-system/notifications.servi
3435
import { Bundle } from '../shared/bundle.model';
3536
import { Collection } from '../shared/collection.model';
3637
import { ExternalSourceEntry } from '../shared/external-source-entry.model';
38+
import { FollowLinkConfig } from '../shared/follow-link-config.model';
3739
import { GenericConstructor } from '../shared/generic-constructor';
3840
import { HALEndpointService } from '../shared/hal-endpoint.service';
3941
import { Item } from '../shared/item.model';
@@ -58,6 +60,7 @@ import {
5860
PatchData,
5961
PatchDataImpl,
6062
} from './base/patch-data';
63+
import { SearchDataImpl } from './base/search-data';
6164
import { BundleDataService } from './bundle-data.service';
6265
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
6366
import { FindListOptions } from './find-list-options.model';
@@ -83,6 +86,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
8386
private createData: CreateData<Item>;
8487
private patchData: PatchData<Item>;
8588
private deleteData: DeleteData<Item>;
89+
private searchData: SearchDataImpl<Item>;
8690

8791
protected constructor(
8892
protected linkPath,
@@ -101,6 +105,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
101105
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
102106
this.patchData = new PatchDataImpl<Item>(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint);
103107
this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
108+
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
104109
}
105110

106111
/**
@@ -425,8 +430,95 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
425430
return this.createData.create(object, ...params);
426431
}
427432

433+
/**
434+
* Returns an observable of {@link RemoteData} of an object, based on its custom URL, with a list of
435+
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
436+
* @param id custom URL of object we want to retrieve
437+
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
438+
* no valid cached version. Defaults to true
439+
* @param reRequestOnStale Whether or not the request should automatically be re-
440+
* requested after the response becomes stale
441+
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
442+
* {@link HALLink}s should be automatically resolved
443+
* @param projections List of {@link projections} used to pass as parameters
444+
*/
445+
public findByCustomUrl(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, linksToFollow: FollowLinkConfig<Item>[], projections: string[] = []): Observable<RemoteData<Item>> {
446+
const searchHref = 'findByCustomURL';
447+
448+
const options = Object.assign({}, {
449+
searchParams: [
450+
new RequestParam('q', id),
451+
],
452+
});
453+
454+
projections.forEach((projection) => {
455+
options.searchParams.push(new RequestParam('projection', projection));
456+
});
457+
458+
const hrefObs = this.searchData.getSearchByHref(searchHref, options, ...linksToFollow);
459+
460+
return this.findByHref(hrefObs, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
461+
}
462+
463+
464+
/**
465+
* Invalidate cache of request findByCustomURL
466+
*
467+
* @param customUrl
468+
* @param projections
469+
*/
470+
public invalidateFindByCustomUrlCache(customUrl: string, projections: string[] = []): void {
471+
const options: any = {
472+
searchParams: [new RequestParam('q', customUrl)],
473+
};
474+
475+
projections.forEach((p) => options.searchParams.push(new RequestParam('projection', p)));
476+
477+
this.searchData.getSearchByHref('findByCustomURL', options).pipe(take(1)).subscribe((href: string) => {
478+
this.requestService.setStaleByHrefSubstring(href);
479+
this.objectCache.remove(href);
480+
});
481+
}
482+
483+
/**
484+
* Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of
485+
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
486+
* @param id ID of object we want to retrieve
487+
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
488+
* no valid cached version. Defaults to true
489+
* @param reRequestOnStale Whether or not the request should automatically be re-
490+
* requested after the response becomes stale
491+
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
492+
* {@link HALLink}s should be automatically resolved
493+
*/
494+
public findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Item>[]): Observable<RemoteData<Item>> {
495+
const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow);
496+
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
497+
}
498+
499+
/**
500+
* Returns an observable of {@link RemoteData} of an object, based on its ID or custom URL if the parameter is not a valid id/uuid, with a list of
501+
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
502+
* @param id ID of object we want to retrieve
503+
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
504+
* no valid cached version. Defaults to true
505+
* @param reRequestOnStale Whether or not the request should automatically be re-
506+
* requested after the response becomes stale
507+
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
508+
* {@link HALLink}s should be automatically resolved
509+
*/
510+
public findByIdOrCustomUrl(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Item>[]): Observable<RemoteData<Item>> {
511+
if (uuidValidate(id)) {
512+
return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
513+
} else {
514+
return this.findByCustomUrl(id, useCachedVersionIfAvailable, reRequestOnStale, linksToFollow);
515+
}
516+
}
517+
428518
}
429519

520+
521+
430522
/**
431523
* A service for CRUD operations on Items
432524
*/

src/app/core/data/version-data.service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Injectable } from '@angular/core';
22
import { RestRequestMethod } from '@dspace/config/rest-request-method';
3+
import { Item } from '@dspace/core/shared/item.model';
34
import { isNotEmpty } from '@dspace/shared/utils/empty.util';
45
import { Operation } from 'fast-json-patch';
56
import {
@@ -106,4 +107,14 @@ export class VersionDataService extends IdentifiableDataService<Version> impleme
106107
return this.patchData.createPatchFromCache(object);
107108
}
108109

110+
111+
/**
112+
* Invalidates the cache of the version link for this item.
113+
*
114+
* @param item
115+
*/
116+
invalidateVersionHrefCache(item: Item): void {
117+
this.requestService.setStaleByHrefSubstring(item._links.version.href);
118+
}
119+
109120
}

src/app/core/provide-core.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
makeEnvironmentProviders,
55
} from '@angular/core';
66
import { APP_CONFIG } from '@dspace/config/app-config.interface';
7+
import { SubmissionCustomUrl } from '@dspace/core/submission/models/submission-custom-url.model';
78

89
import { Audit } from './audit/model/audit.model';
910
import { AuthStatus } from './auth/models/auth-status.model';
@@ -230,4 +231,5 @@ export const models =
230231
StatisticsEndpoint,
231232
CorrectionType,
232233
SupervisionOrder,
234+
SubmissionCustomUrl,
233235
];

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,16 @@ export function getCommunityPageRoute(communityId: string) {
3131
*/
3232
export function getItemPageRoute(item: Item) {
3333
const type = item.firstMetadataValue('dspace.entity.type');
34-
return getEntityPageRoute(type, item.uuid);
34+
let url = item.uuid;
35+
36+
if (isNotEmpty(item.metadata) && item.hasMetadata('dspace.customurl')) {
37+
url = item.firstMetadataValue('dspace.customurl');
38+
}
39+
40+
return getEntityPageRoute(type, url);
3541
}
3642

43+
3744
export function getEntityPageRoute(entityType: string, itemId: string) {
3845
if (isNotEmpty(entityType)) {
3946
return new URLCombiner(`/${ENTITY_MODULE_PATH}`, encodeURIComponent(entityType.toLowerCase()), itemId).toString();

src/app/core/shared/authorized.operators.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,28 @@ export const redirectOn4xx = <T>(router: Router, authService: AuthService) =>
5656
}),
5757
map(([rd]: [RemoteData<T>, boolean]) => rd),
5858
);
59+
60+
61+
/**
62+
* Redirect to 404 if the requested content is not found (204 No Content)
63+
*
64+
* @param router
65+
* @param authService
66+
*/
67+
export const redirectOn204 = <T>(router: Router, authService: AuthService) =>
68+
(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
69+
source.pipe(
70+
withLatestFrom(authService.isAuthenticated()),
71+
filter(([rd, isAuthenticated]: [RemoteData<T>, boolean]) => {
72+
if (rd.hasNoContent) {
73+
router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true });
74+
return false;
75+
}
76+
return true;
77+
}),
78+
map(([rd]: [RemoteData<T>, boolean]) => rd),
79+
);
80+
5981
/**
6082
* Operator that returns a UrlTree to a forbidden page or the login page when the boolean received is false
6183
* @param router The router used to navigate to a forbidden page

0 commit comments

Comments
 (0)