Merge pull request #541 from atmire/metadata-and-relationships-combined-in-submission

Combining relationships and metadata during submission
This commit is contained in:
Tim Donohue
2020-07-22 09:26:55 -05:00
committed by GitHub
163 changed files with 2134 additions and 1128 deletions

View File

@@ -209,7 +209,7 @@ describe('BitstreamFormatsComponent', () => {
selectBitstreamFormat: {},
deselectBitstreamFormat: {},
deselectAllBitstreamFormats: {},
delete: observableOf(true),
delete: observableOf({ isSuccessful: true }),
clearBitStreamFormatRequests: observableOf('cleared')
});

View File

@@ -11,6 +11,7 @@ import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { RestResponse } from '../../../core/cache/response.models';
/**
* This component renders a list of bitstream formats
@@ -64,7 +65,7 @@ export class BitstreamFormatsComponent implements OnInit {
const tasks$ = [];
for (const format of formats) {
if (hasValue(format.id)) {
tasks$.push(this.bitstreamFormatService.delete(format.id));
tasks$.push(this.bitstreamFormatService.delete(format.id).pipe(map((response: RestResponse) => response.isSuccessful)));
}
}
zip(...tasks$).subscribe((results: boolean[]) => {

View File

@@ -5,8 +5,7 @@ import { hasNoValue, hasValue } from '../../shared/empty.util';
import { CommunityDataService } from '../../core/data/community-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { getFinishedRemoteData } from '../../core/shared/operators';
import { map, tap } from 'rxjs/operators';
import { map, tap, find } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
/**
@@ -29,12 +28,9 @@ export class CreateCollectionPageGuard implements CanActivate {
this.router.navigate(['/404']);
return observableOf(false);
}
const parent: Observable<RemoteData<Community>> = this.communityService.findById(parentID)
return this.communityService.findById(parentID)
.pipe(
getFinishedRemoteData(),
);
return parent.pipe(
find((communityRD: RemoteData<Community>) => hasValue(communityRD.payload) || hasValue(communityRD.error)),
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
tap((isValid: boolean) => {
if (!isValid) {

View File

@@ -5,8 +5,7 @@ import { hasNoValue, hasValue } from '../../shared/empty.util';
import { CommunityDataService } from '../../core/data/community-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { getFinishedRemoteData } from '../../core/shared/operators';
import { map, tap } from 'rxjs/operators';
import { map, tap, find } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
/**
@@ -29,18 +28,16 @@ export class CreateCommunityPageGuard implements CanActivate {
return observableOf(true);
}
const parent: Observable<RemoteData<Community>> = this.communityService.findById(parentID)
return this.communityService.findById(parentID)
.pipe(
getFinishedRemoteData(),
);
return parent.pipe(
find((communityRD: RemoteData<Community>) => hasValue(communityRD.payload) || hasValue(communityRD.error)),
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
tap((isValid: boolean) => {
if (!isValid) {
this.router.navigate(['/404']);
}
})
}
)
);
}
}

View File

@@ -114,7 +114,7 @@ describe('ItemBitstreamsComponent', () => {
}
);
bitstreamService = jasmine.createSpyObj('bitstreamService', {
deleteAndReturnResponse: jasmine.createSpy('deleteAndReturnResponse')
delete: jasmine.createSpy('delete')
});
objectCache = jasmine.createSpyObj('objectCache', {
remove: jasmine.createSpy('remove')
@@ -182,12 +182,25 @@ describe('ItemBitstreamsComponent', () => {
comp.submit();
});
it('should call deleteAndReturnResponse on the bitstreamService for the marked field', () => {
expect(bitstreamService.deleteAndReturnResponse).toHaveBeenCalledWith(bitstream2.id);
it('should call delete on the bitstreamService for the marked field', () => {
expect(bitstreamService.delete).toHaveBeenCalledWith(bitstream2.id);
});
it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => {
expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1.id);
it('should not call delete on the bitstreamService for the unmarked field', () => {
expect(bitstreamService.delete).not.toHaveBeenCalledWith(bitstream1.id);
});
});
describe('when dropBitstream is called', () => {
const event = {
fromIndex: 0,
toIndex: 50,
// tslint:disable-next-line:no-empty
finish: () => {}
};
beforeEach(() => {
comp.dropBitstream(bundle, event);
});
});

View File

@@ -165,7 +165,7 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme
take(1),
switchMap((removedBistreams: Bitstream[]) => {
if (isNotEmpty(removedBistreams)) {
return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.deleteAndReturnResponse(bitstream.id)));
return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.delete(bitstream.id)));
} else {
return observableOf(undefined);
}

View File

@@ -21,6 +21,7 @@ import { RelationshipService } from '../../../core/data/relationship.service';
import { EntityTypeService } from '../../../core/data/entity-type.service';
import { LinkService } from '../../../core/cache/builders/link.service';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { RestResponse } from '../../../core/cache/response.models';
@Component({
selector: 'ds-item-delete',
@@ -327,8 +328,8 @@ export class ItemDeleteComponent
),
).subscribe((types) => {
this.itemDataService.delete(this.item.id, types).pipe(first()).subscribe(
(succeeded: boolean) => {
this.notify(succeeded);
(response: RestResponse) => {
this.notify(response.isSuccessful);
}
);
});

View File

@@ -5,7 +5,7 @@ import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../../core/shared/item.model';
import { getFinishedRemoteData, getSucceededRemoteData } from '../../../../core/shared/operators';
import { getFirstSucceededRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
import { hasValue } from '../../../../shared/empty.util';
/**
@@ -75,16 +75,19 @@ export const paginatedRelationsToItems = (thisId: string) =>
getSucceededRemoteData(),
switchMap((relationshipsRD: RemoteData<PaginatedList<Relationship>>) => {
return observableCombineLatest(
...relationshipsRD.payload.page.map((rel: Relationship) => observableCombineLatest(rel.leftItem.pipe(getFinishedRemoteData()), rel.rightItem.pipe(getFinishedRemoteData())))
).pipe(
relationshipsRD.payload.page.map((rel: Relationship) =>
observableCombineLatest([
rel.leftItem.pipe(getFirstSucceededRemoteDataPayload()),
rel.rightItem.pipe(getFirstSucceededRemoteDataPayload())]
)
)).pipe(
map((arr) =>
arr
.filter(([leftItem, rightItem]) => leftItem.hasSucceeded && rightItem.hasSucceeded)
.map(([leftItem, rightItem]) => {
if (leftItem.payload.id === thisId) {
return rightItem.payload;
} else if (rightItem.payload.id === thisId) {
return leftItem.payload;
if (leftItem.id === thisId) {
return rightItem;
} else if (rightItem.id === thisId) {
return leftItem;
}
})
.filter((item: Item) => hasValue(item))

View File

@@ -7,6 +7,8 @@ import { RouteService } from '../../core/services/route.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { RequestService } from '../../core/data/request.service';
import { map } from 'rxjs/operators';
import { RestResponse } from '../../core/cache/response.models';
@Component({
selector: 'ds-workflow-item-delete',
@@ -39,6 +41,6 @@ export class WorkflowItemDeleteComponent extends WorkflowItemActionPageComponent
*/
sendRequest(id: string): Observable<boolean> {
this.requestService.removeByHrefSubstring('/discover');
return this.workflowItemService.delete(id);
return this.workflowItemService.delete(id).pipe(map((response: RestResponse) => response.isSuccessful));
}
}

View File

@@ -1,13 +1,7 @@
import { Injectable } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs';
import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators';
import {
hasValue,
hasValueOperator,
isEmpty,
isNotEmpty,
isNotUndefined
} from '../../../shared/empty.util';
import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../data/paginated-list';
@@ -15,12 +9,7 @@ import { RemoteData } from '../../data/remote-data';
import { RemoteDataError } from '../../data/remote-data-error';
import { RequestEntry } from '../../data/request.reducer';
import { RequestService } from '../../data/request.service';
import {
filterSuccessfulResponses,
getRequestFromRequestHref,
getRequestFromRequestUUID,
getResourceLinksFromResponse
} from '../../shared/operators';
import { filterSuccessfulResponses, getRequestFromRequestHref, getRequestFromRequestUUID, getResourceLinksFromResponse } from '../../shared/operators';
import { PageInfo } from '../../shared/page-info.model';
import { CacheableObject } from '../object-cache.reducer';
import { ObjectCacheService } from '../object-cache.service';
@@ -98,7 +87,8 @@ export class RemoteDataBuildService {
let error: RemoteDataError;
const response = reqEntry ? reqEntry.response : undefined;
if (hasValue(response)) {
isSuccessful = response.isSuccessful;
isSuccessful = response.statusCode === 204 ||
response.statusCode >= 200 && response.statusCode < 300 && hasValue(payload);
const errorMessage = isSuccessful === false ? (response as ErrorResponse).errorMessage : undefined;
if (hasValue(errorMessage)) {
error = new RemoteDataError(
@@ -155,7 +145,7 @@ export class RemoteDataBuildService {
})
);
const payload$ = observableCombineLatest(tDomainList$, pageInfo$).pipe(
const payload$ = observableCombineLatest([tDomainList$, pageInfo$]).pipe(
map(([tDomainList, pageInfo]) => {
return new PaginatedList(pageInfo, tDomainList);
})

View File

@@ -1,7 +1,5 @@
import { autoserialize, deserialize } from 'cerialize';
import { HALLink } from '../shared/hal-link.model';
import { HALResource } from '../shared/hal-resource.model';
import { excludeFromEquals } from '../utilities/equals.decorators';
import {
ObjectCacheAction,
ObjectCacheActionTypes,
@@ -15,12 +13,6 @@ import { CacheEntry } from './cache-entry';
import { ResourceType } from '../shared/resource-type';
import { applyPatch, Operation } from 'fast-json-patch';
export enum DirtyType {
Created = 'Created',
Updated = 'Updated',
Deleted = 'Deleted'
}
/**
* An interface to represent a JsonPatch
*/
@@ -72,6 +64,7 @@ export class ObjectCacheEntry implements CacheEntry {
patches: Patch[] = [];
isDirty: boolean;
}
/* tslint:enable:max-classes-per-file */
/**

View File

@@ -9,7 +9,7 @@ import { RestRequestMethod } from '../data/rest-request-method';
/**
* An entry in the ServerSyncBufferState
* href: unique href of an ObjectCacheEntry
* href: unique href of an ServerSyncBufferEntry
* method: RestRequestMethod type
*/
export class ServerSyncBufferEntry {
@@ -48,6 +48,7 @@ export function serverSyncBufferReducer(state = initialState, action: ServerSync
case ServerSyncBufferActionTypes.EMPTY: {
return emptyServerSyncQueue(state, action as EmptySSBAction);
}
default: {
return state;
}

View File

@@ -177,6 +177,7 @@ describe('ConfigResponseParsingService', () => {
Object.assign(new SubmissionDefinitionModel(), {
isDefault: true,
name: 'traditional',
id: 'traditional',
type: 'submissiondefinition',
_links: {
sections: { href: 'https://rest.api/config/submissiondefinitions/traditional/sections' },
@@ -187,6 +188,7 @@ describe('ConfigResponseParsingService', () => {
header: 'submit.progressbar.describe.stepone',
mandatory: true,
sectionType: 'submission-form',
id: 'traditionalpageone',
visibility: {
main: null,
other: 'READONLY'
@@ -201,6 +203,7 @@ describe('ConfigResponseParsingService', () => {
header: 'submit.progressbar.describe.steptwo',
mandatory: true,
sectionType: 'submission-form',
id: 'traditionalpagetwo',
visibility: {
main: null,
other: 'READONLY'
@@ -215,6 +218,7 @@ describe('ConfigResponseParsingService', () => {
header: 'submit.progressbar.upload',
mandatory: false,
sectionType: 'upload',
id: 'upload',
visibility: {
main: null,
other: 'READONLY'
@@ -229,6 +233,7 @@ describe('ConfigResponseParsingService', () => {
header: 'submit.progressbar.license',
mandatory: true,
sectionType: 'license',
id: 'license',
visibility: {
main: null,
other: 'READONLY'

View File

@@ -6,6 +6,12 @@ import { excludeFromEquals } from '../../utilities/equals.decorators';
export abstract class ConfigObject implements CacheableObject {
/**
* The name for this configuration
*/
@autoserialize
public id: string;
/**
* The name for this configuration
*/

View File

@@ -281,7 +281,7 @@ describe('BitstreamFormatDataService', () => {
format.uuid = 'format-uuid';
format.id = 'format-id';
const expected = cold('(b|)', {b: true});
const expected = cold('(b|)', { b: responseCacheEntry.response });
const result = service.delete(format.id);
expect(result).toBeObservable(expected);

View File

@@ -155,9 +155,9 @@ export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
/**
* Delete an existing DSpace Object on the server
* @param formatID The DSpace Object'id to be removed
* Return an observable that emits true when the deletion was successful, false when it failed
* @return the RestResponse as an Observable
*/
delete(formatID: string): Observable<boolean> {
delete(formatID: string): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
@@ -173,7 +173,7 @@ export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
map((request: RequestEntry) => request.response)
);
}

View File

@@ -0,0 +1,94 @@
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { compare, Operation } from 'fast-json-patch';
import { Observable, of as observableOf } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SortDirection, SortOptions } from '../cache/models/sort-options.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { ChangeAnalyzer } from './change-analyzer';
import { DataService } from './data.service';
import { FindListOptions, PatchRequest } from './request.models';
import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { BundleDataService } from './bundle-data.service';
import { HALLink } from '../shared/hal-link.model';
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
diff(object1: Item, object2: Item): Operation[] {
return compare((object1 as any).metadata, (object2 as any).metadata);
}
}
describe('BundleDataService', () => {
let service: BundleDataService;
let requestService;
let halService;
let rdbService;
let notificationsService;
let http;
let comparator;
let objectCache;
let store;
let item;
let bundleLink;
let bundleHALLink;
function initTestService(): BundleDataService {
bundleLink = '/items/0fdc0cd7-ff8c-433d-b33c-9b56108abc07/bundles';
bundleHALLink = new HALLink();
bundleHALLink.href = bundleLink;
item = new Item();
item._links = {
bundles: bundleHALLink
};
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = {} as RemoteDataBuildService;
notificationsService = {} as NotificationsService;
http = {} as HttpClient;
comparator = new DummyChangeAnalyzer() as any;
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
}
} as any;
store = {} as Store<CoreState>;
return new BundleDataService(
requestService,
rdbService,
store,
objectCache,
halService,
notificationsService,
http,
comparator,
);
}
beforeEach(() => {
service = initTestService();
});
describe('findAllByItem', () => {
beforeEach(() => {
spyOn(service, 'findAllByHref');
service.findAllByItem(item);
});
it('should call findAllByHref with the item\'s bundles link', () => {
expect(service.findAllByHref).toHaveBeenCalledWith(bundleLink, undefined);
})
});
});

View File

@@ -73,7 +73,12 @@ describe('CollectionDataService', () => {
describe('when the requests are successful', () => {
beforeEach(() => {
createService();
createService(observableOf({
request: {
href: 'https://rest.api/request'
},
completed: true
}));
});
describe('when calling getContentSource', () => {
@@ -133,7 +138,7 @@ describe('CollectionDataService', () => {
});
it('should return a RemoteData<PaginatedList<Colletion>> for the getAuthorizedCollection', () => {
const result = service.getAuthorizedCollection(queryString)
const result = service.getAuthorizedCollection(queryString);
const expected = cold('a|', {
a: paginatedListRD
});
@@ -148,7 +153,7 @@ describe('CollectionDataService', () => {
});
it('should return a RemoteData<PaginatedList<Colletion>> for the getAuthorizedCollectionByCommunity', () => {
const result = service.getAuthorizedCollectionByCommunity(communityId, queryString)
const result = service.getAuthorizedCollectionByCommunity(communityId, queryString);
const expected = cold('a|', {
a: paginatedListRD
});

View File

@@ -2,19 +2,8 @@ import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch';
import { Observable } from 'rxjs';
import {
distinctUntilChanged,
filter,
find,
first,
map,
mergeMap,
skipWhile,
switchMap,
take,
tap
} from 'rxjs/operators';
import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -28,25 +17,12 @@ import { CoreState } from '../core.reducers';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import {
configureRequest,
getRemoteDataPayload,
getResponseFromEntry,
getSucceededRemoteData
} from '../shared/operators';
import { configureRequest, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import { ChangeAnalyzer } from './change-analyzer';
import { PaginatedList } from './paginated-list';
import { RemoteData } from './remote-data';
import {
CreateRequest,
DeleteByIDRequest,
FindByIDRequest,
FindListOptions,
FindListRequest,
GetRequest,
PatchRequest, PutRequest
} from './request.models';
import { CreateRequest, DeleteByIDRequest, FindByIDRequest, FindListOptions, FindListRequest, GetRequest, PatchRequest, PutRequest } from './request.models';
import { RequestEntry } from './request.reducer';
import { RequestService } from './request.service';
import { RestRequestMethod } from './rest-request-method';
@@ -353,26 +329,24 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<T>>> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
return hrefObs.pipe(
find((href: string) => hasValue(href)),
tap((href: string) => {
this.requestService.removeByHrefSubstring(href);
const request = new FindListRequest(this.requestService.generateRequestId(), href, options);
hrefObs.pipe(
find((href: string) => hasValue(href))
).subscribe((href: string) => {
const request = new FindListRequest(requestId, href, options);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.configure(request);
}
});
return this.requestService.getByUUID(requestId).pipe(
find((requestEntry) => hasValue(requestEntry) && requestEntry.completed),
switchMap((requestEntry) =>
this.rdbService.buildList<T>(requestEntry.request.href, ...linksToFollow)
),
switchMap((href) => this.requestService.getByHref(href)),
skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed),
switchMap((href) =>
this.rdbService.buildList<T>(hrefObs, ...linksToFollow) as Observable<RemoteData<PaginatedList<T>>>
)
);
}
@@ -391,6 +365,9 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new PatchRequest(requestId, href, operations);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.configure(request);
})
).subscribe();
@@ -464,7 +441,13 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
const request$ = endpoint$.pipe(
take(1),
map((endpoint: string) => new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso)))
map((endpoint: string) => {
const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso));
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
return request
})
);
// Execute the post request
@@ -513,7 +496,13 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
const request$ = endpoint$.pipe(
take(1),
map((endpoint: string) => new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso)))
map((endpoint: string) => {
const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso));
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
return request
})
);
// Execute the post request
@@ -542,42 +531,9 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* @param dsoID The DSpace Object' id to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return an observable that emits true when the deletion was successful, false when it failed
* @return the RestResponse as an Observable
*/
delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<boolean> {
const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata);
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => isNotEmpty(request) && request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
);
}
/**
* Delete an existing DSpace Object on the server
* @param dsoID The DSpace Object' id to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* Return an observable of the completed response
*/
deleteAndReturnResponse(dsoID: string, copyVirtualMetadata?: string[]): Observable<RestResponse> {
const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata);
return this.requestService.getByUUID(requestId).pipe(
hasValueOperator(),
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response)
);
}
/**
* Delete an existing DSpace Object on the server
* @param dsoID The DSpace Object' id to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* Return the delete request's ID
*/
private deleteAndReturnRequestId(dsoID: string, copyVirtualMetadata?: string[]): string {
delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getIDHrefObs(dsoID);
@@ -593,11 +549,17 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
);
}
const request = new DeleteByIDRequest(requestId, href, dsoID);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.configure(request);
})
).subscribe();
return requestId;
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response)
);
}
/**
@@ -608,4 +570,15 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
this.requestService.commit(method);
}
/**
* Return the links to traverse from the root of the api to the
* endpoint this DataService represents
*
* e.g. if the api root links to 'foo', and the endpoint at 'foo'
* links to 'bar' the linkPath for the BarDataService would be
* 'foo/bar'
*/
getLinkPath(): string {
return this.linkPath;
}
}

View File

@@ -16,9 +16,10 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { HttpClient } from '@angular/common/http';
import { BrowseService } from '../browse/browse.service';
import { CollectionDataService } from './collection-data.service';
import { switchMap } from 'rxjs/operators';
import { switchMap, map } from 'rxjs/operators';
import { BundleDataService } from './bundle-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RestResponse } from '../cache/response.models';
/* tslint:disable:max-classes-per-file */
/**
@@ -121,7 +122,7 @@ class DataServiceImpl extends ItemDataService {
*/
deleteByCollectionID(item: Item, collectionID: string): Observable<boolean> {
this.setRegularEndpoint();
return super.delete(item.uuid);
return super.delete(item.uuid).pipe(map((response: RestResponse) => response.isSuccessful));
}
}

View File

@@ -63,6 +63,7 @@ export class LookupRelationService {
concat(subject.pipe(take(1)))
)
) as any
,
) as Observable<RemoteData<PaginatedList<SearchResult<Item>>>>;
}

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
import { filter, find, map, switchMap } from 'rxjs/operators';
import { filter, find, map, mergeMap, switchMap } from 'rxjs/operators';
import { AppState } from '../../app.reducer';
import { isNotUndefined } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
@@ -76,7 +76,7 @@ export class RelationshipTypeService extends DataService<RelationshipType> {
getSucceededRemoteData(),
/* Flatten the page so we can treat it like an observable */
switchMap((typeListRD: RemoteData<PaginatedList<RelationshipType>>) => typeListRD.payload.page),
switchMap((type: RelationshipType) => {
mergeMap((type: RelationshipType) => {
if (type.leftwardType === label) {
return this.checkType(type, firstType, secondType);
} else if (type.rightwardType === label) {
@@ -92,7 +92,7 @@ export class RelationshipTypeService extends DataService<RelationshipType> {
// returns a void observable if there's not match
// returns an observable that emits the relationship type when there is a match
private checkType(type: RelationshipType, firstType: string, secondType: string): Observable<RelationshipType> {
const entityTypes = observableCombineLatest(type.leftType.pipe(getSucceededRemoteData()), type.rightType.pipe(getSucceededRemoteData()));
const entityTypes = observableCombineLatest([type.leftType.pipe(getSucceededRemoteData()), type.rightType.pipe(getSucceededRemoteData())]);
return entityTypes.pipe(
find(([leftTypeRD, rightTypeRD]: [RemoteData<ItemType>, RemoteData<ItemType>]) => leftTypeRD.payload.label === firstType && rightTypeRD.payload.label === secondType),
filter((types) => isNotUndefined(types)),

View File

@@ -1,11 +1,6 @@
import { Observable } from 'rxjs/internal/Observable';
import { of as observableOf } from 'rxjs/internal/observable/of';
import * as ItemRelationshipsUtils from '../../+item-page/simple/item-types/shared/item-relationships-utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { spyOnOperator } from '../../shared/testing/utils.test';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
@@ -13,11 +8,16 @@ import { Relationship } from '../shared/item-relationships/relationship.model';
import { Item } from '../shared/item.model';
import { PageInfo } from '../shared/page-info.model';
import { PaginatedList } from './paginated-list';
import { RelationshipService } from './relationship.service';
import { RemoteData } from './remote-data';
import { DeleteRequest, FindListOptions } from './request.models';
import { RequestEntry } from './request.reducer';
import { RelationshipService } from './relationship.service';
import { RequestService } from './request.service';
import { RemoteData } from './remote-data';
import { RequestEntry } from './request.reducer';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/remote-data-build.service.mock';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { spyOnOperator } from '../../shared/testing/utils.test';
describe('RelationshipService', () => {
let service: RelationshipService;
@@ -159,8 +159,8 @@ describe('RelationshipService', () => {
it('should clear the cache of the related items', () => {
expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1._links.self.href);
expect(objectCache.remove).toHaveBeenCalledWith(item._links.self.href);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.uuid);
expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.uuid);
});
});
@@ -175,37 +175,6 @@ describe('RelationshipService', () => {
});
});
describe('getRelatedItems', () => {
let mockItem;
beforeEach(() => {
mockItem = { uuid: 'someid' } as Item;
spyOn(service, 'getItemRelationshipsArray').and.returnValue(observableOf(relationships));
spyOnOperator(ItemRelationshipsUtils, 'relationsToItems').and.returnValue((v) => v);
});
it('should call getItemRelationshipsArray with the correct params', (done) => {
service.getRelatedItems(mockItem).subscribe(() => {
expect(service.getItemRelationshipsArray).toHaveBeenCalledWith(
mockItem,
followLink('leftItem'),
followLink('rightItem'),
followLink('relationshipType')
);
done();
});
});
it('should use the relationsToItems operator', (done) => {
service.getRelatedItems(mockItem).subscribe(() => {
expect(ItemRelationshipsUtils.relationsToItems).toHaveBeenCalledWith(mockItem.uuid);
done();
});
});
});
describe('getRelatedItemsByLabel', () => {
let relationsList;
let mockItem;
@@ -258,7 +227,6 @@ describe('RelationshipService', () => {
});
});
})
});
function getRemotedataObservable(obj: any): Observable<RemoteData<any>> {

View File

@@ -1,21 +1,14 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs';
import { combineLatest as observableCombineLatest } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators';
import {
compareArraysUsingIds,
paginatedRelationsToItems,
relationsToItems
} from '../../+item-page/simple/item-types/shared/item-relationships-utils';
import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils';
import { AppState, keySelector } from '../../app.reducer';
import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { ReorderableRelationship } from '../../shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component';
import {
RemoveNameVariantAction,
SetNameVariantAction
} from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions';
import { RemoveNameVariantAction, SetNameVariantAction } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions';
import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -31,12 +24,7 @@ import { RelationshipType } from '../shared/item-relationships/relationship-type
import { Relationship } from '../shared/item-relationships/relationship.model';
import { RELATIONSHIP } from '../shared/item-relationships/relationship.resource-type';
import { Item } from '../shared/item.model';
import {
configureRequest,
getRemoteDataPayload,
getResponseFromEntry,
getSucceededRemoteData
} from '../shared/operators';
import { configureRequest, getFirstSucceededRemoteDataPayload, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators';
import { DataService } from './data.service';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { ItemDataService } from './item-data.service';
@@ -55,6 +43,19 @@ const relationshipStateSelector = (listID: string, itemID: string): MemoizedSele
return keySelector<string>(itemID, relationshipListStateSelector(listID));
};
/**
* Return true if the Item in the payload of the source observable matches
* the given Item by UUID
*
* @param itemCheck the Item to compare with
*/
const compareItemsByUUID = (itemCheck: Item) =>
(source: Observable<RemoteData<Item>>): Observable<boolean> =>
source.pipe(
getFirstSucceededRemoteDataPayload(),
map((item: Item) => item.uuid === itemCheck.uuid)
);
/**
* The service handling all relationship requests
*/
@@ -62,6 +63,7 @@ const relationshipStateSelector = (listID: string, itemID: string): MemoizedSele
@dataService(RELATIONSHIP)
export class RelationshipService extends DataService<Relationship> {
protected linkPath = 'relationships';
protected responseMsToLive = 15 * 60 * 1000;
constructor(protected itemService: ItemDataService,
protected requestService: RequestService,
@@ -101,11 +103,7 @@ export class RelationshipService extends DataService<Relationship> {
configureRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)),
getResponseFromEntry(),
switchMap((response) =>
this.clearRelatedCache(id).pipe(
map(() => response),
)
),
tap(() => this.refreshRelationshipItemsInCacheByRelationship(id)),
);
}
@@ -132,8 +130,8 @@ export class RelationshipService extends DataService<Relationship> {
configureRequest(this.requestService),
switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)),
getResponseFromEntry(),
tap(() => this.removeRelationshipItemsFromCache(item1)),
tap(() => this.removeRelationshipItemsFromCache(item2))
tap(() => this.refreshRelationshipItemsInCache(item1)),
tap(() => this.refreshRelationshipItemsInCache(item2))
) as Observable<RestResponse>;
}
@@ -141,19 +139,19 @@ export class RelationshipService extends DataService<Relationship> {
* Method to remove two items of a relationship from the cache using the identifier of the relationship
* @param relationshipId The identifier of the relationship
*/
private removeRelationshipItemsFromCacheByRelationship(relationshipId: string) {
this.findById(relationshipId).pipe(
private refreshRelationshipItemsInCacheByRelationship(relationshipId: string) {
this.findById(relationshipId, followLink('leftItem'), followLink('rightItem')).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
switchMap((rel: Relationship) => combineLatest(
switchMap((rel: Relationship) => observableCombineLatest(
rel.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()),
rel.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload())
)
),
take(1)
).subscribe(([item1, item2]) => {
this.removeRelationshipItemsFromCache(item1);
this.removeRelationshipItemsFromCache(item2);
this.refreshRelationshipItemsInCache(item1);
this.refreshRelationshipItemsInCache(item2);
})
}
@@ -161,13 +159,13 @@ export class RelationshipService extends DataService<Relationship> {
* Method to remove an item that's part of a relationship from the cache
* @param item The item to remove from the cache
*/
private removeRelationshipItemsFromCache(item) {
public refreshRelationshipItemsInCache(item) {
this.objectCache.remove(item._links.self.href);
this.requestService.removeByHrefSubstring(item.uuid);
combineLatest(
observableCombineLatest([
this.objectCache.hasBySelfLinkObservable(item._links.self.href),
this.requestService.hasByHrefObservable(item.uuid)
).pipe(
this.requestService.hasByHrefObservable(item.self)
]).pipe(
filter(([existsInOC, existsInRC]) => !existsInOC && !existsInRC),
take(1),
switchMap(() => this.itemService.findByHref(item._links.self.href).pipe(take(1)))
@@ -176,7 +174,10 @@ export class RelationshipService extends DataService<Relationship> {
/**
* Get an item's relationships in the form of an array
* @param item
*
* @param item The {@link Item} to get {@link Relationship}s for
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s
* should be automatically resolved
*/
getItemRelationshipsArray(item: Item, ...linksToFollow: Array<FollowLinkConfig<Relationship>>): Observable<Relationship[]> {
return this.findAllByHref(item._links.relationships.href, undefined, ...linksToFollow).pipe(
@@ -275,10 +276,10 @@ export class RelationshipService extends DataService<Relationship> {
getRelationshipsByRelatedItemIds(item: Item, uuids: string[]): Observable<Relationship[]> {
return this.getItemRelationshipsArray(item, followLink('leftItem'), followLink('rightItem')).pipe(
switchMap((relationships: Relationship[]) => {
return observableCombineLatest(...relationships.map((relationship: Relationship) => {
return observableCombineLatest(relationships.map((relationship: Relationship) => {
const isLeftItem$ = this.isItemInUUIDArray(relationship.leftItem, uuids);
const isRightItem$ = this.isItemInUUIDArray(relationship.rightItem, uuids);
return observableCombineLatest(isLeftItem$, isRightItem$).pipe(
return observableCombineLatest([isLeftItem$, isRightItem$]).pipe(
filter(([isLeftItem, isRightItem]) => isLeftItem || isRightItem),
map(() => relationship),
startWith(undefined)
@@ -304,34 +305,31 @@ export class RelationshipService extends DataService<Relationship> {
* @param label The rightward or leftward type of the relationship
*/
getRelationshipByItemsAndLabel(item1: Item, item2: Item, label: string, options?: FindListOptions): Observable<Relationship> {
return this.getItemRelationshipsByLabel(item1, label, options, followLink('relationshipType'), followLink('leftItem'), followLink('rightItem'))
.pipe(
getSucceededRemoteData(),
isNotEmptyOperator(),
map((relationshipListRD: RemoteData<PaginatedList<Relationship>>) => relationshipListRD.payload.page),
mergeMap((relationships: Relationship[]) => {
return observableCombineLatest(...relationships.map((relationship: Relationship) => {
return observableCombineLatest(
this.isItemMatchWithItemRD(relationship.leftItem, item2),
this.isItemMatchWithItemRD(relationship.rightItem, item2)
return this.getItemRelationshipsByLabel(
item1,
label,
options,
followLink('relationshipType'),
followLink('leftItem'),
followLink('rightItem')
).pipe(
getSucceededRemoteData(),
// the mergemap below will emit all elements of the list as separate events
mergeMap((relationshipListRD: RemoteData<PaginatedList<Relationship>>) => relationshipListRD.payload.page),
mergeMap((relationship: Relationship) => {
return observableCombineLatest([
this.itemService.findByHref(relationship._links.leftItem.href).pipe(compareItemsByUUID(item2)),
this.itemService.findByHref(relationship._links.rightItem.href).pipe(compareItemsByUUID(item2))
]).pipe(
map(([isLeftItem, isRightItem]) => isLeftItem || isRightItem),
map((isMatch) => isMatch ? relationship : undefined)
);
}))
}),
map((relationships: Relationship[]) => relationships.find(((relationship) => hasValue(relationship))))
filter((relationship) => hasValue(relationship)),
take(1)
)
}
private isItemMatchWithItemRD(itemRD$: Observable<RemoteData<Item>>, itemCheck: Item): Observable<boolean> {
return itemRD$.pipe(
getSucceededRemoteData(),
map((itemRD: RemoteData<Item>) => itemRD.payload),
map((item: Item) => item.uuid === itemCheck.uuid)
);
}
/**
* Method to set the name variant for specific list and item
* @param listID The list for which to save the name variant
@@ -378,7 +376,7 @@ export class RelationshipService extends DataService<Relationship> {
* @param nameVariant The name variant to set for the matching relationship
*/
public updateNameVariant(item1: Item, item2: Item, relationshipLabel: string, nameVariant: string): Observable<RemoteData<Relationship>> {
const update$: Observable<RemoteData<Relationship>> = this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel)
return this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel)
.pipe(
switchMap((relation: Relationship) =>
relation.relationshipType.pipe(
@@ -400,16 +398,6 @@ export class RelationshipService extends DataService<Relationship> {
return this.update(updatedRelationship);
}),
);
update$.pipe(
filter((relationshipRD: RemoteData<Relationship>) => relationshipRD.state === RemoteDataState.RequestPending),
take(1),
).subscribe(() => {
this.removeRelationshipItemsFromCache(item1);
this.removeRelationshipItemsFromCache(item2);
});
return update$
}
/**
@@ -432,7 +420,7 @@ export class RelationshipService extends DataService<Relationship> {
take(1),
).subscribe((relationshipRD: RemoteData<Relationship>) => {
if (relationshipRD.state === RemoteDataState.ResponsePending) {
this.removeRelationshipItemsFromCacheByRelationship(reoRel.relationship.id);
this.refreshRelationshipItemsInCacheByRelationship(reoRel.relationship.id);
}
});
@@ -440,18 +428,11 @@ export class RelationshipService extends DataService<Relationship> {
}
/**
* Clear object and request caches of the items related to a relationship (left and right items)
* @param uuid The uuid of the relationship for which to clear the related items from the cache
* Patch isn't supported on the relationship endpoint, so use put instead.
*
* @param object the {@link Relationship} to update
*/
clearRelatedCache(uuid: string): Observable<void> {
return this.findById(uuid).pipe(
getSucceededRemoteData(),
map((rd: RemoteData<Relationship>) => {
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);
})
);
update(object: Relationship): Observable<RemoteData<Relationship>> {
return this.put(object);
}
}

View File

@@ -224,7 +224,7 @@ export class EPersonDataService extends DataService<EPerson> {
* @param ePerson The EPerson to delete
*/
public deleteEPerson(ePerson: EPerson): Observable<boolean> {
return this.delete(ePerson.id);
return this.delete(ePerson.id).pipe(map((response: RestResponse) => response.isSuccessful));
}
/**

View File

@@ -135,7 +135,7 @@ export class GroupDataService extends DataService<Group> {
* @param id The group id to delete
*/
public deleteGroup(group: Group): Observable<boolean> {
return this.delete(group.id);
return this.delete(group.id).pipe(map((response: RestResponse) => response.isSuccessful));
}
/**

View File

@@ -1,13 +1,9 @@
import { Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
import {
NewPatchAddOperationAction,
NewPatchRemoveOperationAction,
NewPatchReplaceOperationAction
} from '../json-patch-operations.actions';
import { NewPatchAddOperationAction, NewPatchMoveOperationAction, NewPatchRemoveOperationAction, NewPatchReplaceOperationAction } from '../json-patch-operations.actions';
import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner';
import { Injectable } from '@angular/core';
import { isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { hasNoValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { dateToISOFormat } from '../../../shared/date.util';
import { AuthorityValue } from '../../integration/models/authority.value';
import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model';
@@ -53,6 +49,9 @@ export class JsonPatchOperationsBuilder {
* a boolean representing if the value to be added is a plain text value
*/
replace(path: JsonPatchOperationPathObject, value, plain = false) {
if (hasNoValue(value) || (typeof value === 'object' && hasNoValue(value.value))) {
this.remove(path);
} else {
this.store.dispatch(
new NewPatchReplaceOperationAction(
path.rootElement,
@@ -60,6 +59,26 @@ export class JsonPatchOperationsBuilder {
path.path,
this.prepareValue(value, plain, false)));
}
}
/**
* Dispatch a new NewPatchMoveOperationAction
*
* @param path
* the new path tho move to
* @param prevPath
* the original path to move from
*/
move(path: JsonPatchOperationPathObject, prevPath: string) {
this.store.dispatch(
new NewPatchMoveOperationAction(
path.rootElement,
path.subRootElement,
prevPath,
path.path
)
);
}
/**
* Dispatches a new NewPatchRemoveOperationAction

View File

@@ -196,7 +196,8 @@ function newOperation(state: JsonPatchOperationsState, action): JsonPatchOperati
body,
action.type,
action.payload.path,
hasValue(action.payload.value) ? action.payload.value : null);
hasValue(action.payload.value) ? action.payload.value : null,
hasValue(action.payload.from) ? action.payload.from : null);
if (hasValue(newState[ action.payload.resourceType ])
&& hasValue(newState[ action.payload.resourceType ].children)) {
@@ -293,7 +294,21 @@ function flushOperation(state: JsonPatchOperationsState, action: FlushPatchOpera
}
}
function addOperationToList(body: JsonPatchOperationObject[], actionType, targetPath, value?) {
/**
* Add a new operation to a patch
*
* @param body
* The current patch
* @param actionType
* The type of operation to add
* @param targetPath
* The path for the operation
* @param value
* The new value
* @param fromPath
* The previous path (in case of a move operation)
*/
function addOperationToList(body: JsonPatchOperationObject[], actionType, targetPath, value?, fromPath?) {
const newBody = Array.from(body);
switch (actionType) {
case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_ADD_OPERATION:
@@ -313,6 +328,9 @@ function addOperationToList(body: JsonPatchOperationObject[], actionType, target
case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_REMOVE_OPERATION:
newBody.push(makeOperationEntry({ op: JsonPatchOperationType.remove, path: targetPath }));
break;
case JsonPatchOperationsActionTypes.NEW_JSON_PATCH_MOVE_OPERATION:
newBody.push(makeOperationEntry({ op: JsonPatchOperationType.move, from: fromPath, path: targetPath }));
break;
}
return newBody;
}

View File

@@ -21,10 +21,8 @@ import {
RollbacktPatchOperationsAction,
StartTransactionPatchOperationsAction
} from './json-patch-operations.actions';
import { StoreMock } from '../../shared/testing/store.mock';
import { RequestEntry } from '../data/request.reducer';
import { catchError } from 'rxjs/operators';
import { storeModuleConfig } from '../../app.reducer';
class TestService extends JsonPatchOperationsService<SubmitDataResponseDefinitionObject, SubmissionPatchRequest> {
protected linkPath = '';
@@ -99,27 +97,22 @@ describe('JsonPatchOperationsService test suite', () => {
}
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({}, storeModuleConfig),
],
providers: [
{ provide: Store, useClass: StoreMock }
]
}).compileComponents();
}));
function getStore() {
return jasmine.createSpyObj('store', {
dispatch: {},
select: observableOf(mockState['json/patch'][testJsonPatchResourceType]),
pipe: observableOf(true)
});
}
beforeEach(() => {
store = TestBed.get(Store);
store = getStore();
requestService = getMockRequestService(getRequestEntry$(true));
rdbService = getMockRemoteDataBuildService();
scheduler = getTestScheduler();
halService = new HALEndpointServiceStub(resourceEndpointURL);
service = initTestService();
spyOn(store, 'select').and.returnValue(observableOf(mockState['json/patch'][testJsonPatchResourceType]));
spyOn(store, 'dispatch').and.callThrough();
spyOn(Date.prototype, 'getTime').and.callFake(() => {
return timestamp;
});
@@ -164,7 +157,7 @@ describe('JsonPatchOperationsService test suite', () => {
describe('when request is not successful', () => {
beforeEach(() => {
store = TestBed.get(Store);
store = getStore();
requestService = getMockRequestService(getRequestEntry$(false));
rdbService = getMockRemoteDataBuildService();
scheduler = getTestScheduler();
@@ -227,7 +220,7 @@ describe('JsonPatchOperationsService test suite', () => {
describe('when request is not successful', () => {
beforeEach(() => {
store = TestBed.get(Store);
store = getStore();
requestService = getMockRequestService(getRequestEntry$(false));
rdbService = getMockRemoteDataBuildService();
scheduler = getTestScheduler();

View File

@@ -127,7 +127,7 @@ describe('RegistryService', () => {
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(mockSchemasList)),
findById: createSuccessfulRemoteDataObject$(mockSchemasList[0]),
createOrUpdateMetadataSchema: createSuccessfulRemoteDataObject$(mockSchemasList[0]),
deleteAndReturnResponse: observableOf(new RestResponse(true, 200, 'OK')),
delete: observableOf(new RestResponse(true, 200, 'OK')),
clearRequests: observableOf('href')
});
@@ -136,7 +136,7 @@ describe('RegistryService', () => {
findById: createSuccessfulRemoteDataObject$(mockFieldsList[0]),
create: createSuccessfulRemoteDataObject$(mockFieldsList[0]),
put: createSuccessfulRemoteDataObject$(mockFieldsList[0]),
deleteAndReturnResponse: observableOf(new RestResponse(true, 200, 'OK')),
delete: observableOf(new RestResponse(true, 200, 'OK')),
clearRequests: observableOf('href')
});
}

View File

@@ -223,7 +223,7 @@ export class RegistryService {
* @param id The id of the metadata schema to delete
*/
public deleteMetadataSchema(id: number): Observable<RestResponse> {
return this.metadataSchemaService.deleteAndReturnResponse(`${id}`);
return this.metadataSchemaService.delete(`${id}`);
}
/**
@@ -269,7 +269,7 @@ export class RegistryService {
* @param id The id of the metadata field to delete
*/
public deleteMetadataField(id: number): Observable<RestResponse> {
return this.metadataFieldService.deleteAndReturnResponse(`${id}`);
return this.metadataFieldService.delete(`${id}`);
}
/**
* Method that clears a cached metadata field request and returns its REST url

View File

@@ -96,6 +96,8 @@ describe('ResourcePolicyService', () => {
});
responseCacheEntry = new RequestEntry();
responseCacheEntry.request = { href: 'https://rest.api/' } as any;
responseCacheEntry.completed = true;
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
requestService = jasmine.createSpyObj('requestService', {

View File

@@ -24,6 +24,8 @@ import { PaginatedList } from '../data/paginated-list';
import { ActionType } from './models/action-type.model';
import { RequestParam } from '../cache/models/request-param.model';
import { isNotEmpty } from '../../shared/empty.util';
import { map } from 'rxjs/operators';
import { RestResponse } from '../cache/response.models';
/* tslint:disable:max-classes-per-file */
@@ -100,7 +102,7 @@ export class ResourcePolicyService {
* @return an observable that emits true when the deletion was successful, false when it failed
*/
delete(resourcePolicyID: string): Observable<boolean> {
return this.dataService.delete(resourcePolicyID);
return this.dataService.delete(resourcePolicyID).pipe(map((response: RestResponse) => response.isSuccessful));
}
/**

View File

@@ -9,7 +9,8 @@ export enum Context {
Workflow = 'workflow',
Workspace = 'workspace',
AdminMenu = 'adminMenu',
SubmissionModal = 'submissionModal',
EntitySearchModalWithNameVariants = 'EntitySearchModalWithNameVariants',
EntitySearchModal = 'EntitySearchModal',
AdminSearch = 'adminSearch',
AdminWorkflowSearch = 'adminWorkflowSearch',
}

View File

@@ -3,7 +3,7 @@ import { autoserialize, Serialize, Deserialize } from 'cerialize';
import { hasValue } from '../../shared/empty.util';
/* tslint:disable:max-classes-per-file */
const VIRTUAL_METADATA_PREFIX = 'virtual::';
export const VIRTUAL_METADATA_PREFIX = 'virtual::';
/** A single metadata value and its properties. */
export interface MetadataValueInterface {

View File

@@ -66,7 +66,7 @@ export const getPaginatedListPayload = () =>
export const getSucceededRemoteData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(find((rd: RemoteData<T>) => rd.hasSucceeded));
source.pipe(filter((rd: RemoteData<T>) => rd.hasSucceeded), take(1));
export const getSucceededRemoteWithNotEmptyData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>

View File

@@ -7,12 +7,14 @@ import { SubmissionObjectDataService } from './submission-object-data.service';
import { SubmissionScopeType } from './submission-scope-type';
import { WorkflowItemDataService } from './workflowitem-data.service';
import { WorkspaceitemDataService } from './workspaceitem-data.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
describe('SubmissionObjectDataService', () => {
let service: SubmissionObjectDataService;
let submissionService: SubmissionService;
let workspaceitemDataService: WorkspaceitemDataService;
let workflowItemDataService: WorkflowItemDataService;
let halService: HALEndpointService;
const submissionId = '1234';
const wsiResult = 'wsiResult' as any;
@@ -25,6 +27,9 @@ describe('SubmissionObjectDataService', () => {
workflowItemDataService = jasmine.createSpyObj('WorkflowItemDataService', {
findById: wfiResult
});
halService = jasmine.createSpyObj('HALEndpointService', {
getEndpoint: '/workspaceItem'
});
});
describe('findById', () => {
@@ -32,7 +37,7 @@ describe('SubmissionObjectDataService', () => {
submissionService = jasmine.createSpyObj('SubmissionService', {
getSubmissionScope: {}
});
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService);
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService);
service.findById(submissionId);
expect(submissionService.getSubmissionScope).toHaveBeenCalled();
});
@@ -42,7 +47,7 @@ describe('SubmissionObjectDataService', () => {
submissionService = jasmine.createSpyObj('SubmissionService', {
getSubmissionScope: SubmissionScopeType.WorkspaceItem
});
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService);
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService);
});
it('should forward the result of WorkspaceitemDataService.findByIdAndIDType()', () => {
@@ -57,7 +62,7 @@ describe('SubmissionObjectDataService', () => {
submissionService = jasmine.createSpyObj('SubmissionService', {
getSubmissionScope: SubmissionScopeType.WorkflowItem
});
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService);
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService);
});
it('should forward the result of WorkflowItemDataService.findByIdAndIDType()', () => {
@@ -72,7 +77,7 @@ describe('SubmissionObjectDataService', () => {
submissionService = jasmine.createSpyObj('SubmissionService', {
getSubmissionScope: 'Something else'
});
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService);
service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService, halService);
});
it('shouldn\'t call any data service methods', () => {

View File

@@ -8,6 +8,9 @@ import { SubmissionObject } from './models/submission-object.model';
import { SubmissionScopeType } from './submission-scope-type';
import { WorkflowItemDataService } from './workflowitem-data.service';
import { WorkspaceitemDataService } from './workspaceitem-data.service';
import { DataService } from '../data/data.service';
import { map } from 'rxjs/operators';
import { HALEndpointService } from '../shared/hal-endpoint.service';
/**
* A service to retrieve submission objects (WorkspaceItem/WorkflowItem)
@@ -20,10 +23,22 @@ export class SubmissionObjectDataService {
constructor(
private workspaceitemDataService: WorkspaceitemDataService,
private workflowItemDataService: WorkflowItemDataService,
private submissionService: SubmissionService
private submissionService: SubmissionService,
private halService: HALEndpointService
) {
}
/**
* Create the HREF for a specific object based on its identifier
* @param id The identifier for the object
*/
getHrefByID(id): Observable<string> {
const dataService: DataService<SubmissionObject> = this.submissionService.getSubmissionScope() === SubmissionScopeType.WorkspaceItem ? this.workspaceitemDataService : this.workflowItemDataService;
return this.halService.getEndpoint(dataService.getLinkPath()).pipe(
map((endpoint: string) => dataService.getIDHref(endpoint, encodeURIComponent(id))));
}
/**
* Retrieve a submission object based on its ID.
*

View File

@@ -176,5 +176,4 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService
return definition;
}
}

View File

@@ -17,6 +17,7 @@ import { Observable } from 'rxjs';
import { find, map } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util';
import { RequestEntry } from '../data/request.reducer';
import { RestResponse } from '../cache/response.models';
/**
* A service that provides methods to make REST requests with workflow items endpoint.
@@ -44,7 +45,7 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
* @param id The Workflow Item's id to be removed
* @return an observable that emits true when the deletion was successful, false when it failed
*/
delete(id: string): Observable<boolean> {
delete(id: string): Observable<RestResponse> {
return this.deleteWFI(id, true)
}
@@ -54,7 +55,7 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
* @return an observable that emits true when sending back the item was successful, false when it failed
*/
sendBack(id: string): Observable<boolean> {
return this.deleteWFI(id, false)
return this.deleteWFI(id, false).pipe(map((response: RestResponse) => response.isSuccessful));
}
/**
@@ -64,7 +65,7 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
* When true, the workflow item and its item will be permanently expunged on the server
* When false, the workflow item will be removed, but the item will still be available as a workspace item
*/
private deleteWFI(id: string, expunge: boolean): Observable<boolean> {
private deleteWFI(id: string, expunge: boolean): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
@@ -82,7 +83,7 @@ export class WorkflowItemDataService extends DataService<WorkflowItem> {
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
map((request: RequestEntry) => request.response)
);
}
}

View File

@@ -21,7 +21,6 @@ import { WorkspaceItem } from './models/workspaceitem.model';
@dataService(WorkspaceItem.type)
export class WorkspaceitemDataService extends DataService<WorkspaceItem> {
protected linkPath = 'workspaceitems';
protected responseMsToLive = 10 * 1000;
constructor(
protected comparator: DSOChangeAnalyzer<WorkspaceItem>,

View File

@@ -1 +1 @@
<ds-journal-issue-search-result-grid-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-issue-search-result-grid-element>
<ds-journal-issue-search-result-grid-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-issue-search-result-grid-element>

View File

@@ -1 +1 @@
<ds-journal-volume-search-result-grid-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-volume-search-result-grid-element>
<ds-journal-volume-search-result-grid-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-volume-search-result-grid-element>

View File

@@ -1 +1 @@
<ds-journal-search-result-grid-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-search-result-grid-element>
<ds-journal-search-result-grid-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-search-result-grid-element>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="firstMetadataValue('dc.title')"></h4>
</ds-truncatable-part>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="dso.firstMetadataValue('dc.title')"></h4>
</ds-truncatable-part>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="firstMetadataValue('dc.title')"></h4>
</ds-truncatable-part>

View File

@@ -1 +1 @@
<ds-journal-issue-search-result-list-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-issue-search-result-list-element>
<ds-journal-issue-search-result-list-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-issue-search-result-list-element>

View File

@@ -1 +1 @@
<ds-journal-volume-search-result-list-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-volume-search-result-list-element>
<ds-journal-volume-search-result-list-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-volume-search-result-list-element>

View File

@@ -1 +1 @@
<ds-journal-search-result-list-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-search-result-list-element>
<ds-journal-search-result-list-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-journal-search-result-list-element>

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1 +1 @@
<ds-org-unit-search-result-grid-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-org-unit-search-result-grid-element>
<ds-org-unit-search-result-grid-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-org-unit-search-result-grid-element>

View File

@@ -1 +1 @@
<ds-person-search-result-grid-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-person-search-result-grid-element>
<ds-person-search-result-grid-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-person-search-result-grid-element>

View File

@@ -1 +1 @@
<ds-project-search-result-grid-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-project-search-result-grid-element>
<ds-project-search-result-grid-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-project-search-result-grid-element>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="firstMetadataValue('organization.legalName')"></h4>
</ds-truncatable-part>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title"
[innerHTML]="firstMetadataValue('person.familyName') + ', ' + firstMetadataValue('person.givenName')"></h4>

View File

@@ -17,7 +17,7 @@
</div>
</span>
<div class="card-body">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable-part [id]="dso.id" [minLines]="3" type="h4">
<h4 class="card-title" [innerHTML]="firstMetadataValue('dc.title')"></h4>
</ds-truncatable-part>

View File

@@ -1 +1 @@
<ds-org-unit-search-result-list-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-org-unit-search-result-list-element>
<ds-org-unit-search-result-list-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-org-unit-search-result-list-element>

View File

@@ -1 +1 @@
<ds-person-search-result-list-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-person-search-result-list-element>
<ds-person-search-result-list-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-person-search-result-list-element>

View File

@@ -1 +1 @@
<ds-project-search-result-list-element [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-project-search-result-list-element>
<ds-project-search-result-list-element [showLabel]="showLabel" [object]="{ indexableObject: object, hitHighlights: {} }" [linkType]="linkType"></ds-project-search-result-list-element>

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1,4 +1,4 @@
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<ds-truncatable [id]="dso.id">
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"

View File

@@ -1,5 +1,5 @@
<ds-truncatable [id]="dso.id">
<ds-item-type-badge [object]="dso"></ds-item-type-badge>
<ds-item-type-badge *ngIf="showLabel" [object]="dso"></ds-item-type-badge>
<a *ngIf="linkType != linkTypes.None" [target]="(linkType == linkTypes.ExternalLink) ? '_blank' : '_self'" rel="noopener noreferrer"
[routerLink]="['/items/' + dso.id]" class="lead"
[innerHTML]="firstMetadataValue('dc.title')"></a>

View File

@@ -1,7 +1,6 @@
<ng-template #descTemplate>
<span class="text-muted">
<span *ngIf="metadataRepresentation.allMetadata(['dc.description']).length > 0"
class="item-list-job-title">
<span class="item-list-job-title">
<span [innerHTML]="metadataRepresentation.firstMetadataValue(['dc.description'])"></span>
</span>
</span>
@@ -9,5 +8,5 @@
<ds-truncatable [id]="metadataRepresentation.id">
<a [routerLink]="['/items/' + metadataRepresentation.id]"
[innerHTML]="metadataRepresentation.getValue()"
[tooltip]="metadataRepresentation.allMetadata(['organization.legalName']).length > 0 ? descTemplate : null"></a>
[tooltip]="metadataRepresentation.allMetadata(['dc.description']).length > 0 ? descTemplate : null"></a>
</ds-truncatable>

View File

@@ -7,7 +7,7 @@ import { Component, Inject, OnInit } from '@angular/core';
import { Metadata } from '../../../../../core/shared/metadata.utils';
import { MetadataValue } from '../../../../../core/shared/metadata.models';
@listableObjectComponent(ExternalSourceEntry, ViewMode.ListElement, Context.SubmissionModal)
@listableObjectComponent(ExternalSourceEntry, ViewMode.ListElement, Context.EntitySearchModalWithNameVariants)
@Component({
selector: 'ds-external-source-entry-list-submission-element',
styleUrls: ['./external-source-entry-list-submission-element.component.scss'],

View File

@@ -1,10 +1,15 @@
<div class="d-flex">
<div class="person-thumbnail pr-2">
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>
</div>
<!-- <div class="person-thumbnail pr-2">-->
<!-- <ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>-->
<!-- </div>-->
<div class="flex-grow-1">
<ds-org-unit-input-suggestions [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)"
<ds-org-unit-input-suggestions *ngIf="useNameVariants" [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)"
(submitSuggestion)="selectCustom($event)"></ds-org-unit-input-suggestions>
<div *ngIf="!useNameVariants"
class="lead"
[innerHTML]="firstMetadataValue('organization.legalName')"></div>
<span class="text-muted">
<span *ngIf="dso.allMetadata('organization.address.addressLocality').length > 0"
class="item-list-address-locality">

View File

@@ -20,7 +20,8 @@ import { ItemDataService } from '../../../../../core/data/item-data.service';
import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service';
import { NameVariantModalComponent } from '../../name-variant-modal/name-variant-modal.component';
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.SubmissionModal)
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModal)
@listableObjectComponent('OrgUnitSearchResult', ViewMode.ListElement, Context.EntitySearchModalWithNameVariants)
@Component({
selector: 'ds-person-search-result-list-submission-element',
styleUrls: ['./org-unit-search-result-list-submission-element.component.scss'],
@@ -34,6 +35,7 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
allSuggestions: string[];
selectedName: string;
alternativeField = 'dc.title.alternative';
useNameVariants = false;
constructor(protected truncatableService: TruncatableService,
private relationshipService: RelationshipService,
@@ -48,6 +50,10 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
ngOnInit() {
super.ngOnInit();
this.useNameVariants = this.context === Context.EntitySearchModalWithNameVariants;
if (this.useNameVariants) {
const defaultValue = this.firstMetadataValue('organization.legalName');
const alternatives = this.allMetadataValues(this.alternativeField);
this.allSuggestions = [defaultValue, ...alternatives];
@@ -59,6 +65,7 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
}
);
}
}
select(value) {
this.selectableListService.isObjectSelected(this.listID, this.object)
@@ -75,7 +82,7 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
if (!this.allSuggestions.includes(value)) {
this.openModal(value)
.then(() => {
// user clicked ok: store the name variant in the item
const newName: MetadataValue = new MetadataValue();
newName.value = value;
@@ -89,9 +96,12 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
},
});
this.itemDataService.update(updatedItem).pipe(take(1)).subscribe();
}).catch(() => {
// user clicked cancel: use the name variant only for this relation, no further action required
}).finally(() => {
this.select(value);
})
}
this.select(value);
}
openModal(value): Promise<any> {

View File

@@ -1,6 +1,7 @@
form {
z-index: 1;
&:before {
pointer-events: none; // prevent the icon from catching the click
position: absolute;
font-weight: 900;
font-family: "Font Awesome 5 Free";

View File

@@ -1,7 +1,4 @@
<div class="d-flex">
<div class="person-thumbnail pr-2">
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/person-placeholder.svg'"></ds-thumbnail>
</div>
<div class="flex-grow-1">
<ds-person-input-suggestions [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)" (submitSuggestion)="selectCustom($event)"></ds-person-input-suggestions>
<span class="text-muted">

View File

@@ -20,7 +20,7 @@ import { MetadataValue } from '../../../../../core/shared/metadata.models';
import { ItemDataService } from '../../../../../core/data/item-data.service';
import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service';
@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.SubmissionModal)
@listableObjectComponent('PersonSearchResult', ViewMode.ListElement, Context.EntitySearchModalWithNameVariants)
@Component({
selector: 'ds-person-search-result-list-submission-element',
styleUrls: ['./person-search-result-list-submission-element.component.scss'],
@@ -75,7 +75,7 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu
if (!this.allSuggestions.includes(value)) {
this.openModal(value)
.then(() => {
// user clicked ok: store the name variant in the item
const newName: MetadataValue = new MetadataValue();
newName.value = value;
@@ -89,13 +89,18 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu
},
});
this.itemDataService.update(updatedItem).pipe(take(1)).subscribe();
this.itemDataService.commitUpdates();
}).catch(() => {
// user clicked cancel: use the name variant only for this relation, no further action required
}).finally(() => {
this.select(value);
})
}
this.select(value);
}
openModal(value): Promise<any> {
const modalRef = this.modalService.open(NameVariantModalComponent, { centered: true });
const modalComp = modalRef.componentInstance;
modalComp.value = value;
return modalRef.result;

View File

@@ -1,11 +1,12 @@
<form #form="ngForm" (ngSubmit)="onSubmit(value)"
<form #form="ngForm" (ngSubmit)="onSubmit(inputField.value)"
[action]="action" (keydown)="onKeydown($event)"
(keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close();">
<input #inputField type="text" [(ngModel)]="value" [name]="name"
<input #inputField type="text" [ngModel]="value" [name]="name"
class="form-control suggestion_input"
(focus)="open()"
(blur)="onSubmit(inputField.value)"
(click)="open()"
[ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)"

View File

@@ -1,6 +1,6 @@
form {
z-index: 1;
&:before {
pointer-events: none; // prevent the icon from catching the click
position: absolute;
font-weight: 900;
font-family: "Font Awesome 5 Free";
@@ -9,7 +9,6 @@ form {
right: 0;
height: 20px;
width: 20px;
z-index: -1;
}
input.suggestion_input {

View File

@@ -33,9 +33,11 @@ export class PersonInputSuggestionsComponent extends InputSuggestionsComponent i
}
onSubmit(data) {
if (data !== this.value) {
this.value = data;
this.submitSuggestion.emit(data);
}
}
onClickSuggestion(data) {
this.value = data;

View File

@@ -48,7 +48,7 @@ describe('DeleteComColPageComponent', () => {
dsoDataService = jasmine.createSpyObj(
'dsoDataService',
{
delete: observableOf(true)
delete: observableOf({ isSuccessful: true })
});
routerStub = {
@@ -106,7 +106,7 @@ describe('DeleteComColPageComponent', () => {
});
it('should show an error notification on failure', () => {
(dsoDataService.delete as any).and.returnValue(observableOf(false));
(dsoDataService.delete as any).and.returnValue(observableOf({ isSuccessful: false }));
spyOn(router, 'navigate');
comp.onConfirm(data2);
fixture.detectChanges();

View File

@@ -7,6 +7,7 @@ import { DataService } from '../../../core/data/data.service';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { NotificationsService } from '../../notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { RestResponse } from '../../../core/cache/response.models';
/**
* Component representing the delete page for communities and collections
@@ -45,8 +46,8 @@ export class DeleteComColPageComponent<TDomain extends DSpaceObject> implements
onConfirm(dso: TDomain) {
this.dsoDataService.delete(dso.id)
.pipe(first())
.subscribe((success: boolean) => {
if (success) {
.subscribe((response: RestResponse) => {
if (response.isSuccessful) {
const successMessage = this.translate.instant((dso as any).type + '.delete.notification.success');
this.notifications.success(successMessage)
} else {

View File

@@ -1,20 +1,17 @@
<div [class.form-group]="(model.type !== 'GROUP' && asBootstrapFormGroup) || getClass('element', 'container').includes('form-group')"
<div [class.form-group]="asBootstrapFormGroup"
[formGroup]="group"
[ngClass]="[getClass('element', 'container'), getClass('grid', 'container')]">
<label *ngIf="!isCheckbox && hasLabel"
[for]="model.id"
[innerHTML]="(model.required && model.label) ? (model.label | translate) + ' *' : (model.label | translate)"
[ngClass]="[getClass('element', 'label'), getClass('grid', 'label')]"></label>
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: model"></ng-container>
<div [ngClass]="{'form-row': model.hasLanguages || hasRelationLookup }">
<!-- Should be *ngIf instead of class d-none, but that breaks the #componentViewContainer reference-->
<div [ngClass]="{'form-row': model.hasLanguages || isRelationship,
'd-none': value?.isVirtual && (model.hasSelectableMetadata || context?.index > 0)}">
<div [ngClass]="getClass('grid', 'control')">
<ng-container #componentViewContainer></ng-container>
<small *ngIf="hasHint && (!showErrorMessages || errorMessages.length === 0)"
<small *ngIf="hasHint && (model.repeatable === false || context?.index === 0) && (!showErrorMessages || errorMessages.length === 0)"
class="text-muted" [innerHTML]="model.hint | translate" [ngClass]="getClass('element', 'hint')"></small>
<div *ngIf="showErrorMessages" [ngClass]="[getClass('element', 'errors'), getClass('grid', 'errors')]">
@@ -22,7 +19,6 @@
</div>
</div>
<div *ngIf="model.languageCodes && model.languageCodes.length > 0" class="col-xs-2" >
<select
#language="ngModel"
@@ -36,32 +32,41 @@
<option *ngFor="let lang of model.languageCodes" [value]="lang.code">{{lang.display}}</option>
</select>
</div>
<div *ngIf="hasRelationLookup" class="col-auto text-center">
<div *ngIf="isRelationship" class="col-auto text-center" [class.invisible]="context?.index > 0">
<button class="btn btn-secondary"
type="submit"
type="button"
ngbTooltip="{{'form.lookup-help' | translate}}"
placement="top"
(click)="openLookup(); $event.stopPropagation();">{{'form.lookup' | translate}}
</button>
</div>
</div>
<ng-container *ngTemplateOutlet="endTemplate?.templateRef; context: model"></ng-container>
<ng-content></ng-content>
<div *ngIf="hasRelationLookup" class="mt-3">
<ul class="list-unstyled" cdkDropList (cdkDropListDropped)="moveSelection($event)">
<ds-existing-metadata-list-element cdkDrag
*ngFor="let reorderable of reorderables; trackBy: trackReorderable"
[reoRel]="reorderable"
[submissionItem]="item"
<ng-container *ngIf="value?.isVirtual">
<ds-existing-metadata-list-element
*ngIf="model.hasSelectableMetadata"
[reoRel]="relationshipValue$ | async"
[submissionItem]="item$ | async"
[listId]="listId"
[metadataFields]="model.metadataFields"
[relationshipOptions]="model.relationship">
[submissionId]="model.submissionId"
[relationshipOptions]="model.relationship"
(remove)="onRemove()"
>
</ds-existing-metadata-list-element>
</ul>
</div>
<ds-existing-relation-list-element
*ngIf="!model.hasSelectableMetadata"
[ngClass]="{'d-block pb-2 pt-2': !context?.index}"
[reoRel]="relationshipValue$ | async"
[submissionItem]="item$ | async"
[listId]="listId"
[metadataFields]="model.metadataFields"
[submissionId]="model.submissionId"
[relationshipOptions]="model.relationship"
>
</ds-existing-relation-list-element>
</ng-container>
<ng-content></ng-content>
</div>

View File

@@ -71,6 +71,9 @@ import { Item } from '../../../../core/shared/item.model';
import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject } from '../../../remote-data.utils';
import { FormService } from '../../form.service';
import { SubmissionService } from '../../../../submission/submission.service';
import { FormBuilderService } from '../form-builder.service';
describe('DsDynamicFormControlContainerComponent test suite', () => {
@@ -101,15 +104,16 @@ describe('DsDynamicFormControlContainerComponent test suite', () => {
new DynamicSwitchModel({ id: 'switch' }),
new DynamicTextAreaModel({ id: 'textarea' }),
new DynamicTimePickerModel({ id: 'timepicker' }),
new DynamicTypeaheadModel({ id: 'typeahead', metadataFields: [], repeatable: false, submissionId: '1234' }),
new DynamicTypeaheadModel({ id: 'typeahead', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false }),
new DynamicScrollableDropdownModel({
id: 'scrollableDropdown',
authorityOptions: authorityOptions,
metadataFields: [],
repeatable: false,
submissionId: '1234'
submissionId: '1234',
hasSelectableMetadata: false
}),
new DynamicTagModel({ id: 'tag', metadataFields: [], repeatable: false, submissionId: '1234' }),
new DynamicTagModel({ id: 'tag', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false }),
new DynamicListCheckboxGroupModel({
id: 'checkboxList',
authorityOptions: authorityOptions,
@@ -130,11 +134,12 @@ describe('DsDynamicFormControlContainerComponent test suite', () => {
scopeUUID: '',
submissionScope: '',
repeatable: false,
metadataFields: []
metadataFields: [],
hasSelectableMetadata: false
}),
new DynamicDsDatePickerModel({ id: 'datepicker' }),
new DynamicLookupModel({ id: 'lookup', metadataFields: [], repeatable: false, submissionId: '1234' }),
new DynamicLookupNameModel({ id: 'lookupName', metadataFields: [], repeatable: false, submissionId: '1234' }),
new DynamicLookupModel({ id: 'lookup', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false }),
new DynamicLookupNameModel({ id: 'lookupName', metadataFields: [], repeatable: false, submissionId: '1234', hasSelectableMetadata: false}),
new DynamicQualdropModel({ id: 'combobox', readOnly: false, required: false })
];
const testModel = formModel[8];
@@ -175,6 +180,9 @@ describe('DsDynamicFormControlContainerComponent test suite', () => {
{ provide: Store, useValue: {} },
{ provide: RelationshipService, useValue: {} },
{ provide: SelectableListService, useValue: {} },
{ provide: FormService, useValue: {} },
{ provide: FormBuilderService, useValue: {} },
{ provide: SubmissionService, useValue: {} },
{
provide: SubmissionObjectDataService,
useValue: {
@@ -220,7 +228,6 @@ describe('DsDynamicFormControlContainerComponent test suite', () => {
expect(component.group instanceof FormGroup).toBe(true);
expect(component.model instanceof DynamicFormControlModel).toBe(true);
expect(component.hasErrorMessaging).toBe(false);
expect(component.asBootstrapFormGroup).toBe(true);
expect(component.onControlValueChanges).toBeDefined();
expect(component.onModelDisabledUpdates).toBeDefined();

View File

@@ -1,5 +1,6 @@
import {
ChangeDetectionStrategy, ChangeDetectorRef,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ComponentFactoryResolver,
ContentChildren,
@@ -16,7 +17,7 @@ import {
ViewChild,
ViewContainerRef
} from '@angular/core';
import { FormGroup } from '@angular/forms';
import { FormArray, FormGroup } from '@angular/forms';
import {
DYNAMIC_FORM_CONTROL_TYPE_ARRAY,
@@ -29,13 +30,18 @@ import {
DYNAMIC_FORM_CONTROL_TYPE_SELECT,
DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA,
DYNAMIC_FORM_CONTROL_TYPE_TIMEPICKER,
DynamicDatePickerModel, DynamicFormComponentService,
DynamicDatePickerModel,
DynamicFormArrayGroupModel,
DynamicFormArrayModel,
DynamicFormComponentService,
DynamicFormControl,
DynamicFormControlContainerComponent,
DynamicFormControlEvent,
DynamicFormControlEventType,
DynamicFormControlModel,
DynamicFormLayout,
DynamicFormLayoutService, DynamicFormRelationService,
DynamicFormLayoutService,
DynamicFormRelationService,
DynamicFormValidationService,
DynamicTemplateDirective,
} from '@ng-dynamic-forms/core';
@@ -50,11 +56,7 @@ import {
DynamicNGBootstrapTimePickerComponent
} from '@ng-dynamic-forms/ui-ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
import { followLink } from '../../../utils/follow-link-config.model';
import {
Reorderable,
ReorderableRelationship
} from './existing-metadata-list-element/existing-metadata-list-element.component';
import { ReorderableRelationship } from './existing-metadata-list-element/existing-metadata-list-element.component';
import { DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD } from './models/typeahead/dynamic-typeahead.model';
import { DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.model';
@@ -63,7 +65,7 @@ import { DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER } from './models/date-picker/dat
import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP } from './models/lookup/dynamic-lookup.model';
import { DynamicListCheckboxGroupModel } from './models/list/dynamic-list-checkbox-group.model';
import { DynamicListRadioGroupModel } from './models/list/dynamic-list-radio-group.model';
import { hasValue, isNotEmpty, isNotUndefined } from '../../../empty.util';
import { hasNoValue, hasValue, isNotEmpty, isNotUndefined } from '../../../empty.util';
import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME } from './models/lookup/dynamic-lookup-name.model';
import { DsDynamicTagComponent } from './models/tag/dynamic-tag.component';
import { DsDatePickerComponent } from './models/date-picker/date-picker.component';
@@ -78,8 +80,8 @@ import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './models/relation-grou
import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component';
import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/custom-switch.model';
import { CustomSwitchComponent } from './models/custom-switch/custom-switch.component';
import { map, startWith, switchMap, find } from 'rxjs/operators';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
import { find, map, startWith, switchMap, take } from 'rxjs/operators';
import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
import { SearchResult } from '../../../search/search-result.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
@@ -88,7 +90,7 @@ import { SelectableListService } from '../../../object-list/selectable-list/sele
import { DsDynamicDisabledComponent } from './models/disabled/dynamic-disabled.component';
import { DYNAMIC_FORM_CONTROL_TYPE_DISABLED } from './models/disabled/dynamic-disabled.model';
import { DsDynamicLookupRelationModalComponent } from './relation-lookup-modal/dynamic-lookup-relation-modal.component';
import { getAllSucceededRemoteData, getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
import { getAllSucceededRemoteData, getFirstSucceededRemoteDataPayload, getPaginatedListPayload, getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
import { RemoteData } from '../../../../core/data/remote-data';
import { Item } from '../../../../core/shared/item.model';
import { ItemDataService } from '../../../../core/data/item-data.service';
@@ -98,9 +100,16 @@ import { SubmissionObjectDataService } from '../../../../core/submission/submiss
import { SubmissionObject } from '../../../../core/submission/models/submission-object.model';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { Collection } from '../../../../core/shared/collection.model';
import { MetadataValue, VIRTUAL_METADATA_PREFIX } from '../../../../core/shared/metadata.models';
import { FormService } from '../../form.service';
import { SelectableListState } from '../../../object-list/selectable-list/selectable-list.reducer';
import { SubmissionService } from '../../../../submission/submission.service';
import { followLink } from '../../../utils/follow-link-config.model';
import { paginatedRelationsToItems } from '../../../../+item-page/simple/item-types/shared/item-relationships-utils';
import { RelationshipOptions } from '../models/relationship-options.model';
import { FormBuilderService } from '../form-builder.service';
export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type<DynamicFormControl> | null {
switch (model.type) {
@@ -180,22 +189,22 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
@Input('templates') inputTemplateList: QueryList<DynamicTemplateDirective>;
@Input() formId: string;
@Input() asBootstrapFormGroup = true;
@Input() asBootstrapFormGroup = false;
@Input() bindId = true;
@Input() context: any | null = null;
@Input() group: FormGroup;
@Input() hasErrorMessaging = false;
@Input() layout = null as DynamicFormLayout;
@Input() model: any;
reorderables$: Observable<ReorderableRelationship[]>;
reorderables: ReorderableRelationship[];
hasRelationLookup: boolean;
relationshipValue$: Observable<ReorderableRelationship>;
isRelationship: boolean;
modalRef: NgbModalRef;
item: Item;
item$: Observable<Item>;
collection: Collection;
listId: string;
searchConfig: string;
value: MetadataValue;
/**
* List of subscriptions to unsubscribe from
*/
@@ -229,9 +238,11 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
private zone: NgZone,
private store: Store<AppState>,
private submissionObjectService: SubmissionObjectDataService,
private ref: ChangeDetectorRef
private ref: ChangeDetectorRef,
private formService: FormService,
private formBuilderService: FormBuilderService,
private submissionService: SubmissionService
) {
super(componentFactoryResolver, layoutService, validationService, dynamicFormComponentService, relationService);
}
@@ -239,62 +250,76 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
* Sets up the necessary variables for when this control can be used to add relationships to the submitted item
*/
ngOnInit(): void {
this.hasRelationLookup = hasValue(this.model.relationship);
this.reorderables = [];
if (this.hasRelationLookup) {
this.isRelationship = hasValue(this.model.relationship);
const isWrapperAroundRelationshipList = hasValue(this.model.relationshipConfig);
this.listId = 'list-' + this.model.relationship.relationshipType;
if (this.isRelationship || isWrapperAroundRelationshipList) {
const config = this.model.relationshipConfig || this.model.relationship;
const relationshipOptions = Object.assign(new RelationshipOptions(), config);
this.listId = `list-${this.model.submissionId}-${relationshipOptions.relationshipType}`;
this.setItem();
const submissionObject$ = this.submissionObjectService
.findById(this.model.submissionId, followLink('item'), followLink('collection')).pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload()
if (isWrapperAroundRelationshipList || !this.model.repeatable) {
const subscription = this.selectableListService.getSelectableList(this.listId).pipe(
find((list: SelectableListState) => hasNoValue(list)),
switchMap(() => this.item$.pipe(take(1))),
switchMap((item) => {
const relationshipsRD$ = this.relationshipService.getItemRelationshipsByLabel(item,
relationshipOptions.relationshipType,
undefined,
followLink('leftItem'),
followLink('rightItem'),
followLink('relationshipType')
);
const item$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable<RemoteData<Item>>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload())));
const collection$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.collection as Observable<RemoteData<Collection>>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload())));
relationshipsRD$.pipe(
getFirstSucceededRemoteDataPayload(),
getPaginatedListPayload()
).subscribe((relationships: Relationship[]) => {
// set initial namevariants for pre-existing relationships
relationships.forEach((relationship: Relationship) => {
const relationshipMD: MetadataValue = item.firstMetadata(relationshipOptions.metadataField, { authority: `${VIRTUAL_METADATA_PREFIX}${relationship.id}` });
const nameVariantMD: MetadataValue = item.firstMetadata(this.model.metadataFields, { authority: `${VIRTUAL_METADATA_PREFIX}${relationship.id}` });
if (hasValue(relationshipMD) && isNotEmpty(relationshipMD.value) && hasValue(nameVariantMD) && isNotEmpty(nameVariantMD.value)) {
this.relationshipService.setNameVariant(this.listId, relationshipMD.value, nameVariantMD.value);
}
});
});
this.subs.push(item$.subscribe((item) => this.item = item));
this.subs.push(collection$.subscribe((collection) => this.collection = collection));
this.reorderables$ = item$.pipe(
switchMap((item) => this.relationshipService.getItemRelationshipsByLabel(item, this.model.relationship.relationshipType, undefined, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType'))
return relationshipsRD$.pipe(
paginatedRelationsToItems(item.uuid),
getSucceededRemoteData(),
map((items: RemoteData<PaginatedList<Item>>) => items.payload.page.map((i) => Object.assign(new ItemSearchResult(), { indexableObject: i }))),
)
})
).subscribe((relatedItems: Array<SearchResult<Item>>) => this.selectableListService.select(this.listId, relatedItems));
this.subs.push(subscription);
}
if (hasValue(this.model.metadataValue)) {
this.value = Object.assign(new MetadataValue(), this.model.metadataValue);
} else {
this.value = Object.assign(new MetadataValue(), this.model.value);
}
if (hasValue(this.value) && this.value.isVirtual) {
const relationship$ = this.relationshipService.findById(this.value.virtualValue, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType'))
.pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload(),
map((relationshipList: PaginatedList<Relationship>) => relationshipList.page),
startWith([]),
switchMap((relationships: Relationship[]) =>
observableCombineLatest(
relationships.map((relationship: Relationship) =>
getRemoteDataPayload());
this.relationshipValue$ = observableCombineLatest([this.item$.pipe(take(1)), relationship$]).pipe(
switchMap(([item, relationship]: [Item, Relationship]) =>
relationship.leftItem.pipe(
getSucceededRemoteData(),
getAllSucceededRemoteData(),
getRemoteDataPayload(),
map((leftItem: Item) => {
return new ReorderableRelationship(relationship, leftItem.uuid !== this.item.uuid)
return new ReorderableRelationship(relationship, leftItem.uuid !== item.uuid, this.relationshipService, this.store, this.model.submissionId)
}),
)
))),
map((relationships: ReorderableRelationship[]) =>
relationships
.sort((a: Reorderable, b: Reorderable) => {
return Math.sign(a.getPlace() - b.getPlace());
})
)
)
)
),
startWith(undefined)
);
this.subs.push(this.reorderables$.subscribe((rs) => {
this.reorderables = rs;
this.ref.detectChanges();
}));
item$.pipe(
switchMap((item) => this.relationshipService.getRelatedItemsByLabel(item, this.model.relationship.relationshipType)),
map((items: RemoteData<PaginatedList<Item>>) => items.payload.page.map((item) => Object.assign(new ItemSearchResult(), { indexableObject: item }))),
).subscribe((relatedItems: Array<SearchResult<Item>>) => {
this.selectableListService.select(this.listId, relatedItems)
});
}
}
}
@@ -303,7 +328,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
}
ngOnChanges(changes: SimpleChanges) {
if (changes) {
if (changes && !this.isRelationship && hasValue(this.group.get(this.model.id))) {
super.ngOnChanges(changes);
if (this.model && this.model.placeholder) {
this.model.placeholder = this.translateService.instant(this.model.placeholder);
@@ -351,6 +376,27 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
size: 'lg'
});
const modalComp = this.modalRef.componentInstance;
if (hasValue(this.model.value) && !this.model.readOnly) {
if (typeof this.model.value === 'string') {
modalComp.query = this.model.value;
} else if (typeof this.model.value.value === 'string') {
modalComp.query = this.model.value.value;
}
}
if (hasValue(this.model.value)) {
this.model.value = '';
this.onChange({
$event: { previousIndex: 0 },
context: { index: 0 },
control: this.control,
model: this.model,
type: DynamicFormControlEventType.Change
});
}
this.submissionService.dispatchSave(this.model.submissionId);
modalComp.repeatable = this.model.repeatable;
modalComp.listId = this.listId;
modalComp.relationshipOptions = this.model.relationship;
@@ -358,32 +404,18 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
modalComp.metadataFields = this.model.metadataFields;
modalComp.item = this.item;
modalComp.collection = this.collection;
modalComp.submissionId = this.model.submissionId;
}
/**
* Method to move a relationship inside the list of relationships
* This will update the view and update the right or left place field of the relationships in the list
* @param event
* Callback for the remove event,
* remove the current control from its array
*/
moveSelection(event: CdkDragDrop<Relationship>) {
this.zone.runOutsideAngular(() => {
moveItemInArray(this.reorderables, event.previousIndex, event.currentIndex);
const reorderables: Reorderable[] = this.reorderables.map((reo: Reorderable, index: number) => {
reo.oldIndex = reo.getPlace();
reo.newIndex = index;
return reo;
}
);
observableCombineLatest(
reorderables.map((rel: ReorderableRelationship) => {
if (rel.oldIndex !== rel.newIndex) {
return this.relationshipService.updatePlace(rel);
} else {
return observableOf(undefined) as Observable<RemoteData<Relationship>>;
}
})
).subscribe();
})
onRemove(): void {
const arrayContext: DynamicFormArrayModel = (this.context as DynamicFormArrayGroupModel).context;
const path = this.formBuilderService.getPath(arrayContext);
const formArrayControl = this.group.root.get(path) as FormArray;
this.formBuilderService.removeFormArrayGroup(this.context.index, formArrayControl, arrayContext);
}
/**
@@ -396,9 +428,20 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
}
/**
* Prevent unnecessary rerendering so fields don't lose focus
* Initialize this.item$ based on this.model.submissionId
*/
trackReorderable(index, reorderable: Reorderable) {
return hasValue(reorderable) ? reorderable.getId() : undefined;
private setItem() {
const submissionObject$ = this.submissionObjectService
.findById(this.model.submissionId, followLink('item'), followLink('collection')).pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload()
);
this.item$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable<RemoteData<Item>>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload())));
const collection$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.collection as Observable<RemoteData<Collection>>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload())));
this.subs.push(this.item$.subscribe((item) => this.item = item));
this.subs.push(collection$.subscribe((collection) => this.collection = collection));
}
}

View File

@@ -7,6 +7,7 @@
[model]="model"
[ngClass]="[getClass(model, 'element', 'host'), getClass(model, 'grid', 'host')]"
[templates]="templates"
[asBootstrapFormGroup]="true"
(dfBlur)="onEvent($event, 'blur')"
(dfChange)="onEvent($event, 'change')"
(dfFocus)="onEvent($event, 'focus')"></ds-dynamic-form-control-container>

View File

@@ -37,5 +37,4 @@ export class DsDynamicFormComponent extends DynamicFormComponent {
constructor(protected formService: FormBuilderService, protected layoutService: DynamicFormLayoutService) {
super(formService, layoutService);
}
}

View File

@@ -1,11 +1,14 @@
<li *ngIf="metadataRepresentation">
<button type="button" class="close float-left" aria-label="Move button" cdkDragHandle>
<i aria-hidden="true" class="fas fa-arrows-alt fa-xs"></i>
</button>
<button type="button" class="close float-left" aria-label="Close button" (click)="removeSelection()">
<span aria-hidden="true">&times;</span>
</button>
<span class="d-inline-block align-middle ml-1">
<ds-metadata-representation-loader [mdRepresentation]="metadataRepresentation"></ds-metadata-representation-loader>
<div class="d-flex">
<span class="mr-auto text-contents">
<ng-container *ngIf="!(metadataRepresentation$ | async)">
<ds-loading [showMessage]="false"></ds-loading>
</ng-container>
<ng-container *ngIf="(metadataRepresentation$ | async)">
<ds-metadata-representation-loader [mdRepresentation]="metadataRepresentation$ | async"></ds-metadata-representation-loader>
</ng-container>
</span>
</li>
<button type="button" class="btn btn-secondary"
(click)="removeSelection()">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>

View File

@@ -10,6 +10,8 @@ import { RelationshipOptions } from '../../models/relationship-options.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils';
import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions';
import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model';
import { of as observableOf } from 'rxjs';
import { RelationshipService } from '../../../../../core/data/relationship.service';
describe('ExistingMetadataListElementComponent', () => {
let component: ExistingMetadataListElementComponent;
@@ -28,6 +30,8 @@ describe('ExistingMetadataListElementComponent', () => {
let leftItemRD$;
let rightItemRD$;
let relatedSearchResult;
let submissionId;
let relationshipService;
function init() {
uuid1 = '91ce578d-2e63-4093-8c73-3faafd716000';
@@ -42,9 +46,13 @@ describe('ExistingMetadataListElementComponent', () => {
leftItemRD$ = createSuccessfulRemoteDataObject$(relatedItem);
rightItemRD$ = createSuccessfulRemoteDataObject$(submissionItem);
relatedSearchResult = Object.assign(new ItemSearchResult(), { indexableObject: relatedItem });
relationshipService = {
updatePlace:() => observableOf({})
} as any;
relationship = Object.assign(new Relationship(), { leftItem: leftItemRD$, rightItem: rightItemRD$ });
reoRel = new ReorderableRelationship(relationship, true);
submissionId = '1234';
reoRel = new ReorderableRelationship(relationship, true, relationshipService, {} as any, submissionId);
}
beforeEach(async(() => {
@@ -68,6 +76,7 @@ describe('ExistingMetadataListElementComponent', () => {
component.reoRel = reoRel;
component.metadataFields = metadataFields;
component.relationshipOptions = relationshipOptions;
component.submissionId = submissionId;
fixture.detectChanges();
component.ngOnChanges();
});
@@ -84,9 +93,8 @@ describe('ExistingMetadataListElementComponent', () => {
it('should dispatch a RemoveRelationshipAction', () => {
component.removeSelection();
const action = new RemoveRelationshipAction(submissionItem, relatedItem, relationshipOptions.relationshipType);
const action = new RemoveRelationshipAction(submissionItem, relatedItem, relationshipOptions.relationshipType, submissionId);
expect(store.dispatch).toHaveBeenCalledWith(action);
});
})
});

View File

@@ -1,50 +1,126 @@
import { Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core';
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core';
import { FormControl } from '@angular/forms';
import { DynamicFormArrayGroupModel } from '@ng-dynamic-forms/core';
import { Store } from '@ngrx/store';
import { BehaviorSubject, Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { AppState } from '../../../../../app.reducer';
import { RelationshipService } from '../../../../../core/data/relationship.service';
import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../../../core/shared/item.model';
import { ItemMetadataRepresentation } from '../../../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { MetadataRepresentation } from '../../../../../core/shared/metadata-representation/metadata-representation.model';
import { MetadataValue } from '../../../../../core/shared/metadata.models';
import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../../core/shared/operators';
import { hasValue, isNotEmpty } from '../../../../empty.util';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model';
import { MetadataValue } from '../../../../../core/shared/metadata.models';
import { ItemMetadataRepresentation } from '../../../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { RelationshipOptions } from '../../models/relationship-options.model';
import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions';
import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service';
import { Store } from '@ngrx/store';
import { AppState } from '../../../../../app.reducer';
import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model';
import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service';
import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model';
import { RelationshipOptions } from '../../models/relationship-options.model';
import { DynamicConcatModel } from '../models/ds-dynamic-concat.model';
import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions';
// tslint:disable:max-classes-per-file
/**
* Abstract class that defines objects that can be reordered
*/
export abstract class Reorderable {
constructor(public oldIndex?: number, public newIndex?: number) {
}
/**
* Return the id for this Reorderable
*/
abstract getId(): string;
/**
* Return the place metadata for this Reorderable
*/
abstract getPlace(): number;
/**
* Update the Reorderable
*/
update(): void {
this.oldIndex = this.newIndex;
}
/**
* Returns true if the oldIndex of this Reorderable
* differs from the newIndex
*/
get hasMoved(): boolean {
return this.oldIndex !== this.newIndex
}
}
/**
* A Reorderable representation of a FormFieldMetadataValue
*/
export class ReorderableFormFieldMetadataValue extends Reorderable {
constructor(
public metadataValue: FormFieldMetadataValueObject,
public model: DynamicConcatModel,
public control: FormControl,
public group: DynamicFormArrayGroupModel,
oldIndex?: number,
newIndex?: number
) {
super(oldIndex, newIndex);
this.metadataValue = metadataValue;
}
/**
* Return the id for this Reorderable
*/
getId(): string {
if (hasValue(this.metadataValue.authority)) {
return this.metadataValue.authority;
} else {
// can't use UUIDs, they're generated client side
return this.metadataValue.value;
}
}
/**
* Return the place metadata for this Reorderable
*/
getPlace(): number {
return this.metadataValue.place;
}
}
/**
* Represents a single relationship that can be reordered in a list of multiple relationships
*/
export class ReorderableRelationship extends Reorderable {
relationship: Relationship;
useLeftItem: boolean;
constructor(relationship: Relationship, useLeftItem: boolean, oldIndex?: number, newIndex?: number) {
constructor(
public relationship: Relationship,
public useLeftItem: boolean,
protected relationshipService: RelationshipService,
protected store: Store<AppState>,
protected submissionID: string,
oldIndex?: number,
newIndex?: number) {
super(oldIndex, newIndex);
this.relationship = relationship;
this.useLeftItem = useLeftItem;
}
/**
* Return the id for this Reorderable
*/
getId(): string {
return this.relationship.id;
}
/**
* Return the place metadata for this Reorderable
*/
getPlace(): number {
if (this.useLeftItem) {
return this.relationship.rightPlace
@@ -62,15 +138,16 @@ export class ReorderableRelationship extends Reorderable {
templateUrl: './existing-metadata-list-element.component.html',
styleUrls: ['./existing-metadata-list-element.component.scss']
})
export class ExistingMetadataListElementComponent implements OnChanges, OnDestroy {
export class ExistingMetadataListElementComponent implements OnInit, OnChanges, OnDestroy {
@Input() listId: string;
@Input() submissionItem: Item;
@Input() reoRel: ReorderableRelationship;
@Input() metadataFields: string[];
@Input() relationshipOptions: RelationshipOptions;
metadataRepresentation: MetadataRepresentation;
@Input() submissionId: string;
metadataRepresentation$: BehaviorSubject<MetadataRepresentation> = new BehaviorSubject<MetadataRepresentation>(undefined);
relatedItem: Item;
@Output() remove: EventEmitter<any> = new EventEmitter();
/**
* List of subscriptions to unsubscribe from
*/
@@ -82,7 +159,15 @@ export class ExistingMetadataListElementComponent implements OnChanges, OnDestro
) {
}
ngOnInit(): void {
this.ngOnChanges();
}
/**
* Change callback for the component
*/
ngOnChanges() {
if (hasValue(this.reoRel)) {
const item$ = this.reoRel.useLeftItem ?
this.reoRel.relationship.leftItem : this.reoRel.relationship.rightItem;
this.subs.push(item$.pipe(
@@ -94,20 +179,24 @@ export class ExistingMetadataListElementComponent implements OnChanges, OnDestro
const relationMD: MetadataValue = this.submissionItem.firstMetadata(this.relationshipOptions.metadataField, { value: this.relatedItem.uuid });
if (hasValue(relationMD)) {
const metadataRepresentationMD: MetadataValue = this.submissionItem.firstMetadata(this.metadataFields, { authority: relationMD.authority });
this.metadataRepresentation = Object.assign(
const nextValue = Object.assign(
new ItemMetadataRepresentation(metadataRepresentationMD),
this.relatedItem
)
);
this.metadataRepresentation$.next(nextValue);
}
}));
}
}
/**
* Removes the selected relationship from the list
*/
removeSelection() {
this.selectableListService.deselectSingle(this.listId, Object.assign(new ItemSearchResult(), { indexableObject: this.relatedItem }));
this.store.dispatch(new RemoveRelationshipAction(this.submissionItem, this.relatedItem, this.relationshipOptions.relationshipType))
this.store.dispatch(new RemoveRelationshipAction(this.submissionItem, this.relatedItem, this.relationshipOptions.relationshipType, this.submissionId));
this.remove.emit();
}
/**
@@ -120,4 +209,5 @@ export class ExistingMetadataListElementComponent implements OnChanges, OnDestro
}
}
// tslint:enable:max-classes-per-file

View File

@@ -0,0 +1,14 @@
<div class="d-flex">
<span class="mr-auto text-contents">
<ng-container *ngIf="!(relatedItem$ | async)">
<ds-loading [showMessage]="false"></ds-loading>
</ng-container>
<ng-container *ngIf="(relatedItem$ | async)">
<ds-listable-object-component-loader [showLabel]="false" [viewMode]="viewType" [object]="(relatedItem$ | async)"></ds-listable-object-component-loader>
</ng-container>
</span>
<button type="button" class="btn btn-secondary"
(click)="removeSelection()">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>

View File

@@ -0,0 +1,3 @@
span.text-contents{
padding: $btn-padding-y 0;
}

View File

@@ -0,0 +1,100 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ExistingRelationListElementComponent } from './existing-relation-list-element.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service';
import { select, Store } from '@ngrx/store';
import { Item } from '../../../../../core/shared/item.model';
import { Relationship } from '../../../../../core/shared/item-relationships/relationship.model';
import { RelationshipOptions } from '../../models/relationship-options.model';
import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions';
import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model';
import { of as observableOf } from 'rxjs';
import { ReorderableRelationship } from '../existing-metadata-list-element/existing-metadata-list-element.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../remote-data.utils';
describe('ExistingRelationListElementComponent', () => {
let component: ExistingRelationListElementComponent;
let fixture: ComponentFixture<ExistingRelationListElementComponent>;
let selectionService;
let store;
let listID;
let submissionItem;
let relationship;
let reoRel;
let metadataFields;
let relationshipOptions;
let uuid1;
let uuid2;
let relatedItem;
let leftItemRD$;
let rightItemRD$;
let relatedSearchResult;
let submissionId;
let relationshipService;
function init() {
uuid1 = '91ce578d-2e63-4093-8c73-3faafd716000';
uuid2 = '0e9dba1c-e1c3-4e05-a539-446f08ef57a7';
selectionService = jasmine.createSpyObj('selectionService', ['deselectSingle']);
store = jasmine.createSpyObj('store', ['dispatch']);
listID = '1234-listID';
submissionItem = Object.assign(new Item(), { uuid: uuid1 });
metadataFields = ['dc.contributor.author'];
relationshipOptions = Object.assign(new RelationshipOptions(), { relationshipType: 'isPublicationOfAuthor', filter: 'test.filter', searchConfiguration: 'personConfiguration', nameVariants: true })
relatedItem = Object.assign(new Item(), { uuid: uuid2 });
leftItemRD$ = createSuccessfulRemoteDataObject$(relatedItem);
rightItemRD$ = createSuccessfulRemoteDataObject$(submissionItem);
relatedSearchResult = Object.assign(new ItemSearchResult(), { indexableObject: relatedItem });
relationshipService = {
updatePlace:() => observableOf({})
} as any;
relationship = Object.assign(new Relationship(), { leftItem: leftItemRD$, rightItem: rightItemRD$ });
submissionId = '1234';
reoRel = new ReorderableRelationship(relationship, true, relationshipService, {} as any, submissionId);
}
beforeEach(async(() => {
init();
TestBed.configureTestingModule({
declarations: [ExistingRelationListElementComponent],
providers: [
{ provide: SelectableListService, useValue: selectionService },
{ provide: Store, useValue: store },
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ExistingRelationListElementComponent);
component = fixture.componentInstance;
component.listId = listID;
component.submissionItem = submissionItem;
component.reoRel = reoRel;
component.metadataFields = metadataFields;
component.relationshipOptions = relationshipOptions;
component.submissionId = submissionId;
fixture.detectChanges();
component.ngOnChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('removeSelection', () => {
it('should deselect the object in the selectable list service', () => {
component.removeSelection();
expect(selectionService.deselectSingle).toHaveBeenCalledWith(listID, relatedSearchResult);
});
it('should dispatch a RemoveRelationshipAction', () => {
component.removeSelection();
const action = new RemoveRelationshipAction(submissionItem, relatedItem, relationshipOptions.relationshipType, submissionId);
expect(store.dispatch).toHaveBeenCalledWith(action);
});
})
});

View File

@@ -0,0 +1,120 @@
import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core';
import { Store } from '@ngrx/store';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import { AppState } from '../../../../../app.reducer';
import { Item } from '../../../../../core/shared/item.model';
import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../../core/shared/operators';
import { hasValue, isNotEmpty } from '../../../../empty.util';
import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model';
import { SelectableListService } from '../../../../object-list/selectable-list/selectable-list.service';
import { RelationshipOptions } from '../../models/relationship-options.model';
import { RemoveRelationshipAction } from '../relation-lookup-modal/relationship.actions';
import { ViewMode } from '../../../../../core/shared/view-mode.model';
import { ReorderableRelationship } from '../existing-metadata-list-element/existing-metadata-list-element.component';
// tslint:disable:max-classes-per-file
/**
* Abstract class that defines objects that can be reordered
*/
export abstract class Reorderable {
constructor(public oldIndex?: number, public newIndex?: number) {
}
/**
* Return the id for this Reorderable
*/
abstract getId(): string;
/**
* Return the place metadata for this Reorderable
*/
abstract getPlace(): number;
/**
* Update the Reorderable
*/
abstract update(): Observable<any>;
/**
* Returns true if the oldIndex of this Reorderable
* differs from the newIndex
*/
get hasMoved(): boolean {
return this.oldIndex !== this.newIndex
}
}
/**
* Represents a single existing relationship value as metadata in submission
*/
@Component({
selector: 'ds-existing-relation-list-element',
templateUrl: './existing-relation-list-element.component.html',
styleUrls: ['./existing-relation-list-element.component.scss']
})
export class ExistingRelationListElementComponent implements OnInit, OnChanges, OnDestroy {
@Input() listId: string;
@Input() submissionItem: Item;
@Input() reoRel: ReorderableRelationship;
@Input() metadataFields: string[];
@Input() relationshipOptions: RelationshipOptions;
@Input() submissionId: string;
relatedItem$: BehaviorSubject<Item> = new BehaviorSubject<Item>(undefined);
viewType = ViewMode.ListElement;
@Output() remove: EventEmitter<any> = new EventEmitter();
/**
* List of subscriptions to unsubscribe from
*/
private subs: Subscription[] = [];
constructor(
private selectableListService: SelectableListService,
private store: Store<AppState>
) {
}
ngOnInit(): void {
this.ngOnChanges();
}
/**
* Change callback for the component
*/
ngOnChanges() {
if (hasValue(this.reoRel)) {
const item$ = this.reoRel.useLeftItem ?
this.reoRel.relationship.leftItem : this.reoRel.relationship.rightItem;
this.subs.push(item$.pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload(),
filter((item: Item) => hasValue(item) && isNotEmpty(item.uuid))
).subscribe((item: Item) => {
this.relatedItem$.next(item);
}));
}
}
/**
* Removes the selected relationship from the list
*/
removeSelection() {
this.selectableListService.deselectSingle(this.listId, Object.assign(new ItemSearchResult(), { indexableObject: this.relatedItem$.getValue() }));
this.store.dispatch(new RemoveRelationshipAction(this.submissionItem, this.relatedItem$.getValue(), this.relationshipOptions.relationshipType, this.submissionId));
}
/**
* Unsubscribe from all subscriptions
*/
ngOnDestroy(): void {
this.subs
.filter((sub) => hasValue(sub))
.forEach((sub) => sub.unsubscribe());
}
}
// tslint:enable:max-classes-per-file

View File

@@ -1,32 +1,45 @@
<ng-container [formGroup]="group">
<div [dynamicId]="bindId && model.id"
[formArrayName]="model.id"
[ngClass]="getClass('element', 'control')">
<div *ngFor="let groupModel of model.groups; let idx = index" role="group"
[formGroupName]="idx" [ngClass]="[getClass('element', 'group'), getClass('grid', 'group')]">
<div role="group"
formGroupName="0" [ngClass]="[getClass('element', 'group'), getClass('grid', 'group')]">
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: model.groups[0]"></ng-container>
<ng-container *ngTemplateOutlet="controlContainer; context: {$implicit: 0}"></ng-container>
<ng-container *ngTemplateOutlet="endTemplate?.templateRef; context: model.groups[0]"></ng-container>
</div>
<div cdkDropList cdkDropListLockAxis="y" (cdkDropListDropped)="moveSelection($event)">
<div *ngFor="let groupModel of model.groups; let idx = index"
[ngClass]="{'pt-2 pb-2': idx > 0}" cdkDrag cdkDragHandle>
<div [formGroupName]="idx"
[class]="getClass('element', 'group') + ' ' + getClass('grid', 'group')"
[ngClass]="{'d-flex align-items-center': idx > 0}"
>
<ng-container *ngIf="idx > 0">
<i class="drag-icon fas fa-grip-vertical fa-fw"></i>
<ng-container *ngTemplateOutlet="startTemplate?.templateRef; context: groupModel"></ng-container>
<ng-container *ngTemplateOutlet="controlContainer; context: {$implicit: idx}"></ng-container>
<ng-container *ngTemplateOutlet="endTemplate?.templateRef; context: groupModel"></ng-container>
</ng-container>
</div>
</div>
</div>
</div>
</ng-container>
<ds-dynamic-form-control-container *ngFor="let _model of groupModel.group"
<ng-template #controlContainer let-idx>
<ds-dynamic-form-control-container *ngFor="let _model of model.groups[idx].group"
[bindId]="false"
[context]="groupModel"
[context]="model.groups[idx]"
[group]="control.get([idx])"
[hidden]="_model.hidden"
[layout]="layout"
[model]="_model"
[templates]="templates"
[ngClass]="[getClass('element', 'host', _model), getClass('grid', 'host', _model)]"
(dfBlur)="onBlur($event)"
(dfChange)="onChange($event)"
(dfBlur)="update($event, idx)"
(dfChange)="update($event, idx)"
(dfFocus)="onFocus($event)"
(ngbEvent)="onCustomEvent($event, null, true)"></ds-dynamic-form-control-container>
<ng-container *ngTemplateOutlet="endTemplate?.templateRef; context: groupModel"></ng-container>
</div>
</div>
</ng-container>
</ng-template>

View File

@@ -0,0 +1,52 @@
@import './../../../../../../../styles/variables';
:host {
display: block;
}
.cdk-drag {
margin-left: -(2 * $spacer);
margin-right: -(0.5 * $spacer);
padding-right: (0.5 * $spacer);
.drag-icon {
visibility: hidden;
width: (2 * $spacer);
color: $gray-600;
margin: $btn-padding-y 0;
line-height: $btn-line-height;
text-indent: 0.5 * $spacer
}
&:hover, &:focus {
cursor: grab;
.drag-icon {
visibility: visible;
}
}
}
.cdk-drop-list-dragging {
.cdk-drag {
cursor: grabbing;
.drag-icon {
visibility: hidden;
}
}
}
.cdk-drag-preview {
background-color: white;
border-radius: $border-radius-sm;
margin-left: 0;
box-shadow: 0 5px 5px 0px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
.drag-icon {
visibility: visible;
}
}
.cdk-drag-placeholder {
opacity: 0;
}

View File

@@ -1,25 +1,31 @@
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { Component, EventEmitter, Input, Output, QueryList } from '@angular/core';
import { FormGroup } from '@angular/forms';
import {
DynamicFormArrayComponent,
DynamicFormArrayModel,
DynamicFormControlCustomEvent, DynamicFormControlEvent,
DynamicFormControlCustomEvent,
DynamicFormControlEvent,
DynamicFormControlEventType,
DynamicFormLayout,
DynamicFormLayoutService,
DynamicFormValidationService,
DynamicTemplateDirective
} from '@ng-dynamic-forms/core';
import { Relationship } from '../../../../../../core/shared/item-relationships/relationship.model';
import { DynamicRowArrayModel } from '../ds-dynamic-row-array-model';
import { hasValue } from '../../../../../empty.util';
@Component({
selector: 'ds-dynamic-form-array',
templateUrl: './dynamic-form-array.component.html'
templateUrl: './dynamic-form-array.component.html',
styleUrls: ['./dynamic-form-array.component.scss']
})
export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
@Input() bindId = true;
@Input() group: FormGroup;
@Input() layout: DynamicFormLayout;
@Input() model: DynamicFormArrayModel;
@Input() model: DynamicRowArrayModel;
@Input() templates: QueryList<DynamicTemplateDirective> | undefined;
/* tslint:disable:no-output-rename */
@@ -27,12 +33,39 @@ export class DsDynamicFormArrayComponent extends DynamicFormArrayComponent {
@Output('dfChange') change: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>();
@Output('dfFocus') focus: EventEmitter<DynamicFormControlEvent> = new EventEmitter<DynamicFormControlEvent>();
@Output('ngbEvent') customEvent: EventEmitter<DynamicFormControlCustomEvent> = new EventEmitter();
/* tslint:enable:no-output-rename */
constructor(protected layoutService: DynamicFormLayoutService,
protected validationService: DynamicFormValidationService) {
protected validationService: DynamicFormValidationService,
) {
super(layoutService, validationService);
}
moveSelection(event: CdkDragDrop<Relationship>) {
this.model.moveGroup(event.previousIndex, event.currentIndex - event.previousIndex);
const prevIndex = event.previousIndex - 1;
const index = event.currentIndex - 1;
if (hasValue(this.model.groups[index]) && hasValue((this.control as any).controls[index])) {
const $event = {
$event: { previousIndex: prevIndex },
context: { index },
control: (this.control as any).controls[index],
group: this.group,
model: this.model.groups[index].group[0],
type: DynamicFormControlEventType.Change
};
this.onChange($event);
}
}
update(event: any, index: number) {
const $event = Object.assign({}, event, {
context: { index: index - 1}
});
this.onChange($event)
}
}

View File

@@ -17,6 +17,7 @@ export class DynamicDsDatePickerModel extends DynamicDateControlModel {
valueUpdates: Subject<any>;
malformedDate: boolean;
hasLanguages = false;
repeatable = false;
constructor(config: DynamicDateControlModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);

View File

@@ -15,7 +15,7 @@ describe('DsDynamicDisabledComponent', () => {
let model;
function init() {
model = new DynamicDisabledModel({ value: 'test', repeatable: false, metadataFields: [], submissionId: '1234', id: '1' });
model = new DynamicDisabledModel({ value: 'test', repeatable: false, metadataFields: [], submissionId: '1234', id: '1', hasSelectableMetadata: false });
}
beforeEach(async(() => {
@@ -52,7 +52,6 @@ describe('DsDynamicDisabledComponent', () => {
it('should have a disabled input', () => {
const input = de.query(By.css('input'));
console.log(input.nativeElement.getAttribute('disabled'));
expect(input.nativeElement.getAttribute('disabled')).toEqual('');
});
});

View File

@@ -5,6 +5,7 @@ export const DYNAMIC_FORM_CONTROL_TYPE_DISABLED = 'EMPTY';
export interface DsDynamicDisabledModelConfig extends DsDynamicInputModelConfig {
value?: any;
hasSelectableMetadata: boolean;
}
/**
@@ -14,11 +15,14 @@ export class DynamicDisabledModel extends DsDynamicInputModel {
@serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DISABLED;
@serializable() value: any;
@serializable() hasSelectableMetadata: boolean;
constructor(config: DsDynamicDisabledModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);
this.readOnly = true;
this.disabled = true;
this.hasSelectableMetadata = config.hasSelectableMetadata;
this.valueUpdates.next(config.value);
}
}

View File

@@ -2,10 +2,11 @@ import { DynamicFormControlLayout, DynamicFormGroupModel, DynamicFormGroupModelC
import { Subject } from 'rxjs';
import { isNotEmpty } from '../../../../empty.util';
import { hasNoValue, isNotEmpty } from '../../../../empty.util';
import { DsDynamicInputModel } from './ds-dynamic-input.model';
import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model';
import { RelationshipOptions } from '../../models/relationship-options.model';
import { MetadataValue } from '../../../../../core/shared/metadata.models';
export const CONCAT_GROUP_SUFFIX = '_CONCAT_GROUP';
export const CONCAT_FIRST_INPUT_SUFFIX = '_CONCAT_FIRST_INPUT';
@@ -14,11 +15,14 @@ export const CONCAT_SECOND_INPUT_SUFFIX = '_CONCAT_SECOND_INPUT';
export interface DynamicConcatModelConfig extends DynamicFormGroupModelConfig {
separator: string;
value?: any;
hint?: string;
relationship?: RelationshipOptions;
repeatable: boolean;
required: boolean;
metadataFields: string[];
submissionId: string;
hasSelectableMetadata: boolean;
metadataValue?: MetadataValue;
}
export class DynamicConcatModel extends DynamicFormGroupModel {
@@ -28,8 +32,11 @@ export class DynamicConcatModel extends DynamicFormGroupModel {
@serializable() relationship?: RelationshipOptions;
@serializable() repeatable?: boolean;
@serializable() required?: boolean;
@serializable() hint?: string;
@serializable() metadataFields: string[];
@serializable() submissionId: string;
@serializable() hasSelectableMetadata: boolean;
@serializable() metadataValue: MetadataValue;
isCustomGroup = true;
valueUpdates: Subject<string>;
@@ -37,26 +44,30 @@ export class DynamicConcatModel extends DynamicFormGroupModel {
constructor(config: DynamicConcatModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);
this.separator = config.separator + ' ';
this.relationship = config.relationship;
this.repeatable = config.repeatable;
this.required = config.required;
this.hint = config.hint;
this.metadataFields = config.metadataFields;
this.submissionId = config.submissionId;
this.hasSelectableMetadata = config.hasSelectableMetadata;
this.metadataValue = config.metadataValue;
this.valueUpdates = new Subject<string>();
this.valueUpdates.subscribe((value: string) => this.value = value);
}
get value() {
const firstValue = (this.get(0) as DsDynamicInputModel).value;
const secondValue = (this.get(1) as DsDynamicInputModel).value;
if (isNotEmpty(firstValue) && isNotEmpty(secondValue)) {
return new FormFieldMetadataValueObject(firstValue + this.separator + secondValue);
} else if (isNotEmpty(firstValue)) {
return new FormFieldMetadataValueObject(firstValue);
const [firstValue, secondValue] = this.group.map((inputModel: DsDynamicInputModel) =>
(typeof inputModel.value === 'string') ?
Object.assign(new FormFieldMetadataValueObject(), { value: inputModel.value, display: inputModel.value }) :
(inputModel.value as any));
if (isNotEmpty(firstValue) && isNotEmpty(firstValue.value) && isNotEmpty(secondValue) && isNotEmpty(secondValue.value)) {
return Object.assign(new FormFieldMetadataValueObject(), firstValue, { value: firstValue.value + this.separator + secondValue.value });
} else if (isNotEmpty(firstValue) && isNotEmpty(firstValue.value)) {
return Object.assign(new FormFieldMetadataValueObject(), firstValue);
} else if (isNotEmpty(secondValue) && isNotEmpty(secondValue.value)) {
return Object.assign(new FormFieldMetadataValueObject(), secondValue);
} else {
return null;
}
@@ -71,18 +82,21 @@ export class DynamicConcatModel extends DynamicFormGroupModel {
} else {
tempValue = value.value;
}
if (tempValue.includes(this.separator)) {
values = tempValue.split(this.separator);
} else {
values = [tempValue, null];
if (hasNoValue(tempValue)) {
tempValue = '';
}
values = [...tempValue.split(this.separator), null].map((v) =>
Object.assign(new FormFieldMetadataValueObject(), value, { display: v, value: v }));
if (values[0]) {
if (values[0].value) {
(this.get(0) as DsDynamicInputModel).valueUpdates.next(values[0]);
} else {
(this.get(0) as DsDynamicInputModel).valueUpdates.next(undefined);
}
if (values[1]) {
if (values[1].value) {
(this.get(1) as DsDynamicInputModel).valueUpdates.next(values[1]);
} else {
(this.get(1) as DsDynamicInputModel).valueUpdates.next(undefined);
}
}

View File

@@ -6,16 +6,21 @@ import { AuthorityOptions } from '../../../../../core/integration/models/authori
import { hasValue } from '../../../../empty.util';
import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model';
import { RelationshipOptions } from '../../models/relationship-options.model';
import { MetadataValue } from '../../../../../core/shared/metadata.models';
export interface DsDynamicInputModelConfig extends DynamicInputModelConfig {
authorityOptions?: AuthorityOptions;
languageCodes?: LanguageCode[];
language?: string;
place?: number;
value?: any;
relationship?: RelationshipOptions;
repeatable: boolean;
metadataFields: string[];
submissionId: string;
hasSelectableMetadata: boolean;
metadataValue?: MetadataValue;
}
export class DsDynamicInputModel extends DynamicInputModel {
@@ -28,6 +33,8 @@ export class DsDynamicInputModel extends DynamicInputModel {
@serializable() repeatable?: boolean;
@serializable() metadataFields: string[];
@serializable() submissionId: string;
@serializable() hasSelectableMetadata: boolean;
@serializable() metadataValue: MetadataValue;
constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);
@@ -38,6 +45,8 @@ export class DsDynamicInputModel extends DynamicInputModel {
this.value = config.value;
this.relationship = config.relationship;
this.submissionId = config.submissionId;
this.hasSelectableMetadata = config.hasSelectableMetadata;
this.metadataValue = config.metadataValue;
this.language = config.language;
if (!this.language) {

View File

@@ -1,18 +1,34 @@
import { DynamicFormArrayModel, DynamicFormArrayModelConfig, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core';
import { RelationshipOptions } from '../../models/relationship-options.model';
export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig {
notRepeatable: boolean;
required: boolean;
submissionId: string;
relationshipConfig: RelationshipOptions;
metadataKey: string;
metadataFields: string[];
hasSelectableMetadata: boolean;
}
export class DynamicRowArrayModel extends DynamicFormArrayModel {
@serializable() notRepeatable = false;
@serializable() required = false;
@serializable() submissionId: string;
@serializable() relationshipConfig: RelationshipOptions;
@serializable() metadataKey: string;
@serializable() metadataFields: string[];
@serializable() hasSelectableMetadata: boolean;
isRowArray = true;
constructor(config: DynamicRowArrayModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);
this.notRepeatable = config.notRepeatable;
this.required = config.required;
this.submissionId = config.submissionId;
this.relationshipConfig = config.relationshipConfig;
this.metadataKey = config.metadataKey;
this.metadataFields = config.metadataFields;
this.hasSelectableMetadata = config.hasSelectableMetadata;
}
}

View File

@@ -6,7 +6,6 @@
[ngClass]="getClass('element','control')">
<ds-dynamic-form-control-container *ngFor="let _model of model.group"
[asBootstrapFormGroup]="true"
[group]="control"
[hasErrorMessaging]="model.hasErrorMessages"
[hidden]="_model.hidden"
@@ -19,5 +18,4 @@
(dfFocus)="onFocus($event)"
(ngbEvent)="onCustomEvent($event, null, true)"></ds-dynamic-form-control-container>
</div>
</ng-container>

Some files were not shown because too many files have changed in this diff Show More