Skip to content

Commit bf14771

Browse files
[DURACOM-413] port custom url functionality
1 parent bf72132 commit bf14771

12 files changed

Lines changed: 700 additions & 13 deletions

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"start:prod": "npm run build:prod && cross-env NODE_ENV=production npm run serve:ssr",
1010
"start:mirador:prod": "npm run build:mirador && npm run start:prod",
1111
"preserve": "npm run base-href",
12-
"serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts",
12+
"serve": "ts-node -r tsconfig-paths/register --project ./tsconfig.ts-node.json scripts/serve.ts",
1313
"serve:ssr": "node dist/server/main",
1414
"analyze": "webpack-bundle-analyzer dist/browser/stats.json",
1515
"build": "ng build --configuration development",
@@ -43,7 +43,7 @@
4343
"cypress:open": "cypress open",
4444
"cypress:run": "cypress run",
4545
"env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts",
46-
"base-href": "ts-node --project ./tsconfig.ts-node.json scripts/base-href.ts",
46+
"base-href": "ts-node -r tsconfig-paths/register --project ./tsconfig.ts-node.json scripts/base-href.ts",
4747
"check-circ-deps": "npx madge --exclude '.nx|(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts|index.ts$' --circular --extensions ts ./",
4848
"postinstall": "npm run build:lint || echo 'Skipped DSpace ESLint plugins.'"
4949
},

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

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

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

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

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

433+
/**
434+
* Returns an observable of {@link RemoteData} of an object, based on its CustomURL or ID, with a list of
435+
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
436+
* @param id CustomUrl or UUID of object we want to retrieve
437+
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
438+
* no valid cached version. Defaults to true
439+
* @param reRequestOnStale Whether or not the request should automatically be re-
440+
* requested after the response becomes stale
441+
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
442+
* {@link HALLink}s should be automatically resolved
443+
* @param projections List of {@link projections} used to pass as parameters
444+
*/
445+
private findByCustomUrl(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, linksToFollow: FollowLinkConfig<Item>[], projections: string[] = []): Observable<RemoteData<Item>> {
446+
const searchHref = 'findByCustomURL';
447+
448+
const options = Object.assign({}, {
449+
searchParams: [
450+
new RequestParam('q', id),
451+
],
452+
});
453+
454+
projections.forEach((projection) => {
455+
options.searchParams.push(new RequestParam('projection', projection));
456+
});
457+
458+
const hrefObs = this.searchData.getSearchByHref(searchHref, options, ...linksToFollow);
459+
460+
return this.findByHref(hrefObs, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
461+
}
462+
463+
/**
464+
* Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of
465+
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
466+
* @param id ID of object we want to retrieve
467+
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
468+
* no valid cached version. Defaults to true
469+
* @param reRequestOnStale Whether or not the request should automatically be re-
470+
* requested after the response becomes stale
471+
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
472+
* {@link HALLink}s should be automatically resolved
473+
*/
474+
findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Item>[]): Observable<RemoteData<Item>> {
475+
476+
if (uuidValidate(id)) {
477+
const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow);
478+
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
479+
} else {
480+
return this.findByCustomUrl(id, useCachedVersionIfAvailable, reRequestOnStale, linksToFollow);
481+
}
482+
}
483+
428484
}
429485

430486
/**

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

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

43+
3744
export function getEntityPageRoute(entityType: string, itemId: string) {
3845
if (isNotEmpty(entityType)) {
3946
return new URLCombiner(`/${ENTITY_MODULE_PATH}`, encodeURIComponent(entityType.toLowerCase()), itemId).toString();
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* An interface to represent the submission's custom url section data.
3+
*/
4+
export interface WorkspaceitemSectionCustomUrlObject {
5+
'redirected-urls': string[];
6+
'url': string;
7+
}

src/app/core/submission/sections-type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export enum SectionsType {
44
Upload = 'upload',
55
License = 'license',
66
CcLicense = 'cclicense',
7+
CustomUrl = 'custom-url',
78
AccessesCondition = 'accessCondition',
89
SherpaPolicies = 'sherpaPolicy',
910
Identifiers = 'identifiers',
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export enum SubmissionScopeType {
22
WorkspaceItem = 'WORKSPACE',
33
WorkflowItem = 'WORKFLOW',
4+
EditItem = 'EDIT'
45
}

src/app/item-page/item-page.resolver.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,72 @@ describe('itemPageResolver', () => {
101101
});
102102

103103
});
104+
105+
describe('when item has cris.customurl metadata', () => {
106+
107+
108+
const customUrl = 'my-custom-item';
109+
let resolver: any;
110+
let itemService: any;
111+
let store: any;
112+
let router: Router;
113+
let authService: AuthServiceStub;
114+
115+
const uuid = '1234-65487-12354-1235';
116+
let item: DSpaceObject;
117+
118+
beforeEach(() => {
119+
router = TestBed.inject(Router);
120+
item = Object.assign(new DSpaceObject(), {
121+
uuid: uuid,
122+
firstMetadataValue(_keyOrKeys: string | string[], _valueFilter?: MetadataValueFilter): string {
123+
return _keyOrKeys === 'dspace.entity.type' ? 'person' : customUrl;
124+
},
125+
hasMetadata(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): boolean {
126+
return true;
127+
},
128+
metadata: {
129+
'cris.customurl': customUrl,
130+
},
131+
});
132+
itemService = {
133+
findById: (_id: string) => createSuccessfulRemoteDataObject$(item),
134+
};
135+
store = jasmine.createSpyObj('store', {
136+
dispatch: {},
137+
});
138+
authService = new AuthServiceStub();
139+
resolver = itemPageResolver;
140+
});
141+
142+
it('should navigate to the new custom URL if cris.customurl is defined and different from route param', (done) => {
143+
spyOn(router, 'navigateByUrl').and.callThrough();
144+
145+
const route = { params: { id: uuid } } as any;
146+
const state = { url: `/entities/person/${uuid}` } as any;
147+
148+
resolver(route, state, router, itemService, store, authService)
149+
.pipe(first())
150+
.subscribe((rd: any) => {
151+
const expectedUrl = `/entities/person/${customUrl}`;
152+
expect(router.navigateByUrl).toHaveBeenCalledWith(expectedUrl);
153+
done();
154+
});
155+
});
156+
157+
it('should not navigate if cris.customurl matches the current route id', (done) => {
158+
spyOn(router, 'navigateByUrl').and.callThrough();
159+
160+
const route = { params: { id: customUrl } } as any;
161+
const state = { url: `/entities/person/${customUrl}` } as any;
162+
163+
resolver(route, state, router, itemService, store, authService)
164+
.pipe(first())
165+
.subscribe((rd: any) => {
166+
expect(router.navigateByUrl).not.toHaveBeenCalled();
167+
done();
168+
});
169+
});
170+
});
171+
104172
});

src/app/item-page/item-page.resolver.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,27 @@ export const itemPageResolver: ResolveFn<RemoteData<Item>> = (
5959
return itemRD$.pipe(
6060
map((rd: RemoteData<Item>) => {
6161
if (rd.hasSucceeded && hasValue(rd.payload)) {
62-
const thisRoute = state.url;
62+
const isItemEditPage = state.url.includes('/edit');
63+
let itemRoute = isItemEditPage ? state.url : router.parseUrl(getItemPageRoute(rd.payload)).toString();
64+
if (hasValue(rd.payload.metadata) && rd.payload.hasMetadata('cris.customurl')) {
65+
if (route.params.id !== rd.payload.firstMetadataValue('cris.customurl')) {
66+
const newUrl = itemRoute.replace(route.params.id,rd.payload.firstMetadataValue('cris.customurl'));
67+
router.navigateByUrl(newUrl);
68+
}
69+
} else {
70+
const thisRoute = state.url;
6371

64-
// Angular uses a custom function for encodeURIComponent, (e.g. it doesn't encode commas
65-
// or semicolons) and thisRoute has been encoded with that function. If we want to compare
66-
// it with itemRoute, we have to run itemRoute through Angular's version as well to ensure
67-
// the same characters are encoded the same way.
68-
const itemRoute = router.parseUrl(getItemPageRoute(rd.payload)).toString();
72+
// Angular uses a custom function for encodeURIComponent, (e.g. it doesn't encode commas
73+
// or semicolons) and thisRoute has been encoded with that function. If we want to compare
74+
// it with itemRoute, we have to run itemRoute through Angular's version as well to ensure
75+
// the same characters are encoded the same way.
76+
itemRoute = router.parseUrl(getItemPageRoute(rd.payload)).toString();
6977

70-
if (!thisRoute.startsWith(itemRoute)) {
71-
const itemId = rd.payload.uuid;
72-
const subRoute = thisRoute.substring(thisRoute.indexOf(itemId) + itemId.length, thisRoute.length);
73-
void router.navigateByUrl(itemRoute + subRoute);
78+
if (!thisRoute.startsWith(itemRoute)) {
79+
const itemId = rd.payload.uuid;
80+
const subRoute = thisRoute.substring(thisRoute.indexOf(itemId) + itemId.length, thisRoute.length);
81+
void router.navigateByUrl(itemRoute + subRoute);
82+
}
7483
}
7584
}
7685
return rd;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<div class="container-fluid">
2+
3+
<div role="alert" class="alert alert-info fade show w-100 alert-dismissible">
4+
{{'submission.sections.custom-url.alert.info' | translate}}
5+
</div>
6+
7+
<div class="custom-url" style="display: flex;align-items: center;">
8+
<span class="frontend-url"> {{frontendUrl}}/</span>
9+
@if (formModel) {
10+
<ds-form #formRef="formComponent" [formId]="formId" [formModel]="formModel" [displaySubmit]="false" [displayReset]="false"
11+
(dfChange)="onChange($event)"></ds-form>
12+
}
13+
</div>
14+
@if (isEditItemScope && !!customSectionData && !!redirectedUrls) {
15+
<div class="previous-urls">
16+
@if (redirectedUrls.length > 0) {
17+
<h4 >{{'submission.sections.custom-url.label.previous-urls' | translate}}</h4>
18+
}
19+
<ul class="list-group">
20+
@for (redirectedUrl of redirectedUrls; let i = $index; track redirectedUrl) {
21+
<li class="list-item mb-2">
22+
<div class="list-group-item mr-2">
23+
<span>{{frontendUrl+'/'+redirectedUrl}}</span>
24+
</div>
25+
26+
<button type="button" class="btn btn-secondary" role="button" title="{{'submission.sections.custom-url.label.remove' | translate}}"
27+
attr.aria-label="{{'submission.sections.custom-url.label.remove' | translate}}" (click)="remove(i)">
28+
<span>
29+
<i class="fas fa-trash" aria-hidden="true"></i>
30+
</span>
31+
</button>
32+
</li>
33+
}
34+
</ul>
35+
</div>
36+
37+
}
38+
</div>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
.options-select-menu {
2+
max-height: 25vh;
3+
}
4+
5+
.frontend-url{
6+
margin-bottom: 1.3rem;
7+
}
8+
9+
.list-group{
10+
max-width: 600px;
11+
}
12+
13+
.list-group-item{
14+
width: 80%;
15+
display: flex;
16+
justify-content: space-between;
17+
}
18+
19+
.list-item{
20+
align-items: center;
21+
display: flex;
22+
}

0 commit comments

Comments
 (0)