Merge pull request #335 from atmire/Move-item-component

Move item component
This commit is contained in:
Tim Donohue
2019-09-13 18:14:17 +02:00
committed by GitHub
26 changed files with 747 additions and 48 deletions

View File

@@ -238,6 +238,17 @@
"item.edit.modify.overview.field": "Field", "item.edit.modify.overview.field": "Field",
"item.edit.modify.overview.language": "Language", "item.edit.modify.overview.language": "Language",
"item.edit.modify.overview.value": "Value", "item.edit.modify.overview.value": "Value",
"item.edit.move.cancel": "Cancel",
"item.edit.move.description": "Select the collection you wish to move this item to. To narrow down the list of displayed collections, you can enter a search query in the box.",
"item.edit.move.error": "An error occured when attempting to move the item",
"item.edit.move.head": "Move item: {{id}}",
"item.edit.move.inheritpolicies.checkbox": "Inherit policies",
"item.edit.move.inheritpolicies.description": "Inherit the default policies of the destination collection",
"item.edit.move.move": "Move",
"item.edit.move.processing": "Moving...",
"item.edit.move.search.placeholder": "Enter a search query to look for collections",
"item.edit.move.success": "The item has been moved succesfully",
"item.edit.move.title": "Move item",
"item.edit.private.cancel": "Cancel", "item.edit.private.cancel": "Cancel",
"item.edit.private.confirm": "Make it Private", "item.edit.private.confirm": "Make it Private",
"item.edit.private.description": "Are you sure this item should be made private in the archive?", "item.edit.private.description": "Are you sure this item should be made private in the archive?",

View File

@@ -18,6 +18,7 @@ import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.compo
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component'; import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component';
import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component';
import { ItemMoveComponent } from './item-move/item-move.component';
/** /**
* Module that contains all components related to the Edit Item page administrator functionality * Module that contains all components related to the Edit Item page administrator functionality
@@ -44,7 +45,8 @@ import { EditRelationshipListComponent } from './item-relationships/edit-relatio
ItemBitstreamsComponent, ItemBitstreamsComponent,
EditInPlaceFieldComponent, EditInPlaceFieldComponent,
EditRelationshipComponent, EditRelationshipComponent,
EditRelationshipListComponent EditRelationshipListComponent,
ItemMoveComponent,
] ]
}) })
export class EditItemPageModule { export class EditItemPageModule {

View File

@@ -10,6 +10,7 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component';
import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemStatusComponent } from './item-status/item-status.component';
import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
@@ -17,6 +18,7 @@ const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
const ITEM_EDIT_PRIVATE_PATH = 'private'; const ITEM_EDIT_PRIVATE_PATH = 'private';
const ITEM_EDIT_PUBLIC_PATH = 'public'; const ITEM_EDIT_PUBLIC_PATH = 'public';
const ITEM_EDIT_DELETE_PATH = 'delete'; const ITEM_EDIT_DELETE_PATH = 'delete';
const ITEM_EDIT_MOVE_PATH = 'move';
/** /**
* Routing module that handles the routing for the Edit Item page administrator functionality * Routing module that handles the routing for the Edit Item page administrator functionality
@@ -104,6 +106,14 @@ const ITEM_EDIT_DELETE_PATH = 'delete';
resolve: { resolve: {
item: ItemPageResolver item: ItemPageResolver
} }
},
{
path: ITEM_EDIT_MOVE_PATH,
component: ItemMoveComponent,
data: { title: 'item.edit.move.title' },
resolve: {
item: ItemPageResolver
}
}]) }])
], ],
providers: [ providers: [

View File

@@ -4,7 +4,7 @@
<span>{{metadata?.key?.split('.').join('.&#8203;')}}</span> <span>{{metadata?.key?.split('.').join('.&#8203;')}}</span>
</div> </div>
<div *ngIf="(editable | async)" class="field-container"> <div *ngIf="(editable | async)" class="field-container">
<ds-input-suggestions [suggestions]="(metadataFieldSuggestions | async)" <ds-filter-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key" [(ngModel)]="metadata.key"
(submitSuggestion)="update(suggestionControl)" (submitSuggestion)="update(suggestionControl)"
(clickSuggestion)="update(suggestionControl)" (clickSuggestion)="update(suggestionControl)"
@@ -16,7 +16,7 @@
[valid]="(valid | async) !== false" [valid]="(valid | async) !== false"
dsAutoFocus autoFocusSelector=".suggestion_input" dsAutoFocus autoFocusSelector=".suggestion_input"
[ngModelOptions]="{standalone: true}" [ngModelOptions]="{standalone: true}"
></ds-input-suggestions> ></ds-filter-input-suggestions>
</div> </div>
<small class="text-danger" <small class="text-danger"
*ngIf="(valid | async) === false">{{"item.edit.metadata.metadatafield.invalid" | translate}}</small> *ngIf="(valid | async) === false">{{"item.edit.metadata.metadatafield.invalid" | translate}}</small>

View File

@@ -10,7 +10,6 @@ import { By } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { SharedModule } from '../../../../shared/shared.module'; import { SharedModule } from '../../../../shared/shared.module';
import { getTestScheduler } from 'jasmine-marbles'; import { getTestScheduler } from 'jasmine-marbles';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@@ -18,6 +17,7 @@ import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'; import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model';
import { MetadataField } from '../../../../core/metadata/metadata-field.model'; import { MetadataField } from '../../../../core/metadata/metadata-field.model';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
let comp: EditInPlaceFieldComponent; let comp: EditInPlaceFieldComponent;
let fixture: ComponentFixture<EditInPlaceFieldComponent>; let fixture: ComponentFixture<EditInPlaceFieldComponent>;

View File

@@ -4,13 +4,13 @@ import { RegistryService } from '../../../../core/registry/registry.service';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { map, take } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { NgModel } from '@angular/forms'; import { NgModel } from '@angular/forms';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { MetadataField } from '../../../../core/metadata/metadata-field.model'; import { MetadataField } from '../../../../core/metadata/metadata-field.model';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
@Component({ @Component({
// tslint:disable-next-line:component-selector // tslint:disable-next-line:component-selector

View File

@@ -0,0 +1,48 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2>{{'item.edit.move.head' | translate: {id: (itemRD$ | async)?.payload?.handle} }}</h2>
<p>{{'item.edit.move.description' | translate}}</p>
<div class="row">
<div class="col-12">
<ds-dso-input-suggestions #f id="search-form"
[suggestions]="(collectionSearchResults | async)"
[placeholder]="'item.edit.move.search.placeholder'| translate"
[action]="getCurrentUrl()"
[name]="'item-move'"
[(ngModel)]="selectedCollectionName"
(clickSuggestion)="onClick($event)"
(typeSuggestion)="resetCollection($event)"
(findSuggestions)="findSuggestions($event)"
(click)="f.open()"
ngDefaultControl>
</ds-dso-input-suggestions>
</div>
</div>
<div class="row">
<div class="col-12">
<p>
<input type="checkbox" name="tc" [(ngModel)]="inheritPolicies" id="inheritPoliciesCheckbox">
<label for="inheritPoliciesCheckbox">{{'item.edit.move.inheritpolicies.checkbox' |
translate}}</label>
</p>
<p>
{{'item.edit.move.inheritpolicies.description' | translate}}
</p>
</div>
</div>
<button (click)="moveCollection()" class="btn btn-primary" [disabled]=!canSubmit>
<span *ngIf="!processing"> {{'item.edit.move.move' | translate}}</span>
<span *ngIf="processing"><i class='fas fa-circle-notch fa-spin'></i>
{{'item.edit.move.processing' | translate}}
</span>
</button>
<button [routerLink]="['/items/', (itemRD$ | async)?.payload?.id, 'edit']"
class="btn btn-outline-secondary">
{{'item.edit.move.cancel' | translate}}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,172 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Item } from '../../../core/shared/item.model';
import { RouterStub } from '../../../shared/testing/router-stub';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { ItemMoveComponent } from './item-move.component';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { SearchService } from '../../../+search-page/search-service/search.service';
import { of as observableOf } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { ItemDataService } from '../../../core/data/item-data.service';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { RestResponse } from '../../../core/cache/response.models';
import { Collection } from '../../../core/shared/collection.model';
describe('ItemMoveComponent', () => {
let comp: ItemMoveComponent;
let fixture: ComponentFixture<ItemMoveComponent>;
const mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
lastModified: '2018'
});
const itemPageUrl = `fake-url/${mockItem.id}`;
const routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}/edit`
});
const mockItemDataService = jasmine.createSpyObj({
moveToCollection: observableOf(new RestResponse(true, 200, 'Success'))
});
const mockItemDataServiceFail = jasmine.createSpyObj({
moveToCollection: observableOf(new RestResponse(false, 500, 'Internal server error'))
});
const routeStub = {
data: observableOf({
item: new RemoteData(false, false, true, null, {
id: 'item1'
})
})
};
const collection1 = Object.assign(new Collection(),{
uuid: 'collection-uuid-1',
name: 'Test collection 1',
self: 'self-link-1',
});
const collection2 = Object.assign(new Collection(),{
uuid: 'collection-uuid-2',
name: 'Test collection 2',
self: 'self-link-2',
});
const mockSearchService = {
search: () => {
return observableOf(new RemoteData(false, false, true, null,
new PaginatedList(null, [
{
indexableObject: collection1,
hitHighlights: {}
}, {
indexableObject: collection2,
hitHighlights: {}
}
])));
}
};
const notificationsServiceStub = new NotificationsServiceStub();
describe('ItemMoveComponent success', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [ItemMoveComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: routerStub},
{provide: ItemDataService, useValue: mockItemDataService},
{provide: NotificationsService, useValue: notificationsServiceStub},
{provide: SearchService, useValue: mockSearchService},
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemMoveComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should load suggestions', () => {
const expected = [
collection1,
collection2
];
comp.collectionSearchResults.subscribe((value) => {
expect(value).toEqual(expected);
}
);
});
it('should get current url ', () => {
expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit');
});
it('should on click select the correct collection name and id', () => {
const data = collection1;
comp.onClick(data);
expect(comp.selectedCollectionName).toEqual('Test collection 1');
expect(comp.selectedCollection).toEqual(collection1);
});
describe('moveCollection', () => {
it('should call itemDataService.moveToCollection', () => {
comp.itemId = 'item-id';
comp.selectedCollectionName = 'selected-collection-id';
comp.selectedCollection = collection1;
comp.moveCollection();
expect(mockItemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1);
});
it('should call notificationsService success message on success', () => {
comp.moveCollection();
expect(notificationsServiceStub.success).toHaveBeenCalled();
});
});
});
describe('ItemMoveComponent fail', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [ItemMoveComponent],
providers: [
{provide: ActivatedRoute, useValue: routeStub},
{provide: Router, useValue: routerStub},
{provide: ItemDataService, useValue: mockItemDataServiceFail},
{provide: NotificationsService, useValue: notificationsServiceStub},
{provide: SearchService, useValue: mockSearchService},
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemMoveComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should call notificationsService error message on fail', () => {
comp.moveCollection();
expect(notificationsServiceStub.error).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,139 @@
import { Component, OnInit } from '@angular/core';
import { SearchService } from '../../../+search-page/search-service/search.service';
import { first, map } from 'rxjs/operators';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { SearchOptions } from '../../../+search-page/search-options.model';
import { RemoteData } from '../../../core/data/remote-data';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { PaginatedList } from '../../../core/data/paginated-list';
import { SearchResult } from '../../../+search-page/search-result.model';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { ItemDataService } from '../../../core/data/item-data.service';
import { getItemEditPath } from '../../item-page-routing.module';
import { Observable, of as observableOf } from 'rxjs';
import { RestResponse } from '../../../core/cache/response.models';
import { Collection } from '../../../core/shared/collection.model';
import { tap } from 'rxjs/internal/operators/tap';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model';
@Component({
selector: 'ds-item-move',
templateUrl: './item-move.component.html'
})
/**
* Component that handles the moving of an item to a different collection
*/
export class ItemMoveComponent implements OnInit {
/**
* TODO: There is currently no backend support to change the owningCollection and inherit policies,
* TODO: when this is added, the inherit policies option should be used.
*/
selectorType = DSpaceObjectType.COLLECTION;
inheritPolicies = false;
itemRD$: Observable<RemoteData<Item>>;
collectionSearchResults: Observable<any[]> = observableOf([]);
selectedCollectionName: string;
selectedCollection: Collection;
canSubmit = false;
itemId: string;
processing = false;
pagination = new PaginationComponentOptions();
constructor(private route: ActivatedRoute,
private router: Router,
private notificationsService: NotificationsService,
private itemDataService: ItemDataService,
private searchService: SearchService,
private translateService: TranslateService) {
}
ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(map((data) => data.item), getSucceededRemoteData()) as Observable<RemoteData<Item>>;
this.itemRD$.subscribe((rd) => {
this.itemId = rd.payload.id;
}
);
this.pagination.pageSize = 5;
this.loadSuggestions('');
}
/**
* Find suggestions based on entered query
* @param query - Search query
*/
findSuggestions(query): void {
this.loadSuggestions(query);
}
/**
* Load all available collections to move the item to.
* TODO: When the API support it, only fetch collections where user has ADD rights to.
*/
loadSuggestions(query): void {
this.collectionSearchResults = this.searchService.search(new PaginatedSearchOptions({
pagination: this.pagination,
dsoType: DSpaceObjectType.COLLECTION,
query: query
})).pipe(
first(),
map((rd: RemoteData<PaginatedList<SearchResult<DSpaceObject>>>) => {
return rd.payload.page.map((searchResult) => {
return searchResult.indexableObject
})
}) ,
);
}
/**
* Set the collection name and id based on the selected value
* @param data - obtained from the ds-input-suggestions component
*/
onClick(data: any): void {
this.selectedCollection = data;
this.selectedCollectionName = data.name;
this.canSubmit = true;
}
/**
* @returns {string} the current URL
*/
getCurrentUrl() {
return this.router.url;
}
/**
* Moves the item to a new collection based on the selected collection
*/
moveCollection() {
this.processing = true;
this.itemDataService.moveToCollection(this.itemId, this.selectedCollection).pipe(first()).subscribe(
(response: RestResponse) => {
this.router.navigate([getItemEditPath(this.itemId)]);
if (response.isSuccessful) {
this.notificationsService.success(this.translateService.get('item.edit.move.success'));
} else {
this.notificationsService.error(this.translateService.get('item.edit.move.error'));
}
this.processing = false;
}
);
}
/**
* Resets the can submit when the user changes the content of the input field
* @param data
*/
resetCollection(data: any) {
this.canSubmit = false;
}
}

View File

@@ -4,7 +4,7 @@
</span> </span>
</div> </div>
<div *ngIf="!operation.disabled" class="col-9 float-left action-button"> <div *ngIf="!operation.disabled" class="col-9 float-left action-button">
<a class="btn btn-outline-secondary" href="{{operation.operationUrl}}"> <a class="btn btn-outline-secondary" [routerLink]="operation.operationUrl">
{{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}} {{'item.edit.tabs.status.buttons.' + operation.operationKey + '.button' | translate}}
</a> </a>
</div> </div>

View File

@@ -3,6 +3,7 @@ import {async, TestBed} from '@angular/core/testing';
import { ItemOperationComponent } from './item-operation.component'; import { ItemOperationComponent } from './item-operation.component';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
describe('ItemOperationComponent', () => { describe('ItemOperationComponent', () => {
let itemOperation: ItemOperation; let itemOperation: ItemOperation;
@@ -12,7 +13,7 @@ describe('ItemOperationComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()], imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
declarations: [ItemOperationComponent] declarations: [ItemOperationComponent]
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -79,6 +79,7 @@ export class ItemStatusComponent implements OnInit {
this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
} }
this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
this.operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
}); });
} }

View File

@@ -15,7 +15,7 @@
| translate}}</a> | translate}}</a>
</div> </div>
</div> </div>
<ds-input-suggestions [suggestions]="(filterSearchResults | async)" <ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
[action]="currentUrl" [action]="currentUrl"
[name]="filterConfig.paramName" [name]="filterConfig.paramName"
@@ -23,5 +23,5 @@
(submitSuggestion)="onSubmit($event)" (submitSuggestion)="onSubmit($event)"
(clickSuggestion)="onSubmit($event)" (clickSuggestion)="onSubmit($event)"
(findSuggestions)="findSuggestions($event)" (findSuggestions)="findSuggestions($event)"
ngDefaultControl></ds-input-suggestions> ngDefaultControl></ds-filter-input-suggestions>
</div> </div>

View File

@@ -21,9 +21,9 @@ import { SearchService } from '../../../search-service/search.service';
import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
import { getSucceededRemoteData } from '../../../../core/shared/operators'; import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { SearchOptions } from '../../../search-options.model'; import { SearchOptions } from '../../../search-options.model';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
@Component({ @Component({
selector: 'ds-search-facet-filter', selector: 'ds-search-facet-filter',

View File

@@ -15,7 +15,7 @@
| translate}}</a> | translate}}</a>
</div> </div>
</div> </div>
<ds-input-suggestions [suggestions]="(filterSearchResults | async)" <ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
[action]="currentUrl" [action]="currentUrl"
[name]="filterConfig.paramName" [name]="filterConfig.paramName"
@@ -24,5 +24,5 @@
(clickSuggestion)="onClick($event)" (clickSuggestion)="onClick($event)"
(findSuggestions)="findSuggestions($event)" (findSuggestions)="findSuggestions($event)"
ngDefaultControl ngDefaultControl
></ds-input-suggestions> ></ds-filter-input-suggestions>
</div> </div>

View File

@@ -15,7 +15,7 @@
| translate}}</a> | translate}}</a>
</div> </div>
</div> </div>
<ds-input-suggestions [suggestions]="(filterSearchResults | async)" <ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
[action]="currentUrl" [action]="currentUrl"
[name]="filterConfig.paramName" [name]="filterConfig.paramName"
@@ -23,5 +23,5 @@
(submitSuggestion)="onSubmit($event)" (submitSuggestion)="onSubmit($event)"
(clickSuggestion)="onClick($event)" (clickSuggestion)="onClick($event)"
(findSuggestions)="findSuggestions($event)" (findSuggestions)="findSuggestions($event)"
ngDefaultControl></ds-input-suggestions> ngDefaultControl></ds-filter-input-suggestions>
</div> </div>

View File

@@ -1,8 +1,8 @@
import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { distinctUntilChanged, filter, find, map } from 'rxjs/operators';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { isNotEmpty } from '../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { BrowseService } from '../browse/browse.service'; import { BrowseService } from '../browse/browse.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { CoreState } from '../core.reducers'; import { CoreState } from '../core.reducers';
@@ -12,14 +12,17 @@ import { URLCombiner } from '../url-combiner/url-combiner';
import { DataService } from './data.service'; import { DataService } from './data.service';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { FindAllOptions, PatchRequest, RestRequest } from './request.models'; import { FindAllOptions, PatchRequest, PutRequest, RestRequest } from './request.models';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpHeaders } from '@angular/common/http';
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
import { configureRequest, getRequestFromRequestHref } from '../shared/operators'; import { configureRequest, getRequestFromRequestHref } from '../shared/operators';
import { RequestEntry } from './request.reducer'; import { RequestEntry } from './request.reducer';
import { RestResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { Collection } from '../shared/collection.model';
@Injectable() @Injectable()
export class ItemDataService extends DataService<Item> { export class ItemDataService extends DataService<Item> {
@@ -118,4 +121,43 @@ export class ItemDataService extends DataService<Item> {
map((requestEntry: RequestEntry) => requestEntry.response) map((requestEntry: RequestEntry) => requestEntry.response)
); );
} }
/**
* Get the endpoint to move the item
* @param itemId
*/
public getMoveItemEndpoint(itemId: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getIDHref(endpoint, itemId)),
map((endpoint: string) => `${endpoint}/owningCollection`)
);
}
/**
* Move the item to a different owning collection
* @param itemId
* @param collection
*/
public moveToCollection(itemId: string, collection: Collection): Observable<RestResponse> {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list');
options.headers = headers;
const requestId = this.requestService.generateRequestId();
const hrefObs = this.getMoveItemEndpoint(itemId);
hrefObs.pipe(
find((href: string) => hasValue(href)),
map((href: string) => {
const request = new PutRequest(requestId, href, collection.self, options);
this.requestService.configure(request);
})
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response)
);
}
} }

View File

@@ -0,0 +1,25 @@
<form #form="ngForm" (ngSubmit)="onSubmit(currentObject)"
[action]="action" (keydown)="onKeydown($event)"
(keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close();">
<input #inputField type="text" [(ngModel)]="value" [name]="name"
class="form-control suggestion_input mb-2"
[ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)"
[placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"/>
<input type="submit" class="d-none"/>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<div class="dropdown-list">
<div *ngFor="let suggestionOption of suggestions">
<button class="d-block dropdown-item" (click)="onClickSuggestion(suggestionOption)" #suggestion>
<div class="click-blocker">
</div>
<ds-wrapper-list-element [object]="suggestionOption"></ds-wrapper-list-element>
</button>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,71 @@
import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
import { DsoInputSuggestionsComponent } from './dso-input-suggestions.component';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
describe('DsoInputSuggestionsComponent', () => {
let comp: DsoInputSuggestionsComponent;
let fixture: ComponentFixture<DsoInputSuggestionsComponent>;
let de: DebugElement;
let el: HTMLElement;
const dso1 = {
uuid: 'test-uuid-1',
name: 'test-name-1'
} as DSpaceObject;
const dso2 = {
uuid: 'test-uuid-2',
name: 'test-name-2'
} as DSpaceObject;
const dso3 = {
uuid: 'test-uuid-3',
name: 'test-name-3'
} as DSpaceObject;
const suggestions = [dso1, dso2, dso3];
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule],
declarations: [DsoInputSuggestionsComponent],
providers: [],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(DsoInputSuggestionsComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DsoInputSuggestionsComponent);
comp = fixture.componentInstance; // LoadingComponent test instance
comp.suggestions = suggestions;
// query for the message <label> by CSS element selector
de = fixture.debugElement;
el = de.nativeElement;
comp.show.next(true);
fixture.detectChanges();
});
describe('when an element is clicked', () => {
const clickedIndex = 0;
beforeEach(() => {
spyOn(comp, 'onClickSuggestion');
const clickedLink = de.query(By.css('.dropdown-list > div:nth-child(' + (clickedIndex + 1) + ') button'));
clickedLink.triggerEventHandler('click', {});
fixture.detectChanges();
});
it('should call onClickSuggestion() with the suggestion as a parameter', () => {
expect(comp.onClickSuggestion).toHaveBeenCalledWith(suggestions[clickedIndex]);
});
});
});

View File

@@ -0,0 +1,47 @@
import { Component, forwardRef, Input } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { InputSuggestionsComponent } from '../input-suggestions.component';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
@Component({
selector: 'ds-dso-input-suggestions',
styleUrls: ['./../input-suggestions.component.scss'],
templateUrl: './dso-input-suggestions.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
// Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151
// tslint:disable-next-line:no-forward-ref
useExisting: forwardRef(() => DsoInputSuggestionsComponent),
multi: true
}
]
})
/**
* Component representing a form with a autocomplete functionality for DSpaceObjects
*/
export class DsoInputSuggestionsComponent extends InputSuggestionsComponent {
/**
* The suggestions that should be shown
*/
@Input() suggestions: DSpaceObject[] = [];
currentObject: DSpaceObject;
onSubmit(data: DSpaceObject) {
this.value = data.name;
this.currentObject = data;
this.submitSuggestion.emit(data);
}
onClickSuggestion(data: DSpaceObject) {
this.value = data.name;
this.currentObject = data;
this.clickSuggestion.emit(data);
this.close();
this.blockReopen = true;
this.queryInput.nativeElement.focus();
return false;
}
}

View File

@@ -0,0 +1,22 @@
<form #form="ngForm" (ngSubmit)="onSubmit(value)"
[action]="action" (keydown)="onKeydown($event)"
(keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close();">
<input #inputField type="text" [(ngModel)]="value" [name]="name"
class="form-control suggestion_input"
[ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)"
[placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"/>
<input type="submit" class="d-none"/>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<div class="dropdown-list">
<div *ngFor="let suggestionOption of suggestions">
<a href="#" class="d-block dropdown-item" (click)="onClickSuggestion(suggestionOption.value)" #suggestion>
<span [innerHTML]="suggestionOption.displayValue"></span>
</a>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,57 @@
import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
import { FilterInputSuggestionsComponent } from './filter-input-suggestions.component';
describe('FilterInputSuggestionsComponent', () => {
let comp: FilterInputSuggestionsComponent;
let fixture: ComponentFixture<FilterInputSuggestionsComponent>;
let de: DebugElement;
let el: HTMLElement;
const suggestions = [{displayValue: 'suggestion uno', value: 'suggestion uno'}, {
displayValue: 'suggestion dos',
value: 'suggestion dos'
}, {displayValue: 'suggestion tres', value: 'suggestion tres'}];
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule],
declarations: [FilterInputSuggestionsComponent],
providers: [],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(FilterInputSuggestionsComponent, {
set: {changeDetection: ChangeDetectionStrategy.Default}
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(FilterInputSuggestionsComponent);
comp = fixture.componentInstance; // LoadingComponent test instance
comp.suggestions = suggestions;
// query for the message <label> by CSS element selector
de = fixture.debugElement;
el = de.nativeElement;
comp.show.next(true);
fixture.detectChanges();
});
describe('when an element is clicked', () => {
const clickedIndex = 0;
beforeEach(() => {
spyOn(comp, 'onClickSuggestion');
const clickedLink = de.query(By.css('.dropdown-list > div:nth-child(' + (clickedIndex + 1) + ') a'));
clickedLink.triggerEventHandler('click', {});
fixture.detectChanges();
});
it('should call onClickSuggestion() with the suggestion as a parameter', () => {
expect(comp.onClickSuggestion).toHaveBeenCalledWith(suggestions[clickedIndex].value);
});
});
});

View File

@@ -0,0 +1,44 @@
import { Component, forwardRef, Input } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { InputSuggestionsComponent } from '../input-suggestions.component';
import { InputSuggestion } from '../input-suggestions.model';
@Component({
selector: 'ds-filter-input-suggestions',
styleUrls: ['./../input-suggestions.component.scss'],
templateUrl: './filter-input-suggestions.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
// Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151
// tslint:disable-next-line:no-forward-ref
useExisting: forwardRef(() => FilterInputSuggestionsComponent),
multi: true
}
]
})
/**
* Component representing a form with a autocomplete functionality
*/
export class FilterInputSuggestionsComponent extends InputSuggestionsComponent {
/**
* The suggestions that should be shown
*/
@Input() suggestions: InputSuggestion[] = [];
onSubmit(data) {
this.value = data;
this.submitSuggestion.emit(data);
}
onClickSuggestion(data) {
this.value = data;
this.clickSuggestion.emit(data);
this.close();
this.blockReopen = true;
this.queryInput.nativeElement.focus();
return false;
}
}

View File

@@ -1,12 +1,23 @@
.autocomplete { .autocomplete {
width: 100%; width: 100%;
.dropdown-item { .dropdown-item {
white-space: normal; white-space: normal;
word-break: break-word; word-break: break-word;
padding: $input-padding-y $input-padding-x; padding: $input-padding-y $input-padding-x;
position: relative;
&:focus { &:focus {
outline: none; outline: none;
} }
.click-blocker {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
} }
} }

View File

@@ -13,22 +13,11 @@ import {
} from '@angular/core'; } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { hasValue, isNotEmpty } from '../empty.util'; import { hasValue, isNotEmpty } from '../empty.util';
import { InputSuggestion } from './input-suggestions.model';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({ @Component({
selector: 'ds-input-suggestions', selector: 'ds-input-suggestions',
styleUrls: ['./input-suggestions.component.scss'],
templateUrl: './input-suggestions.component.html', templateUrl: './input-suggestions.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
// Usage of forwardRef necessary https://github.com/angular/angular.io/issues/1151
// tslint:disable-next-line:no-forward-ref
useExisting: forwardRef(() => InputSuggestionsComponent),
multi: true
}
]
}) })
/** /**
@@ -38,7 +27,7 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
/** /**
* The suggestions that should be shown * The suggestions that should be shown
*/ */
@Input() suggestions: InputSuggestion[] = []; @Input() suggestions: any[] = [];
/** /**
* The time waited to detect if any other input will follow before requesting the suggestions * The time waited to detect if any other input will follow before requesting the suggestions
@@ -188,6 +177,15 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
this.show.next(false); this.show.next(false);
} }
/**
* Changes the show variable so the suggestion dropdown opens
*/
open() {
if (!this.blockReopen) {
this.show.next(true);
}
}
/** /**
* For usage of the isNotEmpty function in the template * For usage of the isNotEmpty function in the template
*/ */
@@ -195,16 +193,15 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
return isNotEmpty(data); return isNotEmpty(data);
} }
onSubmit(data: any) {
// sub class should decide how to handle the date
}
/** /**
* Make sure that if a suggestion is clicked, the suggestions dropdown closes, does not reopen and the focus moves to the input field * Make sure that if a suggestion is clicked, the suggestions dropdown closes, does not reopen and the focus moves to the input field
*/ */
onClickSuggestion(data) { onClickSuggestion(data: any) {
this.value = data; // sub class should decide how to handle the date
this.clickSuggestion.emit(data);
this.close();
this.blockReopen = true;
this.queryInput.nativeElement.focus();
return false;
} }
/** /**
@@ -219,11 +216,6 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
this.blockReopen = false; this.blockReopen = false;
} }
onSubmit(data) {
this.value = data;
this.submitSuggestion.emit(data);
}
/* START - Method's needed to add ngModel (ControlValueAccessor) to a component */ /* START - Method's needed to add ngModel (ControlValueAccessor) to a component */
registerOnChange(fn: any): void { registerOnChange(fn: any): void {
this.propagateChange = fn; this.propagateChange = fn;

View File

@@ -138,6 +138,8 @@ import { RoleDirective } from './roles/role.directive';
import { UserMenuComponent } from './auth-nav-menu/user-menu/user-menu.component'; import { UserMenuComponent } from './auth-nav-menu/user-menu/user-menu.component';
import { ClaimedTaskActionsReturnToPoolComponent } from './mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component'; import { ClaimedTaskActionsReturnToPoolComponent } from './mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component';
import { ItemDetailPreviewFieldComponent } from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component'; import { ItemDetailPreviewFieldComponent } from './object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview-field/item-detail-preview-field.component';
import { FilterInputSuggestionsComponent } from './input-suggestions/filter-suggestions/filter-input-suggestions.component';
import { DsoInputSuggestionsComponent } from './input-suggestions/dso-input-suggestions/dso-input-suggestions.component';
import { TypedItemSearchResultGridElementComponent } from './object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; import { TypedItemSearchResultGridElementComponent } from './object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component';
import { PublicationGridElementComponent } from './object-grid/item-grid-element/item-types/publication/publication-grid-element.component'; import { PublicationGridElementComponent } from './object-grid/item-grid-element/item-types/publication/publication-grid-element.component';
import { ItemTypeBadgeComponent } from './object-list/item-type-badge/item-type-badge.component'; import { ItemTypeBadgeComponent } from './object-list/item-type-badge/item-type-badge.component';
@@ -162,7 +164,7 @@ const MODULES = [
NouisliderModule, NouisliderModule,
MomentModule, MomentModule,
TextMaskModule, TextMaskModule,
MenuModule MenuModule,
]; ];
const ROOT_MODULES = [ const ROOT_MODULES = [
@@ -248,6 +250,8 @@ const COMPONENTS = [
TruncatablePartComponent, TruncatablePartComponent,
BrowseByComponent, BrowseByComponent,
InputSuggestionsComponent, InputSuggestionsComponent,
FilterInputSuggestionsComponent,
DsoInputSuggestionsComponent,
DSOSelectorComponent, DSOSelectorComponent,
CreateCommunityParentSelectorComponent, CreateCommunityParentSelectorComponent,
CreateCollectionParentSelectorComponent, CreateCollectionParentSelectorComponent,