Merge branch 'w2p-67478_Search-external-sources-in-submission' into w2p-67611_Convert-external-source-to-entity

Conflicts:
	src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html
	src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts
	src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts
This commit is contained in:
Kristof De Langhe
2019-12-19 17:00:44 +01:00
61 changed files with 1132 additions and 252 deletions

View File

@@ -9,11 +9,10 @@ module.exports = {
},
// The REST API server settings.
rest: {
ssl: true,
host: 'dspace7.4science.cloud',
port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server/api'
ssl: true,
host: 'dspace7-entities.atmire.com',
port: 443,
nameSpace: '/server/api'
},
// Caching settings
cache: {

View File

@@ -829,9 +829,9 @@
"item.page.person.search.title": "Articles by this author",
"item.page.related-items.view-more": "View more",
"item.page.related-items.view-more": "Show {{ amount }} more",
"item.page.related-items.view-less": "View less",
"item.page.related-items.view-less": "Hide last {{ amount }}",
"item.page.relationships.isAuthorOfPublication": "Publications",
@@ -1634,6 +1634,10 @@
"submission.sections.describe.relationship-lookup.search-tab.tab-title.lcname": "LC Names ({{ count }})",
"submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding Agency": "Search for Funding Agencies",
"submission.sections.describe.relationship-lookup.search-tab.tab-title.Funding": "Search for Funding",
"submission.sections.describe.relationship-lookup.selection-tab.tab-title": "Current Selection ({{ count }})",
"submission.sections.describe.relationship-lookup.title.Journal Issue": "Journal Issues",
@@ -1644,6 +1648,10 @@
"submission.sections.describe.relationship-lookup.title.Author": "Authors",
"submission.sections.describe.relationship-lookup.title.Funding Agency": "Funding Agency",
"submission.sections.describe.relationship-lookup.title.Funding": "Funding",
"submission.sections.describe.relationship-lookup.search-tab.toggle-dropdown": "Toggle dropdown",
"submission.sections.describe.relationship-lookup.selection-tab.settings": "Settings",

View File

@@ -28,6 +28,7 @@ import { MetadataValuesComponent } from './field-components/metadata-values/meta
import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component';
import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component';
import { StatisticsModule } from '../statistics/statistics.module';
import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component';
@NgModule({
imports: [
@@ -57,7 +58,8 @@ import { StatisticsModule } from '../statistics/statistics.module';
GenericItemPageFieldComponent,
MetadataRepresentationListComponent,
RelatedEntitiesSearchComponent,
TabbedRelatedEntitiesSearchComponent
TabbedRelatedEntitiesSearchComponent,
AbstractIncrementalListComponent
],
exports: [
ItemComponent,

View File

@@ -0,0 +1,73 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs/internal/Subscription';
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
@Component({
selector: 'ds-abstract-incremental-list',
template: ``,
})
/**
* An abstract component for displaying an incremental list of objects
*/
export class AbstractIncrementalListComponent<T> implements OnInit, OnDestroy {
/**
* The amount to increment the list by
* Define this amount in the child component overriding this component
*/
incrementBy: number;
/**
* All pages of objects to display as an array
*/
objects: T[];
/**
* A list of open subscriptions
*/
subscriptions: Subscription[];
ngOnInit(): void {
this.objects = [];
this.subscriptions = [];
this.increase();
}
/**
* Get a specific page
* > Override this method to return a specific page
* @param page The page to fetch
*/
getPage(page: number): T {
return undefined;
}
/**
* Increase the amount displayed
*/
increase() {
const page = this.getPage(this.objects.length + 1);
if (hasValue(page)) {
this.objects.push(page);
}
}
/**
* Decrease the amount displayed
*/
decrease() {
if (this.objects.length > 1) {
this.objects.pop();
}
}
/**
* Unsubscribe from any open subscriptions
*/
ngOnDestroy(): void {
if (isNotEmpty(this.subscriptions)) {
this.subscriptions.forEach((sub: Subscription) => {
sub.unsubscribe();
});
}
}
}

View File

@@ -1,11 +1,20 @@
<ds-metadata-field-wrapper *ngIf="representations$ && (representations$ | async)?.length > 0" [label]="label">
<ds-metadata-representation-loader *ngFor="let rep of (representations$ | async)"
[mdRepresentation]="rep">
</ds-metadata-representation-loader>
<div *ngIf="(representations$ | async)?.length < total" class="mt-2">
<a [routerLink]="" (click)="viewMore()">{{'item.page.related-items.view-more' | translate}}</a>
</div>
<div *ngIf="limit > originalLimit" class="mt-2">
<a [routerLink]="" (click)="viewLess()">{{'item.page.related-items.view-less' | translate}}</a>
</div>
<ds-metadata-field-wrapper [label]="label">
<ng-container *ngFor="let objectPage of objects; let i = index">
<ng-container *ngVar="(objectPage | async) as representations">
<ds-metadata-representation-loader *ngFor="let rep of representations"
[mdRepresentation]="rep">
</ds-metadata-representation-loader>
<ds-loading *ngIf="(i + 1) === objects.length && (i > 0) && (!representations || representations?.length === 0)" message="{{'loading.default' | translate}}"></ds-loading>
<div class="d-inline-block w-100 mt-2" *ngIf="(i + 1) === objects.length && representations?.length > 0">
<div *ngIf="(objects.length * incrementBy) < total" class="float-left">
<a [routerLink]="" (click)="increase()">{{'item.page.related-items.view-more' |
translate:{ amount: (total - (objects.length * incrementBy) < incrementBy) ? total - (objects.length * incrementBy) : incrementBy } }}</a>
</div>
<div *ngIf="objects.length > 1" class="float-right">
<a [routerLink]="" (click)="decrease()">{{'item.page.related-items.view-less' |
translate:{ amount: representations?.length } }}</a>
</div>
</div>
</ng-container>
</ng-container>
</ds-metadata-field-wrapper>

View File

@@ -7,6 +7,8 @@ import { Item } from '../../../core/shared/item.model';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils';
import { TranslateModule } from '@ngx-translate/core';
import { VarDirective } from '../../../shared/utils/var.directive';
import { of as observableOf } from 'rxjs';
const itemType = 'Person';
const metadataField = 'dc.contributor.author';
@@ -64,7 +66,7 @@ describe('MetadataRepresentationListComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [MetadataRepresentationListComponent],
declarations: [MetadataRepresentationListComponent, VarDirective],
providers: [
{ provide: RelationshipService, useValue: relationshipService }
],
@@ -88,33 +90,29 @@ describe('MetadataRepresentationListComponent', () => {
expect(fields.length).toBe(2);
});
it('should initialize the original limit', () => {
expect(comp.originalLimit).toEqual(comp.limit);
it('should contain one page of items', () => {
expect(comp.objects.length).toEqual(1);
});
describe('when viewMore is called', () => {
describe('when increase is called', () => {
beforeEach(() => {
comp.viewMore();
comp.increase();
});
it('should set the limit to a high number in order to retrieve all metadata representations', () => {
expect(comp.limit).toBeGreaterThanOrEqual(999);
it('should add a new page to the list', () => {
expect(comp.objects.length).toEqual(2);
});
});
describe('when viewLess is called', () => {
let originalLimit;
describe('when decrease is called', () => {
beforeEach(() => {
// Store the original value of limit
originalLimit = comp.limit;
// Set limit to a random number
comp.limit = 458;
comp.viewLess();
// Add a second page
comp.objects.push(observableOf(undefined));
comp.decrease();
});
it('should reset the limit to the original value', () => {
expect(comp.limit).toEqual(originalLimit);
it('should decrease the list of pages', () => {
expect(comp.objects.length).toEqual(1);
});
});

View File

@@ -1,16 +1,16 @@
import { Component, Input, OnInit } from '@angular/core';
import { Component, Input } from '@angular/core';
import { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs';
import { RelationshipService } from '../../../core/data/relationship.service';
import { MetadataValue } from '../../../core/shared/metadata.models';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { switchMap } from 'rxjs/operators';
import { filter, map, switchMap } from 'rxjs/operators';
import { RemoteData } from '../../../core/data/remote-data';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../core/shared/item.model';
import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.model';
import { ItemMetadataRepresentation } from '../../../core/shared/metadata-representation/item/item-metadata-representation.model';
import { map, filter } from 'rxjs/operators';
import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component';
@Component({
selector: 'ds-metadata-representation-list',
@@ -22,7 +22,7 @@ import { map, filter } from 'rxjs/operators';
* It expects an itemType to resolve the metadata to a an item
* It expects a label to put on top of the list
*/
export class MetadataRepresentationListComponent implements OnInit {
export class MetadataRepresentationListComponent extends AbstractIncrementalListComponent<Observable<MetadataRepresentation[]>> {
/**
* The parent of the list of related items to display
*/
@@ -44,22 +44,11 @@ export class MetadataRepresentationListComponent implements OnInit {
@Input() label: string;
/**
* The max amount of representations to display
* The amount to increment the list by when clicking "view more"
* Defaults to 10
* The default can optionally be overridden by providing the limit as input to the component
*/
@Input() limit = 10;
/**
* A list of metadata-representations to display
*/
representations$: Observable<MetadataRepresentation[]>;
/**
* The originally provided limit
* Used for resetting the limit to the original value when collapsing the list
*/
originalLimit: number;
@Input() incrementBy = 10;
/**
* The total amount of metadata values available
@@ -67,30 +56,28 @@ export class MetadataRepresentationListComponent implements OnInit {
total: number;
constructor(public relationshipService: RelationshipService) {
}
ngOnInit(): void {
this.originalLimit = this.limit;
this.setRepresentations();
super();
}
/**
* Initialize the metadata representations
* Get a specific page
* @param page The page to fetch
*/
setRepresentations() {
getPage(page: number): Observable<MetadataRepresentation[]> {
const metadata = this.parentItem.findMetadataSortedByPlace(this.metadataField);
this.total = metadata.length;
this.representations$ = this.resolveMetadataRepresentations(metadata);
return this.resolveMetadataRepresentations(metadata, page);
}
/**
* Resolve a list of metadata values to a list of metadata representations
* @param metadata
* @param metadata The list of all metadata values
* @param page The page to return representations for
*/
resolveMetadataRepresentations(metadata: MetadataValue[]): Observable<MetadataRepresentation[]> {
resolveMetadataRepresentations(metadata: MetadataValue[], page: number): Observable<MetadataRepresentation[]> {
return observableZip(
...metadata
.slice(0, this.limit)
.slice((this.objects.length * this.incrementBy), (this.objects.length * this.incrementBy) + this.incrementBy)
.map((metadatum: any) => Object.assign(new MetadataValue(), metadatum))
.map((metadatum: MetadataValue) => {
if (metadatum.isVirtual) {
@@ -115,20 +102,4 @@ export class MetadataRepresentationListComponent implements OnInit {
})
);
}
/**
* Expand the list to display all metadata representations
*/
viewMore() {
this.limit = 9999;
this.setRepresentations();
}
/**
* Collapse the list to display the originally displayed metadata representations
*/
viewLess() {
this.limit = this.originalLimit;
this.setRepresentations();
}
}

View File

@@ -1,12 +1,12 @@
import { Component, HostBinding, Input, OnDestroy, OnInit } from '@angular/core';
import { Component, Input } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { RelationshipService } from '../../../core/data/relationship.service';
import { FindListOptions } from '../../../core/data/request.models';
import { Subscription } from 'rxjs/internal/Subscription';
import { ViewMode } from '../../../core/shared/view-mode.model';
import { RelationshipService } from '../../../core/data/relationship.service';
import { AbstractIncrementalListComponent } from '../abstract-incremental-list/abstract-incremental-list.component';
@Component({
selector: 'ds-related-items',
@@ -17,7 +17,7 @@ import { ViewMode } from '../../../core/shared/view-mode.model';
* This component is used for displaying relations between items
* It expects a parent item and relationship type, as well as a label to display on top
*/
export class RelatedItemsComponent implements OnInit, OnDestroy {
export class RelatedItemsComponent extends AbstractIncrementalListComponent<Observable<RemoteData<PaginatedList<Item>>>> {
/**
* The parent of the list of related items to display
*/
@@ -30,79 +30,38 @@ export class RelatedItemsComponent implements OnInit, OnDestroy {
@Input() relationType: string;
/**
* Default options to start a search request with
* Optional input, should you wish a different page size (or other options)
* The amount to increment the list by when clicking "view more"
* Defaults to 5
* The default can optionally be overridden by providing the limit as input to the component
*/
@Input() options = Object.assign(new FindListOptions(), { elementsPerPage: 5 });
@Input() incrementBy = 5;
/**
* Default options to start a search request with
* Optional input
*/
@Input() options = new FindListOptions();
/**
* An i18n label to use as a title for the list (usually describes the relation)
*/
@Input() label: string;
/**
* Completely hide the component until there's at least one item visible
*/
@HostBinding('class.d-none') hidden = true;
/**
* The list of related items
*/
items$: Observable<RemoteData<PaginatedList<Item>>>;
/**
* Search options for displaying all elements in a list
*/
allOptions = Object.assign(new FindListOptions(), { elementsPerPage: 9999 });
/**
* The view-mode we're currently on
* @type {ViewMode}
*/
viewMode = ViewMode.ListElement;
/**
* Whether or not the list is currently expanded to show all related items
*/
showingAll = false;
/**
* Subscription on items used to update the "hidden" property of this component
*/
itemSub: Subscription;
constructor(public relationshipService: RelationshipService) {
}
ngOnInit(): void {
this.items$ = this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, this.options);
this.itemSub = this.items$.subscribe((itemsRD: RemoteData<PaginatedList<Item>>) => {
this.hidden = !(itemsRD.hasSucceeded && itemsRD.payload && itemsRD.payload.page.length > 0);
});
super();
}
/**
* Expand the list to display all related items
* Get a specific page
* @param page The page to fetch
*/
viewMore() {
this.items$ = this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, this.allOptions);
this.showingAll = true;
}
/**
* Collapse the list to display the originally displayed items
*/
viewLess() {
this.items$ = this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, this.options);
this.showingAll = false;
}
/**
* Unsubscribe from the item subscription
*/
ngOnDestroy(): void {
if (this.itemSub) {
this.itemSub.unsubscribe();
}
getPage(page: number): Observable<RemoteData<PaginatedList<Item>>> {
return this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, Object.assign(this.options, { elementsPerPage: this.incrementBy, currentPage: page }));
}
}

View File

@@ -1,11 +1,20 @@
<ds-metadata-field-wrapper *ngIf="(items$ | async)?.payload?.page?.length > 0" [label]="label">
<ds-listable-object-component-loader *ngFor="let item of (items$ | async)?.payload?.page"
[object]="item" [viewMode]="viewMode">
</ds-listable-object-component-loader>
<div *ngIf="(items$ | async)?.payload?.page?.length < (items$ | async)?.payload?.totalElements" class="mt-2" id="view-more">
<a [routerLink]="" (click)="viewMore()">{{'item.page.related-items.view-more' | translate}}</a>
</div>
<div *ngIf="showingAll" class="mt-2" id="view-less">
<a [routerLink]="" (click)="viewLess()">{{'item.page.related-items.view-less' | translate}}</a>
</div>
<ds-metadata-field-wrapper [label]="label">
<ng-container *ngFor="let objectPage of objects; let i = index">
<ng-container *ngVar="(objectPage | async) as itemsRD">
<ds-listable-object-component-loader *ngFor="let item of itemsRD?.payload?.page"
[object]="item" [viewMode]="viewMode">
</ds-listable-object-component-loader>
<ds-loading *ngIf="(i + 1) === objects.length && (itemsRD || i > 0) && !(itemsRD?.hasSucceeded && itemsRD?.payload && itemsRD?.payload?.page?.length > 0)" message="{{'loading.default' | translate}}"></ds-loading>
<div class="d-inline-block w-100 mt-2" *ngIf="(i + 1) === objects.length && itemsRD?.payload?.page?.length > 0">
<div *ngIf="itemsRD?.payload?.totalPages > objects.length" class="float-left" id="view-more">
<a [routerLink]="" (click)="increase()">{{'item.page.related-items.view-more' |
translate:{ amount: (itemsRD?.payload?.totalElements - (incrementBy * objects.length) < incrementBy) ? itemsRD?.payload?.totalElements - (incrementBy * objects.length) : incrementBy } }}</a>
</div>
<div *ngIf="objects.length > 1" class="float-right" id="view-less">
<a [routerLink]="" (click)="decrease()">{{'item.page.related-items.view-less' |
translate:{ amount: itemsRD?.payload?.page?.length } }}</a>
</div>
</div>
</ng-container>
</ng-container>
</ds-metadata-field-wrapper>

View File

@@ -9,6 +9,8 @@ import { createRelationshipsObservable } from '../item-types/shared/item.compone
import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils';
import { RelationshipService } from '../../../core/data/relationship.service';
import { TranslateModule } from '@ngx-translate/core';
import { VarDirective } from '../../../shared/utils/var.directive';
import { of as observableOf } from 'rxjs';
const parentItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
@@ -42,7 +44,7 @@ describe('RelatedItemsComponent', () => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [RelatedItemsComponent],
declarations: [RelatedItemsComponent, VarDirective],
providers: [
{ provide: RelationshipService, useValue: relationshipService }
],
@@ -65,31 +67,33 @@ describe('RelatedItemsComponent', () => {
expect(fields.length).toBe(mockItems.length);
});
describe('when viewMore is called', () => {
it('should contain one page of items', () => {
expect(comp.objects.length).toEqual(1);
});
describe('when increase is called', () => {
beforeEach(() => {
comp.viewMore();
comp.increase();
});
it('should call relationship-service\'s getRelatedItemsByLabel with the correct arguments', () => {
expect(relationshipService.getRelatedItemsByLabel).toHaveBeenCalledWith(parentItem, relationType, comp.allOptions);
it('should add a new page to the list', () => {
expect(comp.objects.length).toEqual(2);
});
it('should set showingAll to true', () => {
expect(comp.showingAll).toEqual(true);
it('should call relationship-service\'s getRelatedItemsByLabel with the correct arguments (second page)', () => {
expect(relationshipService.getRelatedItemsByLabel).toHaveBeenCalledWith(parentItem, relationType, Object.assign(comp.options, { elementsPerPage: comp.incrementBy, currentPage: 2 }));
});
});
describe('when viewLess is called', () => {
describe('when decrease is called', () => {
beforeEach(() => {
comp.viewLess();
// Add a second page
comp.objects.push(observableOf(undefined));
comp.decrease();
});
it('should call relationship-service\'s getRelatedItemsByLabel with the correct arguments', () => {
expect(relationshipService.getRelatedItemsByLabel).toHaveBeenCalledWith(parentItem, relationType, comp.options);
});
it('should set showingAll to false', () => {
expect(comp.showingAll).toEqual(false);
it('should decrease the list of pages', () => {
expect(comp.objects.length).toEqual(1);
});
});

View File

@@ -8,6 +8,7 @@ import { SearchConfigurationService } from '../core/shared/search/search-configu
import { hasValue } from '../shared/empty.util';
import { RouteService } from '../core/services/route.service';
import { SearchService } from '../core/shared/search/search.service';
import { Router } from '@angular/router';
/**
* This component renders a search page using a configuration as input.
@@ -43,8 +44,9 @@ export class ConfigurationSearchPageComponent extends SearchComponent implements
protected sidebarService: SidebarService,
protected windowService: HostWindowService,
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
protected routeService: RouteService) {
super(service, sidebarService, windowService, searchConfigService, routeService);
protected routeService: RouteService,
protected router: Router) {
super(service, sidebarService, windowService, searchConfigService, routeService, router);
}
/**

View File

@@ -1,7 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SearchComponent } from './search.component';
import { ConfigurationSearchPageGuard } from './configuration-search-page.guard';
import { ConfigurationSearchPageComponent } from './configuration-search-page.component';
import { SearchPageComponent } from './search-page.component';

View File

@@ -9,6 +9,7 @@ import { RouteService } from '../core/services/route.service';
import { hasValue } from '../shared/empty.util';
import { SearchSuccessResponse } from '../core/cache/response.models';
import { SearchConfigurationService } from '../core/shared/search/search-configuration.service';
import { Router } from '@angular/router';
import { SearchService } from '../core/shared/search/search.service';
import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model';
import { SearchQueryResponse } from '../shared/search/search-query-response.model';
@@ -30,14 +31,15 @@ import { SearchQueryResponse } from '../shared/search/search-query-response.mode
export class SearchTrackerComponent extends SearchComponent implements OnInit {
constructor(
protected service:SearchService,
protected sidebarService:SidebarService,
protected windowService:HostWindowService,
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService:SearchConfigurationService,
protected routeService:RouteService,
public angulartics2:Angulartics2
protected service: SearchService,
protected sidebarService: SidebarService,
protected windowService: HostWindowService,
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
protected routeService: RouteService,
public angulartics2: Angulartics2,
protected router: Router
) {
super(service, sidebarService, windowService, searchConfigService, routeService);
super(service, sidebarService, windowService, searchConfigService, routeService, router);
}
ngOnInit():void {
@@ -58,9 +60,9 @@ export class SearchTrackerComponent extends SearchComponent implements OnInit {
)
)
.subscribe((entry) => {
const config:PaginatedSearchOptions = entry.searchOptions;
const searchQueryResponse:SearchQueryResponse = entry.response;
const filters:Array<{ filter:string, operator:string, value:string, label:string; }> = [];
const config: PaginatedSearchOptions = entry.searchOptions;
const searchQueryResponse: SearchQueryResponse = entry.response;
const filters:Array<{ filter: string, operator: string, value: string, label: string; }> = [];
const appliedFilters = searchQueryResponse.appliedFilters || [];
for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) {
const appliedFilter = appliedFilters[i];

View File

@@ -46,5 +46,9 @@
[scopes]="(scopeListRD$ | async)"
[inPlaceSearch]="inPlaceSearch">
</ds-search-form>
<div class="row mb-3 mb-md-1">
<div class="labels col-sm-9 offset-sm-3">
<ds-search-labels *ngIf="searchEnabled" [inPlaceSearch]="inPlaceSearch"></ds-search-labels>
</div>
</div>
</ng-template>

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core';
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { startWith, switchMap, } from 'rxjs/operators';
import { PaginatedList } from '../core/data/paginated-list';
import { RemoteData } from '../core/data/remote-data';
@@ -11,10 +11,12 @@ import { hasValue, isNotEmpty } from '../shared/empty.util';
import { getSucceededRemoteData } from '../core/shared/operators';
import { RouteService } from '../core/services/route.service';
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component';
import { SearchResult } from '../shared/search/search-result.model';
import { SearchConfigurationService } from '../core/shared/search/search-configuration.service';
import { SearchResult } from '../shared/search/search-result.model';
import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model';
import { SearchService } from '../core/shared/search/search.service';
import { currentPath } from '../shared/utils/route.utils';
import { Router } from '@angular/router';
@Component({
selector: 'ds-search',
@@ -96,7 +98,8 @@ export class SearchComponent implements OnInit {
protected sidebarService: SidebarService,
protected windowService: HostWindowService,
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
protected routeService: RouteService) {
protected routeService: RouteService,
protected router: Router) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
}
@@ -159,7 +162,7 @@ export class SearchComponent implements OnInit {
*/
private getSearchLink(): string {
if (this.inPlaceSearch) {
return './';
return currentPath(this.router);
}
return this.service.getSearchLink();
}

View File

@@ -1,8 +1,8 @@
import { StoreEffects } from './store.effects';
import { NotificationsEffects } from './shared/notifications/notifications.effects';
import { NavbarEffects } from './navbar/navbar.effects';
import { RelationshipEffects } from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects';
import { SidebarEffects } from './shared/sidebar/sidebar-effects.service';
import { RelationshipEffects } from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects';
export const appEffects = [
StoreEffects,

View File

@@ -37,9 +37,9 @@ import { AdminSidebarComponent } from './+admin/admin-sidebar/admin-sidebar.comp
import { AdminSidebarSectionComponent } from './+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component';
import { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
import { NavbarModule } from './navbar/navbar.module';
import { ClientCookieService } from './core/services/client-cookie.service';
import { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module';
import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module';
import { ClientCookieService } from './core/services/client-cookie.service';
export function getConfig() {
return ENV_CONFIG;

View File

@@ -1,7 +1,7 @@
import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store';
import * as fromRouter from '@ngrx/router-store';
import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer';
import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer';
import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer';
import { formReducer, FormState } from './shared/form/form.reducer';
import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer';
import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer';

View File

@@ -4,6 +4,9 @@ import { ExternalSourceEntry } from '../../shared/external-source-entry.model';
import { mapsTo } from '../builders/build-decorators';
import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models';
/**
* Normalized model class for an external source entry
*/
@mapsTo(ExternalSourceEntry)
@inheritSerialization(NormalizedObject)
export class NormalizedExternalSourceEntry extends NormalizedObject<ExternalSourceEntry> {

View File

@@ -3,6 +3,9 @@ import { NormalizedObject } from './normalized-object.model';
import { ExternalSource } from '../../shared/external-source.model';
import { mapsTo } from '../builders/build-decorators';
/**
* Normalized model class for an external source
*/
@mapsTo(ExternalSource)
@inheritSerialization(NormalizedObject)
export class NormalizedExternalSource extends NormalizedObject<ExternalSource> {

View File

@@ -128,8 +128,8 @@ import {
MOCK_RESPONSE_MAP,
MockResponseMap,
mockResponseMap
} from './dspace-rest-v2/mocks/mock-response-map';
import { EndpointMockingRestService } from './dspace-rest-v2/endpoint-mocking-rest.service';
} from '../shared/mocks/dspace-rest-v2/mocks/mock-response-map';
import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service';
import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../../config';
import { SearchFilterService } from './shared/search/search-filter.service';
import { SearchConfigurationService } from './shared/search/search-configuration.service';
@@ -141,6 +141,10 @@ import { NormalizedExternalSourceEntry } from './cache/models/normalized-externa
import { ExternalSourceService } from './data/external-source.service';
import { LookupRelationService } from './data/lookup-relation.service';
/**
* When not in production, endpoint responses can be mocked for testing purposes
* If there is no mock version available for the endpoint, the actual REST response will be used just like in production mode
*/
export const restServiceFactory = (cfg: GlobalConfig, mocks: MockResponseMap, http: HttpClient) => {
if (ENV_CONFIG.production) {
return new DSpaceRESTv2Service(http);

View File

@@ -21,6 +21,9 @@ import { PaginatedList } from './paginated-list';
import { ExternalSourceEntry } from '../shared/external-source-entry.model';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
/**
* A service handling all external source requests
*/
@Injectable()
export class ExternalSourceService extends DataService<ExternalSource> {
protected linkPath = 'externalsources';
@@ -38,6 +41,11 @@ export class ExternalSourceService extends DataService<ExternalSource> {
super();
}
/**
* Get the endpoint to browse external sources
* @param options
* @param linkPath
*/
getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return this.halService.getEndpoint(linkPath);
}

View File

@@ -12,8 +12,7 @@ import { Item } from '../shared/item.model';
import { Relationship } from '../shared/item-relationships/relationship.model';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
import { RemoteData } from './remote-data';
import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest';
import { zip as observableZip } from 'rxjs';
import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs';
import { PaginatedList } from './paginated-list';
import { ItemDataService } from './item-data.service';
import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils';
@@ -93,6 +92,14 @@ export class RelationshipService extends DataService<Relationship> {
);
}
/**
* Method to create a new relationship
* @param typeId The identifier of the relationship type
* @param item1 The first item of the relationship
* @param item2 The second item of the relationship
* @param leftwardValue The leftward value of the relationship
* @param rightwardValue The rightward value of the relationship
*/
addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string): Observable<RestResponse> {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
@@ -113,11 +120,15 @@ 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(
getSucceededRemoteData(),
getRemoteDataPayload(),
switchMap((relationship: Relationship) => observableCombineLatest(
switchMap((relationship: Relationship) => combineLatest(
relationship.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()),
relationship.rightItem.pipe(getSucceededRemoteData(), getRemoteDataPayload())
)
@@ -129,10 +140,14 @@ 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) {
this.objectCache.remove(item.self);
this.requestService.removeByHrefSubstring(item.self);
observableCombineLatest(
combineLatest(
this.objectCache.hasBySelfLinkObservable(item.self),
this.requestService.hasByHrefObservable(item.self)
).pipe(
@@ -259,6 +274,12 @@ export class RelationshipService extends DataService<Relationship> {
);
}
/**
* Method to retrieve a relationship based on two items and a relationship type label
* @param item1 The first item in the relationship
* @param item2 The second item in the relationship
* @param label The rightward or leftward type of the relationship
*/
getRelationshipByItemsAndLabel(item1: Item, item2: Item, label: string): Observable<Relationship> {
return this.getItemRelationshipsByLabel(item1, label)
.pipe(
@@ -288,24 +309,51 @@ export class RelationshipService extends DataService<Relationship> {
);
}
/**
* Method to set the name variant for specific list and item
* @param listID The list for which to save the name variant
* @param itemID The item ID for which to save the name variant
* @param nameVariant The name variant to save
*/
public setNameVariant(listID: string, itemID: string, nameVariant: string) {
this.appStore.dispatch(new SetNameVariantAction(listID, itemID, nameVariant));
}
/**
* Method to retrieve the name variant for a specific list and item
* @param listID The list for which to retrieve the name variant
* @param itemID The item ID for which to retrieve the name variant
*/
public getNameVariant(listID: string, itemID: string): Observable<string> {
return this.appStore.pipe(
select(relationshipStateSelector(listID, itemID))
);
}
/**
* Method to remove the name variant for specific list and item
* @param listID The list for which to remove the name variant
* @param itemID The item ID for which to remove the name variant
*/
public removeNameVariant(listID: string, itemID: string) {
this.appStore.dispatch(new RemoveNameVariantAction(listID, itemID));
}
/**
* Method to retrieve all name variants for a single list
* @param listID The id of the list
*/
public getNameVariantsByListID(listID: string) {
return this.appStore.pipe(select(relationshipListStateSelector(listID)));
}
/**
* Method to update the name variant on the server
* @param item1 The first item of the relationship
* @param item2 The second item of the relationship
* @param relationshipLabel The leftward or rightward type of the 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>> {
return this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel)
.pipe(

View File

@@ -187,14 +187,27 @@ export class RouteService {
);
}
/**
* Add a parameter to the current route
* @param key The parameter name
* @param value The parameter value
*/
public addParameter(key, value) {
this.store.dispatch(new AddParameterAction(key, value));
}
/**
* Set a parameter in the current route (overriding the previous value)
* @param key The parameter name
* @param value The parameter value
*/
public setParameter(key, value) {
this.store.dispatch(new SetParameterAction(key, value));
}
/**
* Sets the current route parameters and query parameters in the store
*/
public setCurrentRouteInfo() {
combineLatest(this.getRouteParams(), this.route.queryParams)
.pipe(take(1))

View File

@@ -118,7 +118,7 @@ export class SearchService implements OnDestroy {
* @returns {Observable<RequestEntry>} Emits an observable with the request entries
*/
searchEntries(searchOptions?: PaginatedSearchOptions, responseMsToLive?:number)
:Observable<{searchOptions:PaginatedSearchOptions, requestEntry:RequestEntry}> {
:Observable<{searchOptions: PaginatedSearchOptions, requestEntry: RequestEntry}> {
const hrefObs = this.getEndpoint(searchOptions);

View File

@@ -5,6 +5,10 @@ import { EquatableObject } from './equatable';
const excludedFromEquals = new Map();
const fieldsForEqualsMap = new Map();
/**
* Decorator function that adds the equatable settings from the given (parent) object
* @param parentCo The constructor of the parent object
*/
export function inheritEquatable(parentCo: GenericConstructor<EquatableObject<any>>) {
return function decorator(childCo: GenericConstructor<EquatableObject<any>>) {
const parentExcludedFields = getExcludedFromEqualsFor(parentCo) || [];
@@ -21,6 +25,11 @@ export function inheritEquatable(parentCo: GenericConstructor<EquatableObject<an
}
}
/**
* Function to mark properties as excluded from the equals method
* @param object The object to exclude the property for
* @param propertyName The name of the property to exclude
*/
export function excludeFromEquals(object: any, propertyName: string): any {
if (!object) {
return;
@@ -37,6 +46,10 @@ export function getExcludedFromEqualsFor(constructor: Function): string[] {
return excludedFromEquals.get(constructor) || [];
}
/**
* Function to save the fields that are to be used for a certain property in the equals method for the given object
* @param fields The fields to use to equate the property of the object
*/
export function fieldsForEquals(...fields: string[]): any {
return function i(object: any, propertyName: string): any {
if (!object) {

View File

@@ -1,6 +1,12 @@
import { getExcludedFromEqualsFor, getFieldsForEquals } from './equals.decorators';
import { hasNoValue, hasValue } from '../../shared/empty.util';
/**
* Method to compare fields of two objects against each other
* @param object1 The first object for the comparison
* @param object2 The second object for the comparison
* @param fieldList The list of property/field names to compare
*/
function equalsByFields(object1, object2, fieldList): boolean {
const unequalProperty = fieldList.find((key) => {
if (object1[key] === object2[key]) {
@@ -27,6 +33,10 @@ function equalsByFields(object1, object2, fieldList): boolean {
return hasNoValue(unequalProperty);
}
/**
* Abstract class to represent objects that can be compared to each other
* It provides a default way of comparing
*/
export abstract class EquatableObject<T> {
equals(other: T): boolean {
if (hasNoValue(other)) {

View File

@@ -1,4 +1,4 @@
<div class="d-inline-block">
<div>{{object.display}}</div>
<div *ngIf="uri"><a [href]="uri.value">{{uri.value}}</a></div>
<div *ngIf="uri"><a target="_blank" [href]="uri.value">{{uri.value}}</a></div>
</div>

View File

@@ -13,6 +13,9 @@ import { MetadataValue } from '../../../../../core/shared/metadata.models';
styleUrls: ['./external-source-entry-list-submission-element.component.scss'],
templateUrl: './external-source-entry-list-submission-element.component.html'
})
/**
* The component for displaying a list element of an external source entry
*/
export class ExternalSourceEntryListSubmissionElementComponent extends AbstractListableElementComponent<ExternalSourceEntry> implements OnInit {
/**
* The metadata value for the object's uri

View File

@@ -24,7 +24,7 @@ import { NameVariantModalComponent } from '../../name-variant-modal/name-variant
})
/**
* The component for displaying a list element for an item search result of the type Person
* The component for displaying a list element for an item search result of the type OrgUnit
*/
export class OrgUnitSearchResultListSubmissionElementComponent extends SearchResultListElementComponent<ItemSearchResult, Item> implements OnInit {
allSuggestions: string[];

View File

@@ -8,6 +8,6 @@
{{'submission.sections.describe.relationship-lookup.name-variant.notification.content' | translate: { value: value } }}
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-light" (click)="modal.close()">{{'submission.sections.describe.relationship-lookup.name-variant.notification.confirm' | translate }}</button>
<button type="button" class="btn btn-light" (click)="modal.dismiss()">{{'submission.sections.describe.relationship-lookup.name-variant.notification.decline' | translate }}</button>
<button type="button" class="btn btn-light confirm-button" (click)="modal.close()">{{'submission.sections.describe.relationship-lookup.name-variant.notification.confirm' | translate }}</button>
<button type="button" class="btn btn-light decline-button" (click)="modal.dismiss()">{{'submission.sections.describe.relationship-lookup.name-variant.notification.decline' | translate }}</button>
</div>

View File

@@ -3,16 +3,23 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NameVariantModalComponent } from './name-variant-modal.component';
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
describe('NameVariantModalComponent', () => {
let component: NameVariantModalComponent;
let fixture: ComponentFixture<NameVariantModalComponent>;
let debugElement;
let modal;
function init() {
modal = jasmine.createSpyObj('modal', ['close', 'dismiss']);
}
beforeEach(async(() => {
init();
TestBed.configureTestingModule({
declarations: [NameVariantModalComponent],
imports: [NgbModule.forRoot(), TranslateModule.forRoot()],
providers: [NgbActiveModal]
providers: [{ provide: NgbActiveModal, useValue: modal }]
})
.compileComponents();
}));
@@ -20,10 +27,27 @@ describe('NameVariantModalComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(NameVariantModalComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('when close button is clicked, dismiss should be called on the modal', () => {
debugElement.query(By.css('button.close')).triggerEventHandler('click', {});
expect(modal.dismiss).toHaveBeenCalled();
});
it('when confirm button is clicked, close should be called on the modal', () => {
debugElement.query(By.css('button.confirm-button')).triggerEventHandler('click', {});
expect(modal.close).toHaveBeenCalled();
});
it('when decline button is clicked, dismiss should be called on the modal', () => {
debugElement.query(By.css('button.decline-button')).triggerEventHandler('click', {});
expect(modal.dismiss).toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,22 @@
import { Component, Input } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
/**
* This component a pop up for when the user selects a custom name variant during submission for a relationship$
* The user can either choose to decline or accept to save the name variant as a metadata in the entity
*/
@Component({
selector: 'ds-name-variant-modal',
templateUrl: './name-variant-modal.component.html',
styleUrls: ['./name-variant-modal.component.scss']
})
/**
* The component for the modal to add a name variant to an item
*/
export class NameVariantModalComponent {
/**
* The name variant
*/
@Input() value: string;
constructor(public modal: NgbActiveModal) {

View File

@@ -232,6 +232,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
super(componentFactoryResolver, layoutService, validationService);
}
/**
* 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);
if (this.hasRelationLookup) {
@@ -321,6 +324,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
return this.model.value.pipe(map((list: Array<SearchResult<DSpaceObject>>) => isNotEmpty(list)));
}
/**
* Open a modal where the user can select relationships to be added to item being submitted
*/
openLookup() {
this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, {
size: 'lg'
@@ -335,12 +341,13 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
modalComp.collection = this.collection;
}
/**
* Method to remove a selected relationship from the item
* @param object The second item in the relationship, the submitted item being the first
*/
removeSelection(object: SearchResult<Item>) {
this.selectableListService.deselectSingle(this.listId, object);
this.store.dispatch(new RemoveRelationshipAction(this.item, object.indexableObject, this.model.relationship.relationshipType))
// this.zone.runOutsideAngular(
// () => );
}
/**

View File

@@ -0,0 +1,58 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { DsDynamicDisabledComponent } from './dynamic-disabled.component';
import { FormsModule } from '@angular/forms';
import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { DynamicDisabledModel } from './dynamic-disabled.model';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
describe('DsDynamicDisabledComponent', () => {
let comp: DsDynamicDisabledComponent;
let fixture: ComponentFixture<DsDynamicDisabledComponent>;
let de: DebugElement;
let el: HTMLElement;
let model;
function init() {
model = new DynamicDisabledModel({ value: 'test', repeatable: false, metadataFields: [], submissionId: '1234', id: '1' });
}
beforeEach(async(() => {
init();
TestBed.configureTestingModule({
declarations: [DsDynamicDisabledComponent],
imports: [FormsModule, TranslateModule.forRoot()],
providers: [
{
provide: DynamicFormLayoutService,
useValue: {}
},
{
provide: DynamicFormValidationService,
useValue: {}
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DsDynamicDisabledComponent);
comp = fixture.componentInstance; // DsDynamicDisabledComponent test instance
de = fixture.debugElement;
el = de.nativeElement;
comp.model = model;
fixture.detectChanges();
});
it('should create', () => {
expect(comp).toBeTruthy();
});
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

@@ -3,12 +3,17 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { DynamicFormControlComponent, DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { FormGroup } from '@angular/forms';
import { DynamicDisabledModel } from './dynamic-disabled.model';
import { RelationshipTypeService } from '../../../../../../core/data/relationship-type.service';
/**
* Component representing a simple disabled input field
*/
@Component({
selector: 'ds-dynamic-disabled',
templateUrl: './dynamic-disabled.component.html'
})
/**
* Component for displaying a form input with a disabled property
*/
export class DsDynamicDisabledComponent extends DynamicFormControlComponent {
@Input() formId: string;
@@ -21,8 +26,7 @@ export class DsDynamicDisabledComponent extends DynamicFormControlComponent {
@Output() focus: EventEmitter<any> = new EventEmitter<any>();
constructor(protected layoutService: DynamicFormLayoutService,
protected validationService: DynamicFormValidationService,
protected relationshipTypeService: RelationshipTypeService
protected validationService: DynamicFormValidationService
) {
super(layoutService, validationService);
}

View File

@@ -7,6 +7,9 @@ export interface DsDynamicDisabledModelConfig extends DsDynamicInputModelConfig
value?: any;
}
/**
* This model represents the data for a disabled input field
*/
export class DynamicDisabledModel extends DsDynamicInputModel {
@serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DISABLED;
@@ -14,7 +17,6 @@ export class DynamicDisabledModel extends DsDynamicInputModel {
constructor(config: DsDynamicDisabledModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout);
this.readOnly = true;
this.disabled = true;
this.valueUpdates.next(config.value);

View File

@@ -42,16 +42,58 @@ import { ExternalSourceService } from '../../../../../core/data/external-source.
]
})
/**
* Represents a modal where the submitter can select items to be added as a certain relationship type to the object being submitted
*/
export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy {
/**
* The label to use to display i18n messages (describing the type of relationship)
*/
label: string;
/**
* Options for searching related items
*/
relationshipOptions: RelationshipOptions;
/**
* The ID of the list to add/remove selected items to/from
*/
listId: string;
/**
* The item we're adding relationships to
*/
item;
/**
* The collection we're submitting an item to
*/
collection;
/**
* Is the selection repeatable?
*/
repeatable: boolean;
/**
* The list of selected items
*/
selection$: Observable<ListableObject[]>;
/**
* The context to display lists
*/
context: Context;
/**
* The metadata-fields describing these relationships
*/
metadataFields: string;
/**
* A map of subscriptions within this component
*/
subMap: {
[uuid: string]: Subscription
} = {};
@@ -105,6 +147,10 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
this.modal.close();
}
/**
* Select (a list of) objects and add them to the store
* @param selectableObjects
*/
select(...selectableObjects: Array<SearchResult<Item>>) {
this.zone.runOutsideAngular(
() => {
@@ -132,6 +178,10 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
});
}
/**
* Add a subscription updating relationships with name variants
* @param sri The search result to track name variants for
*/
private addNameVariantSubscription(sri: SearchResult<Item>) {
const nameVariant$ = this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid);
this.subMap[sri.indexableObject.uuid] = nameVariant$.pipe(
@@ -139,6 +189,10 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
).subscribe((nameVariant: string) => this.store.dispatch(new UpdateRelationshipAction(this.item, sri.indexableObject, this.relationshipOptions.relationshipType, nameVariant)))
}
/**
* Deselect (a list of) objects and remove them from the store
* @param selectableObjects
*/
deselect(...selectableObjects: Array<SearchResult<Item>>) {
this.zone.runOutsideAngular(
() => selectableObjects.forEach((object) => {
@@ -148,6 +202,9 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
);
}
/**
* Set existing name variants for items by the item's virtual metadata
*/
private setExistingNameVariants() {
const virtualMDs: MetadataValue[] = this.item.allMetadata(this.metadataFields).filter((mdValue) => mdValue.isVirtual);

View File

@@ -49,7 +49,7 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit
@Input() label: string;
/**
* The ID of the list of selected entries
* The ID of the list to add/remove selected items to/from
*/
@Input() listId: string;

View File

@@ -11,7 +11,9 @@ export const NameVariantActionTypes = {
};
/* tslint:disable:max-classes-per-file */
/**
* Abstract class for actions that happen to name variants
*/
export abstract class NameVariantListAction implements Action {
type;
payload: {
@@ -24,6 +26,9 @@ export abstract class NameVariantListAction implements Action {
}
}
/**
* Action for setting a new name on an item in a certain list
*/
export class SetNameVariantAction extends NameVariantListAction {
type = NameVariantActionTypes.SET_NAME_VARIANT;
payload: {
@@ -38,6 +43,9 @@ export class SetNameVariantAction extends NameVariantListAction {
}
}
/**
* Action for removing a name on an item in a certain list
*/
export class RemoveNameVariantAction extends NameVariantListAction {
type = NameVariantActionTypes.REMOVE_NAME_VARIANT;
constructor(listID: string, itemID: string) {

View File

@@ -72,6 +72,11 @@ export class RelationshipEffects {
)
);
/**
* Updates the namevariant in a relationship
* If the relationship is currently being added or removed, it will add the name variant to an update map so it will be sent with the next add request instead
* Otherwise the update is done immediately
*/
@Effect({ dispatch: false }) updateNameVariantsActions$ = this.actions$
.pipe(
ofType(RelationshipActionTypes.UPDATE_RELATIONSHIP),

View File

@@ -34,24 +34,81 @@ import { LookupRelationService } from '../../../../../../core/data/lookup-relati
]
})
/**
* Tab for inside the lookup model that represents the items that can be used as a relationship in this submission
*/
export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDestroy {
/**
* Options for searching related items
*/
@Input() relationship: RelationshipOptions;
/**
* The ID of the list to add/remove selected items to/from
*/
@Input() listId: string;
/**
* Is the selection repeatable?
*/
@Input() repeatable: boolean;
/**
* The list of selected items
*/
@Input() selection$: Observable<ListableObject[]>;
/**
* The context to display lists
*/
@Input() context: Context;
/**
* Send an event to deselect an object from the list
*/
@Output() deselectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
/**
* Send an event to select an object from the list
*/
@Output() selectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
/**
* Search results
*/
resultsRD$: Observable<RemoteData<PaginatedList<SearchResult<Item>>>>;
/**
* Are all results selected?
*/
allSelected: boolean;
/**
* Are some results selected?
*/
someSelected$: Observable<boolean>;
/**
* Is it currently loading to select all results?
*/
selectAllLoading: boolean;
/**
* Subscription to unsubscribe from
*/
subscription;
/**
* The initial pagination to use
*/
initialPagination = Object.assign(new PaginationComponentOptions(), {
id: 'submission-relation-list',
pageSize: 5
});
/**
* The type of links to display
*/
linkTypes = CollectionElementLinkType;
constructor(
@@ -65,6 +122,9 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
) {
}
/**
* Sets up the pagination and fixed query parameters
*/
ngOnInit(): void {
this.resetRoute();
this.routeService.setParameter('fixedFilterQuery', this.relationship.filter);
@@ -76,12 +136,19 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
);
}
/**
* Method to reset the route when the window is opened to make sure no strange pagination issues appears
*/
resetRoute() {
this.router.navigate([], {
queryParams: Object.assign({}, { pageSize: this.initialPagination.pageSize }, this.route.snapshot.queryParams, { page: 1 })
});
}
/**
* Selects a page in the store
* @param page The page to select
*/
selectPage(page: Array<SearchResult<Item>>) {
this.selection$
.pipe(take(1))
@@ -92,6 +159,10 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
this.selectableListService.select(this.listId, page);
}
/**
* Deselects a page in the store
* @param page the page to deselect
*/
deselectPage(page: Array<SearchResult<Item>>) {
this.allSelected = false;
this.selection$
@@ -103,6 +174,9 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
this.selectableListService.deselect(this.listId, page);
}
/**
* Select all items that were found using the current search query
*/
selectAll() {
this.allSelected = true;
this.selectAllLoading = true;
@@ -128,6 +202,9 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
);
}
/**
* Deselect all items
*/
deselectAll() {
this.allSelected = false;
this.selection$

View File

@@ -25,20 +25,65 @@ import { Context } from '../../../../../../core/shared/context.model';
]
})
/**
* Tab for inside the lookup model that represents the currently selected relationships
*/
export class DsDynamicLookupRelationSelectionTabComponent {
/**
* The label to use to display i18n messages (describing the type of relationship)
*/
@Input() label: string;
/**
* The ID of the list to add/remove selected items to/from
*/
@Input() listId: string;
/**
* Is the selection repeatable?
*/
@Input() repeatable: boolean;
/**
* The list of selected items
*/
@Input() selection$: Observable<ListableObject[]>;
/**
* The paginated list of selected items
*/
@Input() selectionRD$: Observable<RemoteData<PaginatedList<ListableObject>>>;
/**
* The context to display lists
*/
@Input() context: Context;
/**
* Send an event to deselect an object from the list
*/
@Output() deselectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
/**
* Send an event to select an object from the list
*/
@Output() selectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
/**
* The initial pagination to use
*/
initialPagination = Object.assign(new PaginationComponentOptions(), {
id: 'submission-relation-list',
pageSize: 5
});
constructor(private router: Router,
private searchConfigService: SearchConfigurationService) {
}
/**
* Set up the selection and pagination on load
*/
ngOnInit() {
this.selectionRD$ = this.searchConfigService.paginatedSearchOptions
.pipe(

View File

@@ -1,5 +1,8 @@
const RELATION_METADATA_PREFIX = 'relation.'
/**
* The submission options for fields that can represent relationships
*/
export class RelationshipOptions {
relationshipType: string;
filter: string;

View File

@@ -0,0 +1,66 @@
import { FormFieldModel } from '../models/form-field.model';
import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model';
import { ParserOptions } from './parser-options';
import { DisabledFieldParser } from './disabled-field-parser';
import { DynamicDisabledModel } from '../ds-dynamic-form-ui/models/disabled/dynamic-disabled.model';
describe('DisabledFieldParser test suite', () => {
let field: FormFieldModel;
let initFormValues: any = {};
const submissionId = '1234';
const parserOptions: ParserOptions = {
readOnly: false,
submissionScope: null,
authorityUuid: null
};
beforeEach(() => {
field = {
input: {
type: ''
},
label: 'Description',
mandatory: 'false',
repeatable: false,
hints: 'Enter a description.',
selectableMetadata: [
{
metadata: 'description'
}
],
languageCodes: []
} as FormFieldModel;
});
it('should init parser properly', () => {
const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions);
expect(parser instanceof DisabledFieldParser).toBe(true);
});
it('should return a DynamicDisabledModel object when repeatable option is false', () => {
const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions);
const fieldModel = parser.parse();
expect(fieldModel instanceof DynamicDisabledModel).toBe(true);
});
it('should set init value properly', () => {
initFormValues = {
description: [
new FormFieldMetadataValueObject('test description'),
],
};
const expectedValue ='test description';
const parser = new DisabledFieldParser(submissionId, field, initFormValues, parserOptions);
const fieldModel = parser.parse();
console.log(fieldModel);
expect(fieldModel.value).toEqual(expectedValue);
});
});

View File

@@ -2,10 +2,15 @@ import { FieldParser } from './field-parser';
import { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model';
import { DsDynamicDisabledModelConfig, DynamicDisabledModel } from '../ds-dynamic-form-ui/models/disabled/dynamic-disabled.model';
/**
* Field parser for disabled fields
*/
export class DisabledFieldParser extends FieldParser {
public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any {
console.log(fieldValue);
const emptyModelConfig: DsDynamicDisabledModelConfig = this.initModel(null, label);
this.setValues(emptyModelConfig, fieldValue);
return new DynamicDisabledModel(emptyModelConfig)
}
}

View File

@@ -27,6 +27,9 @@ const fieldParserDeps = [
PARSER_OPTIONS,
];
/**
* Method to retrieve a field parder with its providers based on the input type
*/
export class ParserFactory {
public static getProvider(type: ParserType): StaticProvider {
switch (type) {

View File

@@ -27,6 +27,10 @@ export const ROW_ID_PREFIX = 'df-row-group-config-';
@Injectable({
providedIn: 'root'
})
/**
* Parser the submission data for a single row
*/
export class RowParser {
constructor(private parentInjector: Injector) {
}

View File

@@ -1,7 +1,7 @@
import { HttpHeaders, HttpResponse } from '@angular/common/http';
import { of as observableOf } from 'rxjs';
import { GlobalConfig } from '../../../config/global-config.interface';
import { RestRequestMethod } from '../data/rest-request-method';
import { GlobalConfig } from '../../../../config/global-config.interface';
import { RestRequestMethod } from '../../../core/data/rest-request-method';
import { EndpointMockingRestService } from './endpoint-mocking-rest.service';
import { MockResponseMap } from './mocks/mock-response-map';

View File

@@ -1,12 +1,12 @@
import { HttpClient, HttpHeaders } from '@angular/common/http'
import { Inject, Injectable } from '@angular/core';
import { Observable, of as observableOf } from 'rxjs';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { isEmpty } from '../../shared/empty.util';
import { RestRequestMethod } from '../data/rest-request-method';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
import { isEmpty } from '../../empty.util';
import { RestRequestMethod } from '../../../core/data/rest-request-method';
import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model';
import { DSpaceRESTv2Service, HttpOptions } from './dspace-rest-v2.service';
import { DSpaceRESTV2Response } from '../../../core/dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceRESTv2Service, HttpOptions } from '../../../core/dspace-rest-v2/dspace-rest-v2.service';
import { MOCK_RESPONSE_MAP, MockResponseMap } from './mocks/mock-response-map';
import * as URL from 'url-parse';
@@ -14,6 +14,8 @@ import * as URL from 'url-parse';
* Service to access DSpace's REST API.
*
* If a URL is found in this.mockResponseMap, it returns the mock response instead
* This service can be used for mocking REST responses when developing new features
* This is especially useful, when a REST endpoint is broken or does not exist yet
*/
@Injectable()
export class EndpointMockingRestService extends DSpaceRESTv2Service {

View File

@@ -1,5 +1,5 @@
import { InjectionToken } from '@angular/core';
import mockSubmissionResponse from '../mocks/mock-submission-response.json';
import mockSubmissionResponse from './mock-submission-response.json';
export class MockResponseMap extends Map<string, any> {};

View File

@@ -3,10 +3,10 @@
[name]="'checkbox' + index"
[id]="'object' + index"
[ngModel]="selected$ | async"
(ngModelChange)="selectCheckbox($event, object)">
(ngModelChange)="selectCheckbox($event)">
<input *ngIf="!selectionConfig.repeatable" class="form-check-input" type="radio"
[name]="'radio' + index"
[id]="'object' + index"
[checked]="selected$ | async"
(click)="selectRadio(!checked, object)">
(click)="selectRadio(!checked)">
</ng-container>

View File

@@ -0,0 +1,91 @@
import { ComponentFixture, TestBed, async, tick, fakeAsync } from '@angular/core/testing';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { SelectableListService } from '../../../object-list/selectable-list/selectable-list.service';
import { SelectableListItemControlComponent } from './selectable-list-item-control.component';
import { Item } from '../../../../core/shared/item.model';
import { FormsModule } from '@angular/forms';
import { VarDirective } from '../../../utils/var.directive';
import { of as observableOf } from 'rxjs';
import { ListableObject } from '../listable-object.model';
describe('SelectableListItemControlComponent', () => {
let comp: SelectableListItemControlComponent;
let fixture: ComponentFixture<SelectableListItemControlComponent>;
let de: DebugElement;
let el: HTMLElement;
let object;
let otherObject;
let selectionConfig;
let listId;
let index;
let selectionService;
let selection: ListableObject[];
let uuid1: string;
let uuid2: string;
function init() {
uuid1 = '0beb44f8-d2ed-459a-a1e7-ffbe059089a9';
uuid2 = 'e1dc80aa-c269-4aa5-b6bd-008d98056247';
listId = 'Test List ID';
object = Object.assign(new Item(), {uuid: uuid1});
otherObject = Object.assign(new Item(), {uuid: uuid2});
selectionConfig = {repeatable: false, listId};
index = 0;
selection = [otherObject];
selectionService = jasmine.createSpyObj('selectionService', {
selectSingle: jasmine.createSpy('selectSingle'),
deselectSingle: jasmine.createSpy('deselectSingle'),
isObjectSelected: observableOf(true),
getSelectableList: observableOf({ selection })
}
);
}
beforeEach(async(() => {
init();
TestBed.configureTestingModule({
declarations: [SelectableListItemControlComponent, VarDirective],
imports: [FormsModule],
providers: [
{
provide: SelectableListService,
useValue: selectionService
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SelectableListItemControlComponent);
comp = fixture.componentInstance; // SelectableListItemControlComponent test instance
de = fixture.debugElement;
el = de.nativeElement;
comp.object = object;
comp.selectionConfig = selectionConfig;
comp.index = index;
fixture.detectChanges();
});
it('should call deselectSingle on the service when the object when selectCheckbox is called with value false', () => {
comp.selectCheckbox(false);
expect(selectionService.deselectSingle).toHaveBeenCalledWith(listId, object);
});
it('should call selectSingle on the service when the object when selectCheckbox is called with value false', () => {
comp.selectCheckbox(true);
expect(selectionService.selectSingle).toHaveBeenCalledWith(listId, object);
});
it('should call selectSingle on the service when the object when selectRadio is called with value true and deselect all others in the selection', () => {
comp.selectRadio(true );
expect(selectionService.deselectSingle).toHaveBeenCalledWith(listId, selection[0]);
expect(selectionService.selectSingle).toHaveBeenCalledWith(listId, object);
});
it('should not call selectSingle on the service when the object when selectRadio is called with value false and not deselect all others in the selection', () => {
comp.selectRadio(false );
expect(selectionService.deselectSingle).not.toHaveBeenCalledWith(listId, selection[0]);
expect(selectionService.selectSingle).not.toHaveBeenCalledWith(listId, object);
});
});

View File

@@ -49,29 +49,30 @@ export class SelectableListItemControlComponent implements OnInit {
})
}
selectCheckbox(value: boolean, object: ListableObject) {
selectCheckbox(value: boolean) {
if (value) {
this.selectionService.selectSingle(this.selectionConfig.listId, object);
this.selectionService.selectSingle(this.selectionConfig.listId, this.object);
} else {
this.selectionService.deselectSingle(this.selectionConfig.listId, object);
this.selectionService.deselectSingle(this.selectionConfig.listId, this.object);
}
}
selectRadio(value: boolean, object: ListableObject) {
const selected$ = this.selectionService.getSelectableList(this.selectionConfig.listId);
selected$.pipe(
take(1),
map((selected) => selected ? selected.selection : [])
).subscribe((selection) => {
// First deselect any existing selections, this is a radio button
selection.forEach((selectedObject) => {
this.selectionService.deselectSingle(this.selectionConfig.listId, selectedObject);
this.deselectObject.emit(selectedObject);
});
if (value) {
this.selectionService.selectSingle(this.selectionConfig.listId, object);
this.selectObject.emit(object);
}
});
selectRadio(value: boolean) {
if (value) {
const selected$ = this.selectionService.getSelectableList(this.selectionConfig.listId);
selected$.pipe(
take(1),
map((selected) => selected ? selected.selection : [])
).subscribe((selection) => {
// First deselect any existing selections, this is a radio button
selection.forEach((selectedObject) => {
this.selectionService.deselectSingle(this.selectionConfig.listId, selectedObject);
this.deselectObject.emit(selectedObject);
});
this.selectionService.selectSingle(this.selectionConfig.listId, this.object);
this.selectObject.emit(this.object);
}
);
}
}
}

View File

@@ -19,6 +19,9 @@ export const SelectableListActionTypes = {
DESELECT_ALL: type('dspace/selectable-lists/DESELECT_ALL')
};
/**
* Abstract action class for actions on selectable lists
*/
/* tslint:disable:max-classes-per-file */
export abstract class SelectableListAction implements Action {
// tslint:disable-next-line:no-shadowed-variable
@@ -27,7 +30,7 @@ export abstract class SelectableListAction implements Action {
}
/**
* Used to select an item in a the selectable list
* Action to select objects in a the selectable list
*/
export class SelectableListSelectAction extends SelectableListAction {
payload: ListableObject[];
@@ -37,7 +40,9 @@ export class SelectableListSelectAction extends SelectableListAction {
this.payload = objects;
}
}
/**
* Action to select a single object in a the selectable list
*/
export class SelectableListSelectSingleAction extends SelectableListAction {
payload: {
object: ListableObject,
@@ -49,6 +54,9 @@ export class SelectableListSelectSingleAction extends SelectableListAction {
}
}
/**
* Action to deselect objects in a the selectable list
*/
export class SelectableListDeselectSingleAction extends SelectableListAction {
payload: ListableObject;
@@ -58,6 +66,9 @@ export class SelectableListDeselectSingleAction extends SelectableListAction {
}
}
/**
* Action to deselect a single object in a the selectable list
*/
export class SelectableListDeselectAction extends SelectableListAction {
payload: ListableObject[];
@@ -67,6 +78,9 @@ export class SelectableListDeselectAction extends SelectableListAction {
}
}
/**
* Action to set a new or overwrite an existing selection
*/
export class SelectableListSetSelectionAction extends SelectableListAction {
payload: ListableObject[];
@@ -76,6 +90,9 @@ export class SelectableListSetSelectionAction extends SelectableListAction {
}
}
/**
* Action to deselect all currently selected objects
*/
export class SelectableListDeselectAllAction extends SelectableListAction {
constructor(id: string) {
super(SelectableListActionTypes.DESELECT_ALL, id);

View File

@@ -0,0 +1,112 @@
import {
SelectableListAction,
SelectableListDeselectAction, SelectableListDeselectAllAction,
SelectableListDeselectSingleAction,
SelectableListSelectAction,
SelectableListSelectSingleAction,
SelectableListSetSelectionAction
} from './selectable-list.actions';
import { selectableListReducer } from './selectable-list.reducer';
import { ListableObject } from '../../object-collection/shared/listable-object.model';
import { hasValue } from '../../empty.util';
// tslint:disable:max-classes-per-file
class SelectableObject extends ListableObject {
constructor(private value: string) {
super();
}
equals(other: SelectableObject): boolean {
return hasValue(this.value) && hasValue(other.value) && this.value === other.value;
}
getRenderTypes() {
return ['selectable'];
}
}
class NullAction extends SelectableListAction {
type = null;
constructor() {
super(undefined, undefined);
}
}
// tslint:enable:max-classes-per-file
const listID1 = 'id1';
const listID2 = 'id2';
const value1 = 'Selected object';
const value2 = 'Another selected object';
const value3 = 'Selection';
const value4 = 'Selected object numero 4';
const selected1 = new SelectableObject(value1);
const selected2 = new SelectableObject(value2);
const selected3 = new SelectableObject(value3);
const selected4 = new SelectableObject(value4);
const testState = { [listID1]: { id: listID1, selection: [selected1, selected2] } };
describe('selectableListReducer', () => {
it('should return the current state when no valid actions have been made', () => {
const state = {};
state[listID1] = {};
state[listID1] = { id: listID1, selection: [selected1, selected2] };
const action = new NullAction();
const newState = selectableListReducer(state, action);
expect(newState).toEqual(state);
});
it('should start with an empty object', () => {
const state = {};
const action = new NullAction();
const newState = selectableListReducer(undefined, action);
expect(newState).toEqual(state);
});
it('should add the payload to the existing list in response to the SELECT action for the given id', () => {
const action = new SelectableListSelectAction(listID1, [selected3, selected4]);
const newState = selectableListReducer(testState, action);
expect(newState[listID1].selection).toEqual([selected1, selected2, selected3, selected4]);
});
it('should add the payload to the existing list in response to the SELECT_SINGLE action for the given id', () => {
const action = new SelectableListSelectSingleAction(listID1, selected4);
const newState = selectableListReducer(testState, action);
expect(newState[listID1].selection).toEqual([selected1, selected2, selected4]);
});
it('should remove the payload from the existing list in response to the DESELECT action for the given id', () => {
const action = new SelectableListDeselectAction(listID1, [selected1, selected2]);
const newState = selectableListReducer(testState, action);
expect(newState[listID1].selection).toEqual([]);
});
it('should remove the payload from the existing list in response to the DESELECT_SINGLE action for the given id', () => {
const action = new SelectableListDeselectSingleAction(listID1, selected2);
const newState = selectableListReducer(testState, action);
expect(newState[listID1].selection).toEqual([selected1]);
});
it('should set the list to the payload in response to the SET_SELECTION action for the given id', () => {
const action = new SelectableListSetSelectionAction(listID2, [selected2, selected4]);
const newState = selectableListReducer(testState, action);
expect(newState[listID1].selection).toEqual(testState[listID1].selection);
expect(newState[listID2].selection).toEqual([selected2, selected4]);
});
it('should remove the payload from the existing list in response to the DESELECT action for the given id', () => {
const action = new SelectableListDeselectAllAction(listID1);
const newState = selectableListReducer(testState, action);
expect(newState[listID1].selection).toEqual([]);
});
});

View File

@@ -63,12 +63,22 @@ export function selectableListReducer(state: SelectableListsState = {}, action:
}
}
/**
* Adds multiple objects to the existing selection state
* @param state The current state
* @param action The action to perform
*/
function select(state: SelectableListState, action: SelectableListSelectAction) {
const filteredNewObjects = action.payload.filter((object) => !isObjectInSelection(state.selection, object));
const newSelection = [...state.selection, ...filteredNewObjects];
return Object.assign({}, state, { selection: newSelection });
}
/**
* Adds a single object to the existing selection state
* @param state The current state
* @param action The action to perform
*/
function selectSingle(state: SelectableListState, action: SelectableListSelectSingleAction) {
let newSelection = state.selection;
if (!isObjectInSelection(state.selection, action.payload.object)) {
@@ -77,11 +87,21 @@ function selectSingle(state: SelectableListState, action: SelectableListSelectSi
return Object.assign({}, state, { selection: newSelection });
}
/**
* Removes multiple objects in the existing selection state
* @param state The current state
* @param action The action to perform
*/
function deselect(state: SelectableListState, action: SelectableListDeselectAction) {
const newSelection = state.selection.filter((selected) => hasNoValue(action.payload.find((object) => object.equals(selected))));
return Object.assign({}, state, { selection: newSelection });
}
/** Removes a single object from the existing selection state
*
* @param state The current state
* @param action The action to perform
*/
function deselectSingle(state: SelectableListState, action: SelectableListDeselectSingleAction) {
const newSelection = state.selection.filter((selected) => {
return !selected.equals(action.payload);
@@ -89,14 +109,29 @@ function deselectSingle(state: SelectableListState, action: SelectableListDesele
return Object.assign({}, state, { selection: newSelection });
}
/**
* Sets the selection state of the list
* @param state The current state
* @param action The action to perform
*/
function setList(state: SelectableListState, action: SelectableListSetSelectionAction) {
return Object.assign({}, state, { selection: action.payload });
}
/**
* Clears the selection
* @param state The current state
* @param action The action to perform
*/
function clearSelection(id: string) {
return { id: id, selection: [] };
}
/**
* Checks whether the object is in currently in the selection
* @param state The current state
* @param action The action to perform
*/
function isObjectInSelection(selection: ListableObject[], object: ListableObject) {
return selection.findIndex((selected) => selected.equals(object)) >= 0
}

View File

@@ -0,0 +1,98 @@
import { Store } from '@ngrx/store';
import { async, TestBed } from '@angular/core/testing';
import { SelectableListService } from './selectable-list.service';
import { SelectableListsState } from './selectable-list.reducer';
import { ListableObject } from '../../object-collection/shared/listable-object.model';
import { hasValue } from '../../empty.util';
import { SelectableListDeselectAction, SelectableListDeselectSingleAction, SelectableListSelectAction, SelectableListSelectSingleAction } from './selectable-list.actions';
class SelectableObject extends ListableObject {
constructor(private value: string) {
super();
}
equals(other: SelectableObject): boolean {
return hasValue(this.value) && hasValue(other.value) && this.value === other.value;
}
getRenderTypes() {
return ['selectable'];
}
}
describe('SelectableListService', () => {
const listID1 = 'id1';
const value1 = 'Selected object';
const value2 = 'Another selected object';
const value3 = 'Selection';
const value4 = 'Selected object numero 4';
const selected1 = new SelectableObject(value1);
const selected2 = new SelectableObject(value2);
const selected3 = new SelectableObject(value3);
const selected4 = new SelectableObject(value4);
let service: SelectableListService;
const store: Store<SelectableListsState> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
});
beforeEach(async(() => {
TestBed.configureTestingModule({
providers: [
{
provide: Store, useValue: store
}
]
}).compileComponents();
}));
beforeEach(() => {
service = new SelectableListService(store);
});
describe('when the selectSingle method is triggered', () => {
beforeEach(() => {
service.selectSingle(listID1, selected3);
});
it('SelectableListSelectSingleAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SelectableListSelectSingleAction(listID1, selected3));
});
});
describe('when the select method is triggered', () => {
beforeEach(() => {
service.select(listID1, [selected1, selected4]);
});
it('SelectableListSelectAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SelectableListSelectAction(listID1, [selected1, selected4]));
});
});
describe('when the deselectSingle method is triggered', () => {
beforeEach(() => {
service.deselectSingle(listID1, selected4);
});
it('SelectableListDeselectSingleAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SelectableListDeselectSingleAction(listID1, selected4));
});
});
describe('when the deselect method is triggered', () => {
beforeEach(() => {
service.deselect(listID1, [selected2, selected4]);
});
it('SelectableListDeselectAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SelectableListDeselectAction(listID1, [selected2, selected4]));
});
});
});

View File

@@ -3,7 +3,6 @@ import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { SearchFormComponent } from './search-form.component';
import { FormsModule } from '@angular/forms';
import { ResourceType } from '../../core/shared/resource-type';
import { RouterTestingModule } from '@angular/router/testing';
import { Community } from '../../core/shared/community.model';
import { TranslateModule } from '@ngx-translate/core';

View File

@@ -15,24 +15,24 @@ import { SearchOptions } from '../shared/search/search-options.model';
export class StatisticsService {
constructor(
protected requestService:RequestService,
protected halService:HALEndpointService,
protected requestService: RequestService,
protected halService: HALEndpointService,
) {
}
private sendEvent(linkPath:string, body:any) {
private sendEvent(linkPath: string, body: any) {
const requestId = this.requestService.generateRequestId();
this.halService.getEndpoint(linkPath).pipe(
map((endpoint:string) => new TrackRequest(requestId, endpoint, JSON.stringify(body))),
map((endpoint: string) => new TrackRequest(requestId, endpoint, JSON.stringify(body))),
take(1) // otherwise the previous events will fire again
).subscribe((request:RestRequest) => this.requestService.configure(request));
).subscribe((request: RestRequest) => this.requestService.configure(request));
}
/**
* To track a page view
* @param dso: The dso which was viewed
*/
trackViewEvent(dso:DSpaceObject) {
trackViewEvent(dso: DSpaceObject) {
this.sendEvent('/statistics/viewevents', {
targetId: dso.uuid,
targetType: (dso as any).type
@@ -47,10 +47,10 @@ export class StatisticsService {
* @param filters: An array of search filters used to filter the result set
*/
trackSearchEvent(
searchOptions:SearchOptions,
page:{ size:number, totalElements:number, totalPages:number, number:number },
sort:{ by:string, order:string },
filters?:Array<{ filter:string, operator:string, value:string, label:string }>
searchOptions: SearchOptions,
page: { size: number, totalElements: number, totalPages: number, number: number },
sort: { by: string, order: string },
filters?: Array<{ filter: string, operator: string, value: string, label: string }>
) {
const body = {
query: searchOptions.query,
@@ -66,13 +66,13 @@ export class StatisticsService {
},
};
if (hasValue(searchOptions.configuration)) {
Object.assign(body, {configuration: searchOptions.configuration})
Object.assign(body, { configuration: searchOptions.configuration })
}
if (hasValue(searchOptions.dsoType)) {
Object.assign(body, {dsoType: searchOptions.dsoType.toLowerCase()})
Object.assign(body, { dsoType: searchOptions.dsoType.toLowerCase() })
}
if (hasValue(searchOptions.scope)) {
Object.assign(body, {scope: searchOptions.scope})
Object.assign(body, { scope: searchOptions.scope })
}
if (isNotEmpty(filters)) {
const bodyFilters = [];
@@ -85,7 +85,7 @@ export class StatisticsService {
label: filter.label
})
}
Object.assign(body, {appliedFilters: bodyFilters})
Object.assign(body, { appliedFilters: bodyFilters })
}
this.sendEvent('/statistics/searchevents', body);
}