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. // The REST API server settings.
rest: { rest: {
ssl: true, ssl: true,
host: 'dspace7.4science.cloud', host: 'dspace7-entities.atmire.com',
port: 443, port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: '/server/api'
nameSpace: '/server/api'
}, },
// Caching settings // Caching settings
cache: { cache: {

View File

@@ -829,9 +829,9 @@
"item.page.person.search.title": "Articles by this author", "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", "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.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.selection-tab.tab-title": "Current Selection ({{ count }})",
"submission.sections.describe.relationship-lookup.title.Journal Issue": "Journal Issues", "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.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.search-tab.toggle-dropdown": "Toggle dropdown",
"submission.sections.describe.relationship-lookup.selection-tab.settings": "Settings", "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 { 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 { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component';
import { StatisticsModule } from '../statistics/statistics.module'; import { StatisticsModule } from '../statistics/statistics.module';
import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -57,7 +58,8 @@ import { StatisticsModule } from '../statistics/statistics.module';
GenericItemPageFieldComponent, GenericItemPageFieldComponent,
MetadataRepresentationListComponent, MetadataRepresentationListComponent,
RelatedEntitiesSearchComponent, RelatedEntitiesSearchComponent,
TabbedRelatedEntitiesSearchComponent TabbedRelatedEntitiesSearchComponent,
AbstractIncrementalListComponent
], ],
exports: [ exports: [
ItemComponent, 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-field-wrapper [label]="label">
<ds-metadata-representation-loader *ngFor="let rep of (representations$ | async)" <ng-container *ngFor="let objectPage of objects; let i = index">
[mdRepresentation]="rep"> <ng-container *ngVar="(objectPage | async) as representations">
</ds-metadata-representation-loader> <ds-metadata-representation-loader *ngFor="let rep of representations"
<div *ngIf="(representations$ | async)?.length < total" class="mt-2"> [mdRepresentation]="rep">
<a [routerLink]="" (click)="viewMore()">{{'item.page.related-items.view-more' | translate}}</a> </ds-metadata-representation-loader>
</div> <ds-loading *ngIf="(i + 1) === objects.length && (i > 0) && (!representations || representations?.length === 0)" message="{{'loading.default' | translate}}"></ds-loading>
<div *ngIf="limit > originalLimit" class="mt-2"> <div class="d-inline-block w-100 mt-2" *ngIf="(i + 1) === objects.length && representations?.length > 0">
<a [routerLink]="" (click)="viewLess()">{{'item.page.related-items.view-less' | translate}}</a> <div *ngIf="(objects.length * incrementBy) < total" class="float-left">
</div> <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> </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 { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { VarDirective } from '../../../shared/utils/var.directive';
import { of as observableOf } from 'rxjs';
const itemType = 'Person'; const itemType = 'Person';
const metadataField = 'dc.contributor.author'; const metadataField = 'dc.contributor.author';
@@ -64,7 +66,7 @@ describe('MetadataRepresentationListComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot()],
declarations: [MetadataRepresentationListComponent], declarations: [MetadataRepresentationListComponent, VarDirective],
providers: [ providers: [
{ provide: RelationshipService, useValue: relationshipService } { provide: RelationshipService, useValue: relationshipService }
], ],
@@ -88,33 +90,29 @@ describe('MetadataRepresentationListComponent', () => {
expect(fields.length).toBe(2); expect(fields.length).toBe(2);
}); });
it('should initialize the original limit', () => { it('should contain one page of items', () => {
expect(comp.originalLimit).toEqual(comp.limit); expect(comp.objects.length).toEqual(1);
}); });
describe('when viewMore is called', () => { describe('when increase is called', () => {
beforeEach(() => { beforeEach(() => {
comp.viewMore(); comp.increase();
}); });
it('should set the limit to a high number in order to retrieve all metadata representations', () => { it('should add a new page to the list', () => {
expect(comp.limit).toBeGreaterThanOrEqual(999); expect(comp.objects.length).toEqual(2);
}); });
}); });
describe('when viewLess is called', () => { describe('when decrease is called', () => {
let originalLimit;
beforeEach(() => { beforeEach(() => {
// Store the original value of limit // Add a second page
originalLimit = comp.limit; comp.objects.push(observableOf(undefined));
// Set limit to a random number comp.decrease();
comp.limit = 458;
comp.viewLess();
}); });
it('should reset the limit to the original value', () => { it('should decrease the list of pages', () => {
expect(comp.limit).toEqual(originalLimit); 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 { MetadataRepresentation } from '../../../core/shared/metadata-representation/metadata-representation.model';
import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs';
import { RelationshipService } from '../../../core/data/relationship.service'; import { RelationshipService } from '../../../core/data/relationship.service';
import { MetadataValue } from '../../../core/shared/metadata.models'; import { MetadataValue } from '../../../core/shared/metadata.models';
import { getSucceededRemoteData } from '../../../core/shared/operators'; 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 { RemoteData } from '../../../core/data/remote-data';
import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; import { Relationship } from '../../../core/shared/item-relationships/relationship.model';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { MetadatumRepresentation } from '../../../core/shared/metadata-representation/metadatum/metadatum-representation.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 { 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({ @Component({
selector: 'ds-metadata-representation-list', 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 an itemType to resolve the metadata to a an item
* It expects a label to put on top of the list * 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 * The parent of the list of related items to display
*/ */
@@ -44,22 +44,11 @@ export class MetadataRepresentationListComponent implements OnInit {
@Input() label: string; @Input() label: string;
/** /**
* The max amount of representations to display * The amount to increment the list by when clicking "view more"
* Defaults to 10 * Defaults to 10
* The default can optionally be overridden by providing the limit as input to the component * The default can optionally be overridden by providing the limit as input to the component
*/ */
@Input() limit = 10; @Input() incrementBy = 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;
/** /**
* The total amount of metadata values available * The total amount of metadata values available
@@ -67,30 +56,28 @@ export class MetadataRepresentationListComponent implements OnInit {
total: number; total: number;
constructor(public relationshipService: RelationshipService) { constructor(public relationshipService: RelationshipService) {
} super();
ngOnInit(): void {
this.originalLimit = this.limit;
this.setRepresentations();
} }
/** /**
* 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); const metadata = this.parentItem.findMetadataSortedByPlace(this.metadataField);
this.total = metadata.length; 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 * 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( return observableZip(
...metadata ...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: any) => Object.assign(new MetadataValue(), metadatum))
.map((metadatum: MetadataValue) => { .map((metadatum: MetadataValue) => {
if (metadatum.isVirtual) { 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 { Item } from '../../../core/shared/item.model';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list'; import { PaginatedList } from '../../../core/data/paginated-list';
import { RelationshipService } from '../../../core/data/relationship.service';
import { FindListOptions } from '../../../core/data/request.models'; import { FindListOptions } from '../../../core/data/request.models';
import { Subscription } from 'rxjs/internal/Subscription';
import { ViewMode } from '../../../core/shared/view-mode.model'; 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({ @Component({
selector: 'ds-related-items', 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 * 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 * 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 * The parent of the list of related items to display
*/ */
@@ -30,79 +30,38 @@ export class RelatedItemsComponent implements OnInit, OnDestroy {
@Input() relationType: string; @Input() relationType: string;
/** /**
* Default options to start a search request with * The amount to increment the list by when clicking "view more"
* Optional input, should you wish a different page size (or other options) * 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) * An i18n label to use as a title for the list (usually describes the relation)
*/ */
@Input() label: string; @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 * The view-mode we're currently on
* @type {ViewMode} * @type {ViewMode}
*/ */
viewMode = ViewMode.ListElement; 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) { constructor(public relationshipService: RelationshipService) {
} super();
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);
});
} }
/** /**
* Expand the list to display all related items * Get a specific page
* @param page The page to fetch
*/ */
viewMore() { getPage(page: number): Observable<RemoteData<PaginatedList<Item>>> {
this.items$ = this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, this.allOptions); return this.relationshipService.getRelatedItemsByLabel(this.parentItem, this.relationType, Object.assign(this.options, { elementsPerPage: this.incrementBy, currentPage: page }));
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();
}
} }
} }

View File

@@ -1,11 +1,20 @@
<ds-metadata-field-wrapper *ngIf="(items$ | async)?.payload?.page?.length > 0" [label]="label"> <ds-metadata-field-wrapper [label]="label">
<ds-listable-object-component-loader *ngFor="let item of (items$ | async)?.payload?.page" <ng-container *ngFor="let objectPage of objects; let i = index">
[object]="item" [viewMode]="viewMode"> <ng-container *ngVar="(objectPage | async) as itemsRD">
</ds-listable-object-component-loader> <ds-listable-object-component-loader *ngFor="let item of itemsRD?.payload?.page"
<div *ngIf="(items$ | async)?.payload?.page?.length < (items$ | async)?.payload?.totalElements" class="mt-2" id="view-more"> [object]="item" [viewMode]="viewMode">
<a [routerLink]="" (click)="viewMore()">{{'item.page.related-items.view-more' | translate}}</a> </ds-listable-object-component-loader>
</div> <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 *ngIf="showingAll" class="mt-2" id="view-less"> <div class="d-inline-block w-100 mt-2" *ngIf="(i + 1) === objects.length && itemsRD?.payload?.page?.length > 0">
<a [routerLink]="" (click)="viewLess()">{{'item.page.related-items.view-less' | translate}}</a> <div *ngIf="itemsRD?.payload?.totalPages > objects.length" class="float-left" id="view-more">
</div> <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> </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 { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils';
import { RelationshipService } from '../../../core/data/relationship.service'; import { RelationshipService } from '../../../core/data/relationship.service';
import { TranslateModule } from '@ngx-translate/core'; 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(), { const parentItem: Item = Object.assign(new Item(), {
bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])),
@@ -42,7 +44,7 @@ describe('RelatedItemsComponent', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot()],
declarations: [RelatedItemsComponent], declarations: [RelatedItemsComponent, VarDirective],
providers: [ providers: [
{ provide: RelationshipService, useValue: relationshipService } { provide: RelationshipService, useValue: relationshipService }
], ],
@@ -65,31 +67,33 @@ describe('RelatedItemsComponent', () => {
expect(fields.length).toBe(mockItems.length); 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(() => { beforeEach(() => {
comp.viewMore(); comp.increase();
}); });
it('should call relationship-service\'s getRelatedItemsByLabel with the correct arguments', () => { it('should add a new page to the list', () => {
expect(relationshipService.getRelatedItemsByLabel).toHaveBeenCalledWith(parentItem, relationType, comp.allOptions); expect(comp.objects.length).toEqual(2);
}); });
it('should set showingAll to true', () => { it('should call relationship-service\'s getRelatedItemsByLabel with the correct arguments (second page)', () => {
expect(comp.showingAll).toEqual(true); 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(() => { 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', () => { it('should decrease the list of pages', () => {
expect(relationshipService.getRelatedItemsByLabel).toHaveBeenCalledWith(parentItem, relationType, comp.options); expect(comp.objects.length).toEqual(1);
});
it('should set showingAll to false', () => {
expect(comp.showingAll).toEqual(false);
}); });
}); });

View File

@@ -8,6 +8,7 @@ import { SearchConfigurationService } from '../core/shared/search/search-configu
import { hasValue } from '../shared/empty.util'; import { hasValue } from '../shared/empty.util';
import { RouteService } from '../core/services/route.service'; import { RouteService } from '../core/services/route.service';
import { SearchService } from '../core/shared/search/search.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. * 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 sidebarService: SidebarService,
protected windowService: HostWindowService, protected windowService: HostWindowService,
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
protected routeService: RouteService) { protected routeService: RouteService,
super(service, sidebarService, windowService, searchConfigService, routeService); protected router: Router) {
super(service, sidebarService, windowService, searchConfigService, routeService, router);
} }
/** /**

View File

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

View File

@@ -46,5 +46,9 @@
[scopes]="(scopeListRD$ | async)" [scopes]="(scopeListRD$ | async)"
[inPlaceSearch]="inPlaceSearch"> [inPlaceSearch]="inPlaceSearch">
</ds-search-form> </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> <ds-search-labels *ngIf="searchEnabled" [inPlaceSearch]="inPlaceSearch"></ds-search-labels>
</div>
</div>
</ng-template> </ng-template>

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; 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 { startWith, switchMap, } from 'rxjs/operators';
import { PaginatedList } from '../core/data/paginated-list'; import { PaginatedList } from '../core/data/paginated-list';
import { RemoteData } from '../core/data/remote-data'; 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 { getSucceededRemoteData } from '../core/shared/operators';
import { RouteService } from '../core/services/route.service'; import { RouteService } from '../core/services/route.service';
import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; 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 { 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 { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model';
import { SearchService } from '../core/shared/search/search.service'; import { SearchService } from '../core/shared/search/search.service';
import { currentPath } from '../shared/utils/route.utils';
import { Router } from '@angular/router';
@Component({ @Component({
selector: 'ds-search', selector: 'ds-search',
@@ -96,7 +98,8 @@ export class SearchComponent implements OnInit {
protected sidebarService: SidebarService, protected sidebarService: SidebarService,
protected windowService: HostWindowService, protected windowService: HostWindowService,
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService, @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
protected routeService: RouteService) { protected routeService: RouteService,
protected router: Router) {
this.isXsOrSm$ = this.windowService.isXsOrSm(); this.isXsOrSm$ = this.windowService.isXsOrSm();
} }
@@ -159,7 +162,7 @@ export class SearchComponent implements OnInit {
*/ */
private getSearchLink(): string { private getSearchLink(): string {
if (this.inPlaceSearch) { if (this.inPlaceSearch) {
return './'; return currentPath(this.router);
} }
return this.service.getSearchLink(); return this.service.getSearchLink();
} }

View File

@@ -1,8 +1,8 @@
import { StoreEffects } from './store.effects'; import { StoreEffects } from './store.effects';
import { NotificationsEffects } from './shared/notifications/notifications.effects'; import { NotificationsEffects } from './shared/notifications/notifications.effects';
import { NavbarEffects } from './navbar/navbar.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 { 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 = [ export const appEffects = [
StoreEffects, 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 { 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 { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component';
import { NavbarModule } from './navbar/navbar.module'; 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 { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module';
import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module'; import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module';
import { ClientCookieService } from './core/services/client-cookie.service';
export function getConfig() { export function getConfig() {
return ENV_CONFIG; return ENV_CONFIG;

View File

@@ -1,7 +1,7 @@
import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store';
import * as fromRouter from '@ngrx/router-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 { 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 { formReducer, FormState } from './shared/form/form.reducer';
import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer';
import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.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 { mapsTo } from '../builders/build-decorators';
import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models'; import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models';
/**
* Normalized model class for an external source entry
*/
@mapsTo(ExternalSourceEntry) @mapsTo(ExternalSourceEntry)
@inheritSerialization(NormalizedObject) @inheritSerialization(NormalizedObject)
export class NormalizedExternalSourceEntry extends NormalizedObject<ExternalSourceEntry> { 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 { ExternalSource } from '../../shared/external-source.model';
import { mapsTo } from '../builders/build-decorators'; import { mapsTo } from '../builders/build-decorators';
/**
* Normalized model class for an external source
*/
@mapsTo(ExternalSource) @mapsTo(ExternalSource)
@inheritSerialization(NormalizedObject) @inheritSerialization(NormalizedObject)
export class NormalizedExternalSource extends NormalizedObject<ExternalSource> { export class NormalizedExternalSource extends NormalizedObject<ExternalSource> {

View File

@@ -128,8 +128,8 @@ import {
MOCK_RESPONSE_MAP, MOCK_RESPONSE_MAP,
MockResponseMap, MockResponseMap,
mockResponseMap mockResponseMap
} from './dspace-rest-v2/mocks/mock-response-map'; } from '../shared/mocks/dspace-rest-v2/mocks/mock-response-map';
import { EndpointMockingRestService } from './dspace-rest-v2/endpoint-mocking-rest.service'; import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service';
import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../../config'; import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../../config';
import { SearchFilterService } from './shared/search/search-filter.service'; import { SearchFilterService } from './shared/search/search-filter.service';
import { SearchConfigurationService } from './shared/search/search-configuration.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 { ExternalSourceService } from './data/external-source.service';
import { LookupRelationService } from './data/lookup-relation.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) => { export const restServiceFactory = (cfg: GlobalConfig, mocks: MockResponseMap, http: HttpClient) => {
if (ENV_CONFIG.production) { if (ENV_CONFIG.production) {
return new DSpaceRESTv2Service(http); return new DSpaceRESTv2Service(http);

View File

@@ -21,6 +21,9 @@ import { PaginatedList } from './paginated-list';
import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
/**
* A service handling all external source requests
*/
@Injectable() @Injectable()
export class ExternalSourceService extends DataService<ExternalSource> { export class ExternalSourceService extends DataService<ExternalSource> {
protected linkPath = 'externalsources'; protected linkPath = 'externalsources';
@@ -38,6 +41,11 @@ export class ExternalSourceService extends DataService<ExternalSource> {
super(); super();
} }
/**
* Get the endpoint to browse external sources
* @param options
* @param linkPath
*/
getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return this.halService.getEndpoint(linkPath); 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 { Relationship } from '../shared/item-relationships/relationship.model';
import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs';
import { zip as observableZip } from 'rxjs';
import { PaginatedList } from './paginated-list'; import { PaginatedList } from './paginated-list';
import { ItemDataService } from './item-data.service'; import { ItemDataService } from './item-data.service';
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';
@@ -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> { addRelationship(typeId: string, item1: Item, item2: Item, leftwardValue?: string, rightwardValue?: string): Observable<RestResponse> {
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
let headers = new HttpHeaders(); 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) { private removeRelationshipItemsFromCacheByRelationship(relationshipId: string) {
this.findById(relationshipId).pipe( this.findById(relationshipId).pipe(
getSucceededRemoteData(), getSucceededRemoteData(),
getRemoteDataPayload(), getRemoteDataPayload(),
switchMap((relationship: Relationship) => observableCombineLatest( switchMap((relationship: Relationship) => combineLatest(
relationship.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()), relationship.leftItem.pipe(getSucceededRemoteData(), getRemoteDataPayload()),
relationship.rightItem.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) { private removeRelationshipItemsFromCache(item) {
this.objectCache.remove(item.self); this.objectCache.remove(item.self);
this.requestService.removeByHrefSubstring(item.self); this.requestService.removeByHrefSubstring(item.self);
observableCombineLatest( combineLatest(
this.objectCache.hasBySelfLinkObservable(item.self), this.objectCache.hasBySelfLinkObservable(item.self),
this.requestService.hasByHrefObservable(item.self) this.requestService.hasByHrefObservable(item.self)
).pipe( ).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> { getRelationshipByItemsAndLabel(item1: Item, item2: Item, label: string): Observable<Relationship> {
return this.getItemRelationshipsByLabel(item1, label) return this.getItemRelationshipsByLabel(item1, label)
.pipe( .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) { public setNameVariant(listID: string, itemID: string, nameVariant: string) {
this.appStore.dispatch(new SetNameVariantAction(listID, itemID, nameVariant)); 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> { public getNameVariant(listID: string, itemID: string): Observable<string> {
return this.appStore.pipe( return this.appStore.pipe(
select(relationshipStateSelector(listID, itemID)) 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) { public removeNameVariant(listID: string, itemID: string) {
this.appStore.dispatch(new RemoveNameVariantAction(listID, itemID)); 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) { public getNameVariantsByListID(listID: string) {
return this.appStore.pipe(select(relationshipListStateSelector(listID))); 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>> { public updateNameVariant(item1: Item, item2: Item, relationshipLabel: string, nameVariant: string): Observable<RemoteData<Relationship>> {
return this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel) return this.getRelationshipByItemsAndLabel(item1, item2, relationshipLabel)
.pipe( .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) { public addParameter(key, value) {
this.store.dispatch(new AddParameterAction(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) { public setParameter(key, value) {
this.store.dispatch(new SetParameterAction(key, value)); this.store.dispatch(new SetParameterAction(key, value));
} }
/**
* Sets the current route parameters and query parameters in the store
*/
public setCurrentRouteInfo() { public setCurrentRouteInfo() {
combineLatest(this.getRouteParams(), this.route.queryParams) combineLatest(this.getRouteParams(), this.route.queryParams)
.pipe(take(1)) .pipe(take(1))

View File

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

View File

@@ -5,6 +5,10 @@ import { EquatableObject } from './equatable';
const excludedFromEquals = new Map(); const excludedFromEquals = new Map();
const fieldsForEqualsMap = 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>>) { export function inheritEquatable(parentCo: GenericConstructor<EquatableObject<any>>) {
return function decorator(childCo: GenericConstructor<EquatableObject<any>>) { return function decorator(childCo: GenericConstructor<EquatableObject<any>>) {
const parentExcludedFields = getExcludedFromEqualsFor(parentCo) || []; 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 { export function excludeFromEquals(object: any, propertyName: string): any {
if (!object) { if (!object) {
return; return;
@@ -37,6 +46,10 @@ export function getExcludedFromEqualsFor(constructor: Function): string[] {
return excludedFromEquals.get(constructor) || []; 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 { export function fieldsForEquals(...fields: string[]): any {
return function i(object: any, propertyName: string): any { return function i(object: any, propertyName: string): any {
if (!object) { if (!object) {

View File

@@ -1,6 +1,12 @@
import { getExcludedFromEqualsFor, getFieldsForEquals } from './equals.decorators'; import { getExcludedFromEqualsFor, getFieldsForEquals } from './equals.decorators';
import { hasNoValue, hasValue } from '../../shared/empty.util'; 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 { function equalsByFields(object1, object2, fieldList): boolean {
const unequalProperty = fieldList.find((key) => { const unequalProperty = fieldList.find((key) => {
if (object1[key] === object2[key]) { if (object1[key] === object2[key]) {
@@ -27,6 +33,10 @@ function equalsByFields(object1, object2, fieldList): boolean {
return hasNoValue(unequalProperty); 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> { export abstract class EquatableObject<T> {
equals(other: T): boolean { equals(other: T): boolean {
if (hasNoValue(other)) { if (hasNoValue(other)) {

View File

@@ -1,4 +1,4 @@
<div class="d-inline-block"> <div class="d-inline-block">
<div>{{object.display}}</div> <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> </div>

View File

@@ -13,6 +13,9 @@ import { MetadataValue } from '../../../../../core/shared/metadata.models';
styleUrls: ['./external-source-entry-list-submission-element.component.scss'], styleUrls: ['./external-source-entry-list-submission-element.component.scss'],
templateUrl: './external-source-entry-list-submission-element.component.html' 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 { export class ExternalSourceEntryListSubmissionElementComponent extends AbstractListableElementComponent<ExternalSourceEntry> implements OnInit {
/** /**
* The metadata value for the object's uri * 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 { export class OrgUnitSearchResultListSubmissionElementComponent extends SearchResultListElementComponent<ItemSearchResult, Item> implements OnInit {
allSuggestions: string[]; allSuggestions: string[];

View File

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

View File

@@ -3,16 +3,23 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NameVariantModalComponent } from './name-variant-modal.component'; import { NameVariantModalComponent } from './name-variant-modal.component';
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
describe('NameVariantModalComponent', () => { describe('NameVariantModalComponent', () => {
let component: NameVariantModalComponent; let component: NameVariantModalComponent;
let fixture: ComponentFixture<NameVariantModalComponent>; let fixture: ComponentFixture<NameVariantModalComponent>;
let debugElement;
let modal;
function init() {
modal = jasmine.createSpyObj('modal', ['close', 'dismiss']);
}
beforeEach(async(() => { beforeEach(async(() => {
init();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [NameVariantModalComponent], declarations: [NameVariantModalComponent],
imports: [NgbModule.forRoot(), TranslateModule.forRoot()], imports: [NgbModule.forRoot(), TranslateModule.forRoot()],
providers: [NgbActiveModal] providers: [{ provide: NgbActiveModal, useValue: modal }]
}) })
.compileComponents(); .compileComponents();
})); }));
@@ -20,10 +27,27 @@ describe('NameVariantModalComponent', () => {
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(NameVariantModalComponent); fixture = TestBed.createComponent(NameVariantModalComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); 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 { Component, Input } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; 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({ @Component({
selector: 'ds-name-variant-modal', selector: 'ds-name-variant-modal',
templateUrl: './name-variant-modal.component.html', templateUrl: './name-variant-modal.component.html',
styleUrls: ['./name-variant-modal.component.scss'] styleUrls: ['./name-variant-modal.component.scss']
}) })
/**
* The component for the modal to add a name variant to an item
*/
export class NameVariantModalComponent { export class NameVariantModalComponent {
/**
* The name variant
*/
@Input() value: string; @Input() value: string;
constructor(public modal: NgbActiveModal) { constructor(public modal: NgbActiveModal) {

View File

@@ -232,6 +232,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
super(componentFactoryResolver, layoutService, validationService); 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 { ngOnInit(): void {
this.hasRelationLookup = hasValue(this.model.relationship); this.hasRelationLookup = hasValue(this.model.relationship);
if (this.hasRelationLookup) { if (this.hasRelationLookup) {
@@ -321,6 +324,9 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
return this.model.value.pipe(map((list: Array<SearchResult<DSpaceObject>>) => isNotEmpty(list))); 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() { openLookup() {
this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, { this.modalRef = this.modalService.open(DsDynamicLookupRelationModalComponent, {
size: 'lg' size: 'lg'
@@ -335,12 +341,13 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo
modalComp.collection = this.collection; 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>) { removeSelection(object: SearchResult<Item>) {
this.selectableListService.deselectSingle(this.listId, object); this.selectableListService.deselectSingle(this.listId, object);
this.store.dispatch(new RemoveRelationshipAction(this.item, object.indexableObject, this.model.relationship.relationshipType)) 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 { DynamicFormControlComponent, DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { FormGroup } from '@angular/forms'; import { FormGroup } from '@angular/forms';
import { DynamicDisabledModel } from './dynamic-disabled.model'; import { DynamicDisabledModel } from './dynamic-disabled.model';
import { RelationshipTypeService } from '../../../../../../core/data/relationship-type.service';
/**
* Component representing a simple disabled input field
*/
@Component({ @Component({
selector: 'ds-dynamic-disabled', selector: 'ds-dynamic-disabled',
templateUrl: './dynamic-disabled.component.html' templateUrl: './dynamic-disabled.component.html'
}) })
/**
* Component for displaying a form input with a disabled property
*/
export class DsDynamicDisabledComponent extends DynamicFormControlComponent { export class DsDynamicDisabledComponent extends DynamicFormControlComponent {
@Input() formId: string; @Input() formId: string;
@@ -21,8 +26,7 @@ export class DsDynamicDisabledComponent extends DynamicFormControlComponent {
@Output() focus: EventEmitter<any> = new EventEmitter<any>(); @Output() focus: EventEmitter<any> = new EventEmitter<any>();
constructor(protected layoutService: DynamicFormLayoutService, constructor(protected layoutService: DynamicFormLayoutService,
protected validationService: DynamicFormValidationService, protected validationService: DynamicFormValidationService
protected relationshipTypeService: RelationshipTypeService
) { ) {
super(layoutService, validationService); super(layoutService, validationService);
} }

View File

@@ -7,6 +7,9 @@ export interface DsDynamicDisabledModelConfig extends DsDynamicInputModelConfig
value?: any; value?: any;
} }
/**
* This model represents the data for a disabled input field
*/
export class DynamicDisabledModel extends DsDynamicInputModel { export class DynamicDisabledModel extends DsDynamicInputModel {
@serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DISABLED; @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_DISABLED;
@@ -14,7 +17,6 @@ export class DynamicDisabledModel extends DsDynamicInputModel {
constructor(config: DsDynamicDisabledModelConfig, layout?: DynamicFormControlLayout) { constructor(config: DsDynamicDisabledModelConfig, layout?: DynamicFormControlLayout) {
super(config, layout); super(config, layout);
this.readOnly = true; this.readOnly = true;
this.disabled = true; this.disabled = true;
this.valueUpdates.next(config.value); 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 { export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy {
/**
* The label to use to display i18n messages (describing the type of relationship)
*/
label: string; label: string;
/**
* Options for searching related items
*/
relationshipOptions: RelationshipOptions; relationshipOptions: RelationshipOptions;
/**
* The ID of the list to add/remove selected items to/from
*/
listId: string; listId: string;
/**
* The item we're adding relationships to
*/
item; item;
/**
* The collection we're submitting an item to
*/
collection; collection;
/**
* Is the selection repeatable?
*/
repeatable: boolean; repeatable: boolean;
/**
* The list of selected items
*/
selection$: Observable<ListableObject[]>; selection$: Observable<ListableObject[]>;
/**
* The context to display lists
*/
context: Context; context: Context;
/**
* The metadata-fields describing these relationships
*/
metadataFields: string; metadataFields: string;
/**
* A map of subscriptions within this component
*/
subMap: { subMap: {
[uuid: string]: Subscription [uuid: string]: Subscription
} = {}; } = {};
@@ -105,6 +147,10 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy
this.modal.close(); this.modal.close();
} }
/**
* Select (a list of) objects and add them to the store
* @param selectableObjects
*/
select(...selectableObjects: Array<SearchResult<Item>>) { select(...selectableObjects: Array<SearchResult<Item>>) {
this.zone.runOutsideAngular( 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>) { private addNameVariantSubscription(sri: SearchResult<Item>) {
const nameVariant$ = this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid); const nameVariant$ = this.relationshipService.getNameVariant(this.listId, sri.indexableObject.uuid);
this.subMap[sri.indexableObject.uuid] = nameVariant$.pipe( 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))) ).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>>) { deselect(...selectableObjects: Array<SearchResult<Item>>) {
this.zone.runOutsideAngular( this.zone.runOutsideAngular(
() => selectableObjects.forEach((object) => { () => 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() { private setExistingNameVariants() {
const virtualMDs: MetadataValue[] = this.item.allMetadata(this.metadataFields).filter((mdValue) => mdValue.isVirtual); 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; @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; @Input() listId: string;

View File

@@ -11,7 +11,9 @@ export const NameVariantActionTypes = {
}; };
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
/**
* Abstract class for actions that happen to name variants
*/
export abstract class NameVariantListAction implements Action { export abstract class NameVariantListAction implements Action {
type; type;
payload: { 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 { export class SetNameVariantAction extends NameVariantListAction {
type = NameVariantActionTypes.SET_NAME_VARIANT; type = NameVariantActionTypes.SET_NAME_VARIANT;
payload: { 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 { export class RemoveNameVariantAction extends NameVariantListAction {
type = NameVariantActionTypes.REMOVE_NAME_VARIANT; type = NameVariantActionTypes.REMOVE_NAME_VARIANT;
constructor(listID: string, itemID: string) { 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$ @Effect({ dispatch: false }) updateNameVariantsActions$ = this.actions$
.pipe( .pipe(
ofType(RelationshipActionTypes.UPDATE_RELATIONSHIP), 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 { export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDestroy {
/**
* Options for searching related items
*/
@Input() relationship: RelationshipOptions; @Input() relationship: RelationshipOptions;
/**
* The ID of the list to add/remove selected items to/from
*/
@Input() listId: string; @Input() listId: string;
/**
* Is the selection repeatable?
*/
@Input() repeatable: boolean; @Input() repeatable: boolean;
/**
* The list of selected items
*/
@Input() selection$: Observable<ListableObject[]>; @Input() selection$: Observable<ListableObject[]>;
/**
* The context to display lists
*/
@Input() context: Context; @Input() context: Context;
/**
* Send an event to deselect an object from the list
*/
@Output() deselectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>(); @Output() deselectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
/**
* Send an event to select an object from the list
*/
@Output() selectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>(); @Output() selectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
/**
* Search results
*/
resultsRD$: Observable<RemoteData<PaginatedList<SearchResult<Item>>>>; resultsRD$: Observable<RemoteData<PaginatedList<SearchResult<Item>>>>;
/**
* Are all results selected?
*/
allSelected: boolean; allSelected: boolean;
/**
* Are some results selected?
*/
someSelected$: Observable<boolean>; someSelected$: Observable<boolean>;
/**
* Is it currently loading to select all results?
*/
selectAllLoading: boolean; selectAllLoading: boolean;
/**
* Subscription to unsubscribe from
*/
subscription; subscription;
/**
* The initial pagination to use
*/
initialPagination = Object.assign(new PaginationComponentOptions(), { initialPagination = Object.assign(new PaginationComponentOptions(), {
id: 'submission-relation-list', id: 'submission-relation-list',
pageSize: 5 pageSize: 5
}); });
/**
* The type of links to display
*/
linkTypes = CollectionElementLinkType; linkTypes = CollectionElementLinkType;
constructor( constructor(
@@ -65,6 +122,9 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
) { ) {
} }
/**
* Sets up the pagination and fixed query parameters
*/
ngOnInit(): void { ngOnInit(): void {
this.resetRoute(); this.resetRoute();
this.routeService.setParameter('fixedFilterQuery', this.relationship.filter); 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() { resetRoute() {
this.router.navigate([], { this.router.navigate([], {
queryParams: Object.assign({}, { pageSize: this.initialPagination.pageSize }, this.route.snapshot.queryParams, { page: 1 }) 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>>) { selectPage(page: Array<SearchResult<Item>>) {
this.selection$ this.selection$
.pipe(take(1)) .pipe(take(1))
@@ -92,6 +159,10 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
this.selectableListService.select(this.listId, page); this.selectableListService.select(this.listId, page);
} }
/**
* Deselects a page in the store
* @param page the page to deselect
*/
deselectPage(page: Array<SearchResult<Item>>) { deselectPage(page: Array<SearchResult<Item>>) {
this.allSelected = false; this.allSelected = false;
this.selection$ this.selection$
@@ -103,6 +174,9 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
this.selectableListService.deselect(this.listId, page); this.selectableListService.deselect(this.listId, page);
} }
/**
* Select all items that were found using the current search query
*/
selectAll() { selectAll() {
this.allSelected = true; this.allSelected = true;
this.selectAllLoading = true; this.selectAllLoading = true;
@@ -128,6 +202,9 @@ export class DsDynamicLookupRelationSearchTabComponent implements OnInit, OnDest
); );
} }
/**
* Deselect all items
*/
deselectAll() { deselectAll() {
this.allSelected = false; this.allSelected = false;
this.selection$ 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 { export class DsDynamicLookupRelationSelectionTabComponent {
/**
* The label to use to display i18n messages (describing the type of relationship)
*/
@Input() label: string; @Input() label: string;
/**
* The ID of the list to add/remove selected items to/from
*/
@Input() listId: string; @Input() listId: string;
/**
* Is the selection repeatable?
*/
@Input() repeatable: boolean; @Input() repeatable: boolean;
/**
* The list of selected items
*/
@Input() selection$: Observable<ListableObject[]>; @Input() selection$: Observable<ListableObject[]>;
/**
* The paginated list of selected items
*/
@Input() selectionRD$: Observable<RemoteData<PaginatedList<ListableObject>>>; @Input() selectionRD$: Observable<RemoteData<PaginatedList<ListableObject>>>;
/**
* The context to display lists
*/
@Input() context: Context; @Input() context: Context;
/**
* Send an event to deselect an object from the list
*/
@Output() deselectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>(); @Output() deselectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
/**
* Send an event to select an object from the list
*/
@Output() selectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>(); @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, constructor(private router: Router,
private searchConfigService: SearchConfigurationService) { private searchConfigService: SearchConfigurationService) {
} }
/**
* Set up the selection and pagination on load
*/
ngOnInit() { ngOnInit() {
this.selectionRD$ = this.searchConfigService.paginatedSearchOptions this.selectionRD$ = this.searchConfigService.paginatedSearchOptions
.pipe( .pipe(

View File

@@ -1,5 +1,8 @@
const RELATION_METADATA_PREFIX = 'relation.' const RELATION_METADATA_PREFIX = 'relation.'
/**
* The submission options for fields that can represent relationships
*/
export class RelationshipOptions { export class RelationshipOptions {
relationshipType: string; relationshipType: string;
filter: 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 { FormFieldMetadataValueObject } from '../models/form-field-metadata-value.model';
import { DsDynamicDisabledModelConfig, DynamicDisabledModel } from '../ds-dynamic-form-ui/models/disabled/dynamic-disabled.model'; import { DsDynamicDisabledModelConfig, DynamicDisabledModel } from '../ds-dynamic-form-ui/models/disabled/dynamic-disabled.model';
/**
* Field parser for disabled fields
*/
export class DisabledFieldParser extends FieldParser { export class DisabledFieldParser extends FieldParser {
public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any { public modelFactory(fieldValue?: FormFieldMetadataValueObject | any, label?: boolean): any {
console.log(fieldValue);
const emptyModelConfig: DsDynamicDisabledModelConfig = this.initModel(null, label); const emptyModelConfig: DsDynamicDisabledModelConfig = this.initModel(null, label);
this.setValues(emptyModelConfig, fieldValue);
return new DynamicDisabledModel(emptyModelConfig) return new DynamicDisabledModel(emptyModelConfig)
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { HttpClient, HttpHeaders } from '@angular/common/http' import { HttpClient, HttpHeaders } from '@angular/common/http'
import { Inject, Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
import { isEmpty } from '../../shared/empty.util'; import { isEmpty } from '../../empty.util';
import { RestRequestMethod } from '../data/rest-request-method'; import { RestRequestMethod } from '../../../core/data/rest-request-method';
import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; import { DSpaceRESTV2Response } from '../../../core/dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceRESTv2Service, HttpOptions } from './dspace-rest-v2.service'; import { DSpaceRESTv2Service, HttpOptions } from '../../../core/dspace-rest-v2/dspace-rest-v2.service';
import { MOCK_RESPONSE_MAP, MockResponseMap } from './mocks/mock-response-map'; import { MOCK_RESPONSE_MAP, MockResponseMap } from './mocks/mock-response-map';
import * as URL from 'url-parse'; import * as URL from 'url-parse';
@@ -14,6 +14,8 @@ import * as URL from 'url-parse';
* Service to access DSpace's REST API. * Service to access DSpace's REST API.
* *
* If a URL is found in this.mockResponseMap, it returns the mock response instead * 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() @Injectable()
export class EndpointMockingRestService extends DSpaceRESTv2Service { export class EndpointMockingRestService extends DSpaceRESTv2Service {

View File

@@ -1,5 +1,5 @@
import { InjectionToken } from '@angular/core'; 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> {}; export class MockResponseMap extends Map<string, any> {};

View File

@@ -3,10 +3,10 @@
[name]="'checkbox' + index" [name]="'checkbox' + index"
[id]="'object' + index" [id]="'object' + index"
[ngModel]="selected$ | async" [ngModel]="selected$ | async"
(ngModelChange)="selectCheckbox($event, object)"> (ngModelChange)="selectCheckbox($event)">
<input *ngIf="!selectionConfig.repeatable" class="form-check-input" type="radio" <input *ngIf="!selectionConfig.repeatable" class="form-check-input" type="radio"
[name]="'radio' + index" [name]="'radio' + index"
[id]="'object' + index" [id]="'object' + index"
[checked]="selected$ | async" [checked]="selected$ | async"
(click)="selectRadio(!checked, object)"> (click)="selectRadio(!checked)">
</ng-container> </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) { if (value) {
this.selectionService.selectSingle(this.selectionConfig.listId, object); this.selectionService.selectSingle(this.selectionConfig.listId, this.object);
} else { } else {
this.selectionService.deselectSingle(this.selectionConfig.listId, object); this.selectionService.deselectSingle(this.selectionConfig.listId, this.object);
} }
} }
selectRadio(value: boolean, object: ListableObject) { selectRadio(value: boolean) {
const selected$ = this.selectionService.getSelectableList(this.selectionConfig.listId); if (value) {
selected$.pipe( const selected$ = this.selectionService.getSelectableList(this.selectionConfig.listId);
take(1), selected$.pipe(
map((selected) => selected ? selected.selection : []) take(1),
).subscribe((selection) => { map((selected) => selected ? selected.selection : [])
// First deselect any existing selections, this is a radio button ).subscribe((selection) => {
selection.forEach((selectedObject) => { // First deselect any existing selections, this is a radio button
this.selectionService.deselectSingle(this.selectionConfig.listId, selectedObject); selection.forEach((selectedObject) => {
this.deselectObject.emit(selectedObject); this.selectionService.deselectSingle(this.selectionConfig.listId, selectedObject);
}); this.deselectObject.emit(selectedObject);
if (value) { });
this.selectionService.selectSingle(this.selectionConfig.listId, object); this.selectionService.selectSingle(this.selectionConfig.listId, this.object);
this.selectObject.emit(object); this.selectObject.emit(this.object);
} }
}); );
}
} }
} }

View File

@@ -19,6 +19,9 @@ export const SelectableListActionTypes = {
DESELECT_ALL: type('dspace/selectable-lists/DESELECT_ALL') DESELECT_ALL: type('dspace/selectable-lists/DESELECT_ALL')
}; };
/**
* Abstract action class for actions on selectable lists
*/
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
export abstract class SelectableListAction implements Action { export abstract class SelectableListAction implements Action {
// tslint:disable-next-line:no-shadowed-variable // 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 { export class SelectableListSelectAction extends SelectableListAction {
payload: ListableObject[]; payload: ListableObject[];
@@ -37,7 +40,9 @@ export class SelectableListSelectAction extends SelectableListAction {
this.payload = objects; this.payload = objects;
} }
} }
/**
* Action to select a single object in a the selectable list
*/
export class SelectableListSelectSingleAction extends SelectableListAction { export class SelectableListSelectSingleAction extends SelectableListAction {
payload: { payload: {
object: ListableObject, 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 { export class SelectableListDeselectSingleAction extends SelectableListAction {
payload: ListableObject; 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 { export class SelectableListDeselectAction extends SelectableListAction {
payload: ListableObject[]; 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 { export class SelectableListSetSelectionAction extends SelectableListAction {
payload: ListableObject[]; payload: ListableObject[];
@@ -76,6 +90,9 @@ export class SelectableListSetSelectionAction extends SelectableListAction {
} }
} }
/**
* Action to deselect all currently selected objects
*/
export class SelectableListDeselectAllAction extends SelectableListAction { export class SelectableListDeselectAllAction extends SelectableListAction {
constructor(id: string) { constructor(id: string) {
super(SelectableListActionTypes.DESELECT_ALL, id); 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) { function select(state: SelectableListState, action: SelectableListSelectAction) {
const filteredNewObjects = action.payload.filter((object) => !isObjectInSelection(state.selection, object)); const filteredNewObjects = action.payload.filter((object) => !isObjectInSelection(state.selection, object));
const newSelection = [...state.selection, ...filteredNewObjects]; const newSelection = [...state.selection, ...filteredNewObjects];
return Object.assign({}, state, { selection: newSelection }); 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) { function selectSingle(state: SelectableListState, action: SelectableListSelectSingleAction) {
let newSelection = state.selection; let newSelection = state.selection;
if (!isObjectInSelection(state.selection, action.payload.object)) { if (!isObjectInSelection(state.selection, action.payload.object)) {
@@ -77,11 +87,21 @@ function selectSingle(state: SelectableListState, action: SelectableListSelectSi
return Object.assign({}, state, { selection: newSelection }); 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) { function deselect(state: SelectableListState, action: SelectableListDeselectAction) {
const newSelection = state.selection.filter((selected) => hasNoValue(action.payload.find((object) => object.equals(selected)))); const newSelection = state.selection.filter((selected) => hasNoValue(action.payload.find((object) => object.equals(selected))));
return Object.assign({}, state, { selection: newSelection }); 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) { function deselectSingle(state: SelectableListState, action: SelectableListDeselectSingleAction) {
const newSelection = state.selection.filter((selected) => { const newSelection = state.selection.filter((selected) => {
return !selected.equals(action.payload); return !selected.equals(action.payload);
@@ -89,14 +109,29 @@ function deselectSingle(state: SelectableListState, action: SelectableListDesele
return Object.assign({}, state, { selection: newSelection }); 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) { function setList(state: SelectableListState, action: SelectableListSetSelectionAction) {
return Object.assign({}, state, { selection: action.payload }); 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) { function clearSelection(id: string) {
return { id: id, selection: [] }; 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) { function isObjectInSelection(selection: ListableObject[], object: ListableObject) {
return selection.findIndex((selected) => selected.equals(object)) >= 0 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 { DebugElement } from '@angular/core';
import { SearchFormComponent } from './search-form.component'; import { SearchFormComponent } from './search-form.component';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ResourceType } from '../../core/shared/resource-type';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { Community } from '../../core/shared/community.model'; import { Community } from '../../core/shared/community.model';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';

View File

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