Merge remote-tracking branch 'upstream/master' into configurable_entities

This commit is contained in:
Art Lowel
2019-03-29 13:23:05 +01:00
56 changed files with 2361 additions and 300 deletions

View File

@@ -89,7 +89,7 @@
"angular2-text-mask": "9.0.0",
"angulartics2": "^6.2.0",
"body-parser": "1.18.2",
"bootstrap": "4.1.3",
"bootstrap": "4.3.1",
"cerialize": "0.1.18",
"compression": "1.7.1",
"cookie-parser": "1.4.3",
@@ -111,7 +111,7 @@
"ng-mocks": "^6.2.1",
"ng2-file-upload": "1.2.1",
"ng2-nouislider": "^1.7.11",
"ngx-bootstrap": "^3.0.1",
"ngx-bootstrap": "^3.2.0",
"ngx-infinite-scroll": "6.0.1",
"ngx-moment": "^3.1.0",
"ngx-pagination": "3.0.3",
@@ -162,6 +162,7 @@
"copy-webpack-plugin": "^4.4.1",
"coveralls": "3.0.0",
"css-loader": "1.0.0",
"cssnano": "^4.1.10",
"deep-freeze": "0.0.1",
"exports-loader": "^0.7.0",
"html-webpack-plugin": "^4.0.0-alpha",
@@ -187,6 +188,7 @@
"node-sass": "^4.11.0",
"nodemon": "^1.15.0",
"npm-run-all": "4.1.3",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"postcss": "^7.0.2",
"postcss-apply": "0.11.0",
"postcss-cli": "^6.0.0",

View File

@@ -771,5 +771,33 @@
},
"chips": {
"remove": "Remove chip"
},
"dso-selector": {
"create": {
"community": {
"head": "New community",
"sub-level": "Create a new community in",
"top-level": "Create a new top-level community"
},
"collection": {
"head": "New collection"
},
"item": {
"head": "New item"
}
},
"edit": {
"community": {
"head": "Edit community"
},
"collection": {
"head": "Edit collection"
},
"item": {
"head": "Edit item"
}
},
"placeholder": "Search for a {{ type }}",
"no-results": "No {{ type }} found"
}
}

View File

@@ -12,6 +12,7 @@ import { AuthService } from '../../core/auth/auth.service';
import { of as observableOf } from 'rxjs';
import { By } from '@angular/platform-browser';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
describe('AdminSidebarComponent', () => {
let comp: AdminSidebarComponent;
@@ -26,7 +27,12 @@ describe('AdminSidebarComponent', () => {
{ provide: Injector, useValue: {} },
{ provide: MenuService, useValue: menuService },
{ provide: CSSVariableService, useClass: CSSVariableServiceStub },
{ provide: AuthService, useClass: AuthServiceStub }
{ provide: AuthService, useClass: AuthServiceStub },
{
provide: NgbModal, useValue: {
open: () => {/*comment*/}
}
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(AdminSidebarComponent, {
@@ -96,7 +102,10 @@ describe('AdminSidebarComponent', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('a.shortcut-icon'));
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
});
it('should call toggleMenu on the menuService', () => {
@@ -108,7 +117,10 @@ describe('AdminSidebarComponent', () => {
beforeEach(() => {
spyOn(menuService, 'toggleMenu');
const sidebarToggler = fixture.debugElement.query(By.css('#sidebar-collapse-toggle')).query(By.css('.sidebar-collapsible')).query(By.css('a'));
sidebarToggler.triggerEventHandler('click', {preventDefault: () => {/**/}});
sidebarToggler.triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
});
it('should call toggleMenu on the menuService', () => {
@@ -120,7 +132,10 @@ describe('AdminSidebarComponent', () => {
it('should call expandPreview on the menuService after 100ms', fakeAsync(() => {
spyOn(menuService, 'expandMenuPreview');
const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar'));
sidebarToggler.triggerEventHandler('mouseenter', {preventDefault: () => {/**/}});
sidebarToggler.triggerEventHandler('mouseenter', {
preventDefault: () => {/**/
}
});
tick(99);
expect(menuService.expandMenuPreview).not.toHaveBeenCalled();
tick(1);
@@ -132,7 +147,10 @@ describe('AdminSidebarComponent', () => {
it('should call collapseMenuPreview on the menuService after 400ms', fakeAsync(() => {
spyOn(menuService, 'collapseMenuPreview');
const sidebarToggler = fixture.debugElement.query(By.css('nav.navbar'));
sidebarToggler.triggerEventHandler('mouseleave', {preventDefault: () => {/**/}});
sidebarToggler.triggerEventHandler('mouseleave', {
preventDefault: () => {/**/
}
});
tick(399);
expect(menuService.collapseMenuPreview).not.toHaveBeenCalled();
tick(1);

View File

@@ -1,6 +1,6 @@
import { Component, Injector, OnInit } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { slide, slideHorizontal, slideSidebar } from '../../shared/animations/slide';
import { slideHorizontal, slideSidebar } from '../../shared/animations/slide';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
import { MenuService } from '../../shared/menu/menu.service';
import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state';
@@ -10,6 +10,14 @@ import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model
import { AuthService } from '../../core/auth/auth.service';
import { first, map } from 'rxjs/operators';
import { combineLatest as combineLatestObservable } from 'rxjs';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model';
import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
import { EditCollectionSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
/**
* Component representing the admin sidebar
@@ -52,7 +60,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
constructor(protected menuService: MenuService,
protected injector: Injector,
private variableService: CSSVariableService,
private authService: AuthService
private authService: AuthService,
private modalService: NgbModal
) {
super(menuService, injector);
}
@@ -104,10 +113,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.new_community',
link: '/communities/submission'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(CreateCommunityParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_collection',
@@ -115,10 +126,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.new_collection',
link: '/collections/submission'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(CreateCollectionParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_item',
@@ -126,10 +139,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.new_item',
link: '/items/submission'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(CreateItemParentSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'new_item_version',
@@ -161,10 +176,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_community',
link: '#'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(EditCommunitySelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_collection',
@@ -172,10 +189,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_collection',
link: '#'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(EditCollectionSelectorComponent);
}
} as OnClickMenuItemModel,
},
{
id: 'edit_item',
@@ -183,10 +202,12 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
link: '#'
} as LinkMenuItemModel,
function: () => {
this.modalService.open(EditItemSelectorComponent);
}
} as OnClickMenuItemModel,
},
/* Import */
@@ -223,7 +244,6 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
link: '#'
} as LinkMenuItemModel,
},
/* Export */
{
id: 'export',

View File

@@ -1,12 +1,14 @@
import { NgModule } from '@angular/core';
import { AdminRegistriesModule } from './admin-registries/admin-registries.module';
import { AdminRoutingModule } from './admin-routing.module';
import { SharedModule } from '../shared/shared.module';
@NgModule({
imports: [
AdminRegistriesModule,
AdminRoutingModule,
]
SharedModule,
],
})
export class AdminModule {

View File

@@ -8,17 +8,36 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component';
import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getCollectionModulePath } from '../app-routing.module';
export const COLLECTION_PARENT_PARAMETER = 'parent';
export function getCollectionPageRoute(collectionId: string) {
return new URLCombiner(getCollectionModulePath(), collectionId).toString();
}
export function getCollectionEditPath(id: string) {
return new URLCombiner(getCollectionModulePath(), COLLECTION_EDIT_PATH.replace(/:id/, id)).toString()
}
export function getCollectionCreatePath() {
return new URLCombiner(getCollectionModulePath(), COLLECTION_CREATE_PATH).toString()
}
const COLLECTION_CREATE_PATH = 'create';
const COLLECTION_EDIT_PATH = ':id/edit';
@NgModule({
imports: [
RouterModule.forChild([
{
path: 'create',
path: COLLECTION_CREATE_PATH,
component: CreateCollectionPageComponent,
canActivate: [AuthenticatedGuard, CreateCollectionPageGuard]
},
{
path: ':id/edit',
path: COLLECTION_EDIT_PATH,
pathMatch: 'full',
component: EditCollectionPageComponent,
canActivate: [AuthenticatedGuard],

View File

@@ -8,17 +8,36 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component';
import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard';
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getCommunityModulePath } from '../app-routing.module';
export const COMMUNITY_PARENT_PARAMETER = 'parent';
export function getCommunityPageRoute(communityId: string) {
return new URLCombiner(getCommunityModulePath(), communityId).toString();
}
export function getCommunityEditPath(id: string) {
return new URLCombiner(getCommunityModulePath(), COMMUNITY_EDIT_PATH.replace(/:id/, id)).toString()
}
export function getCommunityCreatePath() {
return new URLCombiner(getCommunityModulePath(), COMMUNITY_CREATE_PATH).toString()
}
const COMMUNITY_CREATE_PATH = 'create';
const COMMUNITY_EDIT_PATH = ':id/edit';
@NgModule({
imports: [
RouterModule.forChild([
{
path: 'create',
path: COMMUNITY_CREATE_PATH,
component: CreateCommunityPageComponent,
canActivate: [AuthenticatedGuard, CreateCommunityPageGuard]
},
{
path: ':id/edit',
path: COMMUNITY_EDIT_PATH,
pathMatch: 'full',
component: EditCommunityPageComponent,
canActivate: [AuthenticatedGuard],

View File

@@ -11,7 +11,6 @@ import { CommunitySearchResultListElementComponent } from '../shared/object-list
import { ItemSearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component';
import { CommunitySearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component'
import { CollectionSearchResultGridElementComponent } from '../shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component';
import { SearchService } from './search-service/search.service';
import { SearchSidebarComponent } from './search-sidebar/search-sidebar.component';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { SearchSidebarEffects } from './search-sidebar/search-sidebar.effects';
@@ -50,13 +49,9 @@ const effects = [
SearchResultsComponent,
SearchSidebarComponent,
SearchSettingsComponent,
ItemSearchResultListElementComponent,
CollectionSearchResultListElementComponent,
CommunitySearchResultListElementComponent,
ItemSearchResultGridElementComponent,
CollectionSearchResultGridElementComponent,
CommunitySearchResultGridElementComponent,
CommunitySearchResultListElementComponent,
SearchFiltersComponent,
SearchFilterComponent,
SearchFacetFilterComponent,
@@ -69,7 +64,6 @@ const effects = [
SearchBooleanFilterComponent,
],
providers: [
SearchService,
SearchSidebarService,
SearchFilterService,
SearchFixedFilterService,

View File

@@ -23,7 +23,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import {
configureRequest,
configureRequest, filterSuccessfulResponses,
getResponseFromEntry,
getSucceededRemoteData
} from '../../core/shared/operators';
@@ -104,7 +104,7 @@ export class SearchService implements OnDestroy {
// get search results from response cache
const sqrObs: Observable<SearchQueryResponse> = requestEntryObs.pipe(
getResponseFromEntry(),
filterSuccessfulResponses(),
map((response: SearchSuccessResponse) => response.results)
);

View File

@@ -8,13 +8,21 @@ const ITEM_MODULE_PATH = 'items';
export function getItemModulePath() {
return `/${ITEM_MODULE_PATH}`;
}
const COLLECTION_MODULE_PATH = 'collections';
export function getCollectionModulePath() {
return `/${COLLECTION_MODULE_PATH}`;
}
const COMMUNITY_MODULE_PATH = 'communities';
export function getCommunityModulePath() {
return `/${COMMUNITY_MODULE_PATH}`;
}
@NgModule({
imports: [
RouterModule.forRoot([
{ path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule' },
{ path: 'communities', loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
{ path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' },

View File

@@ -5,8 +5,14 @@ import {
race as observableRace
} from 'rxjs';
import { Injectable } from '@angular/core';
import { distinctUntilChanged, flatMap, map, startWith, switchMap } from 'rxjs/operators';
import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { distinctUntilChanged, flatMap, map, startWith, switchMap, tap } from 'rxjs/operators';
import {
hasValue,
hasValueOperator,
isEmpty,
isNotEmpty,
isNotUndefined
} from '../../../shared/empty.util';
import { PaginatedList } from '../../data/paginated-list';
import { RemoteData } from '../../data/remote-data';
import { RemoteDataError } from '../../data/remote-data-error';
@@ -50,13 +56,13 @@ export class RemoteDataBuildService {
const payload$ =
observableCombineLatest(
href$.pipe(
switchMap((href: string) => this.objectCache.getBySelfLink<T>(href)),
switchMap((href: string) => this.objectCache.getObjectBySelfLink<T>(href)),
startWith(undefined)),
requestEntry$.pipe(
getResourceLinksFromResponse(),
switchMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) {
return this.objectCache.getBySelfLink<T>(resourceSelfLinks[0]);
return this.objectCache.getObjectBySelfLink<T>(resourceSelfLinks[0]);
} else {
return observableOf(undefined);
}

View File

@@ -80,7 +80,7 @@ describe('ObjectCacheService', () => {
});
// due to the implementation of spyOn above, this subscribe will be synchronous
service.getBySelfLink(selfLink).pipe(first()).subscribe((o) => {
service.getObjectBySelfLink(selfLink).pipe(first()).subscribe((o) => {
expect(o.self).toBe(selfLink);
// this only works if testObj is an instance of TestClass
expect(o instanceof NormalizedItem).toBeTruthy();
@@ -96,7 +96,7 @@ describe('ObjectCacheService', () => {
});
let getObsHasFired = false;
const subscription = service.getBySelfLink(selfLink).subscribe((o) => getObsHasFired = true);
const subscription = service.getObjectBySelfLink(selfLink).subscribe((o) => getObsHasFired = true);
expect(getObsHasFired).toBe(false);
subscription.unsubscribe();
});
@@ -106,7 +106,7 @@ describe('ObjectCacheService', () => {
it('should return an observable of the array of cached objects with the specified self link and type', () => {
const item = new NormalizedItem();
item.self = selfLink;
spyOn(service, 'getBySelfLink').and.returnValue(observableOf(item));
spyOn(service, 'getObjectBySelfLink').and.returnValue(observableOf(item));
service.getList([selfLink, selfLink]).pipe(first()).subscribe((arr) => {
expect(arr[0].self).toBe(selfLink);

View File

@@ -65,29 +65,29 @@ export class ObjectCacheService {
/**
* Get an observable of the object with the specified UUID
*
* The type needs to be specified as well, in order to turn
* the cached plain javascript object in to an instance of
* a class.
*
* e.g. getByUUID('c96588c6-72d3-425d-9d47-fa896255a695', Item)
*
* @param uuid
* The UUID of the object to get
* @param type
* The type of the object to get
* @return Observable<T>
* An observable of the requested object
* @return Observable<NormalizedObject<T>>
* An observable of the requested object in normalized form
*/
getByUUID<T extends CacheableObject>(uuid: string): Observable<NormalizedObject<T>> {
getObjectByUUID<T extends CacheableObject>(uuid: string): Observable<NormalizedObject<T>> {
return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
mergeMap((selfLink: string) => this.getBySelfLink(selfLink)
mergeMap((selfLink: string) => this.getObjectBySelfLink(selfLink)
)
)
}
getBySelfLink<T extends CacheableObject>(selfLink: string): Observable<NormalizedObject<T>> {
return this.getEntry(selfLink).pipe(
/**
* Get an observable of the object with the specified selfLink
*
* @param selfLink
* The selfLink of the object to get
* @return Observable<NormalizedObject<T>>
* An observable of the requested object in normalized form
*/
getObjectBySelfLink<T extends CacheableObject>(selfLink: string): Observable<NormalizedObject<T>> {
return this.getBySelfLink(selfLink).pipe(
map((entry: ObjectCacheEntry) => {
if (isNotEmpty(entry.patches)) {
const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations));
@@ -105,7 +105,15 @@ export class ObjectCacheService {
);
}
private getEntry(selfLink: string): Observable<ObjectCacheEntry> {
/**
* Get an observable of the object cache entry with the specified selfLink
*
* @param selfLink
* The selfLink of the object to get
* @return Observable<ObjectCacheEntry>
* An observable of the requested object cache entry
*/
getBySelfLink(selfLink: string): Observable<ObjectCacheEntry> {
return this.store.pipe(
select(entryFromSelfLinkSelector(selfLink)),
filter((entry) => this.isValid(entry)),
@@ -113,12 +121,28 @@ export class ObjectCacheService {
);
}
/**
* Get an observable of the request's uuid with the specified selfLink
*
* @param selfLink
* The selfLink of the object to get
* @return Observable<string>
* An observable of the request's uuid
*/
getRequestUUIDBySelfLink(selfLink: string): Observable<string> {
return this.getEntry(selfLink).pipe(
return this.getBySelfLink(selfLink).pipe(
map((entry: ObjectCacheEntry) => entry.requestUUID),
distinctUntilChanged());
}
/**
* Get an observable of the request's uuid with the specified uuid
*
* @param uuid
* The uuid of the object to get
* @return Observable<string>
* An observable of the request's uuid
*/
getRequestUUIDByObjectUUID(uuid: string): Observable<string> {
return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)),
@@ -147,7 +171,7 @@ export class ObjectCacheService {
*/
getList<T extends CacheableObject>(selfLinks: string[]): Observable<Array<NormalizedObject<T>>> {
return observableCombineLatest(
selfLinks.map((selfLink: string) => this.getBySelfLink<T>(selfLink))
selfLinks.map((selfLink: string) => this.getObjectBySelfLink<T>(selfLink))
);
}

View File

@@ -47,7 +47,7 @@ describe('ServerSyncBufferEffects', () => {
{ provide: RequestService, useValue: getMockRequestService() },
{
provide: ObjectCacheService, useValue: {
getBySelfLink: (link) => {
getObjectBySelfLink: (link) => {
const object = new DSpaceObject();
object.self = link;
return observableOf(object);

View File

@@ -95,7 +95,7 @@ export class ServerSyncBufferEffects {
* @returns {Observable<Action>} ApplyPatchObjectCacheAction to be dispatched
*/
private applyPatch(href: string): Observable<Action> {
const patchObject = this.objectCache.getBySelfLink(href).pipe(take(1));
const patchObject = this.objectCache.getObjectBySelfLink(href).pipe(take(1));
return patchObject.pipe(
map((object) => {

View File

@@ -70,6 +70,7 @@ import { NormalizedObjectBuildService } from './cache/builders/normalized-object
import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service';
import { ObjectUpdatesService } from './data/object-updates/object-updates.service';
import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service';
import { SearchService } from '../+search-page/search-service/search.service';
const IMPORTS = [
CommonModule,
@@ -139,6 +140,7 @@ const PROVIDERS = [
CSSVariableService,
MenuService,
ObjectUpdatesService,
SearchService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,

View File

@@ -94,7 +94,7 @@ describe('ComColDataService', () => {
function initMockObjectCacheService(): ObjectCacheService {
return jasmine.createSpyObj('objectCache', {
getByUUID: cold('d-', {
getObjectByUUID: cold('d-', {
d: {
_links: {
[LINK_NAME]: scopedEndpoint
@@ -159,7 +159,7 @@ describe('ComColDataService', () => {
it('should fetch the scope Community from the cache', () => {
scheduler.schedule(() => service.getBrowseEndpoint(options).subscribe());
scheduler.flush();
expect(objectCache.getByUUID).toHaveBeenCalledWith(scopeID);
expect(objectCache.getObjectByUUID).toHaveBeenCalledWith(scopeID);
});
it('should return the endpoint to fetch resources within the given scope', () => {

View File

@@ -49,7 +49,7 @@ export abstract class ComColDataService<T extends CacheableObject> extends DataS
);
const successResponses = responses.pipe(
filter((response) => response.isSuccessful),
mergeMap(() => this.objectCache.getByUUID(options.scopeID)),
mergeMap(() => this.objectCache.getObjectByUUID(options.scopeID)),
map((nc: NormalizedCommunity) => nc._links[linkPath]),
filter((href) => isNotEmpty(href))
);

View File

@@ -67,7 +67,7 @@ describe('DataService', () => {
addPatch: () => {
/* empty */
},
getBySelfLink: () => {
getObjectBySelfLink: () => {
/* empty */
}
} as any;
@@ -189,7 +189,7 @@ describe('DataService', () => {
dso2.metadata = [{ key: 'dc.title', value: name2 }];
spyOn(service, 'findById').and.returnValues(observableOf(dso));
spyOn(objectCache, 'getBySelfLink').and.returnValues(observableOf(dso));
spyOn(objectCache, 'getObjectBySelfLink').and.returnValues(observableOf(dso));
spyOn(objectCache, 'addPatch');
});

View File

@@ -139,7 +139,7 @@ export abstract class DataService<T extends CacheableObject> {
* @param {DSpaceObject} object The given object
*/
update(object: T): Observable<RemoteData<T>> {
const oldVersion$ = this.objectCache.getBySelfLink(object.self);
const oldVersion$ = this.objectCache.getObjectBySelfLink(object.self);
return oldVersion$.pipe(take(1), mergeMap((oldVersion: T) => {
const operations = this.comparator.diff(oldVersion, object);
if (isNotEmpty(operations)) {

View File

@@ -1,5 +1,5 @@
import { PageInfo } from '../shared/page-info.model';
import { hasValue } from '../../shared/empty.util';
import { hasNoValue, hasValue } from '../../shared/empty.util';
export class PaginatedList<T> {
@@ -22,6 +22,9 @@ export class PaginatedList<T> {
if (hasValue(this.pageInfo) && hasValue(this.pageInfo.totalElements)) {
return this.pageInfo.totalElements;
}
if (hasNoValue(this.page)) {
return 0;
}
return this.page.length;
}

View File

@@ -14,6 +14,7 @@ import { BrowseItemsResponseParsingService } from './browse-items-response-parsi
import { RegistryMetadataschemasResponseParsingService } from './registry-metadataschemas-response-parsing.service';
import { MetadataschemaParsingService } from './metadataschema-parsing.service';
import { MetadatafieldParsingService } from './metadatafield-parsing.service';
import { URLCombiner } from '../url-combiner/url-combiner';
/* tslint:disable:max-classes-per-file */
@@ -146,11 +147,11 @@ export class FindAllRequest extends GetRequest {
export class EndpointMapRequest extends GetRequest {
constructor(
public uuid: string,
public href: string,
public body?: any
uuid: string,
href: string,
body?: any
) {
super(uuid, href, body);
super(uuid, new URLCombiner(href, '?endpointMap').toString(), body);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {

View File

@@ -1,5 +1,5 @@
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { of as observableOf, EMPTY } from 'rxjs';
import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service';
import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service';
import { ObjectCacheService } from '../cache/object-cache.service';
@@ -7,6 +7,7 @@ import { CoreState } from '../core.reducers';
import { UUIDService } from '../shared/uuid.service';
import { RequestConfigureAction, RequestExecuteAction } from './request.actions';
import * as ngrx from '@ngrx/store';
import { ActionsSubject, Store } from '@ngrx/store';
import {
DeleteRequest,
GetRequest,
@@ -18,11 +19,8 @@ import {
RestRequest
} from './request.models';
import { RequestService } from './request.service';
import { ActionsSubject, Store } from '@ngrx/store';
import { TestScheduler } from 'rxjs/testing';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { MockStore } from '../../shared/testing/mock-store';
import { IndexState } from '../index/index.reducer';
describe('RequestService', () => {
let scheduler: TestScheduler;
@@ -42,6 +40,7 @@ describe('RequestService', () => {
const testHeadRequest = new HeadRequest(testUUID, testHref);
const testPatchRequest = new PatchRequest(testUUID, testHref);
let selectSpy;
beforeEach(() => {
scheduler = getTestScheduler();
@@ -323,6 +322,7 @@ describe('RequestService', () => {
describe('in the ObjectCache', () => {
beforeEach(() => {
(objectCache.hasBySelfLink as any).and.returnValue(true);
spyOn(serviceAsAny, 'hasByHref').and.returnValue(false);
});
it('should return true', () => {
@@ -332,57 +332,11 @@ describe('RequestService', () => {
expect(result).toEqual(expected);
});
});
describe('in the responseCache', () => {
beforeEach(() => {
spyOn(serviceAsAny, 'isReusable').and.returnValue(observableOf(true));
spyOn(serviceAsAny, 'getByHref').and.returnValue(observableOf(undefined));
});
describe('and it\'s a DSOSuccessResponse', () => {
beforeEach(() => {
(serviceAsAny.getByHref as any).and.returnValue(observableOf({
response: {
isSuccessful: true,
resourceSelfLinks: [
'https://rest.api/endpoint/selfLink1',
'https://rest.api/endpoint/selfLink2'
]
}
}
));
});
it('should return true if all top level links in the response are cached in the object cache', () => {
(objectCache.hasBySelfLink as any).and.returnValues(false, true, true);
const result = serviceAsAny.isCachedOrPending(testGetRequest);
const expected = true;
expect(result).toEqual(expected);
});
it('should return false if not all top level links in the response are cached in the object cache', () => {
(objectCache.hasBySelfLink as any).and.returnValues(false, true, false);
spyOn(service, 'isPending').and.returnValue(false);
const result = serviceAsAny.isCachedOrPending(testGetRequest);
const expected = false;
expect(result).toEqual(expected);
});
});
describe('and it isn\'t a DSOSuccessResponse', () => {
describe('in the request cache', () => {
beforeEach(() => {
(objectCache.hasBySelfLink as any).and.returnValue(false);
(service as any).isReusable.and.returnValue(observableOf(true));
(serviceAsAny.getByHref as any).and.returnValue(observableOf({
response: {
isSuccessful: true
}
}
));
spyOn(serviceAsAny, 'hasByHref').and.returnValue(true);
});
it('should return true', () => {
const result = serviceAsAny.isCachedOrPending(testGetRequest);
const expected = true;
@@ -391,7 +345,6 @@ describe('RequestService', () => {
});
});
});
});
describe('when the request is pending', () => {
beforeEach(() => {
@@ -462,89 +415,77 @@ describe('RequestService', () => {
});
});
describe('isReusable', () => {
describe('when the given UUID is has no value', () => {
let reusable;
describe('isValid', () => {
describe('when the given entry has no value', () => {
let valid;
beforeEach(() => {
const uuid = undefined;
reusable = serviceAsAny.isReusable(uuid);
const entry = undefined;
valid = serviceAsAny.isValid(entry);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
expect(valid).toBe(false);
})
});
describe('when the given UUID has a value, but no cached entry is found', () => {
let reusable;
describe('when the given entry has a value, but the request is not completed', () => {
let valid;
const requestEntry = { completed: false };
beforeEach(() => {
spyOn(service, 'getByUUID').and.returnValue(observableOf(undefined));
const uuid = 'a45bb291-1adb-40d9-b2fc-7ad9080607be';
reusable = serviceAsAny.isReusable(uuid);
spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry));
valid = serviceAsAny.isValid(requestEntry);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
expect(valid).toBe(false);
})
});
describe('when the given UUID has a value, a cached entry is found, but it has no response', () => {
let reusable;
describe('when the given entry has a value, but the response is not successful', () => {
let valid;
const requestEntry = { completed: true, response: { isSuccessful: false } };
beforeEach(() => {
spyOn(service, 'getByUUID').and.returnValue(observableOf({ response: undefined }));
const uuid = '53c9b814-ad8b-4567-9bc1-d9bb6cfba6c8';
reusable = serviceAsAny.isReusable(uuid);
spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry));
valid = serviceAsAny.isValid(requestEntry);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
expect(valid).toBe(false);
})
});
describe('when the given UUID has a value, a cached entry is found, but its response was not successful', () => {
let reusable;
beforeEach(() => {
spyOn(service, 'getByUUID').and.returnValue(observableOf({ response: { isSuccessful: false } }));
const uuid = '694c9b32-7b2e-4788-835b-ef3fc2252e6c';
reusable = serviceAsAny.isReusable(uuid);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
})
});
describe('when the given UUID has a value, a cached entry is found, its response was successful, but the response is outdated', () => {
let reusable;
describe('when the given UUID has a value, its response was successful, but the response is outdated', () => {
let valid;
const now = 100000;
const timeAdded = 99899;
const msToLive = 100;
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
spyOn(service, 'getByUUID').and.returnValue(observableOf({
const requestEntry = {
completed: true,
response: {
isSuccessful: true,
timeAdded: timeAdded
},
request: {
responseMsToLive: msToLive
responseMsToLive: msToLive,
}
}));
const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2';
reusable = serviceAsAny.isReusable(uuid);
};
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry));
valid = serviceAsAny.isValid(requestEntry);
});
it('return an observable emitting false', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(false));
expect(valid).toBe(false);
})
});
describe('when the given UUID has a value, a cached entry is found, its response was successful, and the response is not outdated', () => {
let reusable;
let valid;
const now = 100000;
const timeAdded = 99999;
const msToLive = 100;
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
spyOn(service, 'getByUUID').and.returnValue(observableOf({
const requestEntry = {
completed: true,
response: {
isSuccessful: true,
timeAdded: timeAdded
@@ -552,14 +493,50 @@ describe('RequestService', () => {
request: {
responseMsToLive: msToLive
}
}));
const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2';
reusable = serviceAsAny.isReusable(uuid);
};
beforeEach(() => {
spyOn(Date.prototype, 'getTime').and.returnValue(now);
spyOn(service, 'getByUUID').and.returnValue(observableOf(requestEntry));
valid = serviceAsAny.isValid(requestEntry);
});
it('return an observable emitting true', () => {
reusable.subscribe((isReusable) => expect(isReusable).toBe(true));
})
expect(valid).toBe(true);
})
})
});
describe('hasByHref', () => {
describe('when nothing is returned by getByHref', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(EMPTY);
});
it('hasByHref should return false', () => {
const result = service.hasByHref('');
expect(result).toBe(false);
});
});
describe('when isValid returns false', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(observableOf(undefined));
spyOn(service as any, 'isValid').and.returnValue(false);
});
it('hasByHref should return false', () => {
const result = service.hasByHref('');
expect(result).toBe(false);
});
});
describe('when isValid returns true', () => {
beforeEach(() => {
spyOn(service, 'getByHref').and.returnValue(observableOf(undefined));
spyOn(service as any, 'isValid').and.returnValue(true);
});
it('hasByHref should return true', () => {
const result = service.hasByHref('');
expect(result).toBe(true);
});
});
});
});

View File

@@ -1,36 +1,25 @@
import { merge as observableMerge, Observable, of as observableOf } from 'rxjs';
import {
distinctUntilChanged,
filter,
find,
first,
map,
mergeMap,
reduce,
startWith,
switchMap,
take,
tap
} from 'rxjs/operators';
import { race as observableRace } from 'rxjs';
import { Observable, race as observableRace } from 'rxjs';
import { filter, mergeMap, take } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { hasNoValue, hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { CacheableObject } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
import { coreSelector, CoreState } from '../core.reducers';
import { IndexName, IndexState } from '../index/index.reducer';
import { pathSelector } from '../shared/selectors';
import { UUIDService } from '../shared/uuid.service';
import { RequestConfigureAction, RequestExecuteAction, RequestRemoveAction } from './request.actions';
import { GetRequest, RestRequest } from './request.models';
import {
RequestConfigureAction,
RequestExecuteAction,
RequestRemoveAction
} from './request.actions';
import { EndpointMapRequest, GetRequest, RestRequest } from './request.models';
import { RequestEntry } from './request.reducer';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { RestRequestMethod } from './rest-request-method';
import { getResponseFromEntry } from '../shared/operators';
import { AddToIndexAction, RemoveFromIndexBySubstringAction } from '../index/index.actions';
@Injectable()
@@ -91,6 +80,9 @@ export class RequestService {
return `client/${this.uuidService.generate()}`;
}
/**
* Check if a request is currently pending
*/
isPending(request: GetRequest): boolean {
// first check requests that haven't made it to the store yet
if (this.requestsOnTheirWayToTheStore.includes(request.href)) {
@@ -104,10 +96,12 @@ export class RequestService {
.subscribe((re: RequestEntry) => {
isPending = (hasValue(re) && !re.completed)
});
return isPending;
}
/**
* Retrieve a RequestEntry based on their uuid
*/
getByUUID(uuid: string): Observable<RequestEntry> {
return observableRace(
this.store.pipe(select(this.entryFromUUIDSelector(uuid))),
@@ -120,6 +114,9 @@ export class RequestService {
);
}
/**
* Retrieve a RequestEntry based on their href
*/
getByHref(href: string): Observable<RequestEntry> {
return this.store.pipe(
select(this.uuidFromHrefSelector(href)),
@@ -183,31 +180,11 @@ export class RequestService {
* @param {GetRequest} request The request to check
* @returns {boolean} True if the request is cached or still pending
*/
private isCachedOrPending(request: GetRequest) {
let isCached = this.objectCache.hasBySelfLink(request.href);
if (isCached) {
const responses: Observable<RestResponse> = this.isReusable(request.uuid).pipe(
filter((reusable: boolean) => reusable),
switchMap(() => {
return this.getByHref(request.href).pipe(
getResponseFromEntry(),
take(1)
);
}
));
private isCachedOrPending(request: GetRequest): boolean {
const inReqCache = this.hasByHref(request.href);
const inObjCache = this.objectCache.hasBySelfLink(request.href);
const isCached = inReqCache || inObjCache;
const errorResponses = responses.pipe(filter((response) => !response.isSuccessful), map(() => true)); // TODO add a configurable number of retries in case of an error.
const dsoSuccessResponses = responses.pipe(
filter((response) => response.isSuccessful && hasValue((response as DSOSuccessResponse).resourceSelfLinks)),
map((response: DSOSuccessResponse) => response.resourceSelfLinks),
map((resourceSelfLinks: string[]) => resourceSelfLinks
.every((selfLink) => this.objectCache.hasBySelfLink(selfLink))
));
const otherSuccessResponses = responses.pipe(filter((response) => response.isSuccessful && !hasValue((response as DSOSuccessResponse).resourceSelfLinks)), map(() => true));
observableMerge(errorResponses, otherSuccessResponses, dsoSuccessResponses).subscribe((c) => isCached = c);
}
const isPending = this.isPending(request);
return isCached || isPending;
}
@@ -230,7 +207,7 @@ export class RequestService {
*/
private trackRequestsOnTheirWayToTheStore(request: GetRequest) {
this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, request.href];
this.store.pipe(select(this.entryFromUUIDSelector(request.href)),
this.getByHref(request.href).pipe(
filter((re: RequestEntry) => hasValue(re)),
take(1)
).subscribe((re: RequestEntry) => {
@@ -247,31 +224,39 @@ export class RequestService {
}
/**
* Check whether a Response should still be cached
* Check whether a cached response should still be valid
*
* @param uuid
* the uuid of the entry to check
* @param entry
* the entry to check
* @return boolean
* false if the uuid has no value, no entry could be found, the response was nog successful or its time to
* live has exceeded, true otherwise
* false if the uuid has no value, the response was not successful or its time to
* live was exceeded, true otherwise
*/
private isReusable(uuid: string): Observable<boolean> {
if (hasNoValue(uuid)) {
return observableOf(false);
} else {
const requestEntry$ = this.getByUUID(uuid);
return requestEntry$.pipe(
filter((entry: RequestEntry) => hasValue(entry) && hasValue(entry.response)),
map((entry: RequestEntry) => {
if (hasValue(entry) && entry.response.isSuccessful) {
private isValid(entry: RequestEntry): boolean {
if (hasValue(entry) && entry.completed && entry.response.isSuccessful) {
const timeOutdated = entry.response.timeAdded + entry.request.responseMsToLive;
const isOutDated = new Date().getTime() > timeOutdated;
return !isOutDated;
} else {
return false;
}
})
);
}
/**
* Check whether the request with the specified href is cached
*
* @param href
* The link of the request to check
* @return boolean
* true if the request with the specified href is cached,
* false otherwise
*/
hasByHref(href: string): boolean {
let result = false;
this.getByHref(href).pipe(
take(1)
).subscribe((requestEntry: RequestEntry) => result = this.isValid(requestEntry));
return result;
}
}

View File

@@ -0,0 +1,20 @@
<div class="form-group w-100 pr-2 pl-2">
<input type="search"
class="form-control"
(click)="$event.stopPropagation();"
placeholder="{{'dso-selector.placeholder' | translate: { type: type.toString().toLowerCase() } }}"
[formControl]="input" dsAutoFocus (keyup.enter)="selectSingleResult()">
</div>
<div class="dropdown-divider"></div>
<div class="scrollable-menu list-group">
<button class="list-group-item list-group-item-action border-0 disabled"
*ngIf="(listEntries$ | async)?.payload.page.length == 0">
{{'dso-selector.no-results' | translate: { type: type.toString().toLowerCase() } }}
</button>
<button *ngFor="let listEntry of (listEntries$ | async)?.payload.page"
class="list-group-item list-group-item-action border-0 list-entry"
title="{{ listEntry.dspaceObject.name }}"
(click)="onSelect.emit(listEntry.dspaceObject)" #listEntryElement>
<ds-wrapper-list-element [object]="listEntry"></ds-wrapper-list-element>
</button>
</div>

View File

@@ -0,0 +1,73 @@
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { DSOSelectorComponent } from './dso-selector.component';
import { SearchService } from '../../../+search-page/search-service/search.service';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
import { RemoteData } from '../../../core/data/remote-data';
import { ItemSearchResult } from '../../object-collection/shared/item-search-result.model';
import { Item } from '../../../core/shared/item.model';
import { of as observableOf } from 'rxjs';
import { PaginatedList } from '../../../core/data/paginated-list';
import { MetadataValue } from '../../../core/shared/metadata.models';
describe('DSOSelectorComponent', () => {
let component: DSOSelectorComponent;
let fixture: ComponentFixture<DSOSelectorComponent>;
let debugElement: DebugElement;
const currentDSOId = 'test-uuid-ford-sose';
const type = DSpaceObjectType.ITEM;
const searchResult = new ItemSearchResult();
const item = new Item();
item.metadata = {
'dc.title': [Object.assign(new MetadataValue(), {
value: 'Item title',
language: undefined
})]
};
searchResult.dspaceObject = item;
searchResult.hitHighlights = {};
const searchService = jasmine.createSpyObj('searchService', {
search: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(undefined, [searchResult])))
});
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [DSOSelectorComponent],
providers: [
{ provide: SearchService, useValue: searchService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DSOSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
component.currentDSOId = currentDSOId;
component.type = type;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initially call the search method on the SearchService with the given DSO uuid', () => {
const searchOptions = new PaginatedSearchOptions({
query: currentDSOId,
dsoType: type,
pagination: (component as any).defaultPagination
});
expect(searchService.search).toHaveBeenCalledWith(searchOptions);
});
})
;

View File

@@ -0,0 +1,109 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
QueryList,
ViewChildren
} from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable } from 'rxjs';
import { debounceTime, startWith, switchMap } from 'rxjs/operators';
import { SearchService } from '../../../+search-page/search-service/search.service';
import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { SearchResult } from '../../../+search-page/search-result.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
@Component({
selector: 'ds-dso-selector',
// styleUrls: ['./dso-selector.component.scss'],
templateUrl: './dso-selector.component.html'
})
/**
* Component to render a list of DSO's of which one can be selected
* The user can search the list by using the input field
*/
export class DSOSelectorComponent implements OnInit {
/**
* The initially selected DSO's uuid
*/
@Input() currentDSOId: string;
/**
* The type of DSpace objects this components shows a list of
*/
@Input() type: DSpaceObjectType;
/**
* Emits the selected Object when a user selects it in the list
*/
@Output() onSelect: EventEmitter<DSpaceObject> = new EventEmitter();
/**
* Input form control to query the list
*/
public input: FormControl = new FormControl();
/**
* Default pagination for this feature
*/
private defaultPagination = { id: 'dso-selector', currentPage: 1, pageSize: 5 } as any;
/**
* List with search results of DSpace objects for the current query
*/
listEntries$: Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>;
/**
* List of element references to all elements
*/
@ViewChildren('listEntryElement') listElements: QueryList<ElementRef>;
/**
* Time to wait before sending a search request to the server when a user types something
*/
debounceTime = 500;
constructor(private searchService: SearchService) {
}
/**
* Fills the listEntries$ variable with search results based on the input field's current value
* The search will always start with the initial currentDSOId value
*/
ngOnInit(): void {
this.input.setValue(this.currentDSOId);
this.listEntries$ = this.input.valueChanges
.pipe(
debounceTime(this.debounceTime),
startWith(this.currentDSOId),
switchMap((query) => {
return this.searchService.search(
new PaginatedSearchOptions({
query: query,
dsoType: this.type,
pagination: this.defaultPagination
})
)
}
)
)
}
/**
* Set focus on the first list element when there is only one result
*/
selectSingleResult(): void {
if (this.listElements.length > 0) {
this.listElements.first.nativeElement.click();
}
}
}

View File

@@ -0,0 +1,72 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as collectionRouter from '../../../../+collection-page/collection-page-routing.module';
import { Community } from '../../../../core/shared/community.model';
import { CreateCollectionParentSelectorComponent } from './create-collection-parent-selector.component';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('CreateCollectionParentSelectorComponent', () => {
let component: CreateCollectionParentSelectorComponent;
let fixture: ComponentFixture<CreateCollectionParentSelectorComponent>;
let debugElement: DebugElement;
const community = new Community();
community.uuid = '1234-1234-1234-1234';
community.metadata = {
'dc.title': [
Object.assign(new MetadataValue(), {
value: 'Community title',
language: undefined
})]
};
const router = new RouterStub();
const communityRD = new RemoteData(false, false, true, undefined, community);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const createPath = 'testCreatePath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [CreateCollectionParentSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ community: communityRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(collectionRouter, 'getCollectionCreatePath').and.callFake(() => {
return () => createPath;
});
fixture = TestBed.createComponent(CreateCollectionParentSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(community);
expect(router.navigate).toHaveBeenCalledWith([createPath], { queryParams: { parent: community.uuid } });
});
});

View File

@@ -0,0 +1,46 @@
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
import { Community } from '../../../../core/shared/community.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import {
COLLECTION_PARENT_PARAMETER,
getCollectionCreatePath
} from '../../../../+collection-page/collection-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing communities inside a modal
* Used to choose a community from to create a new collection in
*/
@Component({
selector: 'ds-create-collection-parent-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class CreateCollectionParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COLLECTION;
selectorType = DSpaceObjectType.COMMUNITY;
action = SelectorActionType.CREATE;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the collection create page
*/
navigate(dso: DSpaceObject) {
const navigationExtras: NavigationExtras = {
queryParams: {
[COLLECTION_PARENT_PARAMETER]: dso.uuid,
}
};
this.router.navigate([getCollectionCreatePath()], navigationExtras);
}
}

View File

@@ -0,0 +1,19 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-primary btn-lg btn-block" (click)="selectObject(undefined)">{{'dso-selector.create.community.top-level' | translate}}</button>
<h3 class="position-relative py-1 my-3 font-weight-normal">
<hr>
<div id="create-community-or-separator" class="text-center position-absolute w-100">
<span class="px-4 bg-white">or</span>
</div>
</h3>
<h5 class="px-2">{{'dso-selector.create.community.sub-level' | translate}}</h5>
<ds-dso-selector [currentDSOId]="(dsoRD$ | async)?.payload.uuid" [type]="selectorType" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -0,0 +1,3 @@
#create-community-or-separator {
top: 0;
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as communityRouter from '../../../../+community-page/community-page-routing.module';
import { Community } from '../../../../core/shared/community.model';
import { CreateCommunityParentSelectorComponent } from './create-community-parent-selector.component';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('CreateCommunityParentSelectorComponent', () => {
let component: CreateCommunityParentSelectorComponent;
let fixture: ComponentFixture<CreateCommunityParentSelectorComponent>;
let debugElement: DebugElement;
const community = new Community();
community.uuid = '1234-1234-1234-1234';
community.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Community title', language: undefined })] };
const router = new RouterStub();
const communityRD = new RemoteData(false, false, true, undefined, community);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const createPath = 'testCreatePath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [CreateCommunityParentSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ community: communityRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(communityRouter, 'getCommunityCreatePath').and.callFake(() => {
return () => createPath;
});
fixture = TestBed.createComponent(CreateCommunityParentSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(community);
expect(router.navigate).toHaveBeenCalledWith([createPath], { queryParams: { parent: community.uuid } });
});
});

View File

@@ -0,0 +1,51 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { hasValue } from '../../../empty.util';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import {
COMMUNITY_PARENT_PARAMETER,
getCommunityCreatePath
} from '../../../../+community-page/community-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a button - for top communities -
* and a list of parent communities - for sub communities
* inside a modal
* Used to create a new community
*/
@Component({
selector: 'ds-create-community-parent-selector',
styleUrls: ['./create-community-parent-selector.component.scss'],
templateUrl: './create-community-parent-selector.component.html',
})
export class CreateCommunityParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COMMUNITY;
selectorType = DSpaceObjectType.COMMUNITY;
action = SelectorActionType.CREATE;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the community create page
*/
navigate(dso: DSpaceObject) {
let navigationExtras: NavigationExtras = {};
if (hasValue(dso)) {
navigationExtras = {
queryParams: {
[COMMUNITY_PARENT_PARAMETER]: dso.uuid,
}
};
}
this.router.navigate([getCommunityCreatePath()], navigationExtras);
}
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import { Collection } from '../../../../core/shared/collection.model';
import { CreateItemParentSelectorComponent } from './create-item-parent-selector.component';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('CreateItemParentSelectorComponent', () => {
let component: CreateItemParentSelectorComponent;
let fixture: ComponentFixture<CreateItemParentSelectorComponent>;
let debugElement: DebugElement;
const collection = new Collection();
collection.uuid = '1234-1234-1234-1234';
collection.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Collection title', language: undefined })] };
const router = new RouterStub();
const collectionRD = new RemoteData(false, false, true, undefined, collection);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const createPath = 'testCreatePath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [CreateItemParentSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ collection: collectionRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
// spyOnProperty(itemRouter, 'getItemCreatePath').and.callFake(() => {
// return () => createPath;
// });
fixture = TestBed.createComponent(CreateItemParentSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct create path when navigate is called', () => {
/* TODO when there is a specific submission path */
// component.navigate(item);
// expect(router.navigate).toHaveBeenCalledWith([createPath]);
});
});

View File

@@ -0,0 +1,42 @@
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Community } from '../../../../core/shared/community.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { Collection } from '../../../../core/shared/collection.model';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { hasValue } from '../../../empty.util';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing collections inside a modal
* Used to choose a collection from to create a new item in
*/
@Component({
selector: 'ds-create-item-parent-selector',
// styleUrls: ['./create-item-parent-selector.component.scss'],
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class CreateItemParentSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.ITEM;
selectorType = DSpaceObjectType.COLLECTION;
action = SelectorActionType.CREATE;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the item create page
*/
navigate(dso: DSpaceObject) {
// There's no submit path per collection yet...
}
}

View File

@@ -0,0 +1,10 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<ds-dso-selector [currentDSOId]="(dsoRD$ | async)?.payload.uuid" [type]="selectorType" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -0,0 +1,135 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { Component, DebugElement, NO_ERRORS_SCHEMA, OnInit } from '@angular/core';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model';
import { of as observableOf } from 'rxjs';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from './dso-selector-modal-wrapper.component';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute } from '@angular/router';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { first } from 'rxjs/operators';
import { By } from '@angular/platform-browser';
import { DSOSelectorComponent } from '../dso-selector/dso-selector.component';
import { MockComponent } from 'ng-mocks';
import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models';
describe('DSOSelectorModalWrapperComponent', () => {
let component: DSOSelectorModalWrapperComponent;
let fixture: ComponentFixture<DSOSelectorModalWrapperComponent>;
let debugElement: DebugElement;
const item = new Item();
item.uuid = '1234-1234-1234-1234';
item.metadata = {
'dc.title': [Object.assign(new MetadataValue(), {
value: 'Item title',
language: undefined
})]
};
const itemRD = new RemoteData(false, false, true, undefined, item);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [TestComponent, MockComponent(DSOSelectorComponent)],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ item: itemRD }) } } } }
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
component.ngOnInit();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initially set the DSO to the activated route\'s item/collection/community', () => {
component.dsoRD$
.pipe(first())
.subscribe((a) => {
expect(a).toEqual(itemRD);
})
});
describe('selectObject', () => {
beforeEach(() => {
spyOn(component, 'navigate');
spyOn(component, 'close');
component.selectObject(item)
});
it('should call the close and navigate method on the component with the given DSO', () => {
expect(component.close).toHaveBeenCalled();
expect(component.navigate).toHaveBeenCalledWith(item);
});
});
describe('close', () => {
beforeEach(() => {
component.close();
});
it('should call the close method on the æctive modal', () => {
expect(modalStub.close).toHaveBeenCalled();
});
});
describe('when the onSelect method emits on the child component', () => {
beforeEach(() => {
spyOn(component, 'selectObject');
debugElement.query(By.css('ds-dso-selector')).componentInstance.onSelect.emit(item);
fixture.detectChanges();
});
it('should call the selectObject method on the component with the correct object', () => {
expect(component.selectObject).toHaveBeenCalledWith(item);
});
});
describe('when the click method emits on close button', () => {
beforeEach(() => {
spyOn(component, 'close');
debugElement.query(By.css('button.close')).triggerEventHandler('click', {});
fixture.detectChanges();
});
it('should call the close method on the component', () => {
expect(component.close).toHaveBeenCalled();
});
});
});
@Component({
selector: 'ds-test-cmp',
templateUrl: './dso-selector-modal-wrapper.component.html'
})
class TestComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.ITEM;
selectorType = DSpaceObjectType.ITEM;
action = SelectorActionType.EDIT;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute) {
super(activeModal, route);
}
navigate(dso: DSpaceObject) {
/* comment */
}
}

View File

@@ -0,0 +1,73 @@
import { Component, Injectable, Input, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { RemoteData } from '../../../core/data/remote-data';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { map } from 'rxjs/operators';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
export enum SelectorActionType {
CREATE = 'create',
EDIT = 'edit'
}
/**
* Abstract base class that represents a wrapper for modal content used to select a DSpace Object
*/
@Injectable()
export abstract class DSOSelectorModalWrapperComponent implements OnInit {
/**
* The current page's DSO
*/
@Input() dsoRD$: Observable<RemoteData<DSpaceObject>>;
/**
* The type of the DSO that's being edited or created
*/
objectType: DSpaceObjectType;
/**
* The type of DSO that can be selected from this list
*/
selectorType: DSpaceObjectType;
/**
* The type of action to perform
*/
action: SelectorActionType;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute) {
}
/**
* Get de current page's DSO based on the selectorType
*/
ngOnInit(): void {
const typeString = this.selectorType.toString().toLowerCase();
this.dsoRD$ = this.route.root.firstChild.firstChild.data.pipe(map((data) => data[typeString]));
}
/**
* Method called when an object has been selected
* @param dso The selected DSpaceObject
*/
selectObject(dso: DSpaceObject) {
this.close();
this.navigate(dso);
}
/**
* Navigate to a page based on the DSpaceObject provided
* @param dso The DSpaceObject which can be used to calculate the page to navigate to
*/
abstract navigate(dso: DSpaceObject);
/**
* Close the modal
*/
close() {
this.activeModal.close();
}
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as collectionRouter from '../../../../+collection-page/collection-page-routing.module';
import { EditCollectionSelectorComponent } from './edit-collection-selector.component';
import { Collection } from '../../../../core/shared/collection.model';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('EditCollectionSelectorComponent', () => {
let component: EditCollectionSelectorComponent;
let fixture: ComponentFixture<EditCollectionSelectorComponent>;
let debugElement: DebugElement;
const collection = new Collection();
collection.uuid = '1234-1234-1234-1234';
collection.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Collection title', language: undefined })] };
const router = new RouterStub();
const collectionRD = new RemoteData(false, false, true, undefined, collection);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const editPath = 'testEditPath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [EditCollectionSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ collection: collectionRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(collectionRouter, 'getCollectionEditPath').and.callFake(() => {
return () => editPath;
});
fixture = TestBed.createComponent(EditCollectionSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(collection);
expect(router.navigate).toHaveBeenCalledWith([editPath]);
});
});

View File

@@ -0,0 +1,36 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { getCollectionEditPath } from '../../../../+collection-page/collection-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing collections inside a modal
* Used to choose a collection from to edit
*/
@Component({
selector: 'ds-edit-collection-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class EditCollectionSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COLLECTION;
selectorType = DSpaceObjectType.COLLECTION;
action = SelectorActionType.EDIT;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the collection edit page
*/
navigate(dso: DSpaceObject) {
this.router.navigate([getCollectionEditPath(dso.uuid)]);
}
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as communityRouter from '../../../../+community-page/community-page-routing.module';
import { EditCommunitySelectorComponent } from './edit-community-selector.component';
import { Community } from '../../../../core/shared/community.model';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('EditCommunitySelectorComponent', () => {
let component: EditCommunitySelectorComponent;
let fixture: ComponentFixture<EditCommunitySelectorComponent>;
let debugElement: DebugElement;
const community = new Community();
community.uuid = '1234-1234-1234-1234';
community.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Community title', language: undefined })] };
const router = new RouterStub();
const communityRD = new RemoteData(false, false, true, undefined, community);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const editPath = 'testEditPath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [EditCommunitySelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ community: communityRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(communityRouter, 'getCommunityEditPath').and.callFake(() => {
return () => editPath;
});
fixture = TestBed.createComponent(EditCommunitySelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(community);
expect(router.navigate).toHaveBeenCalledWith([editPath]);
});
});

View File

@@ -0,0 +1,37 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { getCommunityEditPath } from '../../../../+community-page/community-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing communities inside a modal
* Used to choose a community from to edit
*/
@Component({
selector: 'ds-edit-community-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class EditCommunitySelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.COMMUNITY;
selectorType = DSpaceObjectType.COMMUNITY;
action = SelectorActionType.EDIT;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the community edit page
*/
navigate(dso: DSpaceObject) {
this.router.navigate([getCommunityEditPath(dso.uuid)]);
}
}

View File

@@ -0,0 +1,66 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { EditItemSelectorComponent } from './edit-item-selector.component';
import { Item } from '../../../../core/shared/item.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { RouterStub } from '../../../testing/router-stub';
import * as itemRouter from '../../../../+item-page/item-page-routing.module';
import { MetadataValue } from '../../../../core/shared/metadata.models';
describe('EditItemSelectorComponent', () => {
let component: EditItemSelectorComponent;
let fixture: ComponentFixture<EditItemSelectorComponent>;
let debugElement: DebugElement;
const item = new Item();
item.uuid = '1234-1234-1234-1234';
item.metadata = { 'dc.title': [Object.assign(new MetadataValue(), { value: 'Item title', language: undefined })] };
const router = new RouterStub();
const itemRD = new RemoteData(false, false, true, undefined, item);
const modalStub = jasmine.createSpyObj('modalStub', ['close']);
const editPath = 'testEditPath';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [EditItemSelectorComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{
provide: ActivatedRoute,
useValue: { root: { firstChild: { firstChild: { data: observableOf({ item: itemRD }) } } } }
},
{
provide: Router, useValue: router
}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
spyOnProperty(itemRouter, 'getItemEditPath').and.callFake(() => {
return () => editPath;
});
fixture = TestBed.createComponent(EditItemSelectorComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call navigate on the router with the correct edit path when navigate is called', () => {
component.navigate(item);
expect(router.navigate).toHaveBeenCalledWith([editPath]);
});
});

View File

@@ -0,0 +1,42 @@
import { Component, Input, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
import { Community } from '../../../../core/shared/community.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { DSpaceObjectType } from '../../../../core/shared/dspace-object-type.model';
import { DSpaceObject } from '../../../../core/shared/dspace-object.model';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Collection } from '../../../../core/shared/collection.model';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Item } from '../../../../core/shared/item.model';
import { getItemEditPath } from '../../../../+item-page/item-page-routing.module';
import {
DSOSelectorModalWrapperComponent,
SelectorActionType
} from '../dso-selector-modal-wrapper.component';
/**
* Component to wrap a list of existing items inside a modal
* Used to choose an item from to edit
*/
@Component({
selector: 'ds-edit-item-selector',
templateUrl: '../dso-selector-modal-wrapper.component.html',
})
export class EditItemSelectorComponent extends DSOSelectorModalWrapperComponent implements OnInit {
objectType = DSpaceObjectType.ITEM;
selectorType = DSpaceObjectType.ITEM;
action = SelectorActionType.EDIT;
constructor(protected activeModal: NgbActiveModal, protected route: ActivatedRoute, private router: Router) {
super(activeModal, route);
}
/**
* Navigate to the item edit page
*/
navigate(dso: DSpaceObject) {
this.router.navigate([getItemEditPath(dso.uuid)]);
}
}

View File

@@ -12,7 +12,7 @@ export enum MenuID {
* List of possible MenuItemTypes
*/
export enum MenuItemType {
TEXT, LINK, ALTMETRIC, SEARCH
TEXT, LINK, ALTMETRIC, SEARCH, ONCLICK
}
/**

View File

@@ -0,0 +1,11 @@
import { MenuItemModel } from './menu-item.model';
import { MenuItemType } from '../../initial-menus-state';
/**
* Model representing an OnClick Menu Section
*/
export class OnClickMenuItemModel implements MenuItemModel {
type = MenuItemType.ONCLICK;
text: string;
function: () => {};
}

View File

@@ -0,0 +1 @@
<a class="nav-item nav-link" role="button" (click)="item.function()">{{item.text | translate}}</a>

View File

@@ -0,0 +1,3 @@
a {
cursor: pointer;
}

View File

@@ -0,0 +1,52 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TextMenuItemComponent } from './text-menu-item.component';
import { TranslateModule } from '@ngx-translate/core';
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { OnClickMenuItemComponent } from './onclick-menu-item.component';
import { OnClickMenuItemModel } from './models/onclick.model';
describe('OnClickMenuItemComponent', () => {
let component: OnClickMenuItemComponent;
let fixture: ComponentFixture<OnClickMenuItemComponent>;
let debugElement: DebugElement;
const text = 'HELLO';
const func = () => {
/* comment */
};
const item = Object.assign(new OnClickMenuItemModel(), { text, function: func });
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [OnClickMenuItemComponent],
providers: [
{ provide: 'itemModelProvider', useValue: item },
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
beforeEach(() => {
spyOn(item, 'function');
fixture = TestBed.createComponent(OnClickMenuItemComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should contain the correct text', () => {
expect(component).toBeTruthy();
});
it('should contain the text element', () => {
const textContent = debugElement.query(By.css('a')).nativeElement.textContent;
expect(textContent).toEqual(text);
});
it('should contain call the function on the item when clicked', () => {
debugElement.query(By.css('a.nav-link')).triggerEventHandler('click', {});
expect(item.function).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,20 @@
import { Component, Inject } from '@angular/core';
import { MenuItemType } from '../initial-menus-state';
import { rendersMenuItemForType } from '../menu-item.decorator';
import { OnClickMenuItemModel } from './models/onclick.model';
/**
* Component that renders a menu section of type ONCLICK
*/
@Component({
selector: 'ds-onclick-menu-item',
styleUrls: ['./onclick-menu-item.component.scss'],
templateUrl: './onclick-menu-item.component.html'
})
@rendersMenuItemForType(MenuItemType.ONCLICK)
export class OnClickMenuItemComponent {
item: OnClickMenuItemModel;
constructor(@Inject('itemModelProvider') item: OnClickMenuItemModel) {
this.item = item;
}
}

View File

@@ -5,17 +5,20 @@ import { TranslateModule } from '@ngx-translate/core';
import { RouterModule } from '@angular/router';
import { LinkMenuItemComponent } from './menu-item/link-menu-item.component';
import { TextMenuItemComponent } from './menu-item/text-menu-item.component';
import { OnClickMenuItemComponent } from './menu-item/onclick-menu-item.component';
const COMPONENTS = [
MenuSectionComponent,
MenuComponent,
LinkMenuItemComponent,
TextMenuItemComponent
TextMenuItemComponent,
OnClickMenuItemComponent
];
const ENTRY_COMPONENTS = [
LinkMenuItemComponent,
TextMenuItemComponent
TextMenuItemComponent,
OnClickMenuItemComponent
];
const MODULES = [

View File

@@ -111,6 +111,17 @@ import { AutoFocusDirective } from './utils/auto-focus.directive';
import { ComcolPageBrowseByComponent } from './comcol-page-browse-by/comcol-page-browse-by.component';
import { StartsWithDateComponent } from './starts-with/date/starts-with-date.component';
import { StartsWithTextComponent } from './starts-with/text/starts-with-text.component';
import { DSOSelectorComponent } from './dso-selector/dso-selector/dso-selector.component';
import { CreateCommunityParentSelectorComponent } from './dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component';
import { CreateItemParentSelectorComponent } from './dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component';
import { CreateCollectionParentSelectorComponent } from './dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component';
import { CommunitySearchResultListElementComponent } from './object-list/search-result-list-element/community-search-result/community-search-result-list-element.component';
import { CollectionSearchResultListElementComponent } from './object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component';
import { ItemSearchResultListElementComponent } from './object-list/search-result-list-element/item-search-result/item-search-result-list-element.component';
import { EditItemSelectorComponent } from './dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component';
import { EditCommunitySelectorComponent } from './dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component';
import { EditCollectionSelectorComponent } from './dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component';
import { DSOSelectorModalWrapperComponent } from './dso-selector/modal-wrappers/dso-selector-modal-wrapper.component';
const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -197,6 +208,16 @@ const COMPONENTS = [
TruncatablePartComponent,
BrowseByComponent,
InputSuggestionsComponent,
DSOSelectorComponent,
CreateCommunityParentSelectorComponent,
CreateCollectionParentSelectorComponent,
CreateItemParentSelectorComponent,
EditCommunitySelectorComponent,
EditCollectionSelectorComponent,
EditItemSelectorComponent,
CommunitySearchResultListElementComponent,
CollectionSearchResultListElementComponent,
ItemSearchResultListElementComponent,
TypedItemSearchResultListElementComponent,
ItemTypeSwitcherComponent,
BrowseByComponent
@@ -208,6 +229,9 @@ const ENTRY_COMPONENTS = [
CollectionListElementComponent,
CommunityListElementComponent,
SearchResultListElementComponent,
CommunitySearchResultListElementComponent,
CollectionSearchResultListElementComponent,
ItemSearchResultListElementComponent,
ItemGridElementComponent,
CollectionGridElementComponent,
CommunityGridElementComponent,
@@ -223,6 +247,14 @@ const ENTRY_COMPONENTS = [
BrowseEntryListElementComponent,
StartsWithDateComponent,
StartsWithTextComponent,
DSOSelectorComponent,
CreateCommunityParentSelectorComponent,
CreateCollectionParentSelectorComponent,
CreateItemParentSelectorComponent,
EditCommunitySelectorComponent,
EditCollectionSelectorComponent,
EditItemSelectorComponent,
StartsWithTextComponent,
PlainTextMetadataListElementComponent,
ItemMetadataListElementComponent,
MetadataRepresentationListElementComponent

View File

@@ -40,4 +40,8 @@ export class SearchServiceStub {
getFilterLabels() {
return observableOf([]);
}
search() {
return observableOf({});
}
}

View File

@@ -2,6 +2,8 @@ const webpack = require('webpack');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const CompressionPlugin = require("compression-webpack-plugin");
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const cssnano = require("cssnano");
const {
root
@@ -18,12 +20,6 @@ module.exports = {
}
}),
// Loader options
new webpack.LoaderOptionsPlugin({
minimize: true,
debug: false
}),
new BundleAnalyzerPlugin({
analyzerMode: 'disabled', // change it to `server` to view bundle stats
reportFilename: 'report.html',
@@ -64,6 +60,15 @@ module.exports = {
},
sourceMap: true
}
}),
new OptimizeCSSAssetsPlugin({
cssProcessor: cssnano,
cssProcessorOptions: {
discardComments: {
removeAll: true,
}
},
safe: true
})
]
},

View File

@@ -241,18 +241,6 @@ module.exports = function (options) {
'HMR': false,
}
}),
/**
* Plugin LoaderOptionsPlugin (experimental)
*
* See: https://gist.github.com/sokra/27b24881210b56bbaff7
*/
new LoaderOptionsPlugin({
debug: false,
options: {
}
}),
new ForkTsCheckerWebpackPlugin()
],

768
yarn.lock

File diff suppressed because it is too large Load Diff