diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index be657d71dc..71acceeb4c 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -1,4 +1,5 @@ import { Component, Inject } from '@angular/core'; +import { LinkService } from '../../../core/cache/builders/link.service'; import { Item } from '../../../core/shared/item.model'; import { ItemDataService } from '../../../core/data/item-data.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts index cef5e82957..0608eab2d8 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -3,15 +3,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs/internal/observable/of'; +import { LinkService } from '../../../../core/cache/builders/link.service'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { PaginatedList } from '../../../../core/data/paginated-list'; +import { RelationshipTypeService } from '../../../../core/data/relationship-type.service'; import { RemoteData } from '../../../../core/data/remote-data'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { Item } from '../../../../core/shared/item.model'; import { PageInfo } from '../../../../core/shared/page-info.model'; +import { getMockLinkService } from '../../../../shared/mocks/mock-link-service'; import { SharedModule } from '../../../../shared/shared.module'; import { EditRelationshipListComponent } from './edit-relationship-list.component'; @@ -152,6 +155,8 @@ describe('EditRelationshipListComponent', () => { declarations: [EditRelationshipListComponent], providers: [ { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: RelationshipTypeService, useValue: {} }, + { provide: LinkService, useValue: getMockLinkService() }, ], schemas: [ NO_ERRORS_SCHEMA ] diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts index 73e3e1f875..c17762e4a0 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -1,15 +1,21 @@ import { Component, Input, OnInit } from '@angular/core'; +import { LinkService } from '../../../../core/cache/builders/link.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { Observable } from 'rxjs/internal/Observable'; import {FieldUpdate, FieldUpdates} from '../../../../core/data/object-updates/object-updates.reducer'; import {Item} from '../../../../core/shared/item.model'; -import {map, switchMap} from 'rxjs/operators'; +import { map, switchMap, tap } from 'rxjs/operators'; import {hasValue} from '../../../../shared/empty.util'; import {Relationship} from '../../../../core/shared/item-relationships/relationship.model'; import {RelationshipType} from '../../../../core/shared/item-relationships/relationship-type.model'; -import {getRemoteDataPayload, getSucceededRemoteData} from '../../../../core/shared/operators'; -import {combineLatest as observableCombineLatest, combineLatest} from 'rxjs'; -import {ItemType} from '../../../../core/shared/item-relationships/item-type.model'; +import { + getAllSucceededRemoteData, + getRemoteDataPayload, + getSucceededRemoteData +} from '../../../../core/shared/operators'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; +import { followLink } from '../../../../shared/utils/follow-link-config.model'; @Component({ selector: 'ds-edit-relationship-list', @@ -47,6 +53,7 @@ export class EditRelationshipListComponent implements OnInit { constructor( protected objectUpdatesService: ObjectUpdatesService, + protected linkService: LinkService ) { } @@ -71,7 +78,7 @@ export class EditRelationshipListComponent implements OnInit { */ private getLabel(): Observable { - return combineLatest([ + return observableCombineLatest([ this.relationshipType.leftType, this.relationshipType.rightType, ].map((itemTypeRD) => itemTypeRD.pipe( @@ -94,8 +101,20 @@ export class EditRelationshipListComponent implements OnInit { ngOnInit(): void { this.updates$ = this.item.relationships.pipe( + getAllSucceededRemoteData(), map((relationships) => relationships.payload.page.filter((relationship) => relationship)), - switchMap((itemRelationships) => + map((relationships: Relationship[]) => + relationships.map((relationship: Relationship) => { + this.linkService.resolveLinks( + relationship, + followLink('relationshipType'), + followLink('leftItem'), + followLink('rightItem'), + ); + return relationship; + }) + ), + switchMap((itemRelationships: Relationship[]) => observableCombineLatest( itemRelationships .map((relationship) => relationship.relationshipType.pipe( diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts index 731e4885a6..c8bd577e04 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts @@ -26,6 +26,9 @@ import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SharedModule } from '../../../shared/shared.module'; +import { RouterStub } from '../../../shared/testing/router-stub'; import { ItemRelationshipsComponent } from './item-relationships.component'; let comp: any; diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts index 7f6d32d902..36ccca357c 100644 --- a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -98,7 +98,11 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent impl this.relationshipTypes$ = this.entityType$.pipe( switchMap((entityType) => - this.entityTypeService.getEntityTypeRelationships(entityType.id).pipe( + this.entityTypeService.getEntityTypeRelationships( + entityType.id, + followLink('leftType'), + followLink('rightType')) + .pipe( getSucceededRemoteData(), getRemoteDataPayload(), map((relationshipTypes) => relationshipTypes.page), diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 86794f257b..0928afcb19 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Store, StoreModule } from '@ngrx/store'; import { REQUEST } from '@nguniversal/express-engine/tokens'; import { of as observableOf } from 'rxjs'; +import { LinkService } from '../cache/builders/link.service'; import { authReducer, AuthState } from './auth.reducer'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; @@ -38,7 +39,7 @@ describe('AuthService test', () => { let storage: CookieService; let token: AuthTokenInfo; let authenticatedState; - let rdbService; + let linkService; function init() { mockStore = jasmine.createSpyObj('store', { @@ -58,8 +59,10 @@ describe('AuthService test', () => { }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); - rdbService = getMockRemoteDataBuildService(); - spyOn(rdbService, 'build').and.returnValue({authenticated: true, eperson: observableOf({payload: {}})}); + linkService = { + resolveLinks: {} + }; + spyOn(linkService, 'resolveLinks').and.returnValue({authenticated: true, eperson: observableOf({payload: {}})}); } @@ -80,7 +83,7 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: ActivatedRoute, useValue: routeStub }, { provide: Store, useValue: mockStore }, - { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: LinkService, useValue: linkService }, CookieService, AuthService ], @@ -143,7 +146,7 @@ describe('AuthService test', () => { { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, { provide: RouteService, useValue: routeServiceStub }, - { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: RemoteDataBuildService, useValue: linkService }, CookieService, AuthService ] @@ -156,7 +159,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, rdbService); + authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, linkService); })); it('should return true when user is logged in', () => { @@ -195,7 +198,7 @@ describe('AuthService test', () => { { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, { provide: RouteService, useValue: routeServiceStub }, - { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: RemoteDataBuildService, useValue: linkService }, ClientCookieService, CookieService, AuthService @@ -218,7 +221,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, rdbService); + authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, linkService); storage = (authService as any).storage; routeServiceMock = TestBed.get(RouteService); routerStub = TestBed.get(Router); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 69c468f32a..1da9f63b27 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -9,6 +9,7 @@ import { RouterReducerState } from '@ngrx/router-store'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; import { followLink } from '../../shared/utils/follow-link-config.model'; +import { LinkService } from '../cache/builders/link.service'; import { EPerson } from '../eperson/models/eperson.model'; import { AuthRequestService } from './auth-request.service'; @@ -22,8 +23,7 @@ import { AppState, routerStateSelector } from '../../app.reducer'; import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import {RouteService} from '../services/route.service'; +import { RouteService } from '../services/route.service'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -50,7 +50,7 @@ export class AuthService { protected routeService: RouteService, protected storage: CookieService, protected store: Store, - protected rdbService: RemoteDataBuildService + protected linkService: LinkService ) { this.store.pipe( select(isAuthenticated), @@ -134,7 +134,7 @@ export class AuthService { headers = headers.append('Authorization', `Bearer ${token.accessToken}`); options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( - map((status) => this.rdbService.build(status, followLink('eperson'))), + map((status) => this.linkService.resolveLinks(status, followLink('eperson'))), switchMap((status: AuthStatus) => { if (status.authenticated) { return status.eperson.pipe(map((eperson) => eperson.payload)); diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index f17f691a1f..eea2d83867 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -35,7 +35,7 @@ export class ServerAuthService extends AuthService { options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( - map((status) => this.rdbService.build(status, followLink('eperson'))), + map((status) => this.linkService.resolveLinks(status, followLink('eperson'))), switchMap((status: AuthStatus) => { if (status.authenticated) { return status.eperson.pipe(map((eperson) => eperson.payload)); diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index 3c1415b9a4..a1119e5fec 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -33,6 +33,15 @@ export function getClassForType(type: string | ResourceType) { return typeMap.get(type); } +/** + * A class decorator to indicate that this class is a dataservice + * for a given resource type. + * + * "dataservice" in this context means that it has findByHref and + * findAllByHref methods. + * + * @param resourceType the resource type the class is a dataservice for + */ export function dataService(resourceType: ResourceType): any { return (target: any) => { if (hasNoValue(resourceType)) { @@ -48,6 +57,11 @@ export function dataService(resourceType: ResourceType): any { }; } +/** + * Return the dataservice matching the given resource type + * + * @param resourceType the resource type you want the matching dataservice for + */ export function getDataServiceFor(resourceType: ResourceType) { return dataServiceMap.get(resourceType.value); } @@ -71,6 +85,9 @@ export function resolvedLink, K extends keyof T>(prov }; } +/** + * A class to represent the data that can be set by the @link decorator + */ export class LinkDefinition { resourceType: ResourceType; isList = false; @@ -78,6 +95,19 @@ export class LinkDefinition { propertyName: keyof T; } +/** + * A property decorator to indicate that a certain property is the placeholder + * where the contents of a resolved link should be stored. + * + * e.g. if an Item has an hal link for bundles, and an item.bundles property + * this decorator should decorate that item.bundles property. + * + * @param resourceType the resource type of the object(s) the link retrieves + * @param isList an optional boolean indicating whether or not it concerns a list, + * defaults to false + * @param linkName an optional string in case the HALLink name differs from the + * property name + */ export const link = ( resourceType: ResourceType, isList = false, @@ -105,10 +135,20 @@ export const link = ( } }; +/** + * Returns all LinkDefinitions for a model class + * @param source + */ export const getLinkDefinitions = (source: GenericConstructor): Map> => { return linkMap.get(source); }; +/** + * Returns a specific LinkDefinition for a model class + * + * @param source the model class + * @param linkName the name of the link + */ export const getLinkDefinition = (source: GenericConstructor, linkName: keyof T['_links']): LinkDefinition => { const sourceMap = linkMap.get(source); if (hasValue(sourceMap)) { @@ -118,6 +158,12 @@ export const getLinkDefinition = (source: GenericConstruc } }; +/** + * A class level decorator to indicate you want to inherit @link annotations + * from a parent class. + * + * @param parent the parent class to inherit @link annotations from + */ export const inheritLinkAnnotations = (parent: any): any => { return (child: any) => { const parentMap: Map> = linkMap.get(parent) || new Map(); diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts new file mode 100644 index 0000000000..21af7dda7c --- /dev/null +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -0,0 +1,222 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../../data/request.models'; +import { HALLink } from '../../shared/hal-link.model'; +import { HALResource } from '../../shared/hal-resource.model'; +import { ResourceType } from '../../shared/resource-type'; +import * as decorators from './build-decorators'; +import { getDataServiceFor } from './build-decorators'; +import { LinkService } from './link.service'; + +const spyOnFunction = (obj: T, func: keyof T) => { + const spy = jasmine.createSpy(func as string); + spyOnProperty(obj, func, 'get').and.returnValue(spy); + + return spy; +}; + +const TEST_MODEL = new ResourceType('testmodel'); +let result: any; + +/* tslint:disable:max-classes-per-file */ +class TestModel implements HALResource { + static type = TEST_MODEL; + + type = TEST_MODEL; + + value: string; + + _links: { + self: HALLink; + predecessor: HALLink; + successor: HALLink; + }; + + predecessor?: TestModel; + successor?: TestModel; +} + +@Injectable() +class TestDataService { + findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array>) { + return 'findAllByHref' + } + findByHref(href: string, ...linksToFollow: Array>) { + return 'findByHref' + } +} + +let testDataService: TestDataService; + +let testModel: TestModel; + +describe('LinkService', () => { + let service: LinkService; + + beforeEach(() => { + testModel = Object.assign(new TestModel(), { + value: 'a test value', + _links: { + self: { + href: 'http://self.link' + }, + predecessor: { + href: 'http://predecessor.link' + }, + successor: { + href: 'http://successor.link' + }, + } + }); + testDataService = new TestDataService(); + spyOn(testDataService, 'findAllByHref').and.callThrough(); + spyOn(testDataService, 'findByHref').and.callThrough(); + TestBed.configureTestingModule({ + providers: [LinkService, { + provide: TestDataService, + useValue: testDataService + }] + }); + service = TestBed.get(LinkService); + }); + + describe('resolveLink', () => { + describe(`when the linkdefinition concerns a single object`, () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor' + }); + spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); + service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))) + }); + it('should call dataservice.findByHref with the correct href and nested links', () => { + expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, followLink('successor')); + }); + }); + describe(`when the linkdefinition concerns a list`, () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor', + isList: true + }); + spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); + service.resolveLink(testModel, followLink('predecessor', { some: 'options '} as any, followLink('successor'))) + }); + it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => { + expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options '} as any, followLink('successor')); + }); + }); + describe('either way', () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor' + }); + spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); + result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))) + }); + + it('should call getLinkDefinition with the correct model and link', () => { + expect(decorators.getLinkDefinition).toHaveBeenCalledWith(testModel.constructor, 'predecessor'); + }); + + it('should call getDataServiceFor with the correct resource type', () => { + expect(decorators.getDataServiceFor).toHaveBeenCalledWith(TEST_MODEL); + }); + + it('should return the model with the resolved link', () => { + expect(result.type).toBe(TEST_MODEL); + expect(result.value).toBe('a test value'); + expect(result._links.self.href).toBe('http://self.link'); + expect(result.predecessor).toBe('findByHref'); + }); + }); + + describe(`when the specified link doesn't exist on the model's class`, () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue(undefined); + }); + it('should throw an error', () => { + expect(() => { + service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))) + }).toThrow(); + }); + }); + + describe(`when there is no dataservice for the resourcetype in the link`, () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor' + }); + spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(undefined); + }); + it('should throw an error', () => { + expect(() => { + service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))) + }).toThrow(); + }); + }); + }); + + describe('resolveLinks', () => { + beforeEach(() => { + spyOn(service, 'resolveLink'); + service.resolveLinks(testModel, followLink('predecessor'), followLink('successor')) + }); + + it('should call resolveLink with the model for each of the provided links', () => { + expect(service.resolveLink).toHaveBeenCalledWith(testModel, followLink('predecessor')); + expect(service.resolveLink).toHaveBeenCalledWith(testModel, followLink('successor')); + }); + + it('should return the model', () => { + expect(result.type).toBe(TEST_MODEL); + expect(result.value).toBe('a test value'); + expect(result._links.self.href).toBe('http://self.link'); + }); + }); + + describe('removeResolvedLinks', () => { + beforeEach(() => { + testModel.predecessor = 'predecessor value'; + testModel.successor = 'successor value'; + spyOnFunction(decorators, 'getLinkDefinitions').and.returnValue([ + { + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor', + }, + { + resourceType: TEST_MODEL, + linkName: 'successor', + propertyName: 'successor', + } + ]) + }); + + it('should return a new version of the object without any resolved links', () => { + result = service.removeResolvedLinks(testModel); + expect(result.value).toBe(testModel.value); + expect(result.type).toBe(testModel.type); + expect(result._links).toBe(testModel._links); + expect(result.predecessor).toBeUndefined(); + expect(result.successor).toBeUndefined(); + }); + + it('should leave the original object untouched', () => { + service.removeResolvedLinks(testModel); + expect(testModel.predecessor).toBe('predecessor value'); + expect(testModel.successor).toBe('successor value'); + }); + }); + +}); +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index 1b044ebee9..87f8eabd92 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -5,6 +5,10 @@ import { GenericConstructor } from '../../shared/generic-constructor'; import { HALResource } from '../../shared/hal-resource.model'; import { getDataServiceFor, getLinkDefinition, getLinkDefinitions, LinkDefinition } from './build-decorators'; +/** + * A Service to handle the resolving and removing + * of resolved HALLinks on HALResources + */ @Injectable({ providedIn: 'root' }) @@ -15,13 +19,26 @@ export class LinkService { ) { } - public resolveLinks(model: T, ...linksToFollow: Array>) { + /** + * Resolve the given {@link FollowLinkConfig}s for the given model + * + * @param model the {@link HALResource} to resolve the links for + * @param linksToFollow the {@link FollowLinkConfig}s to resolve + */ + public resolveLinks(model: T, ...linksToFollow: Array>): T { linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { this.resolveLink(model, linkToFollow); }); + return model; } - public resolveLink(model, linkToFollow: FollowLinkConfig) { + /** + * Resolve the given {@link FollowLinkConfig} for the given model + * + * @param model the {@link HALResource} to resolve the link for + * @param linkToFollow the {@link FollowLinkConfig} to resolve + */ + public resolveLink(model, linkToFollow: FollowLinkConfig): T { const matchingLinkDef = getLinkDefinition(model.constructor, linkToFollow.name); if (hasNoValue(matchingLinkDef)) { @@ -50,10 +67,14 @@ export class LinkService { throw new Error(`Something went wrong when using @dataService(${matchingLinkDef.resourceType.value}) ${hasValue(service) ? '' : '(undefined) '}to resolve link ${linkToFollow.name} from ${href}`); } } + return model; } /** * Remove any resolved links that the model may have. + * + * @param model the {@link HALResource} to remove the links from + * @returns a copy of the given model, without resolved links. */ public removeResolvedLinks(model: T): T { const result = Object.assign(new (model.constructor as GenericConstructor)(), model); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index f2dc5e4c6e..46b5f28465 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -80,9 +80,9 @@ export class RemoteDataBuildService { } }), hasValueOperator(), - map((obj: T) => { - return this.build(obj, ...linksToFollow); - }), + map((obj: T) => + this.linkService.resolveLinks(obj, ...linksToFollow) + ), startWith(undefined), distinctUntilChanged() ); @@ -135,9 +135,9 @@ export class RemoteDataBuildService { switchMap((resourceUUIDs: string[]) => { return this.objectCache.getList(resourceUUIDs).pipe( map((objs: T[]) => { - return objs.map((obj: T) => { - return this.build(obj, ...linksToFollow); - }); + return objs.map((obj: T) => + this.linkService.resolveLinks(obj, ...linksToFollow) + ); })); }), startWith([]), @@ -166,11 +166,6 @@ export class RemoteDataBuildService { return this.toRemoteDataObservable(requestEntry$, payload$); } - build(model: T, ...linksToFollow: Array>): T { - this.linkService.resolveLinks(model, ...linksToFollow); - return model; - } - aggregate(input: Array>>): Observable> { if (isEmpty(input)) { diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type.service.ts index 583601d898..5726aeb982 100644 --- a/src/app/core/data/entity-type.service.ts +++ b/src/app/core/data/entity-type.service.ts @@ -1,7 +1,7 @@ +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { DataService } from './data.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -25,11 +25,9 @@ import {ItemType} from '../shared/item-relationships/item-type.model'; export class EntityTypeService extends DataService { protected linkPath = 'entitytypes'; - protected forceBypassCache = false; constructor(protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, @@ -56,8 +54,9 @@ export class EntityTypeService extends DataService { /** * Get the allowed relationship types for an entity type * @param entityTypeId + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which HALLinks should be automatically resolved */ - getEntityTypeRelationships(entityTypeId: string): Observable>> { + getEntityTypeRelationships(entityTypeId: string, ...linksToFollow: Array>): Observable>> { const href$ = this.getRelationshipTypesEndpoint(entityTypeId); @@ -66,7 +65,7 @@ export class EntityTypeService extends DataService { this.requestService.configure(request); }); - return this.rdbService.buildList(href$); + return this.rdbService.buildList(href$, ...linksToFollow); } /** diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts index caf1e87469..f5d370dc0b 100644 --- a/src/app/core/data/relationship.service.spec.ts +++ b/src/app/core/data/relationship.service.spec.ts @@ -33,25 +33,6 @@ describe('RelationshipService', () => { rightwardType: 'isPublicationOfAuthor' }); - const relationship1 = Object.assign(new Relationship(), { - _links: { - self: { href: relationshipsEndpointURL + '/2' } - }, - id: '2', - uuid: '2', - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) - }); - const relationship2 = Object.assign(new Relationship(), { - _links: { - self: { href: relationshipsEndpointURL + '/3' } - }, - id: '3', - uuid: '3', - relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) - }); - - const relationships = [relationship1, relationship2]; - const item = Object.assign(new Item(), { id: 'publication', uuid: 'publication', @@ -76,6 +57,42 @@ describe('RelationshipService', () => { self: { href: restEndpointURL + '/author2' } } }); + + const relationship1 = Object.assign(new Relationship(), { + _links: { + self: { + href: relationshipsEndpointURL + '/2' + }, + leftItem: { + href: relatedItem1._links.self.href + }, + rightItem: { + href: item._links.self.href + } + }, + id: '2', + uuid: '2', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + const relationship2 = Object.assign(new Relationship(), { + _links: { + self: { + href: relationshipsEndpointURL + '/3' + }, + leftItem: { + href: relatedItem2._links.self.href + }, + rightItem: { + href: item._links.self.href + }, + }, + id: '3', + uuid: '3', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + + const relationships = [relationship1, relationship2]; + relationship1.leftItem = getRemotedataObservable(relatedItem1); relationship1.rightItem = getRemotedataObservable(item); relationship2.leftItem = getRemotedataObservable(relatedItem2); diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index 7df56252de..4dde567c99 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -446,19 +446,12 @@ export class RelationshipService extends DataService { clearRelatedCache(uuid: string): Observable { return this.findById(uuid).pipe( getSucceededRemoteData(), - switchMap((rd: RemoteData) => - observableCombineLatest( - rd.payload.leftItem.pipe(getSucceededRemoteData()), - rd.payload.rightItem.pipe(getSucceededRemoteData()) - ) - ), - take(1), - map(([leftItem, rightItem]) => { - this.objectCache.remove(leftItem.payload.self); - this.objectCache.remove(rightItem.payload.self); - this.requestService.removeByHrefSubstring(leftItem.payload.self); - this.requestService.removeByHrefSubstring(rightItem.payload.self); - }), + map((rd: RemoteData) => { + this.objectCache.remove(rd.payload._links.leftItem.href); + this.objectCache.remove(rd.payload._links.rightItem.href); + this.requestService.removeByHrefSubstring(rd.payload._links.leftItem.href); + this.requestService.removeByHrefSubstring(rd.payload._links.rightItem.href); + }) ); } } diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index e20cc1dbac..8f5038585d 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -87,7 +87,7 @@ export class Item extends DSpaceObject { * The list of Relationships this Item has with others * Will be undefined unless the relationships HALLink has been resolved. */ - @link(RELATIONSHIP) + @link(RELATIONSHIP, true) relationships?: Observable>>; /** diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 125317298c..1a016e64f8 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -342,8 +342,7 @@ export class SearchService implements OnDestroy { switchMap((dsoRD: RemoteData) => { if ((dsoRD.payload as any).type === Community.type.value) { const community: Community = dsoRD.payload as Community; - this.linkService.resolveLink(community, followLink('subcommunities')); - this.linkService.resolveLink(community, followLink('collections')); + this.linkService.resolveLinks(community, followLink('subcommunities'), followLink('collections')); return observableCombineLatest(community.subcommunities, community.collections).pipe( map(([subCommunities, collections]) => { /*if this is a community, we also need to show the direct children*/ diff --git a/src/app/shared/mocks/mock-remote-data-build.service.ts b/src/app/shared/mocks/mock-remote-data-build.service.ts index 5098c77e34..2dff033a26 100644 --- a/src/app/shared/mocks/mock-remote-data-build.service.ts +++ b/src/app/shared/mocks/mock-remote-data-build.service.ts @@ -21,7 +21,6 @@ export function getMockRemoteDataBuildService(toRemoteDataObservable$?: Observab } }, buildSingle: (href$: string | Observable) => createSuccessfulRemoteDataObject$({}), - build: (obj: any) => Object.create({}), buildList: (href$: string | Observable) => { if (hasValue(buildList$)) { return buildList$; @@ -46,7 +45,6 @@ export function getMockRemoteDataBuildServiceHrefMap(toRemoteDataObservable$?: O } }, buildSingle: (href$: string | Observable) => createSuccessfulRemoteDataObject$({}), - build: (obj: any) => Object.create({}), buildList: (href$: string | Observable) => { if (typeof href$ === 'string') { if (hasValue(buildListHrefMap$[href$])) { diff --git a/src/app/shared/utils/follow-link-config.model.ts b/src/app/shared/utils/follow-link-config.model.ts index 1b0dfa3c08..d42ed7bb3f 100644 --- a/src/app/shared/utils/follow-link-config.model.ts +++ b/src/app/shared/utils/follow-link-config.model.ts @@ -1,12 +1,42 @@ import { FindListOptions } from '../../core/data/request.models'; import { HALResource } from '../../core/shared/hal-resource.model'; +/** + * A class to configure the retrieval of a HALLink + */ export class FollowLinkConfig { + /** + * The name of the link to fetch. + * Can only be a HALLink of the object you're working with + */ name: keyof R['_links']; + + /** + * {@link FindListOptions} for the query, + * allows you to resolve the link using a certain page, or sorted + * in a certain way + */ findListOptions?: FindListOptions; + + /** + * A list of {@link FollowLinkConfig}s to + * use on the retrieved object. + */ linksToFollow?: Array>; } +/** + * A factory function for {@link FollowLinkConfig}s, + * in order to create them in a less verbose way. + * + * @param linkName: the name of the link to fetch. + * Can only be a HALLink of the object you're working with + * @param findListOptions: {@link FindListOptions} for the query, + * allows you to resolve the link using a certain page, or sorted + * in a certain way + * @param linksToFollow: a list of {@link FollowLinkConfig}s to + * use on the retrieved object. + */ export const followLink = ( linkName: keyof R['_links'], findListOptions?: FindListOptions,