erge branch 'w2p-55946_Item-mapping-on-item-level-old' into w2p-55946_Item-mapping-on-item-level

Conflicts:
	resources/i18n/en.json
	src/app/+item-page/edit-item-page/edit-item-page.component.ts
	src/app/+item-page/edit-item-page/edit-item-page.module.ts
	src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
	src/app/+item-page/edit-item-page/item-status/item-status.component.html
	src/app/+item-page/edit-item-page/item-status/item-status.component.ts
This commit is contained in:
Kristof De Langhe
2018-10-09 16:01:32 +02:00
31 changed files with 1439 additions and 9 deletions

View File

@@ -13,6 +13,27 @@
"head": "Recent Submissions"
}
}
},
"item-mapper": {
"head": "Item Mapper - Map Items from Other Collections",
"collection": "Collection: \"<b>{{name}}</b>\"",
"description": "This is the item mapper tool that allows collection administrators to map items from other collections into this collection. You can search for items from other collections and map them, or browse the list of currently mapped items.",
"confirm": "Map selected items",
"tabs": {
"browse": "Browse",
"map": "Map"
},
"notifications": {
"success": {
"head": "Mapping completed",
"content": "Successfully mapped {{amount}} items."
},
"error": {
"head": "Mapping errors",
"content": "Errors occurred for mapping of {{amount}} items."
}
},
"return": "Return"
}
},
"community": {
@@ -103,6 +124,27 @@
"curate": {
"head": "Curate"
}
},
"item-mapper": {
"head": "Item Mapper - Map Item to Collections",
"item": "Item: \"<b>{{name}}</b>\"",
"description": "This is the item mapper tool that allows administrators to map this item to other collections. You can search for collections and map them, or browse the list of collections the item is currently mapped to.",
"confirm": "Map item to selected collections",
"tabs": {
"browse": "Browse",
"map": "Map"
},
"notifications": {
"success": {
"head": "Mapping completed",
"content": "Successfully mapped item to {{amount}} collections."
},
"error": {
"head": "Mapping errors",
"content": "Errors occurred for mapping of item to {{amount}} collections."
}
},
"return": "Return"
}
}
},

View File

@@ -0,0 +1,47 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2>{{'collection.item-mapper.head' | translate}}</h2>
<p [innerHTML]="'collection.item-mapper.collection' | translate:{ name: (collectionRD$ | async)?.payload?.name }" id="collection-name"></p>
<p>{{'collection.item-mapper.description' | translate}}</p>
<div class="row">
<div class="col-12 col-lg-6">
<ds-search-form id="search-form"
[query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope"
[currentUrl]="getCurrentUrl()">
</ds-search-form>
</div>
</div>
<ngb-tabset (tabChange)="tabChange($event)">
<ngb-tab title="{{'collection.item-mapper.tabs.browse' | translate}}">
<ng-template ngbTabContent>
<div class="mt-2">
<ds-viewable-collection
[config]="(searchOptions$ | async)?.pagination"
[sortConfig]="(searchOptions$ | async)?.sort"
[objects]="collectionItemsRD$ | async"
[hideGear]="true">
</ds-viewable-collection>
</div>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'collection.item-mapper.tabs.map' | translate}}">
<ng-template ngbTabContent>
<div class="mt-2">
<ds-item-select class="mt-2"
[itemsRD$]="mappingItemsRD$"
[paginationOptions]="(searchOptions$ | async)?.pagination"
[confirmButton]="'collection.item-mapper.confirm'"
(confirm)="mapItems($event)"></ds-item-select>
</div>
</ng-template>
</ngb-tab>
</ngb-tabset>
<button [routerLink]="['/collections/', (collectionRD$ | async)?.payload?.id]" class="btn btn-outline-secondary">{{'collection.item-mapper.return' | translate}}</button>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
@import '../../../styles/variables.scss';

View File

@@ -0,0 +1,137 @@
import { CollectionItemMapperComponent } from './collection-item-mapper.component';
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { CommonModule } from '@angular/common';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { SearchFormComponent } from '../../shared/search-form/search-form.component';
import { SearchPageModule } from '../../+search-page/search-page.module';
import { ObjectCollectionComponent } from '../../shared/object-collection/object-collection.component';
import { ItemSelectComponent } from '../../shared/item-select/item-select.component';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
import { RouterStub } from '../../shared/testing/router-stub';
import { SearchConfigurationService } from '../../+search-page/search-service/search-configuration.service';
import { SearchService } from '../../+search-page/search-service/search.service';
import { SearchServiceStub } from '../../shared/testing/search-service-stub';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub';
import { ItemDataService } from '../../core/data/item-data.service';
import { FormsModule } from '@angular/forms';
import { SharedModule } from '../../shared/shared.module';
import { Collection } from '../../core/shared/collection.model';
import { RemoteData } from '../../core/data/remote-data';
import { Observable } from 'rxjs/Observable';
import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { EventEmitter } from '@angular/core';
import { HostWindowService } from '../../shared/host-window.service';
import { HostWindowServiceStub } from '../../shared/testing/host-window-service-stub';
import { By } from '@angular/platform-browser';
import { RestResponse } from '../../core/cache/response-cache.models';
import { PaginatedList } from '../../core/data/paginated-list';
import { PageInfo } from '../../core/shared/page-info.model';
describe('CollectionItemMapperComponent', () => {
let comp: CollectionItemMapperComponent;
let fixture: ComponentFixture<CollectionItemMapperComponent>;
let route: ActivatedRoute;
let router: Router;
let searchConfigService: SearchConfigurationService;
let searchService: SearchService;
let notificationsService: NotificationsService;
let itemDataService: ItemDataService;
const mockCollection: Collection = Object.assign(new Collection(), {
id: 'ce41d451-97ed-4a9c-94a1-7de34f16a9f4',
name: 'test-collection'
});
const mockCollectionRD: RemoteData<Collection> = new RemoteData<Collection>(false, false, true, null, mockCollection);
const mockSearchOptions = Observable.of(new PaginatedSearchOptions({
pagination: Object.assign(new PaginationComponentOptions(), {
id: 'search-page-configuration',
pageSize: 10,
currentPage: 1
}),
sort: new SortOptions('dc.title', SortDirection.ASC),
scope: mockCollection.id
}));
const routerStub = Object.assign(new RouterStub(), {
url: 'http://test.url'
});
const searchConfigServiceStub = {
paginatedSearchOptions: mockSearchOptions
};
const itemDataServiceStub = {
mapToCollection: () => Observable.of(new RestResponse(true, '200'))
};
const activatedRouteStub = new ActivatedRouteStub({}, { collection: mockCollectionRD });
const translateServiceStub = {
get: () => Observable.of('test-message of collection ' + mockCollection.name),
onLangChange: new EventEmitter(),
onTranslationChange: new EventEmitter(),
onDefaultLangChange: new EventEmitter()
};
const searchServiceStub = Object.assign(new SearchServiceStub(), {
search: () => Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), [])))
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, SharedModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [CollectionItemMapperComponent],
providers: [
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{ provide: Router, useValue: routerStub },
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
{ provide: SearchService, useValue: searchServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: ItemDataService, useValue: itemDataServiceStub },
{ provide: TranslateService, useValue: translateServiceStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CollectionItemMapperComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
route = (comp as any).route;
router = (comp as any).router;
searchConfigService = (comp as any).searchConfigService;
searchService = (comp as any).searchService;
notificationsService = (comp as any).notificationsService;
itemDataService = (comp as any).itemDataService;
});
it('should display the correct collection name', () => {
const name: HTMLElement = fixture.debugElement.query(By.css('#collection-name')).nativeElement;
expect(name.innerHTML).toContain(mockCollection.name);
});
describe('mapItems', () => {
const ids = ['id1', 'id2', 'id3', 'id4'];
beforeEach(() => {
spyOn(notificationsService, 'success').and.callThrough();
spyOn(notificationsService, 'error').and.callThrough();
});
it('should display a success message if at least one mapping was successful', () => {
comp.mapItems(ids);
expect(notificationsService.success).toHaveBeenCalled();
expect(notificationsService.error).not.toHaveBeenCalled();
});
it('should display an error message if at least one mapping was unsuccessful', () => {
spyOn(itemDataService, 'mapToCollection').and.returnValue(Observable.of(new RestResponse(false, '404')));
comp.mapItems(ids);
expect(notificationsService.success).not.toHaveBeenCalled();
expect(notificationsService.error).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,169 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { fadeIn, fadeInOut } from '../../shared/animations/fade';
import { ActivatedRoute, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router';
import { RemoteData } from '../../core/data/remote-data';
import { Observable } from 'rxjs/Observable';
import { Collection } from '../../core/shared/collection.model';
import { SearchConfigurationService } from '../../+search-page/search-service/search-configuration.service';
import { PaginatedSearchOptions } from '../../+search-page/paginated-search-options.model';
import { PaginatedList } from '../../core/data/paginated-list';
import { flatMap, map, switchMap } from 'rxjs/operators';
import { getSucceededRemoteData, toDSpaceObjectListRD } from '../../core/shared/operators';
import { SearchService } from '../../+search-page/search-service/search.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { ItemDataService } from '../../core/data/item-data.service';
import { RestResponse } from '../../core/cache/response-cache.models';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'ds-collection-item-mapper',
styleUrls: ['./collection-item-mapper.component.scss'],
templateUrl: './collection-item-mapper.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeIn,
fadeInOut
]
})
/**
* Collection used to map items to a collection
*/
export class CollectionItemMapperComponent implements OnInit {
/**
* The collection to map items to
*/
collectionRD$: Observable<RemoteData<Collection>>;
/**
* Search options
*/
searchOptions$: Observable<PaginatedSearchOptions>;
/**
* List of items to show under the "Browse" tab
* Items inside the collection
*/
collectionItemsRD$: Observable<RemoteData<PaginatedList<DSpaceObject>>>;
/**
* List of items to show under the "Map" tab
* Items outside the collection
*/
mappingItemsRD$: Observable<RemoteData<PaginatedList<DSpaceObject>>>;
/**
* Sort on title ASC by default
* @type {SortOptions}
*/
defaultSortOptions: SortOptions = new SortOptions('dc.title', SortDirection.ASC);
constructor(private route: ActivatedRoute,
private router: Router,
private searchConfigService: SearchConfigurationService,
private searchService: SearchService,
private notificationsService: NotificationsService,
private itemDataService: ItemDataService,
private translateService: TranslateService) {
}
ngOnInit(): void {
this.collectionRD$ = this.route.data.map((data) => data.collection).pipe(getSucceededRemoteData()) as Observable<RemoteData<Collection>>;
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
this.loadItemLists();
}
/**
* Load collectionItemsRD$ with a fixed scope to only obtain the items this collection owns
* Load mappingItemsRD$ to only obtain items this collection doesn't own
* TODO: When the API support it, fetch items excluding the collection's scope (currently fetches all items)
*/
loadItemLists() {
const collectionAndOptions$ = Observable.combineLatest(
this.collectionRD$,
this.searchOptions$
);
this.collectionItemsRD$ = collectionAndOptions$.pipe(
switchMap(([collectionRD, options]) => {
return this.searchService.search(Object.assign(options, {
scope: collectionRD.payload.id,
dsoType: DSpaceObjectType.ITEM,
sort: this.defaultSortOptions
}));
}),
toDSpaceObjectListRD()
);
this.mappingItemsRD$ = this.searchOptions$.pipe(
flatMap((options: PaginatedSearchOptions) => {
return this.searchService.search(Object.assign(options, {
scope: undefined,
dsoType: DSpaceObjectType.ITEM,
sort: this.defaultSortOptions
}));
}),
toDSpaceObjectListRD()
);
}
/**
* Map the selected items to the collection and display notifications
* @param {string[]} ids The list of item UUID's to map to the collection
*/
mapItems(ids: string[]) {
const responses$ = this.collectionRD$.pipe(
getSucceededRemoteData(),
map((collectionRD: RemoteData<Collection>) => collectionRD.payload.id),
switchMap((collectionId: string) => Observable.combineLatest(ids.map((id: string) => this.itemDataService.mapToCollection(id, collectionId))))
);
responses$.subscribe((responses: RestResponse[]) => {
const successful = responses.filter((response: RestResponse) => response.isSuccessful);
const unsuccessful = responses.filter((response: RestResponse) => !response.isSuccessful);
if (successful.length > 0) {
const successMessages = Observable.combineLatest(
this.translateService.get('collection.item-mapper.notifications.success.head'),
this.translateService.get('collection.item-mapper.notifications.success.content', { amount: successful.length })
);
successMessages.subscribe(([head, content]) => {
this.notificationsService.success(head, content);
});
}
if (unsuccessful.length > 0) {
const unsuccessMessages = Observable.combineLatest(
this.translateService.get('collection.item-mapper.notifications.error.head'),
this.translateService.get('collection.item-mapper.notifications.error.content', { amount: unsuccessful.length })
);
unsuccessMessages.subscribe(([head, content]) => {
this.notificationsService.error(head, content);
});
}
});
}
/**
* Clear url parameters on tab change (temporary fix until pagination is improved)
* @param event
*/
tabChange(event) {
// TODO: Fix tabs to maintain their own pagination options (once the current pagination system is improved)
// Temporary solution: Clear url params when changing tabs
this.router.navigateByUrl(this.getCurrentUrl());
}
/**
* Get current url without parameters
* @returns {string}
*/
getCurrentUrl(): string {
if (this.router.url.indexOf('?') > -1) {
return this.router.url.substring(0, this.router.url.indexOf('?'));
}
return this.router.url;
}
}

View File

@@ -3,6 +3,8 @@ import { RouterModule } from '@angular/router';
import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageResolver } from './collection-page.resolver';
import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
@NgModule({
imports: [
@@ -14,6 +16,15 @@ import { CollectionPageResolver } from './collection-page.resolver';
resolve: {
collection: CollectionPageResolver
}
},
{
path: ':id/mapper',
component: CollectionItemMapperComponent,
pathMatch: 'full',
resolve: {
collection: CollectionPageResolver
},
canActivate: [AuthenticatedGuard]
}
])
],

View File

@@ -6,6 +6,7 @@ import { SharedModule } from '../shared/shared.module';
import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageRoutingModule } from './collection-page-routing.module';
import { SearchPageModule } from '../+search-page/search-page.module';
import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component';
@NgModule({
imports: [
@@ -16,6 +17,7 @@ import { SearchPageModule } from '../+search-page/search-page.module';
],
declarations: [
CollectionPageComponent,
CollectionItemMapperComponent
]
})
export class CollectionPageModule {

View File

@@ -2,18 +2,22 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '../../shared/shared.module';
import { EditItemPageRoutingModule } from './edit-item-page.routing.module';
import { SearchPageModule } from '../../+search-page/search-page.module';
import { EditItemPageComponent } from './edit-item-page.component';
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemStatusComponent } from './item-status/item-status.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
EditItemPageRoutingModule
EditItemPageRoutingModule,
SearchPageModule
],
declarations: [
EditItemPageComponent,
ItemStatusComponent
ItemStatusComponent,
ItemCollectionMapperComponent
]
})
export class EditItemPageModule {

View File

@@ -2,6 +2,7 @@ import { ItemPageResolver } from '../item-page.resolver';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { EditItemPageComponent } from './edit-item-page.component';
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
@NgModule({
imports: [
@@ -12,6 +13,13 @@ import { EditItemPageComponent } from './edit-item-page.component';
resolve: {
item: ItemPageResolver
}
},
{
path: 'mapper',
component: ItemCollectionMapperComponent,
resolve: {
item: ItemPageResolver
}
}
])
],

View File

@@ -0,0 +1,38 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2>{{'item.edit.item-mapper.head' | translate}}</h2>
<p [innerHTML]="'item.edit.item-mapper.item' | translate:{ name: (itemRD$ | async)?.payload?.name }" id="item-name"></p>
<p>{{'item.edit.item-mapper.description' | translate}}</p>
<div class="row">
<div class="col-12 col-lg-6">
<ds-search-form id="search-form"
[query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope"
[currentUrl]="getCurrentUrl()">
</ds-search-form>
</div>
</div>
<ngb-tabset (tabChange)="tabChange($event)">
<ngb-tab title="{{'item.edit.item-mapper.tabs.browse' | translate}}">
<ng-template ngbTabContent>
<div class="mt-2">
<div *ngFor="let col of (itemCollectionsRD$ | async)?.payload">{{col.name}}</div>
</div>
</ng-template>
</ngb-tab>
<ngb-tab title="{{'item.edit.item-mapper.tabs.map' | translate}}">
<ng-template ngbTabContent>
<div class="mt-2">
</div>
</ng-template>
</ngb-tab>
</ngb-tabset>
<button [routerLink]="['/items/', (itemRD$ | async)?.payload?.id]" class="btn btn-outline-secondary">{{'item.edit.item-mapper.return' | translate}}</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,109 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Observable } from 'rxjs/Observable';
import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { Collection } from '../../../core/shared/collection.model';
import { Item } from '../../../core/shared/item.model';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { ActivatedRoute, Router } from '@angular/router';
import { SearchService } from '../../../+search-page/search-service/search.service';
import { SearchConfigurationService } from '../../../+search-page/search-service/search-configuration.service';
import { map, switchMap } from 'rxjs/operators';
import { CollectionDataService } from '../../../core/data/collection-data.service';
import { ItemDataService } from '../../../core/data/item-data.service';
@Component({
selector: 'ds-item-collection-mapper',
styleUrls: ['./item-collection-mapper.component.scss'],
templateUrl: './item-collection-mapper.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
fadeIn,
fadeInOut
]
})
/**
* Component for mapping collections to an item
*/
export class ItemCollectionMapperComponent implements OnInit {
/**
* The item to map to collections
*/
itemRD$: Observable<RemoteData<Item>>;
/**
* Search options
*/
searchOptions$: Observable<PaginatedSearchOptions>;
/**
* List of collections to show under the "Browse" tab
* Collections that are mapped to the item
*/
itemCollectionsRD$: Observable<RemoteData<Collection[]>>;
/**
* List of collections to show under the "Map" tab
* Collections that are not mapped to the item
*/
mappingCollectionsRD$: Observable<RemoteData<PaginatedList<Collection>>>;
/**
* Sort on title ASC by default
* @type {SortOptions}
*/
defaultSortOptions: SortOptions = new SortOptions('dc.title', SortDirection.ASC);
constructor(private route: ActivatedRoute,
private router: Router,
private searchConfigService: SearchConfigurationService,
private searchService: SearchService,
private collectionDataService: CollectionDataService,
private itemDataService: ItemDataService) {
}
ngOnInit(): void {
this.itemRD$ = this.route.data.map((data) => data.item).pipe(getSucceededRemoteData()) as Observable<RemoteData<Item>>;
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
this.loadCollectionLists();
}
/**
* Load itemCollectionsRD$ with a fixed scope to only obtain the collections that own this item
* Load mappingCollectionsRD$ to only obtain collections that don't own this item
* TODO: When the API support it, fetch collections excluding the item's scope (currently fetches all collections)
*/
loadCollectionLists() {
this.itemCollectionsRD$ = this.itemRD$.pipe(
map((itemRD: RemoteData<Item>) => itemRD.payload),
switchMap((item: Item) => this.itemDataService.getMappedCollections(item.id))
);
this.mappingCollectionsRD$ = this.collectionDataService.findAll();
}
/**
* Clear url parameters on tab change (temporary fix until pagination is improved)
* @param event
*/
tabChange(event) {
// TODO: Fix tabs to maintain their own pagination options (once the current pagination system is improved)
// Temporary solution: Clear url params when changing tabs
this.router.navigateByUrl(this.getCurrentUrl());
}
/**
* Get current url without parameters
* @returns {string}
*/
getCurrentUrl(): string {
if (this.router.url.indexOf('?') > -1) {
return this.router.url.substring(0, this.router.url.indexOf('?'));
}
return this.router.url;
}
}

View File

@@ -59,7 +59,7 @@ export class ItemStatusComponent implements OnInit {
*/
this.actions = Object.assign({
// TODO: Create mapping component on item level
mappedCollections: this.getCurrentUrl() + '/'
mappedCollections: this.getCurrentUrl() + '/mapper'
});
this.actionsKeys = Object.keys(this.actions);
}

View File

@@ -14,6 +14,7 @@ import {
} from './+search-page/search-filters/search-filter/search-filter.reducer';
import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers';
import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer';
import { itemSelectionReducer, ItemSelectionsState } from './shared/item-select/item-select.reducer';
export interface AppState {
router: fromRouter.RouterReducerState;
@@ -23,7 +24,8 @@ export interface AppState {
notifications: NotificationsState;
searchSidebar: SearchSidebarState;
searchFilter: SearchFiltersState;
truncatable: TruncatablesState;
truncatable: TruncatablesState,
itemSelection: ItemSelectionsState
}
export const appReducers: ActionReducerMap<AppState> = {
@@ -34,7 +36,8 @@ export const appReducers: ActionReducerMap<AppState> = {
notifications: notificationsReducer,
searchSidebar: sidebarReducer,
searchFilter: filterReducer,
truncatable: truncatableReducer
truncatable: truncatableReducer,
itemSelection: itemSelectionReducer
};
export const routerStateSelector = (state: AppState) => state.router;

View File

@@ -64,6 +64,8 @@ import { NotificationsService } from '../shared/notifications/notifications.serv
import { UploaderService } from '../shared/uploader/uploader.service';
import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service';
import { DSpaceObjectDataService } from './data/dspace-object-data.service';
import { ItemSelectService } from '../shared/item-select/item-select.service';
import { MappingCollectionsReponseParsingService } from './data/mapping-collections-reponse-parsing.service';
const IMPORTS = [
CommonModule,
@@ -110,6 +112,7 @@ const PROVIDERS = [
RegistryMetadataschemasResponseParsingService,
RegistryMetadatafieldsResponseParsingService,
RegistryBitstreamformatsResponseParsingService,
MappingCollectionsReponseParsingService,
MetadataschemaParsingService,
DebugResponseParsingService,
SearchResponseParsingService,
@@ -128,6 +131,7 @@ const PROVIDERS = [
UploaderService,
UUIDService,
DSpaceObjectDataService,
ItemSelectService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,

View File

@@ -3,7 +3,7 @@ import { Inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
import { ensureArrayHasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { BrowseService } from '../browse/browse.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedItem } from '../cache/models/normalized-item.model';
@@ -15,7 +15,21 @@ import { URLCombiner } from '../url-combiner/url-combiner';
import { DataService } from './data.service';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { FindAllOptions } from './request.models';
import { FindAllOptions, GetRequest, MappingCollectionsRequest, PostRequest, RestRequest } from './request.models';
import { distinctUntilChanged, map } from 'rxjs/operators';
import {
configureRequest,
filterSuccessfulResponses,
getRequestFromSelflink,
getResponseFromSelflink
} from '../shared/operators';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { DSOSuccessResponse, GenericSuccessResponse, RestResponse } from '../cache/response-cache.models';
import { BrowseDefinition } from '../shared/browse-definition.model';
import { Collection } from '../shared/collection.model';
import { NormalizedCollection } from '../cache/models/normalized-collection.model';
import { RemoteData } from './remote-data';
import { PaginatedList } from './paginated-list';
@Injectable()
export class ItemDataService extends DataService<NormalizedItem, Item> {
@@ -48,4 +62,35 @@ export class ItemDataService extends DataService<NormalizedItem, Item> {
.distinctUntilChanged();
}
public getMappingCollectionsEndpoint(itemId: string, collectionId?: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getFindByIDHref(endpoint, itemId)),
map((endpoint: string) => `${endpoint}/mappingCollections${collectionId ? `/${collectionId}` : ''}`)
);
}
public mapToCollection(itemId: string, collectionId: string): Observable<RestResponse> {
return this.getMappingCollectionsEndpoint(itemId, collectionId).pipe(
isNotEmptyOperator(),
distinctUntilChanged(),
map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL)),
configureRequest(this.requestService),
map((request: RestRequest) => request.href),
getResponseFromSelflink(this.responseCache),
map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.response)
);
}
public getMappedCollections(itemId: string): Observable<RemoteData<Collection[]>> {
const request$ = this.getMappingCollectionsEndpoint(itemId).pipe(
isNotEmptyOperator(),
distinctUntilChanged(),
map((endpointURL: string) => new MappingCollectionsRequest(this.requestService.generateRequestId(), endpointURL)),
configureRequest(this.requestService)
);
// TODO: Create a remotedata object
return undefined;
}
}

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response-cache.models';
@Injectable()
export class MappingCollectionsReponseParsingService implements ResponseParsingService {
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
if (payload._embedded && payload._embedded.mappingCollections) {
const mappingCollections = payload._embedded.mappingCollections;
return new GenericSuccessResponse(mappingCollections, data.statusCode);
} else {
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from mappingCollections endpoint'),
{ statusText: data.statusCode }
)
);
}
}
}

View File

@@ -13,6 +13,7 @@ import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { HttpHeaders } from '@angular/common/http';
import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service';
import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service';
import { MappingCollectionsReponseParsingService } from './mapping-collections-reponse-parsing.service';
/* tslint:disable:max-classes-per-file */
@@ -191,6 +192,12 @@ export class BrowseItemsRequest extends GetRequest {
}
}
export class MappingCollectionsRequest extends GetRequest {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return MappingCollectionsReponseParsingService;
}
}
export class ConfigRequest extends GetRequest {
constructor(uuid: string, href: string) {
super(uuid, href);

View File

@@ -0,0 +1,75 @@
import { type } from '../ngrx/type';
import { Action } from '@ngrx/store';
export const ItemSelectionActionTypes = {
INITIAL_DESELECT: type('dspace/item-select/INITIAL_DESELECT'),
INITIAL_SELECT: type('dspace/item-select/INITIAL_SELECT'),
SELECT: type('dspace/item-select/SELECT'),
DESELECT: type('dspace/item-select/DESELECT'),
SWITCH: type('dspace/item-select/SWITCH'),
RESET: type('dspace/item-select/RESET')
};
export class ItemSelectionAction implements Action {
/**
* UUID of the item a select action can be performed on
*/
id: string;
/**
* Type of action that will be performed
*/
type;
/**
* Initialize with the item's UUID
* @param {string} id of the item
*/
constructor(id: string) {
this.id = id;
}
}
/* tslint:disable:max-classes-per-file */
/**
* Used to set the initial state to deselected
*/
export class ItemSelectionInitialDeselectAction extends ItemSelectionAction {
type = ItemSelectionActionTypes.INITIAL_DESELECT;
}
/**
* Used to set the initial state to selected
*/
export class ItemSelectionInitialSelectAction extends ItemSelectionAction {
type = ItemSelectionActionTypes.INITIAL_SELECT;
}
/**
* Used to select an item
*/
export class ItemSelectionSelectAction extends ItemSelectionAction {
type = ItemSelectionActionTypes.SELECT;
}
/**
* Used to deselect an item
*/
export class ItemSelectionDeselectAction extends ItemSelectionAction {
type = ItemSelectionActionTypes.DESELECT;
}
/**
* Used to switch an item between selected and deselected
*/
export class ItemSelectionSwitchAction extends ItemSelectionAction {
type = ItemSelectionActionTypes.SWITCH;
}
/**
* Used to reset all item's selected to be deselected
*/
export class ItemSelectionResetAction extends ItemSelectionAction {
type = ItemSelectionActionTypes.RESET;
}
/* tslint:enable:max-classes-per-file */

View File

@@ -0,0 +1,29 @@
<ds-pagination
*ngIf="(itemsRD$ | async)?.payload?.totalElements > 0"
[paginationOptions]="paginationOptions"
[pageInfoState]="(itemsRD$ | async)?.payload"
[collectionSize]="(itemsRD$ | async)?.payload?.totalElements"
[hidePagerWhenSinglePage]="true"
[hideGear]="true">
<div class="table-responsive">
<table id="item-select" class="table table-striped table-hover">
<thead>
<tr>
<th></th>
<th scope="col">{{'item.select.table.collection' | translate}}</th>
<th scope="col">{{'item.select.table.author' | translate}}</th>
<th scope="col">{{'item.select.table.title' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of (itemsRD$ | async)?.payload?.page">
<td><input class="item-checkbox" [ngModel]="getSelected(item.id) | async" (change)="switch(item.id)" type="checkbox" name="{{item.id}}"></td>
<td><a [routerLink]="['/items', item.id]">{{(item.owningCollection | async)?.payload?.name}}</a></td>
<td><a *ngIf="item.filterMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']).length > 0" [routerLink]="['/items', item.id]">{{item.filterMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*'])[0].value}}</a></td>
<td><a [routerLink]="['/items', item.id]">{{item.findMetadata("dc.title")}}</a></td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<button class="btn btn-outline-secondary item-confirm" (click)="confirmSelected()">{{confirmButton | translate}}</button>

View File

@@ -0,0 +1,131 @@
import { ItemSelectComponent } from './item-select.component';
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { SharedModule } from '../shared.module';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { ItemSelectService } from './item-select.service';
import { ItemSelectServiceStub } from '../testing/item-select-service-stub';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list';
import { PageInfo } from '../../core/shared/page-info.model';
import { Item } from '../../core/shared/item.model';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Route, Router } from '@angular/router';
import { ActivatedRouteStub } from '../testing/active-router-stub';
import { RouterStub } from '../testing/router-stub';
import { HostWindowService } from '../host-window.service';
import { HostWindowServiceStub } from '../testing/host-window-service-stub';
import { PaginationComponentOptions } from '../pagination/pagination-component-options.model';
import { LocationStrategy } from '@angular/common';
import { MockLocationStrategy } from '@angular/common/testing';
import { RouterTestingModule } from '@angular/router/testing';
describe('ItemSelectComponent', () => {
let comp: ItemSelectComponent;
let fixture: ComponentFixture<ItemSelectComponent>;
let itemSelectService: ItemSelectService;
const mockItemList = [
Object.assign(new Item(), {
id: 'id1',
bitstreams: Observable.of({}),
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'This is just a title'
},
{
key: 'dc.type',
language: null,
value: 'Article'
}]
}),
Object.assign(new Item(), {
id: 'id2',
bitstreams: Observable.of({}),
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'This is just another title'
},
{
key: 'dc.type',
language: null,
value: 'Article'
}]
})
];
const mockItems = Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), mockItemList)));
const mockPaginationOptions = Object.assign(new PaginationComponentOptions(), {
id: 'search-page-configuration',
pageSize: 10,
currentPage: 1
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, RouterTestingModule.withRoutes([])],
declarations: [],
providers: [
{ provide: ItemSelectService, useValue: new ItemSelectServiceStub() },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemSelectComponent);
comp = fixture.componentInstance;
comp.itemsRD$ = mockItems;
comp.paginationOptions = mockPaginationOptions;
fixture.detectChanges();
itemSelectService = (comp as any).itemSelectService;
});
it(`should show a list of ${mockItemList.length} items`, () => {
const tbody: HTMLElement = fixture.debugElement.query(By.css('table#item-select tbody')).nativeElement;
expect(tbody.children.length).toBe(mockItemList.length);
});
describe('checkboxes', () => {
let checkbox: HTMLInputElement;
beforeEach(() => {
checkbox = fixture.debugElement.query(By.css('input.item-checkbox')).nativeElement;
});
it('should initially be unchecked',() => {
expect(checkbox.checked).toBeFalsy();
});
it('should be checked when clicked', () => {
checkbox.click();
fixture.detectChanges();
expect(checkbox.checked).toBeTruthy();
});
it('should switch the value through item-select-service', () => {
spyOn((comp as any).itemSelectService, 'switch').and.callThrough();
checkbox.click();
expect((comp as any).itemSelectService.switch).toHaveBeenCalled();
});
});
describe('when confirm is clicked', () => {
let confirmButton: HTMLButtonElement;
beforeEach(() => {
confirmButton = fixture.debugElement.query(By.css('button.item-confirm')).nativeElement;
spyOn(comp.confirm, 'emit').and.callThrough();
});
it('should emit the selected items',() => {
confirmButton.click();
expect(comp.confirm.emit).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,90 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { ItemDataService } from '../../core/data/item-data.service';
import { PaginatedList } from '../../core/data/paginated-list';
import { RemoteData } from '../../core/data/remote-data';
import { Observable } from 'rxjs/Observable';
import { Item } from '../../core/shared/item.model';
import { PaginationComponentOptions } from '../pagination/pagination-component-options.model';
import { ItemSelectService } from './item-select.service';
import { take } from 'rxjs/operators';
@Component({
selector: 'ds-item-select',
styleUrls: ['./item-select.component.scss'],
templateUrl: './item-select.component.html'
})
/**
* A component used to select items from a specific list and returning the UUIDs of the selected items
*/
export class ItemSelectComponent implements OnInit {
/**
* The list of items to display
*/
@Input()
itemsRD$: Observable<RemoteData<PaginatedList<Item>>>;
/**
* The pagination options used to display the items
*/
@Input()
paginationOptions: PaginationComponentOptions;
/**
* The message key used for the confirm button
* @type {string}
*/
@Input()
confirmButton = 'item.select.confirm';
/**
* EventEmitter to return the selected UUIDs when the confirm button is pressed
* @type {EventEmitter<string[]>}
*/
@Output()
confirm: EventEmitter<string[]> = new EventEmitter<string[]>();
/**
* The list of selected UUIDs
*/
selectedIds$: Observable<string[]>;
constructor(private itemSelectService: ItemSelectService) {
}
ngOnInit(): void {
this.selectedIds$ = this.itemSelectService.getAllSelected();
}
/**
* Switch the state of a checkbox
* @param {string} id
*/
switch(id: string) {
this.itemSelectService.switch(id);
}
/**
* Get the current state of a checkbox
* @param {string} id The item's UUID
* @returns {Observable<boolean>}
*/
getSelected(id: string): Observable<boolean> {
return this.itemSelectService.getSelected(id);
}
/**
* Called when the confirm button is pressed
* Sends the selected UUIDs to the parent component
*/
confirmSelected() {
this.selectedIds$.pipe(
take(1)
).subscribe((ids: string[]) => {
this.confirm.emit(ids);
this.itemSelectService.reset();
});
}
}

View File

@@ -0,0 +1,98 @@
import {
ItemSelectionDeselectAction, ItemSelectionInitialDeselectAction,
ItemSelectionInitialSelectAction, ItemSelectionResetAction,
ItemSelectionSelectAction, ItemSelectionSwitchAction
} from './item-select.actions';
import { itemSelectionReducer } from './item-select.reducer';
const itemId1 = 'id1';
const itemId2 = 'id2';
class NullAction extends ItemSelectionSelectAction {
type = null;
constructor() {
super(undefined);
}
}
describe('itemSelectionReducer', () => {
it('should return the current state when no valid actions have been made', () => {
const state = {};
state[itemId1] = { checked: true };
const action = new NullAction();
const newState = itemSelectionReducer(state, action);
expect(newState).toEqual(state);
});
it('should start with an empty object', () => {
const state = {};
const action = new NullAction();
const newState = itemSelectionReducer(undefined, action);
expect(newState).toEqual(state);
});
it('should set checked to true in response to the INITIAL_SELECT action', () => {
const action = new ItemSelectionInitialSelectAction(itemId1);
const newState = itemSelectionReducer(undefined, action);
expect(newState[itemId1].checked).toBeTruthy();
});
it('should set checked to true in response to the INITIAL_DESELECT action', () => {
const action = new ItemSelectionInitialDeselectAction(itemId1);
const newState = itemSelectionReducer(undefined, action);
expect(newState[itemId1].checked).toBeFalsy();
});
it('should set checked to true in response to the SELECT action', () => {
const state = {};
state[itemId1] = { checked: false };
const action = new ItemSelectionSelectAction(itemId1);
const newState = itemSelectionReducer(state, action);
expect(newState[itemId1].checked).toBeTruthy();
});
it('should set checked to false in response to the DESELECT action', () => {
const state = {};
state[itemId1] = { checked: true };
const action = new ItemSelectionDeselectAction(itemId1);
const newState = itemSelectionReducer(state, action);
expect(newState[itemId1].checked).toBeFalsy();
});
it('should set checked from false to true in response to the SWITCH action', () => {
const state = {};
state[itemId1] = { checked: false };
const action = new ItemSelectionSwitchAction(itemId1);
const newState = itemSelectionReducer(state, action);
expect(newState[itemId1].checked).toBeTruthy();
});
it('should set checked from true to false in response to the SWITCH action', () => {
const state = {};
state[itemId1] = { checked: true };
const action = new ItemSelectionSwitchAction(itemId1);
const newState = itemSelectionReducer(state, action);
expect(newState[itemId1].checked).toBeFalsy();
});
it('should set reset the state in response to the RESET action', () => {
const state = {};
state[itemId1] = { checked: true };
state[itemId2] = { checked: false };
const action = new ItemSelectionResetAction(undefined);
const newState = itemSelectionReducer(state, action);
expect(newState).toEqual({});
});
});

View File

@@ -0,0 +1,84 @@
import { isEmpty } from '../empty.util';
import { ItemSelectionAction, ItemSelectionActionTypes } from './item-select.actions';
/**
* Interface that represents the state for a single filters
*/
export interface ItemSelectionState {
checked: boolean;
}
/**
* Interface that represents the state for all available filters
*/
export interface ItemSelectionsState {
[id: string]: ItemSelectionState
}
const initialState: ItemSelectionsState = Object.create(null);
/**
* Performs a search filter action on the current state
* @param {SearchFiltersState} state The state before the action is performed
* @param {SearchFilterAction} action The action that should be performed
* @returns {SearchFiltersState} The state after the action is performed
*/
export function itemSelectionReducer(state = initialState, action: ItemSelectionAction): ItemSelectionsState {
switch (action.type) {
case ItemSelectionActionTypes.INITIAL_SELECT: {
if (isEmpty(state) || isEmpty(state[action.id])) {
return Object.assign({}, state, {
[action.id]: {
checked: true
}
});
}
return state;
}
case ItemSelectionActionTypes.INITIAL_DESELECT: {
if (isEmpty(state) || isEmpty(state[action.id])) {
return Object.assign({}, state, {
[action.id]: {
checked: false
}
});
}
return state;
}
case ItemSelectionActionTypes.SELECT: {
return Object.assign({}, state, {
[action.id]: {
checked: true
}
});
}
case ItemSelectionActionTypes.DESELECT: {
return Object.assign({}, state, {
[action.id]: {
checked: false
}
});
}
case ItemSelectionActionTypes.SWITCH: {
return Object.assign({}, state, {
[action.id]: {
checked: (isEmpty(state) || isEmpty(state[action.id])) ? true : !state[action.id].checked
}
});
}
case ItemSelectionActionTypes.RESET: {
return {};
}
default: {
return state;
}
}
}

View File

@@ -0,0 +1,96 @@
import { ItemSelectService } from './item-select.service';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { ItemSelectionsState } from './item-select.reducer';
import { AppState } from '../../app.reducer';
import {
ItemSelectionDeselectAction,
ItemSelectionInitialDeselectAction,
ItemSelectionInitialSelectAction, ItemSelectionResetAction,
ItemSelectionSelectAction, ItemSelectionSwitchAction
} from './item-select.actions';
describe('ItemSelectService', () => {
let service: ItemSelectService;
const mockItemId = 'id1';
const store: Store<ItemSelectionsState> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
});
const appStore: Store<AppState> = jasmine.createSpyObj('appStore', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
});
beforeEach(() => {
service = new ItemSelectService(store, appStore);
});
describe('when the initialSelect method is triggered', () => {
beforeEach(() => {
service.initialSelect(mockItemId);
});
it('ItemSelectionInitialSelectAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new ItemSelectionInitialSelectAction(mockItemId));
});
});
describe('when the initialDeselect method is triggered', () => {
beforeEach(() => {
service.initialDeselect(mockItemId);
});
it('ItemSelectionInitialDeselectAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new ItemSelectionInitialDeselectAction(mockItemId));
});
});
describe('when the select method is triggered', () => {
beforeEach(() => {
service.select(mockItemId);
});
it('ItemSelectionSelectAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new ItemSelectionSelectAction(mockItemId));
});
});
describe('when the deselect method is triggered', () => {
beforeEach(() => {
service.deselect(mockItemId);
});
it('ItemSelectionDeselectAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new ItemSelectionDeselectAction(mockItemId));
});
});
describe('when the switch method is triggered', () => {
beforeEach(() => {
service.switch(mockItemId);
});
it('ItemSelectionSwitchAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new ItemSelectionSwitchAction(mockItemId));
});
});
describe('when the reset method is triggered', () => {
beforeEach(() => {
service.reset();
});
it('ItemSelectionInitialSelectAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new ItemSelectionResetAction(null));
});
});
});

View File

@@ -0,0 +1,119 @@
import { Injectable } from '@angular/core';
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
import { ItemSelectionsState, ItemSelectionState } from './item-select.reducer';
import {
ItemSelectionDeselectAction,
ItemSelectionInitialDeselectAction,
ItemSelectionInitialSelectAction, ItemSelectionResetAction,
ItemSelectionSelectAction, ItemSelectionSwitchAction
} from './item-select.actions';
import { Observable } from 'rxjs/Observable';
import { hasValue } from '../empty.util';
import { map } from 'rxjs/operators';
import { AppState } from '../../app.reducer';
const selectionStateSelector = (state: ItemSelectionsState) => state.itemSelection;
const itemSelectionsStateSelector = (state: AppState) => state.itemSelection;
/**
* Service that takes care of selecting and deselecting items
*/
@Injectable()
export class ItemSelectService {
constructor(
private store: Store<ItemSelectionsState>,
private appStore: Store<AppState>
) {
}
/**
* Request the current selection of a given item
* @param {string} id The UUID of the item
* @returns {Observable<boolean>} Emits the current selection state of the given item, if it's unavailable, return false
*/
getSelected(id: string): Observable<boolean> {
return this.store.select(selectionByIdSelector(id)).pipe(
map((object: ItemSelectionState) => {
if (object) {
return object.checked;
} else {
return false;
}
})
);
}
/**
* Request the current selection of a given item
* @param {string} id The UUID of the item
* @returns {Observable<boolean>} Emits the current selection state of the given item, if it's unavailable, return false
*/
getAllSelected(): Observable<string[]> {
return this.appStore.select(itemSelectionsStateSelector).pipe(
map((state: ItemSelectionsState) => Object.keys(state).filter((key) => state[key].checked))
);
}
/**
* Dispatches an initial select action to the store for a given item
* @param {string} id The UUID of the item to select
*/
public initialSelect(id: string): void {
this.store.dispatch(new ItemSelectionInitialSelectAction(id));
}
/**
* Dispatches an initial deselect action to the store for a given item
* @param {string} id The UUID of the item to deselect
*/
public initialDeselect(id: string): void {
this.store.dispatch(new ItemSelectionInitialDeselectAction(id));
}
/**
* Dispatches a select action to the store for a given item
* @param {string} id The UUID of the item to select
*/
public select(id: string): void {
this.store.dispatch(new ItemSelectionSelectAction(id));
}
/**
* Dispatches a deselect action to the store for a given item
* @param {string} id The UUID of the item to deselect
*/
public deselect(id: string): void {
this.store.dispatch(new ItemSelectionDeselectAction(id));
}
/**
* Dispatches a switch action to the store for a given item
* @param {string} id The UUID of the item to select
*/
public switch(id: string): void {
this.store.dispatch(new ItemSelectionSwitchAction(id));
}
/**
* Dispatches a reset action to the store for all items
*/
public reset(): void {
this.store.dispatch(new ItemSelectionResetAction(null));
}
}
function selectionByIdSelector(id: string): MemoizedSelector<ItemSelectionsState, ItemSelectionState> {
return keySelector<ItemSelectionState>(id);
}
export function keySelector<T>(key: string): MemoizedSelector<ItemSelectionsState, T> {
return createSelector(selectionStateSelector, (state: ItemSelectionState) => {
if (hasValue(state)) {
return state[key];
} else {
return undefined;
}
});
}

View File

@@ -83,6 +83,7 @@ import { InputSuggestionsComponent } from './input-suggestions/input-suggestions
import { CapitalizePipe } from './utils/capitalize.pipe';
import { MomentModule } from 'angular2-moment';
import { ObjectKeysPipe } from './utils/object-keys-pipe';
import { ItemSelectComponent } from './item-select/item-select.component';
const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -156,7 +157,8 @@ const COMPONENTS = [
TruncatableComponent,
TruncatablePartComponent,
BrowseByComponent,
InputSuggestionsComponent
InputSuggestionsComponent,
ItemSelectComponent
];
const ENTRY_COMPONENTS = [

View File

@@ -5,19 +5,27 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject';
export class ActivatedRouteStub {
private _testParams?: any;
private _testData?: any;
// ActivatedRoute.params is Observable
private subject?: BehaviorSubject<any> = new BehaviorSubject(this.testParams);
private dataSubject?: BehaviorSubject<any> = new BehaviorSubject(this.testData);
params = this.subject.asObservable();
queryParams = this.subject.asObservable();
queryParamMap = this.subject.asObservable().map((params: Params) => convertToParamMap(params));
data = this.dataSubject.asObservable();
constructor(params?: Params) {
constructor(params?: Params, data?: any) {
if (params) {
this.testParams = params;
} else {
this.testParams = {};
}
if (data) {
this.testData = data;
} else {
this.testData = {};
}
}
// Test parameters
@@ -30,6 +38,16 @@ export class ActivatedRouteStub {
this.subject.next(params);
}
// Test data
get testData() {
return this._testParams;
}
set testData(data: {}) {
this._testData = data;
this.dataSubject.next(data);
}
// ActivatedRoute.snapshot.params
get snapshot() {
return {

View File

@@ -0,0 +1,37 @@
import { Observable } from 'rxjs/Observable';
export class ItemSelectServiceStub {
ids: string[] = [];
constructor(ids?: string[]) {
if (ids) {
this.ids = ids;
}
}
getSelected(id: string): Observable<boolean> {
if (this.ids.indexOf(id) > -1) {
return Observable.of(true);
} else {
return Observable.of(false);
}
}
getAllSelected(): Observable<string[]> {
return Observable.of(this.ids);
}
switch(id: string) {
const index = this.ids.indexOf(id);
if (index > -1) {
this.ids.splice(index, 1);
} else {
this.ids.push(id);
}
}
reset() {
this.ids = [];
}
}