Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7acfabe
[DURACOM-413] port custom url functionality
FrancescoMolinaro Nov 12, 2025
7f9b625
[DURACOM-413] additional tests
FrancescoMolinaro Nov 13, 2025
88d13c7
[DURACOM-413] update labels, fix sync script, adapt section handling
FrancescoMolinaro Nov 18, 2025
d59249b
[DURACOM-413] update error handling/validation + resync translations
FrancescoMolinaro Nov 19, 2025
5ead5a4
[DURACOM-413] fix lint
FrancescoMolinaro Dec 5, 2025
80c7858
[DURACOM-413] uodate component comment
FrancescoMolinaro Dec 18, 2025
ee08d7b
[DURACOM-413] add german labels, force uuid in url for administrative…
FrancescoMolinaro Jan 16, 2026
2dfdf7d
[DURACOM-413] restore changes to i18n files
FrancescoMolinaro Jan 29, 2026
24ed3ea
Revert "[DURACOM-413] restore changes to i18n files"
FrancescoMolinaro Jan 29, 2026
156a599
[DURACOM-413] rever i18n changes
FrancescoMolinaro Jan 29, 2026
71fd35c
[DURACOM-413] add new findByIdOrCustomUrl method to item-data.service…
FrancescoMolinaro Jan 29, 2026
49bd3c3
[DURACOM-413] adapt findByCustomUrl description
FrancescoMolinaro Jan 29, 2026
ea0abcf
[DURACOM-413] fix custom url issues with signposting, version and sub…
FrancescoMolinaro Feb 2, 2026
1c720dc
[DURACOM-413] add redirect on no-content response
FrancescoMolinaro Feb 2, 2026
703dccb
[DURACOM-413] add cache invalidation on new version
FrancescoMolinaro Feb 3, 2026
50fb7c9
[DURACOM-413] restore translation files, clear cache of findByCustumU…
FrancescoMolinaro Feb 4, 2026
46fdb55
[DURACOM-413] restore it translation files, fix de translation
FrancescoMolinaro Feb 4, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ describe('ObjectAuditLogsComponent', () => {
{ findOwningCollectionFor: createSuccessfulRemoteDataObject$(createPaginatedList([{ id : 'collectionId' }])) },
);
activatedRoute = new MockActivatedRoute({ objectId: mockItemId });
activatedRoute.paramMap = of({
get: () => mockItemId,
});
activatedRoute.data = of({ dso: {
payload: mockItem,
} });
locationStub = jasmine.createSpyObj('location', {
back: jasmine.createSpy('back'),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@angular/core';
import {
ActivatedRoute,
ParamMap,
Data,
Router,
RouterLink,
} from '@angular/router';
Expand Down Expand Up @@ -111,9 +111,8 @@ export class ObjectAuditLogsComponent implements OnInit {
) {}

ngOnInit(): void {
this.objectId$ = this.route.paramMap.pipe(
map((paramMap: ParamMap) => paramMap.get('id')),
switchMap((id: string) => this.dSpaceObjectDataService.findById(id, true, true)),
this.objectId$ = this.route.data.pipe(
switchMap((data: Data) => this.dSpaceObjectDataService.findById(data.dso.payload.id, true, true)),
getFirstSucceededRemoteDataPayload(),
tap((object) => {
this.objectRoute = getDSORoute(object);
Expand Down
5 changes: 4 additions & 1 deletion src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

import { IdentifiableDataService } from '../data/base/identifiable-data.service';
import { ItemDataService } from '../data/item-data.service';
import { getDSORoute } from '../router/utils/dso-route.utils';
import { DSpaceObject } from '../shared/dspace-object.model';
import { FollowLinkConfig } from '../shared/follow-link-config.model';
Expand Down Expand Up @@ -55,7 +56,9 @@ export const DSOBreadcrumbResolverByUuid: (route: ActivatedRouteSnapshot, state:
dataService: IdentifiableDataService<DSpaceObject>,
...linksToFollow: FollowLinkConfig<DSpaceObject>[]
): Observable<BreadcrumbConfig<DSpaceObject>> => {
return dataService.findById(uuid, true, false, ...linksToFollow).pipe(
const isItemDataService = dataService instanceof ItemDataService;
const findMethod = isItemDataService ? dataService.findByIdOrCustomUrl.bind(dataService) : dataService.findById.bind(dataService);
return findMethod(uuid, true, false, ...linksToFollow).pipe(
getFirstCompletedRemoteData(),
getRemoteDataPayload(),
map((object: DSpaceObject) => {
Expand Down
91 changes: 91 additions & 0 deletions src/app/core/data/item-data.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HttpClient } from '@angular/common/http';
import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils';
import { Store } from '@ngrx/store';
import {
cold,
Expand All @@ -13,6 +14,7 @@ import { RestResponse } from '../cache/response.models';
import { CoreState } from '../core-state.model';
import { NotificationsService } from '../notification-system/notifications.service';
import { ExternalSourceEntry } from '../shared/external-source-entry.model';
import { Item } from '../shared/item.model';
import { HALEndpointServiceStub } from '../testing/hal-endpoint-service.stub';
import { getMockRemoteDataBuildService } from '../testing/remote-data-build.service.mock';
import { getMockRequestService } from '../testing/request.service.mock';
Expand Down Expand Up @@ -209,4 +211,93 @@ describe('ItemDataService', () => {
});
});

describe('findByCustomUrl', () => {
let itemDataService: ItemDataService;
let searchData: any;
let findByHrefSpy: jasmine.Spy;
let getSearchByHrefSpy: jasmine.Spy;
const id = 'custom-id';
const fakeHrefObs = of('https://rest.api/core/items/search/findByCustomURL?q=custom-id');
const linksToFollow = [];
const projections = ['full', 'detailed'];

beforeEach(() => {
searchData = jasmine.createSpyObj('searchData', ['getSearchByHref']);
getSearchByHrefSpy = searchData.getSearchByHref.and.returnValue(fakeHrefObs);
itemDataService = new ItemDataService(
requestService,
rdbService,
objectCache,
halEndpointService,
notificationsService,
comparator,
browseService,
bundleService,
);

(itemDataService as any).searchData = searchData;
findByHrefSpy = spyOn(itemDataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(new Item()));
});

it('should call searchData.getSearchByHref with correct parameters', () => {
itemDataService.findByCustomUrl(id, true, true, linksToFollow, projections).subscribe();

expect(getSearchByHrefSpy).toHaveBeenCalledWith(
'findByCustomURL',
jasmine.objectContaining({
searchParams: jasmine.arrayContaining([
jasmine.objectContaining({ fieldName: 'q', fieldValue: id }),
jasmine.objectContaining({ fieldName: 'projection', fieldValue: 'full' }),
jasmine.objectContaining({ fieldName: 'projection', fieldValue: 'detailed' }),
]),
}),
...linksToFollow,
);
});

it('should call findByHref with the href observable returned from getSearchByHref', () => {
itemDataService.findByCustomUrl(id, true, false, linksToFollow, projections).subscribe();

expect(findByHrefSpy).toHaveBeenCalledWith(fakeHrefObs, true, false, ...linksToFollow);
});
});

describe('findById', () => {
let itemDataService: ItemDataService;

beforeEach(() => {
itemDataService = new ItemDataService(
requestService,
rdbService,
objectCache,
halEndpointService,
notificationsService,
comparator,
browseService,
bundleService,
);
spyOn(itemDataService, 'findByCustomUrl').and.returnValue(createSuccessfulRemoteDataObject$(new Item()));
spyOn(itemDataService, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(new Item()));
spyOn(itemDataService as any, 'getIDHrefObs').and.returnValue(of('uuid-href'));
});

it('should call findByHref when given a valid UUID', () => {
const validUuid = '4af28e99-6a9c-4036-a199-e1b587046d39';
itemDataService.findById(validUuid).subscribe();

expect((itemDataService as any).getIDHrefObs).toHaveBeenCalledWith(encodeURIComponent(validUuid));
expect(itemDataService.findByHref).toHaveBeenCalled();
expect(itemDataService.findByCustomUrl).not.toHaveBeenCalled();
});

it('should call findByCustomUrl when given a non-UUID id', () => {
const nonUuid = 'custom-url';
itemDataService.findByIdOrCustomUrl(nonUuid).subscribe();

expect(itemDataService.findByCustomUrl).toHaveBeenCalledWith(nonUuid, true, true, []);
expect(itemDataService.findByHref).not.toHaveBeenCalled();
});
});


});
92 changes: 92 additions & 0 deletions src/app/core/data/item-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
switchMap,
take,
} from 'rxjs/operators';
import { validate as uuidValidate } from 'uuid';

import { BrowseService } from '../browse/browse.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
Expand All @@ -34,6 +35,7 @@ import { NotificationsService } from '../notification-system/notifications.servi
import { Bundle } from '../shared/bundle.model';
import { Collection } from '../shared/collection.model';
import { ExternalSourceEntry } from '../shared/external-source-entry.model';
import { FollowLinkConfig } from '../shared/follow-link-config.model';
import { GenericConstructor } from '../shared/generic-constructor';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model';
Expand All @@ -58,6 +60,7 @@ import {
PatchData,
PatchDataImpl,
} from './base/patch-data';
import { SearchDataImpl } from './base/search-data';
import { BundleDataService } from './bundle-data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { FindListOptions } from './find-list-options.model';
Expand All @@ -83,6 +86,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService<Item>
private createData: CreateData<Item>;
private patchData: PatchData<Item>;
private deleteData: DeleteData<Item>;
private searchData: SearchDataImpl<Item>;

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

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

/**
* Returns an observable of {@link RemoteData} of an object, based on its custom URL, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param id custom URL of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @param projections List of {@link projections} used to pass as parameters
*/
public findByCustomUrl(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, linksToFollow: FollowLinkConfig<Item>[], projections: string[] = []): Observable<RemoteData<Item>> {
const searchHref = 'findByCustomURL';

const options = Object.assign({}, {
searchParams: [
new RequestParam('q', id),
],
});

projections.forEach((projection) => {
options.searchParams.push(new RequestParam('projection', projection));
});

const hrefObs = this.searchData.getSearchByHref(searchHref, options, ...linksToFollow);

return this.findByHref(hrefObs, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}


/**
* Invalidate cache of request findByCustomURL
*
* @param customUrl
* @param projections
*/
public invalidateFindByCustomUrlCache(customUrl: string, projections: string[] = []): void {
const options: any = {
searchParams: [new RequestParam('q', customUrl)],
};

projections.forEach((p) => options.searchParams.push(new RequestParam('projection', p)));

this.searchData.getSearchByHref('findByCustomURL', options).pipe(take(1)).subscribe((href: string) => {
this.requestService.setStaleByHrefSubstring(href);
this.objectCache.remove(href);
});
}

/**
* Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param id ID of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
public findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Item>[]): Observable<RemoteData<Item>> {
const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow);
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}

/**
* 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
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param id ID of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
public findByIdOrCustomUrl(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Item>[]): Observable<RemoteData<Item>> {
if (uuidValidate(id)) {
return this.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} else {
return this.findByCustomUrl(id, useCachedVersionIfAvailable, reRequestOnStale, linksToFollow);
}
}

}



/**
* A service for CRUD operations on Items
*/
Expand Down
11 changes: 11 additions & 0 deletions src/app/core/data/version-data.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable } from '@angular/core';
import { RestRequestMethod } from '@dspace/config/rest-request-method';
import { Item } from '@dspace/core/shared/item.model';
import { isNotEmpty } from '@dspace/shared/utils/empty.util';
import { Operation } from 'fast-json-patch';
import {
Expand Down Expand Up @@ -106,4 +107,14 @@ export class VersionDataService extends IdentifiableDataService<Version> impleme
return this.patchData.createPatchFromCache(object);
}


/**
* Invalidates the cache of the version link for this item.
*
* @param item
*/
invalidateVersionHrefCache(item: Item): void {
this.requestService.setStaleByHrefSubstring(item._links.version.href);
}

}
2 changes: 2 additions & 0 deletions src/app/core/provide-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
makeEnvironmentProviders,
} from '@angular/core';
import { APP_CONFIG } from '@dspace/config/app-config.interface';
import { SubmissionCustomUrl } from '@dspace/core/submission/models/submission-custom-url.model';

import { Audit } from './audit/model/audit.model';
import { AuthStatus } from './auth/models/auth-status.model';
Expand Down Expand Up @@ -230,4 +231,5 @@ export const models =
StatisticsEndpoint,
CorrectionType,
SupervisionOrder,
SubmissionCustomUrl,
];
9 changes: 8 additions & 1 deletion src/app/core/router/utils/dso-route.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,16 @@ export function getCommunityPageRoute(communityId: string) {
*/
export function getItemPageRoute(item: Item) {
const type = item.firstMetadataValue('dspace.entity.type');
return getEntityPageRoute(type, item.uuid);
let url = item.uuid;

if (isNotEmpty(item.metadata) && item.hasMetadata('dspace.customurl')) {
url = item.firstMetadataValue('dspace.customurl');
}

return getEntityPageRoute(type, url);
}


export function getEntityPageRoute(entityType: string, itemId: string) {
if (isNotEmpty(entityType)) {
return new URLCombiner(`/${ENTITY_MODULE_PATH}`, encodeURIComponent(entityType.toLowerCase()), itemId).toString();
Expand Down
22 changes: 22 additions & 0 deletions src/app/core/shared/authorized.operators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,28 @@ export const redirectOn4xx = <T>(router: Router, authService: AuthService) =>
}),
map(([rd]: [RemoteData<T>, boolean]) => rd),
);


/**
* Redirect to 404 if the requested content is not found (204 No Content)
*
* @param router
* @param authService
*/
export const redirectOn204 = <T>(router: Router, authService: AuthService) =>
(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(
withLatestFrom(authService.isAuthenticated()),
filter(([rd, isAuthenticated]: [RemoteData<T>, boolean]) => {
if (rd.hasNoContent) {
router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true });
return false;
}
return true;
}),
map(([rd]: [RemoteData<T>, boolean]) => rd),
);

/**
* Operator that returns a UrlTree to a forbidden page or the login page when the boolean received is false
* @param router The router used to navigate to a forbidden page
Expand Down
Loading
Loading