diff --git a/src/app/audit-page/object-audit-overview/object-audit-logs.component.spec.ts b/src/app/audit-page/object-audit-overview/object-audit-logs.component.spec.ts index 9171be3e02a..9053f631c9f 100644 --- a/src/app/audit-page/object-audit-overview/object-audit-logs.component.spec.ts +++ b/src/app/audit-page/object-audit-overview/object-audit-logs.component.spec.ts @@ -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'), }); diff --git a/src/app/audit-page/object-audit-overview/object-audit-logs.component.ts b/src/app/audit-page/object-audit-overview/object-audit-logs.component.ts index c09ca7f4f18..864b49d0ec6 100644 --- a/src/app/audit-page/object-audit-overview/object-audit-logs.component.ts +++ b/src/app/audit-page/object-audit-overview/object-audit-logs.component.ts @@ -8,7 +8,7 @@ import { } from '@angular/core'; import { ActivatedRoute, - ParamMap, + Data, Router, RouterLink, } from '@angular/router'; @@ -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); diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts index 250f65aa54b..ad3aec4eca6 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -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'; @@ -55,7 +56,9 @@ export const DSOBreadcrumbResolverByUuid: (route: ActivatedRouteSnapshot, state: dataService: IdentifiableDataService, ...linksToFollow: FollowLinkConfig[] ): Observable> => { - 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) => { diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 0488a9631b9..546c469c5fc 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -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, @@ -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'; @@ -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(); + }); + }); + + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index cb7e62c8fe6..fe83d8ea66e 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -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'; @@ -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'; @@ -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'; @@ -83,6 +86,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService private createData: CreateData; private patchData: PatchData; private deleteData: DeleteData; + private searchData: SearchDataImpl; protected constructor( protected linkPath, @@ -101,6 +105,7 @@ export abstract class BaseItemDataService extends IdentifiableDataService this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive); this.patchData = new PatchDataImpl(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); } /** @@ -425,8 +430,95 @@ export abstract class BaseItemDataService extends IdentifiableDataService 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[], projections: string[] = []): Observable> { + 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[]): Observable> { + 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[]): Observable> { + 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 */ diff --git a/src/app/core/data/version-data.service.ts b/src/app/core/data/version-data.service.ts index e4750b71b1f..09b22d62d1f 100644 --- a/src/app/core/data/version-data.service.ts +++ b/src/app/core/data/version-data.service.ts @@ -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 { @@ -106,4 +107,14 @@ export class VersionDataService extends IdentifiableDataService 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); + } + } diff --git a/src/app/core/provide-core.ts b/src/app/core/provide-core.ts index 29181e4a0e8..2b83615c0c1 100644 --- a/src/app/core/provide-core.ts +++ b/src/app/core/provide-core.ts @@ -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'; @@ -230,4 +231,5 @@ export const models = StatisticsEndpoint, CorrectionType, SupervisionOrder, + SubmissionCustomUrl, ]; diff --git a/src/app/core/router/utils/dso-route.utils.ts b/src/app/core/router/utils/dso-route.utils.ts index d377610d08d..fc0f8b4ffda 100644 --- a/src/app/core/router/utils/dso-route.utils.ts +++ b/src/app/core/router/utils/dso-route.utils.ts @@ -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(); diff --git a/src/app/core/shared/authorized.operators.ts b/src/app/core/shared/authorized.operators.ts index 477c1ed6d74..5ce081360f4 100644 --- a/src/app/core/shared/authorized.operators.ts +++ b/src/app/core/shared/authorized.operators.ts @@ -56,6 +56,28 @@ export const redirectOn4xx = (router: Router, authService: AuthService) => }), map(([rd]: [RemoteData, boolean]) => rd), ); + + +/** + * Redirect to 404 if the requested content is not found (204 No Content) + * + * @param router + * @param authService + */ +export const redirectOn204 = (router: Router, authService: AuthService) => + (source: Observable>): Observable> => + source.pipe( + withLatestFrom(authService.isAuthenticated()), + filter(([rd, isAuthenticated]: [RemoteData, boolean]) => { + if (rd.hasNoContent) { + router.navigateByUrl(getPageNotFoundRoute(), { skipLocationChange: true }); + return false; + } + return true; + }), + map(([rd]: [RemoteData, 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 diff --git a/src/app/core/submission/models/submission-custom-url.model.ts b/src/app/core/submission/models/submission-custom-url.model.ts new file mode 100644 index 00000000000..828080bac3f --- /dev/null +++ b/src/app/core/submission/models/submission-custom-url.model.ts @@ -0,0 +1,27 @@ +import { + autoserialize, + inheritSerialization, +} from 'cerialize'; + +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALResource } from '../../shared/hal-resource.model'; +import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { SUBMISSION_CUSTOM_URL } from './submission-custom-url.resource-type'; + +@typedObject +@inheritSerialization(HALResource) +export class SubmissionCustomUrl extends HALResource { + + static type = SUBMISSION_CUSTOM_URL; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + @autoserialize + url: string; +} diff --git a/src/app/core/submission/models/submission-custom-url.resource-type.ts b/src/app/core/submission/models/submission-custom-url.resource-type.ts new file mode 100644 index 00000000000..a3eae70f522 --- /dev/null +++ b/src/app/core/submission/models/submission-custom-url.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for License + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SUBMISSION_CUSTOM_URL = new ResourceType('submissioncustomcurl'); diff --git a/src/app/core/submission/models/workspaceitem-section-custom-url.model.ts b/src/app/core/submission/models/workspaceitem-section-custom-url.model.ts new file mode 100644 index 00000000000..81f28433711 --- /dev/null +++ b/src/app/core/submission/models/workspaceitem-section-custom-url.model.ts @@ -0,0 +1,7 @@ +/** + * An interface to represent the submission's custom url section data. + */ +export interface WorkspaceitemSectionCustomUrlObject { + 'redirected-urls': string[]; + 'url': string; +} diff --git a/src/app/core/submission/sections-type.ts b/src/app/core/submission/sections-type.ts index 60b4cedfdc9..253670c13b0 100644 --- a/src/app/core/submission/sections-type.ts +++ b/src/app/core/submission/sections-type.ts @@ -4,6 +4,7 @@ export enum SectionsType { Upload = 'upload', License = 'license', CcLicense = 'cclicense', + CustomUrl = 'custom-url', AccessesCondition = 'accessCondition', SherpaPolicies = 'sherpaPolicy', Identifiers = 'identifiers', diff --git a/src/app/core/submission/submission-scope-type.ts b/src/app/core/submission/submission-scope-type.ts index f319e5c473e..683472370d4 100644 --- a/src/app/core/submission/submission-scope-type.ts +++ b/src/app/core/submission/submission-scope-type.ts @@ -1,4 +1,5 @@ export enum SubmissionScopeType { WorkspaceItem = 'WORKSPACE', WorkflowItem = 'WORKFLOW', + EditItem = 'EDIT' } diff --git a/src/app/item-page/edit-item-page/item-page-delete.guard.spec.ts b/src/app/item-page/edit-item-page/item-page-delete.guard.spec.ts index fd98e44ac54..3ca58c92734 100644 --- a/src/app/item-page/edit-item-page/item-page-delete.guard.spec.ts +++ b/src/app/item-page/edit-item-page/item-page-delete.guard.spec.ts @@ -8,6 +8,7 @@ import { AuthorizationDataService } from '@dspace/core/data/feature-authorizatio import { FeatureID } from '@dspace/core/data/feature-authorization/feature-id'; import { ItemDataService } from '@dspace/core/data/item-data.service'; import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; +import { HardRedirectService } from '@dspace/core/services/hard-redirect.service'; import { Item } from '@dspace/core/shared/item.model'; import { getMockTranslateService } from '@dspace/core/testing/translate.service.mock'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; @@ -61,7 +62,7 @@ describe('itemPageDeleteGuard', () => { item = new Item(); item.uuid = uuid; item._links = { self: { href: itemSelfLink } } as any; - itemService = jasmine.createSpyObj('itemService', { findById: createSuccessfulRemoteDataObject$(item) }); + itemService = jasmine.createSpyObj('itemService', { findByIdOrCustomUrl: createSuccessfulRemoteDataObject$(item) }); TestBed.configureTestingModule({ providers: [ @@ -72,6 +73,7 @@ describe('itemPageDeleteGuard', () => { { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: TranslateService, useValue: getMockTranslateService() }, { provide: ItemDataService, useValue: itemService }, + { provide: HardRedirectService, useValue: {} }, ], }); }); diff --git a/src/app/item-page/edit-item-page/item-page-edit-authorizations.guard.spec.ts b/src/app/item-page/edit-item-page/item-page-edit-authorizations.guard.spec.ts index d9f9c51b91f..b381aa64d3d 100644 --- a/src/app/item-page/edit-item-page/item-page-edit-authorizations.guard.spec.ts +++ b/src/app/item-page/edit-item-page/item-page-edit-authorizations.guard.spec.ts @@ -8,6 +8,7 @@ import { AuthorizationDataService } from '@dspace/core/data/feature-authorizatio import { FeatureID } from '@dspace/core/data/feature-authorization/feature-id'; import { ItemDataService } from '@dspace/core/data/item-data.service'; import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; +import { HardRedirectService } from '@dspace/core/services/hard-redirect.service'; import { Item } from '@dspace/core/shared/item.model'; import { getMockTranslateService } from '@dspace/core/testing/translate.service.mock'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; @@ -61,7 +62,7 @@ describe('itemPageEditAuthorizationsGuard', () => { item = new Item(); item.uuid = uuid; item._links = { self: { href: itemSelfLink } } as any; - itemService = jasmine.createSpyObj('itemService', { findById: createSuccessfulRemoteDataObject$(item) }); + itemService = jasmine.createSpyObj('itemService', { findByIdOrCustomUrl: createSuccessfulRemoteDataObject$(item) }); TestBed.configureTestingModule({ providers: [ @@ -72,6 +73,7 @@ describe('itemPageEditAuthorizationsGuard', () => { { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: TranslateService, useValue: getMockTranslateService() }, { provide: ItemDataService, useValue: itemService }, + { provide: HardRedirectService, useValue: {} }, ], }); }); diff --git a/src/app/item-page/edit-item-page/item-page-move.guard.spec.ts b/src/app/item-page/edit-item-page/item-page-move.guard.spec.ts index 9891aed631d..811812cc32e 100644 --- a/src/app/item-page/edit-item-page/item-page-move.guard.spec.ts +++ b/src/app/item-page/edit-item-page/item-page-move.guard.spec.ts @@ -8,6 +8,7 @@ import { AuthorizationDataService } from '@dspace/core/data/feature-authorizatio import { FeatureID } from '@dspace/core/data/feature-authorization/feature-id'; import { ItemDataService } from '@dspace/core/data/item-data.service'; import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; +import { HardRedirectService } from '@dspace/core/services/hard-redirect.service'; import { Item } from '@dspace/core/shared/item.model'; import { getMockTranslateService } from '@dspace/core/testing/translate.service.mock'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; @@ -61,7 +62,7 @@ describe('itemPageMoveGuard', () => { item = new Item(); item.uuid = uuid; item._links = { self: { href: itemSelfLink } } as any; - itemService = jasmine.createSpyObj('itemService', { findById: createSuccessfulRemoteDataObject$(item) }); + itemService = jasmine.createSpyObj('itemService', { findByIdOrCustomUrl: createSuccessfulRemoteDataObject$(item) }); TestBed.configureTestingModule({ providers: [ @@ -72,6 +73,7 @@ describe('itemPageMoveGuard', () => { { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: TranslateService, useValue: getMockTranslateService() }, { provide: ItemDataService, useValue: itemService }, + { provide: HardRedirectService, useValue: {} }, ], }); }); diff --git a/src/app/item-page/edit-item-page/item-page-private.guard.spec.ts b/src/app/item-page/edit-item-page/item-page-private.guard.spec.ts index eb6fec114ba..112eb0992c3 100644 --- a/src/app/item-page/edit-item-page/item-page-private.guard.spec.ts +++ b/src/app/item-page/edit-item-page/item-page-private.guard.spec.ts @@ -8,6 +8,7 @@ import { AuthorizationDataService } from '@dspace/core/data/feature-authorizatio import { FeatureID } from '@dspace/core/data/feature-authorization/feature-id'; import { ItemDataService } from '@dspace/core/data/item-data.service'; import { APP_DATA_SERVICES_MAP } from '@dspace/core/data-services-map-type'; +import { HardRedirectService } from '@dspace/core/services/hard-redirect.service'; import { Item } from '@dspace/core/shared/item.model'; import { getMockTranslateService } from '@dspace/core/testing/translate.service.mock'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; @@ -61,7 +62,7 @@ describe('itemPagePrivateGuard', () => { item = new Item(); item.uuid = uuid; item._links = { self: { href: itemSelfLink } } as any; - itemService = jasmine.createSpyObj('itemService', { findById: createSuccessfulRemoteDataObject$(item) }); + itemService = jasmine.createSpyObj('itemService', { findByIdOrCustomUrl: createSuccessfulRemoteDataObject$(item) }); TestBed.configureTestingModule({ providers: [ @@ -72,6 +73,7 @@ describe('itemPagePrivateGuard', () => { { provide: APP_DATA_SERVICES_MAP, useValue: {} }, { provide: TranslateService, useValue: getMockTranslateService() }, { provide: ItemDataService, useValue: itemService }, + { provide: HardRedirectService, useValue: {} }, ], }); }); diff --git a/src/app/item-page/full/full-item-page.component.spec.ts b/src/app/item-page/full/full-item-page.component.spec.ts index 9d6fa259736..2b7a0e0a0b5 100644 --- a/src/app/item-page/full/full-item-page.component.spec.ts +++ b/src/app/item-page/full/full-item-page.component.spec.ts @@ -19,6 +19,7 @@ import { ItemDataService } from '@dspace/core/data/item-data.service'; import { RemoteData } from '@dspace/core/data/remote-data'; import { SignpostingDataService } from '@dspace/core/data/signposting-data.service'; import { HeadTagService } from '@dspace/core/metadata/head-tag.service'; +import { HardRedirectService } from '@dspace/core/services/hard-redirect.service'; import { LinkHeadService } from '@dspace/core/services/link-head.service'; import { ServerResponseService } from '@dspace/core/services/server-response.service'; import { Item } from '@dspace/core/shared/item.model'; @@ -101,6 +102,7 @@ describe('FullItemPageComponent', () => { beforeEach(waitForAsync(() => { routeData = { dso: createSuccessfulRemoteDataObject(mockItem), + links: [mocklink, mocklink2], }; routeStub = Object.assign(new ActivatedRouteStub(), { @@ -150,6 +152,7 @@ describe('FullItemPageComponent', () => { { provide: NotifyInfoService, useValue: notifyInfoService }, { provide: PLATFORM_ID, useValue: 'server' }, { provide: ThemeService, useValue: getMockThemeService() }, + { provide: HardRedirectService, useValue: {} }, ], schemas: [NO_ERRORS_SCHEMA], }) diff --git a/src/app/item-page/item-page-routes.ts b/src/app/item-page/item-page-routes.ts index 09a45618962..be87323890a 100644 --- a/src/app/item-page/item-page-routes.ts +++ b/src/app/item-page/item-page-routes.ts @@ -21,17 +21,31 @@ import { } from './item-page-routing-paths'; import { OrcidPageComponent } from './orcid-page/orcid-page.component'; import { orcidPageGuard } from './orcid-page/orcid-page.guard'; +import { signpostingLinksResolver } from './simple/link-resolver/signposting-links.resolver'; import { ThemedItemPageComponent } from './simple/themed-item-page.component'; import { versionResolver } from './version-page/version.resolver'; import { VersionPageComponent } from './version-page/version-page/version-page.component'; export const ROUTES: Route[] = [ + { + path: 'version', + children: [ + { + path: ':id', + component: VersionPageComponent, + resolve: { + dso: versionResolver, + }, + }, + ], + }, { path: ':id', resolve: { dso: itemPageResolver, itemRequest: accessTokenResolver, breadcrumb: itemBreadcrumbResolver, + links: signpostingLinksResolver, }, runGuardsAndResolvers: 'always', children: [ @@ -92,16 +106,4 @@ export const ROUTES: Route[] = [ }, ], }, - { - path: 'version', - children: [ - { - path: ':id', - component: VersionPageComponent, - resolve: { - dso: versionResolver, - }, - }, - ], - }, ]; diff --git a/src/app/item-page/item-page.resolver.spec.ts b/src/app/item-page/item-page.resolver.spec.ts index e410fa271f1..0d2132239f6 100644 --- a/src/app/item-page/item-page.resolver.spec.ts +++ b/src/app/item-page/item-page.resolver.spec.ts @@ -1,8 +1,10 @@ +import { PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Router, RouterModule, } from '@angular/router'; +import { HardRedirectService } from '@dspace/core/services/hard-redirect.service'; import { DSpaceObject } from '@dspace/core/shared/dspace-object.model'; import { MetadataValueFilter } from '@dspace/core/shared/metadata.models'; import { AuthServiceStub } from '@dspace/core/testing/auth-service.stub'; @@ -14,6 +16,10 @@ import { itemPageResolver } from './item-page.resolver'; describe('itemPageResolver', () => { beforeEach(() => { TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: HardRedirectService, useValue: {} }, + ], imports: [RouterModule.forRoot([{ path: 'entities/:entity-type/:id', component: {} as any, @@ -27,6 +33,8 @@ describe('itemPageResolver', () => { let store: any; let router: Router; let authService: AuthServiceStub; + let platformId: any; + let hardRedirectService: any; const uuid = '1234-65487-12354-1235'; let item: DSpaceObject; @@ -34,6 +42,10 @@ describe('itemPageResolver', () => { function runTestsWithEntityType(entityType: string) { beforeEach(() => { router = TestBed.inject(Router); + platformId = TestBed.inject(PLATFORM_ID); + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + redirect: {}, + }); item = Object.assign(new DSpaceObject(), { uuid: uuid, firstMetadataValue(_keyOrKeys: string | string[], _valueFilter?: MetadataValueFilter): string { @@ -41,7 +53,7 @@ describe('itemPageResolver', () => { }, }); itemService = { - findById: (_id: string) => createSuccessfulRemoteDataObject$(item), + findByIdOrCustomUrl: (_id: string) => createSuccessfulRemoteDataObject$(item), }; store = jasmine.createSpyObj('store', { dispatch: {}, @@ -60,6 +72,8 @@ describe('itemPageResolver', () => { itemService, store, authService, + platformId, + hardRedirectService, ).pipe(first()) .subscribe( () => { @@ -80,6 +94,8 @@ describe('itemPageResolver', () => { itemService, store, authService, + platformId, + hardRedirectService, ).pipe(first()) .subscribe( () => { @@ -101,4 +117,107 @@ describe('itemPageResolver', () => { }); }); + + describe('when item has dspace.customurl metadata', () => { + + + const customUrl = 'my-custom-item'; + let resolver: any; + let itemService: any; + let store: any; + let router: Router; + let authService: AuthServiceStub; + let platformId: any; + let hardRedirectService: any; + + const uuid = '1234-65487-12354-1235'; + let item: DSpaceObject; + + beforeEach(() => { + router = TestBed.inject(Router); + platformId = TestBed.inject(PLATFORM_ID); + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + redirect: {}, + }); + item = Object.assign(new DSpaceObject(), { + uuid: uuid, + id: uuid, + firstMetadataValue(_keyOrKeys: string | string[], _valueFilter?: MetadataValueFilter): string { + return _keyOrKeys === 'dspace.entity.type' ? 'person' : customUrl; + }, + hasMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): boolean { + return true; + }, + metadata: { + 'dspace.customurl': customUrl, + }, + }); + itemService = { + findByIdOrCustomUrl: (_id: string) => createSuccessfulRemoteDataObject$(item), + }; + store = jasmine.createSpyObj('store', { + dispatch: {}, + }); + authService = new AuthServiceStub(); + resolver = itemPageResolver; + }); + + it('should navigate to the new custom URL if dspace.customurl is defined and different from route param', (done) => { + spyOn(router, 'navigateByUrl').and.callThrough(); + + const route = { params: { id: uuid } } as any; + const state = { url: `/entities/person/${uuid}` } as any; + + resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService) + .pipe(first()) + .subscribe((rd: any) => { + const expectedUrl = `/entities/person/${customUrl}`; + expect(router.navigateByUrl).toHaveBeenCalledWith(expectedUrl); + done(); + }); + }); + + it('should not navigate if dspace.customurl matches the current route id', (done) => { + spyOn(router, 'navigateByUrl').and.callThrough(); + + const route = { params: { id: customUrl } } as any; + const state = { url: `/entities/person/${customUrl}` } as any; + + resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService) + .pipe(first()) + .subscribe((rd: any) => { + expect(router.navigateByUrl).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should replace dspace.customurl if the current route is a sub path (/full excluded)', (done) => { + spyOn(router, 'navigateByUrl').and.callThrough(); + + const route = { params: { id: customUrl } } as any; + const state = { url: `/entities/person/${customUrl}/edit` } as any; + + resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService) + .pipe(first()) + .subscribe(() => { + expect(router.navigateByUrl).toHaveBeenCalledWith(`/entities/person/${uuid}/edit`); + done(); + }); + }); + + it('should not replace dspace.customurl if the current sub path is /full', (done) => { + spyOn(router, 'navigateByUrl').and.callThrough(); + + const route = { params: { id: customUrl } } as any; + const state = { url: `/entities/person/${customUrl}/full` } as any; + + resolver(route, state, router, itemService, store, authService, platformId, hardRedirectService) + .pipe(first()) + .subscribe(() => { + expect(router.navigateByUrl).not.toHaveBeenCalled();; + done(); + }); + }); + }); + }); diff --git a/src/app/item-page/item-page.resolver.ts b/src/app/item-page/item-page.resolver.ts index 9851fc1a5a2..58f690ae309 100644 --- a/src/app/item-page/item-page.resolver.ts +++ b/src/app/item-page/item-page.resolver.ts @@ -1,4 +1,8 @@ -import { inject } from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { + inject, + PLATFORM_ID, +} from '@angular/core'; import { ActivatedRouteSnapshot, ResolveFn, @@ -10,7 +14,11 @@ import { ItemDataService } from '@dspace/core/data/item-data.service'; import { RemoteData } from '@dspace/core/data/remote-data'; import { ResolvedAction } from '@dspace/core/resolving/resolver.actions'; import { getItemPageRoute } from '@dspace/core/router/utils/dso-route.utils'; -import { redirectOn4xx } from '@dspace/core/shared/authorized.operators'; +import { HardRedirectService } from '@dspace/core/services/hard-redirect.service'; +import { + redirectOn4xx, + redirectOn204, +} from '@dspace/core/shared/authorized.operators'; import { getItemPageLinksToFollow, Item, @@ -41,14 +49,17 @@ export const itemPageResolver: ResolveFn> = ( itemService: ItemDataService = inject(ItemDataService), store: Store = inject(Store), authService: AuthService = inject(AuthService), + platformId: any = inject(PLATFORM_ID), + hardRedirectService: HardRedirectService = inject(HardRedirectService), ): Observable> => { - const itemRD$ = itemService.findById( + const itemRD$ = itemService.findByIdOrCustomUrl( route.params.id, true, - false, + true, ...getItemPageLinksToFollow(), ).pipe( getFirstCompletedRemoteData(), + redirectOn204(router, authService), redirectOn4xx(router, authService), ); @@ -59,18 +70,41 @@ export const itemPageResolver: ResolveFn> = ( return itemRD$.pipe( map((rd: RemoteData) => { if (rd.hasSucceeded && hasValue(rd.payload)) { - const thisRoute = state.url; + let itemRoute; + if (hasValue(rd.payload.metadata) && rd.payload.hasMetadata('dspace.customurl')) { + const customUrl = rd.payload.firstMetadataValue('dspace.customurl'); + const isSubPath = !(state.url.endsWith(customUrl) || state.url.endsWith(rd.payload.id) || state.url.endsWith('/full')); + itemRoute = isSubPath ? state.url : router.parseUrl(getItemPageRoute(rd.payload)).toString(); + let newUrl: string; + if (route.params.id !== customUrl && !isSubPath) { + newUrl = itemRoute.replace(route.params.id,rd.payload.firstMetadataValue('dspace.customurl')); + } else if (isSubPath && route.params.id === customUrl) { + // In case of a sub path, we need to ensure we navigate to the edit page of the item ID, not the custom URL + const itemId = rd.payload.uuid; + newUrl = itemRoute.replace(rd.payload.firstMetadataValue('dspace.customurl'), itemId); + } + + if (hasValue(newUrl)) { + router.navigateByUrl(newUrl); + } + } else { + const thisRoute = state.url; - // Angular uses a custom function for encodeURIComponent, (e.g. it doesn't encode commas - // or semicolons) and thisRoute has been encoded with that function. If we want to compare - // it with itemRoute, we have to run itemRoute through Angular's version as well to ensure - // the same characters are encoded the same way. - const itemRoute = router.parseUrl(getItemPageRoute(rd.payload)).toString(); + // Angular uses a custom function for encodeURIComponent, (e.g. it doesn't encode commas + // or semicolons) and thisRoute has been encoded with that function. If we want to compare + // it with itemRoute, we have to run itemRoute through Angular's version as well to ensure + // the same characters are encoded the same way. + itemRoute = router.parseUrl(getItemPageRoute(rd.payload)).toString(); - if (!thisRoute.startsWith(itemRoute)) { - const itemId = rd.payload.uuid; - const subRoute = thisRoute.substring(thisRoute.indexOf(itemId) + itemId.length, thisRoute.length); - void router.navigateByUrl(itemRoute + subRoute); + if (!thisRoute.startsWith(itemRoute)) { + const itemId = rd.payload.uuid; + const subRoute = thisRoute.substring(thisRoute.indexOf(itemId) + itemId.length, thisRoute.length); + if (isPlatformServer(platformId)) { + hardRedirectService.redirect(itemRoute + subRoute, 301); + } else { + router.navigateByUrl(itemRoute + subRoute); + } + } } } return rd; diff --git a/src/app/item-page/item.resolver.ts b/src/app/item-page/item.resolver.ts index 78e6d2cbb7f..e4ce90605e2 100644 --- a/src/app/item-page/item.resolver.ts +++ b/src/app/item-page/item.resolver.ts @@ -23,7 +23,7 @@ export const itemResolver: ResolveFn> = ( itemService: ItemDataService = inject(ItemDataService), store: Store = inject(Store), ): Observable> => { - const itemRD$ = itemService.findById( + const itemRD$ = itemService.findByIdOrCustomUrl( route.params.id, true, false, diff --git a/src/app/item-page/simple/item-page.component.spec.ts b/src/app/item-page/simple/item-page.component.spec.ts index fb56239e6c5..aa8eb9d5d9a 100644 --- a/src/app/item-page/simple/item-page.component.spec.ts +++ b/src/app/item-page/simple/item-page.component.spec.ts @@ -19,6 +19,7 @@ import { AuthorizationDataService } from '@dspace/core/data/feature-authorizatio import { ItemDataService } from '@dspace/core/data/item-data.service'; import { SignpostingDataService } from '@dspace/core/data/signposting-data.service'; import { SignpostingLink } from '@dspace/core/data/signposting-links.model'; +import { HardRedirectService } from '@dspace/core/services/hard-redirect.service'; import { LinkDefinition, LinkHeadService, @@ -26,6 +27,7 @@ import { import { ServerResponseService } from '@dspace/core/services/server-response.service'; import { Item } from '@dspace/core/shared/item.model'; import { ActivatedRouteStub } from '@dspace/core/testing/active-router.stub'; +import { RouterMock } from '@dspace/core/testing/router.mock'; import { TranslateLoaderMock } from '@dspace/core/testing/translate-loader.mock'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; import { @@ -87,9 +89,10 @@ describe('ItemPageComponent', () => { let signpostingDataService: jasmine.SpyObj; let linkHeadService: jasmine.SpyObj; let notifyInfoService: jasmine.SpyObj; + let hardRedirectService: HardRedirectService; const mockRoute = Object.assign(new ActivatedRouteStub(), { - data: of({ dso: createSuccessfulRemoteDataObject(mockItem) }), + data: of({ dso: createSuccessfulRemoteDataObject(mockItem) , links: [mocklink, mocklink2] }), }); const getCoarLdnLocalInboxUrls = ['http://InboxUrls.org', 'http://InboxUrls2.org']; @@ -117,6 +120,11 @@ describe('ItemPageComponent', () => { getCoarLdnLocalInboxUrls: of(getCoarLdnLocalInboxUrls), }); + hardRedirectService = jasmine.createSpyObj('hardRedirectService', { + redirect: {}, + getCurrentRoute: {}, + }); + TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ loader: { @@ -127,13 +135,14 @@ describe('ItemPageComponent', () => { providers: [ { provide: ActivatedRoute, useValue: mockRoute }, { provide: ItemDataService, useValue: {} }, - { provide: Router, useValue: {} }, + { provide: Router, useValue: new RouterMock() }, { provide: AuthorizationDataService, useValue: authorizationDataService }, { provide: ServerResponseService, useValue: serverResponseService }, { provide: SignpostingDataService, useValue: signpostingDataService }, { provide: LinkHeadService, useValue: linkHeadService }, { provide: NotifyInfoService, useValue: notifyInfoService }, { provide: PLATFORM_ID, useValue: 'server' }, + { provide: HardRedirectService, useValue: hardRedirectService }, ], schemas: [NO_ERRORS_SCHEMA], }).overrideComponent(ItemPageComponent, { diff --git a/src/app/item-page/simple/item-page.component.ts b/src/app/item-page/simple/item-page.component.ts index ca8564235c9..8260e2fd8d4 100644 --- a/src/app/item-page/simple/item-page.component.ts +++ b/src/app/item-page/simple/item-page.component.ts @@ -170,42 +170,40 @@ export class ItemPageComponent implements OnInit, OnDestroy { * @private */ private initPageLinks(): void { - this.route.params.subscribe(params => { - combineLatest([this.signpostingDataService.getLinks(params.id).pipe(take(1)), this.getCoarLdnLocalInboxUrls()]) - .subscribe(([signpostingLinks, coarRestApiUrls]) => { - let links = ''; - this.signpostingLinks = signpostingLinks; + combineLatest([this.route.data.pipe(take(1)), this.getCoarLdnLocalInboxUrls()]) + .subscribe(([data, coarRestApiUrls]) => { + let links = ''; + this.signpostingLinks = data.links ?? []; - signpostingLinks.forEach((link: SignpostingLink) => { - links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' ') - + (isNotEmpty(link.profile) ? ` ; profile="${link.profile}" ` : ''); - let tag: LinkDefinition = { - href: link.href, - rel: link.rel, - }; - if (isNotEmpty(link.type)) { - tag = Object.assign(tag, { - type: link.type, - }); - } - if (isNotEmpty(link.profile)) { - tag = Object.assign(tag, { - profile: link.profile, - }); - } - this.linkHeadService.addTag(tag); - }); - - if (coarRestApiUrls.length > 0) { - const inboxLinks = this.initPageInboxLinks(coarRestApiUrls); - links = links + (isNotEmpty(links) ? ', ' : '') + inboxLinks; + this.signpostingLinks.forEach((link: SignpostingLink) => { + links = links + (isNotEmpty(links) ? ', ' : '') + `<${link.href}> ; rel="${link.rel}"` + (isNotEmpty(link.type) ? ` ; type="${link.type}" ` : ' ') + + (isNotEmpty(link.profile) ? ` ; profile="${link.profile}" ` : ''); + let tag: LinkDefinition = { + href: link.href, + rel: link.rel, + }; + if (isNotEmpty(link.type)) { + tag = Object.assign(tag, { + type: link.type, + }); } - - if (isPlatformServer(this.platformId)) { - this.responseService.setHeader('Link', links); + if (isNotEmpty(link.profile)) { + tag = Object.assign(tag, { + profile: link.profile, + }); } + this.linkHeadService.addTag(tag); }); - }); + + if (coarRestApiUrls.length > 0) { + const inboxLinks = this.initPageInboxLinks(coarRestApiUrls); + links = links + (isNotEmpty(links) ? ', ' : '') + inboxLinks; + } + + if (isPlatformServer(this.platformId)) { + this.responseService.setHeader('Link', links); + } + }); } /** diff --git a/src/app/item-page/simple/link-resolver/signposting-links.resolver.spec.ts b/src/app/item-page/simple/link-resolver/signposting-links.resolver.spec.ts new file mode 100644 index 00000000000..c3676babea5 --- /dev/null +++ b/src/app/item-page/simple/link-resolver/signposting-links.resolver.spec.ts @@ -0,0 +1,79 @@ +import { TestBed } from '@angular/core/testing'; +import { + ActivatedRouteSnapshot, + RouterStateSnapshot, +} from '@angular/router'; +import { ItemDataService } from '@dspace/core/data/item-data.service'; +import { SignpostingDataService } from '@dspace/core/data/signposting-data.service'; +import { Item } from '@dspace/core/shared/item.model'; +import { MetadataMap } from '@dspace/core/shared/metadata.models'; +import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { of } from 'rxjs'; + +import { signpostingLinksResolver } from './signposting-links.resolver'; + +describe('signpostingLinksResolver', () => { + let resolver: any; + let route: ActivatedRouteSnapshot; + let state = {}; + let itemDataService: ItemDataService; + let signpostingDataService: SignpostingDataService; + const testUuid = '1234567890'; + const mocklink = { + href: 'http://test.org', + rel: 'rel1', + type: 'type1', + }; + const mocklink2 = { + href: 'http://test2.org', + rel: 'rel2', + type: undefined, + }; + const resolvedLinks = `<${mocklink.href}> ; rel="${mocklink.rel}" ; type="${mocklink.type}" , <${mocklink2.href}> ; rel="${mocklink2.rel}" `; + const mockTag2 = { + href: 'http://test2.org', + rel: 'rel2', + }; + const mockItem = Object.assign(new Item(), { + uuid: testUuid, + metadata: new MetadataMap(), + }); + function init() { + route = Object.assign(new ActivatedRouteSnapshot(), { + params: { + id: testUuid, + }, + }); + itemDataService = jasmine.createSpyObj('itemDataService', { + findByIdOrCustomUrl: createSuccessfulRemoteDataObject$(mockItem), + }); + signpostingDataService = jasmine.createSpyObj('signpostingDataService', { + getLinks: of([mocklink, mocklink2]), + setLinks: () => null, + }); + resolver = signpostingLinksResolver; + } + function initTestbed() { + TestBed.configureTestingModule({ + providers: [ + { provide: RouterStateSnapshot, useValue: state }, + { provide: ActivatedRouteSnapshot, useValue: route }, + { provide: ItemDataService, useValue: itemDataService }, + { provide: SignpostingDataService, useValue: signpostingDataService }, + ], + }); + } + describe('when an item page is loaded', () => { + beforeEach(() => { + init(); + initTestbed(); + }); + it('should retrieve links and set header and head tags', () => { + TestBed.runInInjectionContext(() => { + resolver(route, state).subscribe(() => { + expect(signpostingDataService.getLinks).toHaveBeenCalledWith(testUuid); + }); + }); + }); + }); +}); diff --git a/src/app/item-page/simple/link-resolver/signposting-links.resolver.ts b/src/app/item-page/simple/link-resolver/signposting-links.resolver.ts new file mode 100644 index 00000000000..1b780ec6b0a --- /dev/null +++ b/src/app/item-page/simple/link-resolver/signposting-links.resolver.ts @@ -0,0 +1,47 @@ +import { inject } from '@angular/core'; +import { + ActivatedRouteSnapshot, + ResolveFn, + RouterStateSnapshot, +} from '@angular/router'; +import { ItemDataService } from '@dspace/core/data/item-data.service'; +import { RemoteData } from '@dspace/core/data/remote-data'; +import { SignpostingDataService } from '@dspace/core/data/signposting-data.service'; +import { SignpostingLink } from '@dspace/core/data/signposting-links.model'; +import { + getItemPageLinksToFollow, + Item, +} from '@dspace/core/shared/item.model'; +import { getFirstCompletedRemoteData } from '@dspace/core/shared/operators'; +import { hasValue } from '@dspace/shared/utils/empty.util'; +import { + Observable, + of, +} from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +/** + * Resolver to retrieve signposting links before an eventual redirect of any route guard + * + * @param route + * @param state + * @param itemService + * @param signpostingDataService + */ +export const signpostingLinksResolver: ResolveFn> = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot, + itemService: ItemDataService = inject(ItemDataService), + signpostingDataService: SignpostingDataService = inject(SignpostingDataService), +): Observable => { + const uuid = route.params.id; + if (!hasValue(uuid)) { + return of([]); + } + return itemService.findByIdOrCustomUrl(uuid, true, true, ...getItemPageLinksToFollow()).pipe( + getFirstCompletedRemoteData(), + switchMap((itemRD: RemoteData) => { + return itemRD.hasSucceeded ? signpostingDataService.getLinks(itemRD.payload.uuid) : of([]); + }), + ); +}; diff --git a/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.spec.ts b/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.spec.ts index 779af6444bc..4b974e0c913 100644 --- a/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.spec.ts +++ b/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.spec.ts @@ -1,13 +1,18 @@ -import { waitForAsync } from '@angular/core/testing'; +import { + fakeAsync, + flush, + waitForAsync, +} from '@angular/core/testing'; import { buildPaginatedList } from '@dspace/core/data/paginated-list.model'; import { Item } from '@dspace/core/shared/item.model'; import { MetadataMap } from '@dspace/core/shared/metadata.models'; import { PageInfo } from '@dspace/core/shared/page-info.model'; import { Version } from '@dspace/core/shared/version.model'; +import { WorkspaceItem } from '@dspace/core/submission/models/workspaceitem.model'; import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; import { - EMPTY, of, + Subject, } from 'rxjs'; import { createRelationshipsObservable } from '../../../item-page/simple/item-types/shared/item.component.spec'; @@ -23,6 +28,9 @@ describe('DsoVersioningModalService', () => { let workspaceItemDataService; let itemService; + let createVersionEvent$: Subject; + + const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])), metadata: new MetadataMap(), @@ -37,21 +45,40 @@ describe('DsoVersioningModalService', () => { }, }); + const mockVersion = Object.assign(new Version(), { + _links: { + self: { + href: 'version-href', + }, + item: { + href: 'item-href', + }, + }, + }); + beforeEach(waitForAsync(() => { + createVersionEvent$ = new Subject(); modalService = jasmine.createSpyObj('modalService', { - open: { componentInstance: { firstVersion: {}, versionNumber: {}, createVersionEvent: EMPTY } }, + open: { + componentInstance: { firstVersion: {}, versionNumber: {}, createVersionEvent: createVersionEvent$.asObservable() }, + close: jasmine.createSpy('close'), + }, }); versionService = jasmine.createSpyObj('versionService', { findByHref: createSuccessfulRemoteDataObject$(new Version()), + invalidateVersionHrefCache: undefined, }); versionHistoryService = jasmine.createSpyObj('versionHistoryService', { - createVersion: createSuccessfulRemoteDataObject$(new Version()), + createVersion: createSuccessfulRemoteDataObject$(mockVersion), hasDraftVersion$: of(false), }); itemVersionShared = jasmine.createSpyObj('itemVersionShared', ['notifyCreateNewVersion']); router = jasmine.createSpyObj('router', ['navigateByUrl']); workspaceItemDataService = jasmine.createSpyObj('workspaceItemDataService', ['findByItem']); - itemService = jasmine.createSpyObj('itemService', ['findByHref']); + workspaceItemDataService.findByItem.and.returnValue(createSuccessfulRemoteDataObject$(new WorkspaceItem())); + + itemService = jasmine.createSpyObj('itemService', ['findByHref', 'invalidateFindByCustomUrlCache']); + itemService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockItem)); service = new DsoVersioningModalService( modalService, @@ -93,4 +120,27 @@ describe('DsoVersioningModalService', () => { }); }); }); + + describe('version modal', () => { + it('should invalidate version href cache after a successful create', fakeAsync(() => { + service.openCreateVersionModal(mockItem); + createVersionEvent$.next('summary'); + flush(); + expect(versionService.invalidateVersionHrefCache).toHaveBeenCalledWith(mockItem); + expect(itemService.invalidateFindByCustomUrlCache).not.toHaveBeenCalled(); + })); + + it('should invalidate findByCustomUrl cache when item has dspace.customurl metadata', fakeAsync(() => { + const itemWithCustomUrl: Item = Object.assign(new Item(), mockItem, { + metadata: Object.assign(new MetadataMap(), { + 'dspace.customurl': [{ value: 'my-custom-url' }], + }), + }); + service.openCreateVersionModal(itemWithCustomUrl); + createVersionEvent$.next('summary'); + flush(); + expect(versionService.invalidateVersionHrefCache).toHaveBeenCalledWith(itemWithCustomUrl); + expect(itemService.invalidateFindByCustomUrlCache).toHaveBeenCalledWith('my-custom-url'); + })); + }); }); diff --git a/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.ts b/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.ts index 8091fccbcec..4e37503f229 100644 --- a/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.ts +++ b/src/app/shared/dso-page/dso-versioning-modal-service/dso-versioning-modal.service.ts @@ -81,6 +81,11 @@ export class DsoVersioningModalService { switchMap((newVersionItem: Item) => this.workspaceItemDataService.findByItem(newVersionItem.uuid, true, false)), getFirstSucceededRemoteDataPayload(), ).subscribe((wsItem) => { + this.versionService.invalidateVersionHrefCache(item); + if (item.hasMetadata('dspace.customurl')) { + // when a new version is created we need to invalidate the cache for findByCustomURL so the item will not be resolved with the cached old version. + this.itemService.invalidateFindByCustomUrlCache(item.firstMetadataValue('dspace.customurl')); + } const wsiId = wsItem.id; const route = 'workspaceitems/' + wsiId + '/edit'; this.router.navigateByUrl(route); diff --git a/src/app/submission/edit/submission-edit.component.html b/src/app/submission/edit/submission-edit.component.html index 9c0e9eae723..40b569c2020 100644 --- a/src/app/submission/edit/submission-edit.component.html +++ b/src/app/submission/edit/submission-edit.component.html @@ -6,5 +6,6 @@ [submissionErrors]="submissionErrors" [item]="item" [collectionModifiable]="collectionModifiable" - [submissionId]="submissionId"> + [submissionId]="submissionId" + [entityType]="entityType"> diff --git a/src/app/submission/edit/submission-edit.component.spec.ts b/src/app/submission/edit/submission-edit.component.spec.ts index 2749ebe5684..b00d0bdbb6e 100644 --- a/src/app/submission/edit/submission-edit.component.spec.ts +++ b/src/app/submission/edit/submission-edit.component.spec.ts @@ -51,7 +51,13 @@ describe('SubmissionEditComponent Component', () => { const submissionId = '826'; const route: ActivatedRouteStub = new ActivatedRouteStub(); - const submissionObject: any = mockSubmissionObject; + const submissionObject: any = Object.assign({}, mockSubmissionObject, { + collection: { + ...mockSubmissionObject.collection, + hasMetadata: (_: string) => true, + firstMetadataValue: (_: string) => true, + }, + }); beforeEach(waitForAsync(() => { itemDataService = jasmine.createSpyObj('itemDataService', { diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 9eeb6aecd5e..ded2c60e1ef 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -66,6 +66,11 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { */ public collectionModifiable: boolean | null = null; + /** + * The entity type of the submission + * @type {string} + */ + public entityType: string; /** * The list of submission's sections @@ -154,6 +159,9 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { this.notificationsService.info(null, this.translate.get('submission.general.cannot_submit')); this.router.navigate(['/mydspace']); } else { + const collection = submissionObjectRD.payload.collection as Collection; + this.entityType = (hasValue(collection) && collection.hasMetadata('dspace.entity.type')) + ? collection.firstMetadataValue('dspace.entity.type') : null; const { errors } = submissionObjectRD.payload; this.submissionErrors = parseSectionErrors(errors); this.submissionId = submissionObjectRD.payload.id.toString(); diff --git a/src/app/submission/form/submission-form.component.html b/src/app/submission/form/submission-form.component.html index b193e551fc9..a8b3b5a5afe 100644 --- a/src/app/submission/form/submission-form.component.html +++ b/src/app/submission/form/submission-form.component.html @@ -36,6 +36,7 @@ @for (object of $any(submissionSections | async); track object) { } diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 7097d63867e..89ca7286df0 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -115,6 +115,12 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { */ @Input() submissionId: string; + /** + * The entity type input used to create a new submission + * @type {string} + */ + @Input() entityType: string; + /** * The configuration id that define this submission * @type {string} diff --git a/src/app/submission/form/themed-submission-form.component.ts b/src/app/submission/form/themed-submission-form.component.ts index af3fe244c8c..20c4d375276 100644 --- a/src/app/submission/form/themed-submission-form.component.ts +++ b/src/app/submission/form/themed-submission-form.component.ts @@ -31,7 +31,9 @@ export class ThemedSubmissionFormComponent extends ThemedComponent (this.collectionId), deps: [] }, { provide: 'sectionDataProvider', useFactory: () => (this.sectionData), deps: [] }, { provide: 'submissionIdProvider', useFactory: () => (this.submissionId), deps: [] }, + { provide: 'entityType', useFactory: () => (this.entityType), deps: [] }, ], parent: this.injector, }); diff --git a/src/app/submission/sections/container/themed-section-container.component.ts b/src/app/submission/sections/container/themed-section-container.component.ts index f8bab3b932c..b904095c064 100644 --- a/src/app/submission/sections/container/themed-section-container.component.ts +++ b/src/app/submission/sections/container/themed-section-container.component.ts @@ -15,8 +15,9 @@ export class ThemedSubmissionSectionContainerComponent extends ThemedComponent + + + +
+ {{frontendUrl}}/ + @if (formModel) { + + } +
+ @if (isEditItemScope && !!customSectionData && !!redirectedUrls) { +
+ @if (redirectedUrls.length > 0) { +

{{'submission.sections.custom-url.label.previous-urls' | translate}}

+ } +
    + @for (redirectedUrl of redirectedUrls; let i = $index; track redirectedUrl) { +
  • +
    + {{frontendUrl+'/'+redirectedUrl}} +
    + + +
  • + } +
+
+ } + diff --git a/src/app/submission/sections/custom-url/submission-section-custom-url.component.scss b/src/app/submission/sections/custom-url/submission-section-custom-url.component.scss new file mode 100644 index 00000000000..3bd4d0f522e --- /dev/null +++ b/src/app/submission/sections/custom-url/submission-section-custom-url.component.scss @@ -0,0 +1,18 @@ +.options-select-menu { + max-height: 25vh; +} + +.list-group{ + max-width: 600px; +} + +.list-group-item{ + width: 80%; + display: flex; + justify-content: space-between; +} + +.list-item{ + align-items: center; + display: flex; +} diff --git a/src/app/submission/sections/custom-url/submission-section-custom-url.component.spec.ts b/src/app/submission/sections/custom-url/submission-section-custom-url.component.spec.ts new file mode 100644 index 00000000000..1ee130e6518 --- /dev/null +++ b/src/app/submission/sections/custom-url/submission-section-custom-url.component.spec.ts @@ -0,0 +1,225 @@ +import { DebugElement } from '@angular/core'; +import { + ComponentFixture, + TestBed, + waitForAsync, +} from '@angular/core/testing'; +import { + FormControl, + FormGroup, +} from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { JsonPatchOperationsBuilder } from '@dspace/core/json-patch/builder/json-patch-operations-builder'; +import { WorkspaceitemSectionCustomUrlObject } from '@dspace/core/submission/models/workspaceitem-section-custom-url.model'; +import { + DynamicFormControlEvent, + DynamicInputModel, +} from '@ng-dynamic-forms/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; + +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; +import { FormService } from '../../../shared/form/form.service'; +import { SubmissionService } from '../../submission.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; +import { SubmissionSectionCustomUrlComponent } from './submission-section-custom-url.component'; +import SpyObj = jasmine.SpyObj; +import { FormFieldMetadataValueObject } from '@dspace/core/shared/form/models/form-field-metadata-value.model'; +import { SectionsType } from '@dspace/core/submission/sections-type'; + +import { FormComponent } from '../../../shared/form/form.component'; +import { getMockFormBuilderService } from '../../../shared/form/testing/form-builder-service.mock'; +import { getMockFormOperationsService } from '../../../shared/form/testing/form-operations-service.mock'; +import { getMockFormService } from '../../../shared/form/testing/form-service.mock'; + +describe('SubmissionSectionCustomUrlComponent', () => { + + let component: SubmissionSectionCustomUrlComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + const builderService: SpyObj = getMockFormBuilderService() as SpyObj; + const sectionFormOperationsService: SpyObj = getMockFormOperationsService() as SpyObj; + const customUrlData = { + 'url': 'test', + 'redirected-urls': [ + 'redirected1', + 'redirected2', + ], + } as WorkspaceitemSectionCustomUrlObject; + + let formService: any; + + const sectionService = jasmine.createSpyObj('sectionService', { + getSectionState: of({ data: customUrlData }), + setSectionStatus: () => undefined, + updateSectionData: (submissionId, sectionId, updatedData) => { + component.sectionData.data = updatedData; + }, + getSectionServerErrors: of([]), + checkSectionErrors: () => undefined, + }); + + const sectionObject: SectionDataObject = { + config: 'test config', + mandatory: true, + opened: true, + data: {}, + errorsToShow: [], + serverValidationErrors: [], + header: 'test header', + id: 'test section id', + sectionType: SectionsType.CustomUrl, + sectionVisibility: null, + }; + + const operationsBuilder = jasmine.createSpyObj('operationsBuilder', { + add: undefined, + remove: undefined, + replace: undefined, + }); + + const submissionService = jasmine.createSpyObj('SubmissionService', { + getSubmissionScope: jasmine.createSpy('getSubmissionScope'), + }); + + const changeEvent: DynamicFormControlEvent = { + $event: { + + type: 'change', + }, + context: null, + control: new FormControl({ + errors: null, + pristine: false, + status: 'VALID', + touched: true, + value: 'test-url', + _updateOn: 'change', + }), + group: new FormGroup({}), + model: new DynamicInputModel({ + additional: null, + asyncValidators: null, + controlTooltip: null, + errorMessages: null, + hidden: false, + hint: null, + id: 'url', + label: 'Url', + labelTooltip: null, + name: 'url', + relations: [], + required: false, + tabIndex: null, + updateOn: null, + }), + type: 'change', + }; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + SubmissionSectionCustomUrlComponent, + MockComponent(FormComponent), + ], + providers: [ + { provide: SectionsService, useValue: sectionService }, + { provide: SubmissionService, useValue: submissionService }, + { provide: JsonPatchOperationsBuilder, useValue: operationsBuilder }, + { provide: FormBuilderService, useValue: builderService }, + { provide: SectionFormOperationsService, useValue: sectionFormOperationsService }, + { provide: FormService, useValue: getMockFormService() }, + { provide: 'entityType', useValue: 'Person' }, + { provide: 'collectionIdProvider', useValue: 'test collection id' }, + { provide: 'sectionDataProvider', useValue: Object.assign({}, sectionObject) }, + { provide: 'submissionIdProvider', useValue: 'test submission id' }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubmissionSectionCustomUrlComponent); + component = fixture.componentInstance; + + formService = TestBed.inject(FormService); + formService.validateAllFormFields.and.callFake(() => null); + formService.isValid.and.returnValue(of(true)); + formService.getFormData.and.returnValue(of({})); + + de = fixture.debugElement; + fixture.detectChanges(); + }); + + afterEach(() => { + sectionFormOperationsService.getFieldValueFromChangeEvent.calls.reset(); + operationsBuilder.replace.calls.reset(); + operationsBuilder.add.calls.reset(); + }); + + it('should display custom url section', () => { + expect(de.query(By.css('.custom-url'))).toBeTruthy(); + }); + + it('should have the right url formed', () => { + expect(component.frontendUrl).toContain('/entities/person'); + }); + + it('formModel should have length of 1', () => { + expect(component.formModel.length).toEqual(1); + }); + + it('formModel should have 1 DynamicInputModel', () => { + expect(component.formModel[0] instanceof DynamicInputModel).toBeTrue(); + }); + + it('if edit item true should show redirected urls managment', () => { + expect(de.query(By.css('.previous-urls'))).toBeFalsy(); + }); + + it('if edit item true should show redirected urls managment', () => { + component.isEditItemScope = true; + fixture.detectChanges(); + expect(de.query(By.css('.previous-urls'))).toBeTruthy(); + }); + + it('when input changed it should call operationsBuilder replace', () => { + component.onChange(changeEvent); + fixture.detectChanges(); + + expect(operationsBuilder.replace).toHaveBeenCalled(); + }); + + it('when input changed and is not empty it should call operationsBuilder add function for redirected urls', () => { + component.isEditItemScope = true; + component.customSectionData.url = 'url'; + sectionFormOperationsService.getFieldValueFromChangeEvent.and.returnValue(new FormFieldMetadataValueObject('testurl')); + component.onChange(changeEvent); + fixture.detectChanges(); + + expect(operationsBuilder.add).toHaveBeenCalled(); + }); + + it('when input changed and is empty it should not call operationsBuilder add function for redirected urls', () => { + component.isEditItemScope = true; + component.customSectionData.url = 'url'; + sectionFormOperationsService.getFieldValueFromChangeEvent.and.returnValue(new FormFieldMetadataValueObject('')); + component.onChange(changeEvent); + fixture.detectChanges(); + + expect(operationsBuilder.add).not.toHaveBeenCalled(); + }); + + + it('when remove button clicked it should call operationsBuilder remove function for redirected urls', () => { + component.remove(1); + fixture.detectChanges(); + expect(operationsBuilder.remove).toHaveBeenCalled(); + }); + +}); diff --git a/src/app/submission/sections/custom-url/submission-section-custom-url.component.ts b/src/app/submission/sections/custom-url/submission-section-custom-url.component.ts new file mode 100644 index 00000000000..416e05c532c --- /dev/null +++ b/src/app/submission/sections/custom-url/submission-section-custom-url.component.ts @@ -0,0 +1,307 @@ +import { + AfterViewInit, + Component, + Inject, + ViewChild, +} from '@angular/core'; +import { + AbstractControl, + ValidationErrors, +} from '@angular/forms'; +import { JsonPatchOperationPathCombiner } from '@dspace/core/json-patch/builder/json-patch-operation-path-combiner'; +import { JsonPatchOperationsBuilder } from '@dspace/core/json-patch/builder/json-patch-operations-builder'; +import { SubmissionSectionError } from '@dspace/core/submission/models/submission-section-error.model'; +import { SubmissionSectionObject } from '@dspace/core/submission/models/submission-section-object.model'; +import { WorkspaceitemSectionCustomUrlObject } from '@dspace/core/submission/models/workspaceitem-section-custom-url.model'; +import { SectionsType } from '@dspace/core/submission/sections-type'; +import { SubmissionScopeType } from '@dspace/core/submission/submission-scope-type'; +import { URLCombiner } from '@dspace/core/url-combiner/url-combiner'; +import { + hasValue, + isEmpty, + isNotEmpty, +} from '@dspace/shared/utils/empty.util'; +import { + DynamicFormControlEvent, + DynamicFormControlModel, + DynamicInputModel, +} from '@ng-dynamic-forms/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { + combineLatest as observableCombineLatest, + Observable, + Subscription, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + take, +} from 'rxjs/operators'; + +import { FormComponent } from '../../../shared/form/form.component'; +import { FormService } from '../../../shared/form/form.service'; +import { SubmissionService } from '../../submission.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SectionModelComponent } from '../models/section.model'; +import { SectionDataObject } from '../models/section-data.model'; +import { SectionsService } from '../sections.service'; + + +/** + * This component represents the submission section for the custom url creation. + */ +@Component({ + selector: 'ds-submission-section-custom-url', + templateUrl: './submission-section-custom-url.component.html', + styleUrls: ['./submission-section-custom-url.component.scss'], + imports: [ + FormComponent, + TranslateModule, + ], +}) +export class SubmissionSectionCustomUrlComponent extends SectionModelComponent implements AfterViewInit { + + /** + * The form id + * @type {string} + */ + public formId: string; + + /** + * A boolean representing if this section is loading + * @type {boolean} + */ + public isLoading = true; + + /** + * The [JsonPatchOperationPathCombiner] object + * @type {JsonPatchOperationPathCombiner} + */ + protected pathCombiner: JsonPatchOperationPathCombiner; + + /** + * The list of Subscriptions this component subscribes to. + */ + private subs: Subscription[] = []; + + /** + * The current custom section data + */ + customSectionData: WorkspaceitemSectionCustomUrlObject; + + /** + * A list of all dynamic input models + */ + formModel: DynamicFormControlModel[]; + + /** + * Full path of the item page + */ + frontendUrl: string; + + /** + * Represents if the section is used in the editItem Scope of submission + */ + isEditItemScope = false; + + /** + * Represents the list of redirected urls to be managed + */ + redirectedUrls: string[] = []; + + private readonly errorMessagePrefix = 'error.validation.custom-url.'; + + /** + * The FormComponent reference + */ + @ViewChild('formRef') public formRef: FormComponent; + + constructor( + protected sectionService: SectionsService, + protected operationsBuilder: JsonPatchOperationsBuilder, + protected formOperationsService: SectionFormOperationsService, + protected formService: FormService, + protected submissionService: SubmissionService, + @Inject('entityType') public entityType: string, + @Inject('collectionIdProvider') public injectedCollectionId: string, + @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, + @Inject('submissionIdProvider') public injectedSubmissionId: string, + ) { + super( + injectedCollectionId, + injectedSectionData, + injectedSubmissionId, + ); + } + + /** + * Unsubscribe from all subscriptions + */ + onSectionDestroy(): void { + this.subs.forEach((subscription) => subscription.unsubscribe()); + } + + /** + * Initialize the section. + * Define if submission is in EditItem scope to allow user to manage redirect urls + * Setup the full path of the url that will be seen by the users + * Get current information and build the form + */ + onSectionInit(): void { + this.formId = this.formService.getUniqueId(this.sectionData.id); + this.setSubmissionScope(); + this.frontendUrl = new URLCombiner(window.location.origin, '/entities', encodeURIComponent(this.entityType.toLowerCase())).toString(); + this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); + + this.sectionService.getSectionState(this.submissionId, this.sectionData.id, SectionsType.CustomUrl).pipe( + take(1), + ).subscribe((state: SubmissionSectionObject) => { + this.initForm(state.data as WorkspaceitemSectionCustomUrlObject); + this.subscriptionOnSectionChange(); + }); + } + + setSubmissionScope() { + if (this.submissionService.getSubmissionScope() === SubmissionScopeType.EditItem) { + this.isEditItemScope = true; + } + } + + + /** + * Get section status + * + * @return Observable + * the section status + */ + protected getSectionStatus(): Observable { + const formStatus$ = this.formService.isValid(this.formId); + const serverValidationStatus$ = this.sectionService.getSectionServerErrors(this.submissionId, this.sectionData.id).pipe( + map((validationErrors) => isEmpty(validationErrors)), + ); + + return observableCombineLatest([formStatus$, serverValidationStatus$]).pipe( + map(([formValidation, serverSideValidation]: [boolean, boolean]) => { + return isEmpty(this.customSectionData.url) || formValidation && serverSideValidation; + }), + ); + } + + /** + * Initialize form model + * + * @param sectionData + * the section data retrieved from the server + */ + initForm(sectionData: WorkspaceitemSectionCustomUrlObject): void { + this.formModel = [ + new DynamicInputModel({ + id: 'url', + name: 'url', + value: sectionData.url, + placeholder: 'submission.sections.custom-url.url.placeholder', + errorMessages: { + 'conflict': 'error.validation.custom-url.conflict', + 'empty': 'error.validation.custom-url.empty', + 'invalid-characters': 'error.validation.custom-url.invalid-characters', + }, + }), + ]; + this.updateSectionData(sectionData); + } + + customUrlValidator = (_: AbstractControl): ValidationErrors | null => { + if (this.sectionData.errorsToShow?.length) { + const urlErrors = this.sectionData.errorsToShow.map((error) => + error.message.replace(this.errorMessagePrefix, '')); + const validationErrors: ValidationErrors = {}; + + urlErrors.forEach((error) => { + validationErrors[error] = true; + }); + return validationErrors; + } + return null; + }; + + /** + * Update control status + * @param addValidator + */ + updateControlStatus(addValidator = false): void { + const control = this.formRef?.formGroup?.get('url'); + if (control) { + if (addValidator) { + control.addValidators(this.customUrlValidator); + // reset errors on user input + this.subs.push(control.valueChanges.subscribe(() => { + control.setErrors(null); + })); + } + control.updateValueAndValidity({ onlySelf: true, emitEvent: false }); + } + } + + ngAfterViewInit(): void { + this.updateControlStatus(true); + } + + /** + * When an information is changed build the formOperations + * If the submission scope is in EditItem also manage redirected-urls formOperations + */ + onChange(event: DynamicFormControlEvent): void { + const path = this.formOperationsService.getFieldPathSegmentedFromChangeEvent(event); + const metadataValue = this.formOperationsService.getFieldValueFromChangeEvent(event); + this.operationsBuilder.replace(this.pathCombiner.getPath(path), metadataValue.value, true); + + if (isNotEmpty(metadataValue.value) && this.isEditItemScope && hasValue(this.customSectionData.url)) { + // Utilizing submissionCustomUrl.url as the last value saved we can add to the redirected-urls + this.operationsBuilder.add(this.pathCombiner.getPath(['redirected-urls']), this.customSectionData.url, false, true); + } + } + + /** + * When removing a redirected url build the formOperations + */ + remove(index: number): void { + this.operationsBuilder.remove(this.pathCombiner.getPath(['redirected-urls', index.toString()])); + this.redirectedUrls.splice(index, 1); + } + + /** + * Update section data + * + * @param sectionData + */ + private updateSectionData(sectionData: WorkspaceitemSectionCustomUrlObject): void { + this.customSectionData = sectionData; + // Remove sealed object so we can remove urls from array + if (hasValue(sectionData['redirected-urls']) && isNotEmpty(sectionData['redirected-urls'])) { + this.redirectedUrls = [...sectionData['redirected-urls']]; + } else { + this.redirectedUrls = []; + } + } + + private subscriptionOnSectionChange(): void { + this.subs.push( + this.sectionService.getSectionState(this.submissionId, this.sectionData.id, SectionsType.CustomUrl).pipe( + filter((sectionState) => { + return isNotEmpty(sectionState) && (isNotEmpty(sectionState.data) || isNotEmpty(sectionState.errorsToShow)); + }), + distinctUntilChanged(), + ).subscribe((state: SubmissionSectionObject) => { + this.updateSectionData(state.data as WorkspaceitemSectionCustomUrlObject); + const errors: SubmissionSectionError[] = state.errorsToShow; + + if (isNotEmpty(errors) || isNotEmpty(this.sectionData.errorsToShow)) { + this.sectionService.checkSectionErrors(this.submissionId, this.sectionData.id, this.formId, errors, this.sectionData.errorsToShow); + this.sectionData.errorsToShow = errors; + this.updateControlStatus(true); + } + }), + ); + } +} diff --git a/src/app/submission/sections/sections-decorator.ts b/src/app/submission/sections/sections-decorator.ts index 5f584196cd6..471889d3b42 100644 --- a/src/app/submission/sections/sections-decorator.ts +++ b/src/app/submission/sections/sections-decorator.ts @@ -2,6 +2,7 @@ import { SectionsType } from '@dspace/core/submission/sections-type'; import { SubmissionSectionAccessesComponent } from './accesses/section-accesses.component'; import { SubmissionSectionCcLicensesComponent } from './cc-license/submission-section-cc-licenses.component'; +import { SubmissionSectionCustomUrlComponent } from './custom-url/submission-section-custom-url.component'; import { SubmissionSectionDuplicatesComponent } from './duplicates/section-duplicates.component'; import { SubmissionSectionFormComponent } from './form/section-form.component'; import { SubmissionSectionIdentifiersComponent } from './identifiers/section-identifiers.component'; @@ -21,6 +22,7 @@ submissionSectionsMap.set(SectionsType.SubmissionForm, SubmissionSectionFormComp submissionSectionsMap.set(SectionsType.Identifiers, SubmissionSectionIdentifiersComponent); submissionSectionsMap.set(SectionsType.CoarNotify, SubmissionSectionCoarNotifyComponent); submissionSectionsMap.set(SectionsType.Duplicates, SubmissionSectionDuplicatesComponent); +submissionSectionsMap.set(SectionsType.CustomUrl, SubmissionSectionCustomUrlComponent); /** * @deprecated diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index 3774f7e89ea..09e9d5d9cc0 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -128,7 +128,6 @@ export class SectionsService { // Iterate over the previous error list prevErrors.forEach((error: SubmissionSectionError) => { const errorPaths: SectionErrorPath[] = parseSectionErrorPaths(error.path); - errorPaths.forEach((path: SectionErrorPath) => { if (path.fieldId) { if (!dispatchedErrors.includes(path.fieldId)) { diff --git a/src/app/submission/utils/submission.mock.ts b/src/app/submission/utils/submission.mock.ts index 991c6afc943..35b97993c4c 100644 --- a/src/app/submission/utils/submission.mock.ts +++ b/src/app/submission/utils/submission.mock.ts @@ -404,6 +404,11 @@ export const mockSubmissionObject = { language: null, value: 'Collection of Sample Items', }, + { + key: 'dspace.entity.type', + language: null, + value: 'Entity type of Sample Collection', + }, ], _links: { license: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license' }, diff --git a/src/assets/i18n/de.json5 b/src/assets/i18n/de.json5 index 462b91bfd59..55198b6c766 100644 --- a/src/assets/i18n/de.json5 +++ b/src/assets/i18n/de.json5 @@ -2918,13 +2918,23 @@ // "error.top-level-communities": "Error fetching top-level communities", "error.top-level-communities": "Hauptbereich konnte nicht geladen werden", - // "error.validation.license.notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission.", - "error.validation.license.notgranted": "Um die Veröffentlichung abzuschließen, müssen Sie die Lizenzbedingungen akzeptieren. Wenn Sie zur Zeit dazu nicht in der Lage sind, können Sie Ihre Arbeit sichern und später dazu zurückkehren, um zuzustimmen oder die Einreichung zu löschen.", + // "error.validation.license.required": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission.", + // TODO New key - Add a translation + "error.validation.license.required": "Um die Veröffentlichung abzuschließen, müssen Sie die Lizenzbedingungen akzeptieren. Wenn Sie zur Zeit dazu nicht in der Lage sind, können Sie Ihre Arbeit sichern und später dazu zurückkehren, um zuzustimmen oder die Einreichung zu löschen.", // "error.validation.cclicense.required": "You must grant this cclicense to complete your submission. If you are unable to grant the cclicense at this time, you may save your work and return later or remove the submission.", // TODO New key - Add a translation "error.validation.cclicense.required": "You must grant this cclicense to complete your submission. If you are unable to grant the cclicense at this time, you may save your work and return later or remove the submission.", + // "error.validation.custom-url.conflict": "The custom url has been already used, please try with a new one.", + "error.validation.custom-url.conflict": "Die sprechende URL wird bereits andersweitig genutzt. Bitte versuchen Sie es mit einer anderen URL.", + + // "error.validation.custom-url.empty": "The custom url is required and cannot be empty.", + "error.validation.custom-url.empty": "Die sprechende URL ist erforderlich und darf nicht leer sein.", + + // "error.validation.custom-url.invalid-characters": "The custom url contains invalid characters.", + "error.validation.custom-url.invalid-characters": "Die sprechende URL enthält ungültige Zeichen.", + // "error.validation.pattern": "This input is restricted by the current pattern: {{ pattern }}.", "error.validation.pattern": "Die Eingabe muss dem folgenden Muster entsprechen: {{ pattern }}.", @@ -8483,6 +8493,9 @@ // "submission.sections.submit.progressbar.CClicense": "Creative commons license", "submission.sections.submit.progressbar.CClicense": "Creative commons license", + // "submission.sections.submit.progressbar.CustomUrlStep": "Custom Url", + "submission.sections.submit.progressbar.CustomUrlStep": "Sprechende URL", + // "submission.sections.submit.progressbar.describe.recycle": "Recycle", "submission.sections.submit.progressbar.describe.recycle": "Wiederverwerten", @@ -8648,6 +8661,16 @@ // "submission.sections.upload.upload-successful": "Upload successful", "submission.sections.upload.upload-successful": "Hochladen erfolgreich", + // "submission.sections.custom-url.label.previous-urls": "Previous Urls", + // TODO New key - Add a translation + "submission.sections.custom-url.label.previous-urls": "Previous Urls", + + // "submission.sections.custom-url.alert.info": "Define here a custom URL which will be used to reach the item instead of using an internal randomly generated UUID identifier. ", + "submission.sections.custom-url.alert.info": "Legen Sie hier eine sprechende URL fest, über die das Item anstelle eines zufällig generierten internen UUID Identifiers erreichbar ist. ", + + // "submission.sections.custom-url.url.placeholder": "Custom URL", + "submission.sections.custom-url.url.placeholder": "Sprechende URL", + // "submission.sections.accesses.form.discoverable-description": "When checked, this item will be discoverable in search/browse. When unchecked, the item will only be available via a direct link and will never appear in search/browse.", "submission.sections.accesses.form.discoverable-description": "Wenn ausgewählt, kann das Item in der Suche/im Browsing gefunden werden. Wenn nicht ausgewählt, ist das Item nur über einen direkten Link aufrufbar und erscheint nie in der Suche/im Browsing.", @@ -11111,5 +11134,16 @@ // TODO New key - Add a translation "file-download-link.request-copy": "Request a copy of ", + // "item.preview.organization.url": "URL", + // TODO New key - Add a translation + "item.preview.organization.url": "URL", + + // "item.preview.organization.address.addressLocality": "City", + "item.preview.organization.address.addressLocality": "Stadt", + + // "item.preview.organization.alternateName": "Alternative name", + // TODO New key - Add a translation + "item.preview.organization.alternateName": "Alternativer name", + } diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index c3bdb7b0374..0fc94d16210 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1979,10 +1979,16 @@ "error.top-level-communities": "Error fetching top-level communities", - "error.validation.license.notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission.", + "error.validation.license.required": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission.", "error.validation.cclicense.required": "You must grant this cclicense to complete your submission. If you are unable to grant the cclicense at this time, you may save your work and return later or remove the submission.", + "error.validation.custom-url.conflict": "The custom url has been already used, please try with a new one.", + + "error.validation.custom-url.empty": "The custom url is required and cannot be empty.", + + "error.validation.custom-url.invalid-characters": "The custom url contains invalid characters. Only alphanumeric characters, dashes and underscores are supported.", + "error.validation.pattern": "This input is restricted by the current pattern: {{ pattern }}.", "error.validation.filerequired": "The file upload is mandatory", @@ -5608,6 +5614,8 @@ "submission.sections.submit.progressbar.CClicense": "Creative commons license", + "submission.sections.submit.progressbar.CustomUrlStep": "Custom Url", + "submission.sections.submit.progressbar.describe.recycle": "Recycle", "submission.sections.submit.progressbar.describe.stepcustom": "Describe", @@ -5718,6 +5726,12 @@ "submission.sections.upload.upload-successful": "Upload successful", + "submission.sections.custom-url.label.previous-urls": "Previous Urls", + + "submission.sections.custom-url.alert.info": "Define here a custom URL which will be used to reach the item instead of using an internal randomly generated UUID identifier. ", + + "submission.sections.custom-url.url.placeholder": "Custom URL", + "submission.sections.accesses.form.discoverable-description": "When checked, this item will be discoverable in search/browse. When unchecked, the item will only be available via a direct link and will never appear in search/browse.", "submission.sections.accesses.form.discoverable-label": "Discoverable",