mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-08 10:34:15 +00:00
Merge branch 'master' into angular-cli
This commit is contained in:
@@ -1,11 +1,32 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
||||||
|
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
|
||||||
|
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
|
||||||
|
import { URLCombiner } from '../../core/url-combiner/url-combiner';
|
||||||
|
import { getAccessControlModulePath } from '../admin-routing.module';
|
||||||
|
|
||||||
|
const GROUP_EDIT_PATH = 'groups';
|
||||||
|
|
||||||
|
export function getGroupEditPath(id: string) {
|
||||||
|
return new URLCombiner(getAccessControlModulePath(), GROUP_EDIT_PATH, id).toString();
|
||||||
|
}
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild([
|
RouterModule.forChild([
|
||||||
{ path: 'epeople', component: EPeopleRegistryComponent, data: { title: 'admin.access-control.epeople.title' } },
|
{ path: 'epeople', component: EPeopleRegistryComponent, data: { title: 'admin.access-control.epeople.title' } },
|
||||||
|
{ path: GROUP_EDIT_PATH, component: GroupsRegistryComponent, data: { title: 'admin.access-control.groups.title' } },
|
||||||
|
{
|
||||||
|
path: `${GROUP_EDIT_PATH}/:groupId`,
|
||||||
|
component: GroupFormComponent,
|
||||||
|
data: {title: 'admin.registries.schema.title'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${GROUP_EDIT_PATH}/newGroup`,
|
||||||
|
component: GroupFormComponent,
|
||||||
|
data: {title: 'admin.registries.schema.title'}
|
||||||
|
},
|
||||||
])
|
])
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@@ -6,6 +6,10 @@ import { SharedModule } from '../../shared/shared.module';
|
|||||||
import { AdminAccessControlRoutingModule } from './admin-access-control-routing.module';
|
import { AdminAccessControlRoutingModule } from './admin-access-control-routing.module';
|
||||||
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
|
||||||
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
|
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
|
||||||
|
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
|
||||||
|
import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component';
|
||||||
|
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
|
||||||
|
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -17,7 +21,11 @@ import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-fo
|
|||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
EPeopleRegistryComponent,
|
EPeopleRegistryComponent,
|
||||||
EPersonFormComponent
|
EPersonFormComponent,
|
||||||
|
GroupsRegistryComponent,
|
||||||
|
GroupFormComponent,
|
||||||
|
SubgroupsListComponent,
|
||||||
|
MembersListComponent
|
||||||
],
|
],
|
||||||
entryComponents: []
|
entryComponents: []
|
||||||
})
|
})
|
||||||
|
@@ -15,7 +15,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}}</h3>
|
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}}
|
||||||
|
<button (click)="clearFormAndResetResult();"
|
||||||
|
class="btn btn-primary float-right">{{labelPrefix + 'button.see-all' | translate}}</button>
|
||||||
|
</h3>
|
||||||
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="row">
|
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="row">
|
||||||
<div class="col-12 col-sm-3">
|
<div class="col-12 col-sm-3">
|
||||||
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
||||||
@@ -64,12 +67,12 @@
|
|||||||
<div class="btn-group edit-field">
|
<div class="btn-group edit-field">
|
||||||
<button (click)="toggleEditEPerson(eperson)"
|
<button (click)="toggleEditEPerson(eperson)"
|
||||||
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
|
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
|
||||||
title="{{labelPrefix + 'table.edit.buttons.edit' | translate}}">
|
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: eperson.name} }}">
|
||||||
<i class="fas fa-edit fa-fw"></i>
|
<i class="fas fa-edit fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<button (click)="deleteEPerson(eperson)"
|
<button (click)="deleteEPerson(eperson)"
|
||||||
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
|
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
|
||||||
title="{{labelPrefix + 'table.edit.buttons.remove' | translate}}">
|
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: eperson.name} }}">
|
||||||
<i class="fas fa-trash-alt fa-fw"></i>
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import { Router } from '@angular/router';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
@@ -14,14 +15,15 @@ import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
|||||||
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||||
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
|
import { FormBuilderService } from '../../../shared/form/builder/form-builder.service';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
import { EPeopleRegistryComponent } from './epeople-registry.component';
|
import { EPeopleRegistryComponent } from './epeople-registry.component';
|
||||||
import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson.mock';
|
import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson.mock';
|
||||||
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
|
||||||
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
|
import { getMockFormBuilderService } from '../../../shared/mocks/form-builder-service.mock';
|
||||||
import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock';
|
import { getMockTranslateService } from '../../../shared/mocks/translate.service.mock';
|
||||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
|
||||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
|
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
|
||||||
|
import { RouterStub } from '../../../shared/testing/router.stub';
|
||||||
|
|
||||||
describe('EPeopleRegistryComponent', () => {
|
describe('EPeopleRegistryComponent', () => {
|
||||||
let component: EPeopleRegistryComponent;
|
let component: EPeopleRegistryComponent;
|
||||||
@@ -29,10 +31,11 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
let translateService: TranslateService;
|
let translateService: TranslateService;
|
||||||
let builderService: FormBuilderService;
|
let builderService: FormBuilderService;
|
||||||
|
|
||||||
const mockEPeople = [EPersonMock, EPersonMock2];
|
let mockEPeople;
|
||||||
let ePersonDataServiceStub: any;
|
let ePersonDataServiceStub: any;
|
||||||
|
|
||||||
beforeEach(async(() => {
|
beforeEach(async(() => {
|
||||||
|
mockEPeople = [EPersonMock, EPersonMock2];
|
||||||
ePersonDataServiceStub = {
|
ePersonDataServiceStub = {
|
||||||
activeEPerson: null,
|
activeEPerson: null,
|
||||||
allEpeople: mockEPeople,
|
allEpeople: mockEPeople,
|
||||||
@@ -50,6 +53,9 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
|
||||||
}
|
}
|
||||||
if (scope === 'metadata') {
|
if (scope === 'metadata') {
|
||||||
|
if (query === '') {
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople));
|
||||||
|
}
|
||||||
const result = this.allEpeople.find((ePerson: EPerson) => {
|
const result = this.allEpeople.find((ePerson: EPerson) => {
|
||||||
return (ePerson.name.includes(query) || ePerson.email.includes(query))
|
return (ePerson.name.includes(query) || ePerson.email.includes(query))
|
||||||
});
|
});
|
||||||
@@ -71,6 +77,9 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
},
|
},
|
||||||
clearEPersonRequests(): void {
|
clearEPersonRequests(): void {
|
||||||
// empty
|
// empty
|
||||||
|
},
|
||||||
|
getEPeoplePageRouterLink(): string {
|
||||||
|
return '/admin/access-control/epeople';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
builderService = getMockFormBuilderService();
|
builderService = getMockFormBuilderService();
|
||||||
@@ -89,7 +98,7 @@ describe('EPeopleRegistryComponent', () => {
|
|||||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||||
{ provide: FormBuilderService, useValue: builderService },
|
{ provide: FormBuilderService, useValue: builderService },
|
||||||
EPeopleRegistryComponent
|
{ provide: Router, useValue: new RouterStub() },
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormBuilder } from '@angular/forms';
|
import { FormBuilder } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
import { map, take } from 'rxjs/operators';
|
import { map, take } from 'rxjs/operators';
|
||||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
import { RemoteData } from '../../../core/data/remote-data';
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
@@ -12,152 +14,189 @@ import { NotificationsService } from '../../../shared/notifications/notification
|
|||||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-epeople-registry',
|
selector: 'ds-epeople-registry',
|
||||||
templateUrl: './epeople-registry.component.html',
|
templateUrl: './epeople-registry.component.html',
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* A component used for managing all existing epeople within the repository.
|
* A component used for managing all existing epeople within the repository.
|
||||||
* The admin can create, edit or delete epeople here.
|
* The admin can create, edit or delete epeople here.
|
||||||
*/
|
*/
|
||||||
export class EPeopleRegistryComponent {
|
export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
labelPrefix = 'admin.access-control.epeople.';
|
labelPrefix = 'admin.access-control.epeople.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A list of all the current EPeople within the repository or the result of the search
|
* A list of all the current EPeople within the repository or the result of the search
|
||||||
*/
|
*/
|
||||||
ePeople: Observable<RemoteData<PaginatedList<EPerson>>>;
|
ePeople: Observable<RemoteData<PaginatedList<EPerson>>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pagination config used to display the list of epeople
|
* Pagination config used to display the list of epeople
|
||||||
*/
|
*/
|
||||||
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
id: 'epeople-list-pagination',
|
id: 'epeople-list-pagination',
|
||||||
pageSize: 5,
|
pageSize: 5,
|
||||||
currentPage: 1
|
currentPage: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not to show the EPerson form
|
||||||
|
*/
|
||||||
|
isEPersonFormShown: boolean;
|
||||||
|
|
||||||
|
// The search form
|
||||||
|
searchForm;
|
||||||
|
|
||||||
|
// Current search in epersons registry
|
||||||
|
currentSearchQuery: string;
|
||||||
|
currentSearchScope: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of subscriptions
|
||||||
|
*/
|
||||||
|
subs: Subscription[] = [];
|
||||||
|
|
||||||
|
constructor(private epersonService: EPersonDataService,
|
||||||
|
private translateService: TranslateService,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private router: Router) {
|
||||||
|
this.currentSearchQuery = '';
|
||||||
|
this.currentSearchScope = 'metadata';
|
||||||
|
this.searchForm = this.formBuilder.group(({
|
||||||
|
scope: 'metadata',
|
||||||
|
query: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.isEPersonFormShown = false;
|
||||||
|
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
|
||||||
|
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||||
|
if (eperson != null && eperson.id) {
|
||||||
|
this.isEPersonFormShown = true;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event triggered when the user changes page
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
onPageChange(event) {
|
||||||
|
this.config.currentPage = event;
|
||||||
|
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
|
||||||
|
* a new REST call
|
||||||
|
*/
|
||||||
|
public forceUpdateEPeople() {
|
||||||
|
this.epersonService.clearEPersonRequests();
|
||||||
|
this.isEPersonFormShown = false;
|
||||||
|
this.search({ query: '', scope: 'metadata' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search in the EPeople by metadata (default) or email
|
||||||
|
* @param data Contains scope and query param
|
||||||
|
*/
|
||||||
|
search(data: any) {
|
||||||
|
const query: string = data.query;
|
||||||
|
const scope: string = data.scope;
|
||||||
|
if (query != null && this.currentSearchQuery !== query) {
|
||||||
|
this.router.navigateByUrl(this.epersonService.getEPeoplePageRouterLink());
|
||||||
|
this.currentSearchQuery = query;
|
||||||
|
this.config.currentPage = 1;
|
||||||
|
}
|
||||||
|
if (scope != null && this.currentSearchScope !== scope) {
|
||||||
|
this.router.navigateByUrl(this.epersonService.getEPeoplePageRouterLink());
|
||||||
|
this.currentSearchScope = scope;
|
||||||
|
this.config.currentPage = 1;
|
||||||
|
}
|
||||||
|
this.ePeople = this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
|
||||||
|
currentPage: this.config.currentPage,
|
||||||
|
elementsPerPage: this.config.pageSize
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not to show the EPerson form
|
* Checks whether the given EPerson is active (being edited)
|
||||||
*/
|
* @param eperson
|
||||||
isEPersonFormShown: boolean;
|
*/
|
||||||
|
isActive(eperson: EPerson): Observable<boolean> {
|
||||||
|
return this.getActiveEPerson().pipe(
|
||||||
|
map((activeEPerson) => eperson === activeEPerson)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The search form
|
/**
|
||||||
searchForm;
|
* Gets the active eperson (being edited)
|
||||||
|
*/
|
||||||
|
getActiveEPerson(): Observable<EPerson> {
|
||||||
|
return this.epersonService.getActiveEPerson();
|
||||||
|
}
|
||||||
|
|
||||||
constructor(private epersonService: EPersonDataService,
|
/**
|
||||||
private translateService: TranslateService,
|
* Start editing the selected EPerson
|
||||||
private notificationsService: NotificationsService,
|
* @param ePerson
|
||||||
private formBuilder: FormBuilder) {
|
*/
|
||||||
this.updateEPeople({
|
toggleEditEPerson(ePerson: EPerson) {
|
||||||
currentPage: 1,
|
this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => {
|
||||||
elementsPerPage: this.config.pageSize
|
if (ePerson === activeEPerson) {
|
||||||
});
|
this.epersonService.cancelEditEPerson();
|
||||||
this.isEPersonFormShown = false;
|
this.isEPersonFormShown = false;
|
||||||
this.searchForm = this.formBuilder.group(({
|
} else {
|
||||||
scope: 'metadata',
|
this.epersonService.editEPerson(ePerson);
|
||||||
query: '',
|
this.isEPersonFormShown = true;
|
||||||
}));
|
}
|
||||||
}
|
});
|
||||||
|
this.scrollToTop()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event triggered when the user changes page
|
* Deletes EPerson, show notification on success/failure & updates EPeople list
|
||||||
* @param event
|
*/
|
||||||
*/
|
deleteEPerson(ePerson: EPerson) {
|
||||||
onPageChange(event) {
|
if (hasValue(ePerson.id)) {
|
||||||
this.updateEPeople({
|
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => {
|
||||||
currentPage: event,
|
if (success) {
|
||||||
elementsPerPage: this.config.pageSize
|
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
|
||||||
});
|
this.forceUpdateEPeople();
|
||||||
}
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name }));
|
||||||
/**
|
|
||||||
* Update the list of EPeople by fetching it from the rest api or cache
|
|
||||||
*/
|
|
||||||
private updateEPeople(options) {
|
|
||||||
this.ePeople = this.epersonService.getEPeople(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
|
|
||||||
* a new REST call
|
|
||||||
*/
|
|
||||||
public forceUpdateEPeople() {
|
|
||||||
this.epersonService.clearEPersonRequests();
|
|
||||||
this.isEPersonFormShown = false;
|
|
||||||
this.search({ query: '', scope: 'metadata' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search in the EPeople by metadata (default) or email
|
|
||||||
* @param data Contains scope and query param
|
|
||||||
*/
|
|
||||||
search(data: any) {
|
|
||||||
this.ePeople = this.epersonService.searchByScope(data.scope, data.query, {
|
|
||||||
currentPage: 1,
|
|
||||||
elementsPerPage: this.config.pageSize
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether the given EPerson is active (being edited)
|
|
||||||
* @param eperson
|
|
||||||
*/
|
|
||||||
isActive(eperson: EPerson): Observable<boolean> {
|
|
||||||
return this.getActiveEPerson().pipe(
|
|
||||||
map((activeEPerson) => eperson === activeEPerson)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the active eperson (being edited)
|
|
||||||
*/
|
|
||||||
getActiveEPerson(): Observable<EPerson> {
|
|
||||||
return this.epersonService.getActiveEPerson();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start editing the selected EPerson
|
|
||||||
* @param ePerson
|
|
||||||
*/
|
|
||||||
toggleEditEPerson(ePerson: EPerson) {
|
|
||||||
this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => {
|
|
||||||
if (ePerson === activeEPerson) {
|
|
||||||
this.epersonService.cancelEditEPerson();
|
|
||||||
this.isEPersonFormShown = false;
|
|
||||||
} else {
|
|
||||||
this.epersonService.editEPerson(ePerson);
|
|
||||||
this.isEPersonFormShown = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.scrollToTop()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes EPerson, show notification on success/failure & updates EPeople list
|
|
||||||
*/
|
|
||||||
deleteEPerson(ePerson: EPerson) {
|
|
||||||
if (hasValue(ePerson.id)) {
|
|
||||||
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => {
|
|
||||||
if (success) {
|
|
||||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
|
|
||||||
this.forceUpdateEPeople();
|
|
||||||
} else {
|
|
||||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name }));
|
|
||||||
}
|
|
||||||
this.epersonService.cancelEditEPerson();
|
|
||||||
this.isEPersonFormShown = false;
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
this.epersonService.cancelEditEPerson();
|
||||||
|
this.isEPersonFormShown = false;
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scrollToTop() {
|
/**
|
||||||
(function smoothscroll() {
|
* Unsub all subscriptions
|
||||||
const currentScroll = document.documentElement.scrollTop || document.body.scrollTop;
|
*/
|
||||||
if (currentScroll > 0) {
|
ngOnDestroy(): void {
|
||||||
window.requestAnimationFrame(smoothscroll);
|
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||||
window.scrollTo(0, currentScroll - (currentScroll / 8));
|
}
|
||||||
}
|
|
||||||
})();
|
scrollToTop() {
|
||||||
}
|
(function smoothscroll() {
|
||||||
|
const currentScroll = document.documentElement.scrollTop || document.body.scrollTop;
|
||||||
|
if (currentScroll > 0) {
|
||||||
|
window.requestAnimationFrame(smoothscroll);
|
||||||
|
window.scrollTo(0, currentScroll - (currentScroll / 8));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all input-fields to be empty and search all search
|
||||||
|
*/
|
||||||
|
clearFormAndResetResult() {
|
||||||
|
this.searchForm.patchValue({
|
||||||
|
query: '',
|
||||||
|
});
|
||||||
|
this.search({ query: '' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -26,7 +26,6 @@ const initialState: EPeopleRegistryState = {
|
|||||||
* @param action The EPeopleRegistryAction to perform on the state
|
* @param action The EPeopleRegistryAction to perform on the state
|
||||||
*/
|
*/
|
||||||
export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegistryAction): EPeopleRegistryState {
|
export function ePeopleRegistryReducer(state = initialState, action: EPeopleRegistryAction): EPeopleRegistryState {
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
|
||||||
case EPeopleRegistryActionTypes.EDIT_EPERSON: {
|
case EPeopleRegistryActionTypes.EDIT_EPERSON: {
|
||||||
|
@@ -15,3 +15,44 @@
|
|||||||
(cancel)="onCancel()"
|
(cancel)="onCancel()"
|
||||||
(submitForm)="onSubmit()">
|
(submitForm)="onSubmit()">
|
||||||
</ds-form>
|
</ds-form>
|
||||||
|
|
||||||
|
<div *ngIf="epersonService.getActiveEPerson() | async">
|
||||||
|
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
|
||||||
|
|
||||||
|
<ds-pagination
|
||||||
|
*ngIf="(groups | async)?.payload?.totalElements > 0"
|
||||||
|
[paginationOptions]="config"
|
||||||
|
[pageInfoState]="(groups | async)?.payload"
|
||||||
|
[collectionSize]="(groups | async)?.payload?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
(pageChange)="onPageChange($event)">
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="groups" class="table table-striped table-hover table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
||||||
|
<td>{{group.id}}</td>
|
||||||
|
<td><a (click)="groupsDataService.startEditingNewGroup(group)"
|
||||||
|
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ds-pagination>
|
||||||
|
|
||||||
|
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||||
|
<div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
|
||||||
|
<div>
|
||||||
|
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]"
|
||||||
|
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
import { of as observableOf } from 'rxjs';
|
import { of as observableOf } from 'rxjs';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
@@ -7,13 +9,18 @@ import { BrowserModule } from '@angular/platform-browser';
|
|||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||||
|
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
|
||||||
import { RestResponse } from '../../../../core/cache/response.models';
|
import { RestResponse } from '../../../../core/cache/response.models';
|
||||||
|
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
|
||||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||||
import { RemoteData } from '../../../../core/data/remote-data';
|
import { RemoteData } from '../../../../core/data/remote-data';
|
||||||
import { FindListOptions } from '../../../../core/data/request.models';
|
import { FindListOptions } from '../../../../core/data/request.models';
|
||||||
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||||
import { EPerson } from '../../../../core/eperson/models/eperson.model';
|
import { EPerson } from '../../../../core/eperson/models/eperson.model';
|
||||||
|
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
|
||||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||||
|
import { UUIDService } from '../../../../core/shared/uuid.service';
|
||||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
import { EPeopleRegistryComponent } from '../epeople-registry.component';
|
import { EPeopleRegistryComponent } from '../epeople-registry.component';
|
||||||
@@ -31,10 +38,11 @@ describe('EPersonFormComponent', () => {
|
|||||||
let translateService: TranslateService;
|
let translateService: TranslateService;
|
||||||
let builderService: FormBuilderService;
|
let builderService: FormBuilderService;
|
||||||
|
|
||||||
const mockEPeople = [EPersonMock, EPersonMock2];
|
let mockEPeople;
|
||||||
let ePersonDataServiceStub: any;
|
let ePersonDataServiceStub: any;
|
||||||
|
|
||||||
beforeEach(async(() => {
|
beforeEach(async(() => {
|
||||||
|
mockEPeople = [EPersonMock, EPersonMock2];
|
||||||
ePersonDataServiceStub = {
|
ePersonDataServiceStub = {
|
||||||
activeEPerson: null,
|
activeEPerson: null,
|
||||||
allEpeople: mockEPeople,
|
allEpeople: mockEPeople,
|
||||||
@@ -52,6 +60,9 @@ describe('EPersonFormComponent', () => {
|
|||||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
|
||||||
}
|
}
|
||||||
if (scope === 'metadata') {
|
if (scope === 'metadata') {
|
||||||
|
if (query === '') {
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allEpeople));
|
||||||
|
}
|
||||||
const result = this.allEpeople.find((ePerson: EPerson) => {
|
const result = this.allEpeople.find((ePerson: EPerson) => {
|
||||||
return (ePerson.name.includes(query) || ePerson.email.includes(query))
|
return (ePerson.name.includes(query) || ePerson.email.includes(query))
|
||||||
});
|
});
|
||||||
@@ -107,6 +118,13 @@ describe('EPersonFormComponent', () => {
|
|||||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||||
{ provide: FormBuilderService, useValue: builderService },
|
{ provide: FormBuilderService, useValue: builderService },
|
||||||
|
{ provide: DSOChangeAnalyzer, useValue: {} },
|
||||||
|
{ provide: HttpClient, useValue: {} },
|
||||||
|
{ provide: ObjectCacheService, useValue: {} },
|
||||||
|
{ provide: UUIDService, useValue: {} },
|
||||||
|
{ provide: Store, useValue: {} },
|
||||||
|
{ provide: RemoteDataBuildService, useValue: {} },
|
||||||
|
{ provide: HALEndpointService, useValue: {} },
|
||||||
EPeopleRegistryComponent
|
EPeopleRegistryComponent
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
@@ -116,7 +134,6 @@ describe('EPersonFormComponent', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(EPersonFormComponent);
|
fixture = TestBed.createComponent(EPersonFormComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
component.ngOnInit();
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,30 +142,37 @@ describe('EPersonFormComponent', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('when submitting the form', () => {
|
describe('when submitting the form', () => {
|
||||||
const firstName = 'testName';
|
let firstName;
|
||||||
const lastName = 'testLastName';
|
let lastName;
|
||||||
const email = 'testEmail@test.com';
|
let email;
|
||||||
const canLogIn = false;
|
let canLogIn;
|
||||||
const requireCertificate = false;
|
let requireCertificate;
|
||||||
|
|
||||||
const expected = Object.assign(new EPerson(), {
|
let expected;
|
||||||
metadata: {
|
|
||||||
'eperson.firstname': [
|
|
||||||
{
|
|
||||||
value: firstName
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'eperson.lastname': [
|
|
||||||
{
|
|
||||||
value: lastName
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
email: email,
|
|
||||||
canLogIn: canLogIn,
|
|
||||||
requireCertificate: requireCertificate,
|
|
||||||
});
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
firstName = 'testName';
|
||||||
|
lastName = 'testLastName';
|
||||||
|
email = 'testEmail@test.com';
|
||||||
|
canLogIn = false;
|
||||||
|
requireCertificate = false;
|
||||||
|
|
||||||
|
expected = Object.assign(new EPerson(), {
|
||||||
|
metadata: {
|
||||||
|
'eperson.firstname': [
|
||||||
|
{
|
||||||
|
value: firstName
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'eperson.lastname': [
|
||||||
|
{
|
||||||
|
value: lastName
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
email: email,
|
||||||
|
canLogIn: canLogIn,
|
||||||
|
requireCertificate: requireCertificate,
|
||||||
|
});
|
||||||
spyOn(component.submitForm, 'emit');
|
spyOn(component.submitForm, 'emit');
|
||||||
component.firstName.value = firstName;
|
component.firstName.value = firstName;
|
||||||
component.lastName.value = lastName;
|
component.lastName.value = lastName;
|
||||||
@@ -171,25 +195,26 @@ describe('EPersonFormComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('with an active eperson', () => {
|
describe('with an active eperson', () => {
|
||||||
const expectedWithId = Object.assign(new EPerson(), {
|
let expectedWithId;
|
||||||
metadata: {
|
|
||||||
'eperson.firstname': [
|
|
||||||
{
|
|
||||||
value: firstName
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'eperson.lastname': [
|
|
||||||
{
|
|
||||||
value: lastName
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
email: email,
|
|
||||||
canLogIn: canLogIn,
|
|
||||||
requireCertificate: requireCertificate,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
expectedWithId = Object.assign(new EPerson(), {
|
||||||
|
metadata: {
|
||||||
|
'eperson.firstname': [
|
||||||
|
{
|
||||||
|
value: firstName
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'eperson.lastname': [
|
||||||
|
{
|
||||||
|
value: lastName
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
email: email,
|
||||||
|
canLogIn: canLogIn,
|
||||||
|
requireCertificate: requireCertificate,
|
||||||
|
});
|
||||||
spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId));
|
spyOn(ePersonDataServiceStub, 'getActiveEPerson').and.returnValue(observableOf(expectedWithId));
|
||||||
component.onSubmit();
|
component.onSubmit();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
@@ -7,17 +7,21 @@ import {
|
|||||||
DynamicInputModel
|
DynamicInputModel
|
||||||
} from '@ng-dynamic-forms/core';
|
} from '@ng-dynamic-forms/core';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { combineLatest } from 'rxjs/internal/observable/combineLatest';
|
import { Subscription, combineLatest } from 'rxjs';
|
||||||
import { Subscription } from 'rxjs/internal/Subscription';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
import { take } from 'rxjs/operators';
|
import { take } from 'rxjs/operators';
|
||||||
import { RestResponse } from '../../../../core/cache/response.models';
|
import { RestResponse } from '../../../../core/cache/response.models';
|
||||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||||
|
import { RemoteData } from '../../../../core/data/remote-data';
|
||||||
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||||
|
import { GroupDataService } from '../../../../core/eperson/group-data.service';
|
||||||
import { EPerson } from '../../../../core/eperson/models/eperson.model';
|
import { EPerson } from '../../../../core/eperson/models/eperson.model';
|
||||||
|
import { Group } from '../../../../core/eperson/models/group.model';
|
||||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
|
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||||
import { hasValue } from '../../../../shared/empty.util';
|
import { hasValue } from '../../../../shared/empty.util';
|
||||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
|
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-eperson-form',
|
selector: 'ds-eperson-form',
|
||||||
@@ -106,12 +110,27 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
subs: Subscription[] = [];
|
subs: Subscription[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all the groups this EPerson is a member of
|
||||||
|
*/
|
||||||
|
groups: Observable<RemoteData<PaginatedList<Group>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination config used to display the list of groups
|
||||||
|
*/
|
||||||
|
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'groups-ePersonMemberOf-list-pagination',
|
||||||
|
pageSize: 5,
|
||||||
|
currentPage: 1
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Try to retrieve initial active eperson, to fill in checkboxes at component creation
|
* Try to retrieve initial active eperson, to fill in checkboxes at component creation
|
||||||
*/
|
*/
|
||||||
epersonInitial: EPerson;
|
epersonInitial: EPerson;
|
||||||
|
|
||||||
constructor(public epersonService: EPersonDataService,
|
constructor(public epersonService: EPersonDataService,
|
||||||
|
public groupsDataService: GroupDataService,
|
||||||
private formBuilderService: FormBuilderService,
|
private formBuilderService: FormBuilderService,
|
||||||
private translateService: TranslateService,
|
private translateService: TranslateService,
|
||||||
private notificationsService: NotificationsService,) {
|
private notificationsService: NotificationsService,) {
|
||||||
@@ -181,6 +200,12 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
];
|
];
|
||||||
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
|
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
|
||||||
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||||
|
if (eperson != null) {
|
||||||
|
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, {
|
||||||
|
currentPage: 1,
|
||||||
|
elementsPerPage: this.config.pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
this.formGroup.patchValue({
|
this.formGroup.patchValue({
|
||||||
firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '',
|
firstName: eperson != null ? eperson.firstMetadataValue('eperson.firstname') : '',
|
||||||
lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '',
|
lastName: eperson != null ? eperson.firstMetadataValue('eperson.lastname') : '',
|
||||||
@@ -209,7 +234,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
onSubmit() {
|
onSubmit() {
|
||||||
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe(
|
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe(
|
||||||
(ePerson: EPerson) => {
|
(ePerson: EPerson) => {
|
||||||
console.log('onsubmit ep', ePerson)
|
|
||||||
const values = {
|
const values = {
|
||||||
metadata: {
|
metadata: {
|
||||||
'eperson.firstname': [
|
'eperson.firstname': [
|
||||||
@@ -241,7 +265,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
* @param values
|
* @param values
|
||||||
*/
|
*/
|
||||||
createNewEPerson(values) {
|
createNewEPerson(values) {
|
||||||
console.log('createNewEPerson(values)', values)
|
|
||||||
const ePersonToCreate = Object.assign(new EPerson(), values);
|
const ePersonToCreate = Object.assign(new EPerson(), values);
|
||||||
|
|
||||||
const response = this.epersonService.tryToCreate(ePersonToCreate);
|
const response = this.epersonService.tryToCreate(ePersonToCreate);
|
||||||
@@ -322,18 +345,25 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset all input-fields to be empty
|
* Event triggered when the user changes page
|
||||||
|
* @param event
|
||||||
*/
|
*/
|
||||||
clearFields() {
|
onPageChange(event) {
|
||||||
this.formGroup.patchValue({
|
this.updateGroups({
|
||||||
firstName: '',
|
currentPage: event,
|
||||||
lastName: '',
|
elementsPerPage: this.config.pageSize
|
||||||
email: '',
|
|
||||||
canLogin: true,
|
|
||||||
requireCertificate: false
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the list of groups by fetching it from the rest api or cache
|
||||||
|
*/
|
||||||
|
private updateGroups(options) {
|
||||||
|
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||||
|
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel the current edit when component is destroyed & unsub all subscriptions
|
* Cancel the current edit when component is destroyed & unsub all subscriptions
|
||||||
*/
|
*/
|
||||||
|
@@ -0,0 +1,33 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="group-form row">
|
||||||
|
<div class="col-12">
|
||||||
|
|
||||||
|
<div *ngIf="groupDataService.getActiveGroup() | async; then editheader; else createHeader"></div>
|
||||||
|
|
||||||
|
<ng-template #createHeader>
|
||||||
|
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h2>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #editheader>
|
||||||
|
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.edit' | translate}}</h2>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ds-form [formId]="formId"
|
||||||
|
[formModel]="formModel"
|
||||||
|
[formGroup]="formGroup"
|
||||||
|
[formLayout]="formLayout"
|
||||||
|
(cancel)="onCancel()"
|
||||||
|
(submitForm)="onSubmit()">
|
||||||
|
</ds-form>
|
||||||
|
|
||||||
|
<ds-members-list *ngIf="groupBeingEdited != null" [messagePrefix]="messagePrefix + '.members-list'"></ds-members-list>
|
||||||
|
<ds-subgroups-list *ngIf="groupBeingEdited != null" [messagePrefix]="messagePrefix + '.subgroups-list'"></ds-subgroups-list>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button [routerLink]="[this.groupDataService.getGroupRegistryRouterLink()]"
|
||||||
|
class="btn btn-primary">{{messagePrefix + '.return' | translate}}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,153 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||||
|
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
|
||||||
|
import { RestResponse } from '../../../../core/cache/response.models';
|
||||||
|
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
|
||||||
|
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||||
|
import { RemoteData } from '../../../../core/data/remote-data';
|
||||||
|
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||||
|
import { GroupDataService } from '../../../../core/eperson/group-data.service';
|
||||||
|
import { Group } from '../../../../core/eperson/models/group.model';
|
||||||
|
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
|
||||||
|
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||||
|
import { UUIDService } from '../../../../core/shared/uuid.service';
|
||||||
|
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||||
|
import { getMockFormBuilderService } from '../../../../shared/mocks/mock-form-builder-service';
|
||||||
|
import { MockRouter } from '../../../../shared/mocks/mock-router';
|
||||||
|
import { getMockTranslateService } from '../../../../shared/mocks/mock-translate.service';
|
||||||
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
|
import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock';
|
||||||
|
import { MockTranslateLoader } from '../../../../shared/testing/mock-translate-loader';
|
||||||
|
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service-stub';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils';
|
||||||
|
import { GroupFormComponent } from './group-form.component';
|
||||||
|
|
||||||
|
describe('GroupFormComponent', () => {
|
||||||
|
let component: GroupFormComponent;
|
||||||
|
let fixture: ComponentFixture<GroupFormComponent>;
|
||||||
|
let translateService: TranslateService;
|
||||||
|
let builderService: FormBuilderService;
|
||||||
|
let ePersonDataServiceStub: any;
|
||||||
|
let groupsDataServiceStub: any;
|
||||||
|
let router;
|
||||||
|
|
||||||
|
let groups;
|
||||||
|
let groupName;
|
||||||
|
let groupDescription;
|
||||||
|
let expected;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
groups = [GroupMock, GroupMock2]
|
||||||
|
groupName = 'testGroupName';
|
||||||
|
groupDescription = 'testDescription';
|
||||||
|
expected = Object.assign(new Group(), {
|
||||||
|
name: groupName,
|
||||||
|
metadata: {
|
||||||
|
'dc.description': [
|
||||||
|
{
|
||||||
|
value: groupDescription
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ePersonDataServiceStub = {};
|
||||||
|
groupsDataServiceStub = {
|
||||||
|
allGroups: groups,
|
||||||
|
activeGroup: null,
|
||||||
|
getActiveGroup(): Observable<Group> {
|
||||||
|
return observableOf(this.activeGroup);
|
||||||
|
},
|
||||||
|
getGroupRegistryRouterLink(): string {
|
||||||
|
return '/admin/access-control/groups';
|
||||||
|
},
|
||||||
|
editGroup(group: Group) {
|
||||||
|
this.activeGroup = group
|
||||||
|
},
|
||||||
|
cancelEditGroup(): void {
|
||||||
|
this.activeGroup = null;
|
||||||
|
},
|
||||||
|
findById(id: string) {
|
||||||
|
return observableOf({ payload: null, hasSucceeded: true });
|
||||||
|
},
|
||||||
|
tryToCreate(group: Group): Observable<RestResponse> {
|
||||||
|
this.allGroups = [...this.allGroups, group]
|
||||||
|
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||||
|
},
|
||||||
|
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
builderService = getMockFormBuilderService();
|
||||||
|
translateService = getMockTranslateService();
|
||||||
|
router = new MockRouter();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||||
|
TranslateModule.forRoot({
|
||||||
|
loader: {
|
||||||
|
provide: TranslateLoader,
|
||||||
|
useClass: MockTranslateLoader
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
declarations: [GroupFormComponent],
|
||||||
|
providers: [GroupFormComponent,
|
||||||
|
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||||
|
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||||
|
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||||
|
{ provide: FormBuilderService, useValue: builderService },
|
||||||
|
{ provide: DSOChangeAnalyzer, useValue: {} },
|
||||||
|
{ provide: HttpClient, useValue: {} },
|
||||||
|
{ provide: ObjectCacheService, useValue: {} },
|
||||||
|
{ provide: UUIDService, useValue: {} },
|
||||||
|
{ provide: Store, useValue: {} },
|
||||||
|
{ provide: RemoteDataBuildService, useValue: {} },
|
||||||
|
{ provide: HALEndpointService, useValue: {} },
|
||||||
|
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }), params: observableOf({}) } },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(GroupFormComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create GroupFormComponent', inject([GroupFormComponent], (comp: GroupFormComponent) => {
|
||||||
|
expect(comp).toBeDefined();
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('when submitting the form', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(component.submitForm, 'emit');
|
||||||
|
component.groupName.value = groupName;
|
||||||
|
component.groupDescription.value = groupDescription;
|
||||||
|
});
|
||||||
|
describe('without active Group', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component.onSubmit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit a new group using the correct values', async(() => {
|
||||||
|
fixture.whenStable().then(() => {
|
||||||
|
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,280 @@
|
|||||||
|
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output } from '@angular/core';
|
||||||
|
import { FormGroup } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import {
|
||||||
|
DynamicFormControlModel,
|
||||||
|
DynamicFormLayout,
|
||||||
|
DynamicInputModel,
|
||||||
|
DynamicTextAreaModel
|
||||||
|
} from '@ng-dynamic-forms/core';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { combineLatest } from 'rxjs/internal/observable/combineLatest';
|
||||||
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
import { RestResponse } from '../../../../core/cache/response.models';
|
||||||
|
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||||
|
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||||
|
import { GroupDataService } from '../../../../core/eperson/group-data.service';
|
||||||
|
import { Group } from '../../../../core/eperson/models/group.model';
|
||||||
|
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||||
|
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
|
||||||
|
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||||
|
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-group-form',
|
||||||
|
templateUrl: './group-form.component.html'
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* A form used for creating and editing groups
|
||||||
|
*/
|
||||||
|
export class GroupFormComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
messagePrefix = 'admin.access-control.groups.form';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unique id used for ds-form
|
||||||
|
*/
|
||||||
|
formId = 'group-form';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic models for the inputs of form
|
||||||
|
*/
|
||||||
|
groupName: DynamicInputModel;
|
||||||
|
groupDescription: DynamicTextAreaModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all dynamic input models
|
||||||
|
*/
|
||||||
|
formModel: DynamicFormControlModel[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout used for structuring the form inputs
|
||||||
|
*/
|
||||||
|
formLayout: DynamicFormLayout = {
|
||||||
|
groupName: {
|
||||||
|
grid: {
|
||||||
|
host: 'row'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
groupDescription: {
|
||||||
|
grid: {
|
||||||
|
host: 'row'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A FormGroup that combines all inputs
|
||||||
|
*/
|
||||||
|
formGroup: FormGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An EventEmitter that's fired whenever the form is being submitted
|
||||||
|
*/
|
||||||
|
@Output() submitForm: EventEmitter<any> = new EventEmitter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An EventEmitter that's fired whenever the form is cancelled
|
||||||
|
*/
|
||||||
|
@Output() cancelForm: EventEmitter<any> = new EventEmitter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of subscriptions
|
||||||
|
*/
|
||||||
|
subs: Subscription[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group currently being edited
|
||||||
|
*/
|
||||||
|
groupBeingEdited: Group;
|
||||||
|
|
||||||
|
constructor(public groupDataService: GroupDataService,
|
||||||
|
private ePersonDataService: EPersonDataService,
|
||||||
|
private formBuilderService: FormBuilderService,
|
||||||
|
private translateService: TranslateService,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
protected router: Router) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.subs.push(this.route.params.subscribe((params) => {
|
||||||
|
this.setActiveGroup(params.groupId)
|
||||||
|
}));
|
||||||
|
combineLatest(
|
||||||
|
this.translateService.get(`${this.messagePrefix}.groupName`),
|
||||||
|
this.translateService.get(`${this.messagePrefix}.groupDescription`),
|
||||||
|
).subscribe(([groupName, groupDescription]) => {
|
||||||
|
this.groupName = new DynamicInputModel({
|
||||||
|
id: 'groupName',
|
||||||
|
label: groupName,
|
||||||
|
name: 'groupName',
|
||||||
|
validators: {
|
||||||
|
required: null,
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
this.groupDescription = new DynamicTextAreaModel({
|
||||||
|
id: 'groupDescription',
|
||||||
|
label: groupDescription,
|
||||||
|
name: 'groupDescription',
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
this.formModel = [
|
||||||
|
this.groupName,
|
||||||
|
this.groupDescription
|
||||||
|
];
|
||||||
|
this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
|
||||||
|
this.subs.push(this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
|
||||||
|
if (activeGroup != null) {
|
||||||
|
this.groupBeingEdited = activeGroup;
|
||||||
|
this.formGroup.patchValue({
|
||||||
|
groupName: activeGroup != null ? activeGroup.name : '',
|
||||||
|
groupDescription: activeGroup != null ? activeGroup.firstMetadataValue('dc.description') : '',
|
||||||
|
});
|
||||||
|
if (activeGroup.permanent) {
|
||||||
|
this.formGroup.get('groupName').disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop editing the currently selected group
|
||||||
|
*/
|
||||||
|
onCancel() {
|
||||||
|
this.groupDataService.cancelEditGroup();
|
||||||
|
this.cancelForm.emit();
|
||||||
|
this.router.navigate([this.groupDataService.getGroupRegistryRouterLink()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the form
|
||||||
|
* When the eperson has an id attached -> Edit the eperson
|
||||||
|
* When the eperson has no id attached -> Create new eperson
|
||||||
|
* Emit the updated/created eperson using the EventEmitter submitForm
|
||||||
|
*/
|
||||||
|
onSubmit() {
|
||||||
|
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe(
|
||||||
|
(group: Group) => {
|
||||||
|
const values = {
|
||||||
|
name: this.groupName.value,
|
||||||
|
metadata: {
|
||||||
|
'dc.description': [
|
||||||
|
{
|
||||||
|
value: this.groupDescription.value
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (group === null) {
|
||||||
|
this.createNewGroup(values);
|
||||||
|
} else {
|
||||||
|
this.editGroup(group, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates new Group based on given values from form
|
||||||
|
* @param values
|
||||||
|
*/
|
||||||
|
createNewGroup(values) {
|
||||||
|
const groupToCreate = Object.assign(new Group(), values);
|
||||||
|
const response = this.groupDataService.tryToCreate(groupToCreate);
|
||||||
|
response.pipe(take(1)).subscribe((restResponse: RestResponse) => {
|
||||||
|
if (restResponse.isSuccessful) {
|
||||||
|
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.created.success', { name: groupToCreate.name }));
|
||||||
|
this.submitForm.emit(groupToCreate);
|
||||||
|
const resp: any = restResponse;
|
||||||
|
if (isNotEmpty(resp.resourceSelfLinks)) {
|
||||||
|
const groupSelfLink = resp.resourceSelfLinks[0];
|
||||||
|
this.setActiveGroupWithLink(groupSelfLink);
|
||||||
|
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLinkWithID(this.groupDataService.getUUIDFromString(groupSelfLink)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.created.failure', { name: groupToCreate.name }));
|
||||||
|
this.showNotificationIfNameInUse(groupToCreate, 'created');
|
||||||
|
this.cancelForm.emit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for the given group if there is already a group in the system with that group name and shows error if that
|
||||||
|
* is the case
|
||||||
|
* @param group group to check
|
||||||
|
* @param notificationSection whether in create or edit
|
||||||
|
*/
|
||||||
|
private showNotificationIfNameInUse(group: Group, notificationSection: string) {
|
||||||
|
// Relevant message for group name in use
|
||||||
|
this.subs.push(this.groupDataService.searchGroups(group.name, {
|
||||||
|
currentPage: 1,
|
||||||
|
elementsPerPage: 0
|
||||||
|
}).pipe(getSucceededRemoteData(), getRemoteDataPayload())
|
||||||
|
.subscribe((list: PaginatedList<Group>) => {
|
||||||
|
if (list.totalElements > 0) {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.' + notificationSection + '.failure.groupNameInUse', {
|
||||||
|
name: group.name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* // TODO
|
||||||
|
* @param group
|
||||||
|
* @param values
|
||||||
|
*/
|
||||||
|
editGroup(group: Group, values) {
|
||||||
|
// TODO (backend)
|
||||||
|
console.log('TODO implement editGroup', values);
|
||||||
|
this.notificationsService.error('TODO implement editGroup (not yet implemented in backend) ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start editing the selected group
|
||||||
|
* @param groupId ID of group to set as active
|
||||||
|
*/
|
||||||
|
setActiveGroup(groupId: string) {
|
||||||
|
this.groupDataService.cancelEditGroup();
|
||||||
|
this.groupDataService.findById(groupId)
|
||||||
|
.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload())
|
||||||
|
.subscribe((group: Group) => {
|
||||||
|
this.groupDataService.editGroup(group);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start editing the selected group
|
||||||
|
* @param groupSelfLink SelfLink of group to set as active
|
||||||
|
*/
|
||||||
|
setActiveGroupWithLink(groupSelfLink: string) {
|
||||||
|
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||||
|
if (activeGroup === null) {
|
||||||
|
this.groupDataService.cancelEditGroup();
|
||||||
|
this.groupDataService.findByHref(groupSelfLink)
|
||||||
|
.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload())
|
||||||
|
.subscribe((group: Group) => {
|
||||||
|
this.groupDataService.editGroup(group);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the current edit when component is destroyed & unsub all subscriptions
|
||||||
|
*/
|
||||||
|
@HostListener('window:beforeunload')
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.onCancel();
|
||||||
|
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,124 @@
|
|||||||
|
<ng-container>
|
||||||
|
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
||||||
|
|
||||||
|
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
|
||||||
|
<button (click)="clearFormAndResetResult();"
|
||||||
|
class="btn btn-primary float-right">{{messagePrefix + '.button.see-all' | translate}}</button>
|
||||||
|
</h4>
|
||||||
|
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="row">
|
||||||
|
<div class="col-12 col-sm-3">
|
||||||
|
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
|
||||||
|
<option value="metadata">{{messagePrefix + '.search.scope.metadata' | translate}}</option>
|
||||||
|
<option value="email">{{messagePrefix + '.search.scope.email' | translate}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-9 col-12">
|
||||||
|
<div class="form-group input-group">
|
||||||
|
<input type="text" name="query" id="query" formControlName="query"
|
||||||
|
class="form-control" aria-label="Search input">
|
||||||
|
<span class="input-group-append">
|
||||||
|
<button type="submit"
|
||||||
|
class="search-button btn btn-secondary">{{ messagePrefix + '.search.button' | translate }}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ds-pagination *ngIf="(ePeopleSearch | async)?.payload.totalElements > 0"
|
||||||
|
[paginationOptions]="configSearch"
|
||||||
|
[pageInfoState]="(ePeopleSearch | async)?.payload"
|
||||||
|
[collectionSize]="(ePeopleSearch | async)?.payload?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
(pageChange)="onPageChangeSearch($event)">
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="epersonsSearch" class="table table-striped table-hover table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||||
|
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let ePerson of (ePeopleSearch | async)?.payload?.page">
|
||||||
|
<td>{{ePerson.id}}</td>
|
||||||
|
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson)"
|
||||||
|
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.name}}</a></td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group edit-field">
|
||||||
|
<button *ngIf="(isMemberOfGroup(ePerson) | async)"
|
||||||
|
(click)="deleteMemberFromGroup(ePerson)"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.name} }}">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button *ngIf="!(isMemberOfGroup(ePerson) | async)"
|
||||||
|
(click)="addMemberToGroup(ePerson)"
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: ePerson.name} }}">
|
||||||
|
<i class="fas fa-plus fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ds-pagination>
|
||||||
|
|
||||||
|
<div *ngIf="(ePeopleSearch | async)?.payload.totalElements == 0 && searchDone"
|
||||||
|
class="alert alert-info w-100 mb-2"
|
||||||
|
role="alert">
|
||||||
|
{{messagePrefix + '.no-items' | translate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>{{messagePrefix + '.headMembers' | translate}}</h4>
|
||||||
|
|
||||||
|
<ds-pagination *ngIf="(ePeopleMembersOfGroup | async)?.payload.totalElements > 0"
|
||||||
|
[paginationOptions]="config"
|
||||||
|
[pageInfoState]="(ePeopleMembersOfGroup | async)?.payload"
|
||||||
|
[collectionSize]="(ePeopleMembersOfGroup | async)?.payload?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
(pageChange)="onPageChange($event)">
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||||
|
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let ePerson of (ePeopleMembersOfGroup | async)?.payload?.page">
|
||||||
|
<td>{{ePerson.id}}</td>
|
||||||
|
<td><a (click)="ePersonDataService.startEditingNewEPerson(ePerson)"
|
||||||
|
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">{{ePerson.name}}</a></td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group edit-field">
|
||||||
|
<button (click)="deleteMemberFromGroup(ePerson)"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: ePerson.name} }}">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ds-pagination>
|
||||||
|
|
||||||
|
<div *ngIf="(ePeopleMembersOfGroup | async)?.payload.totalElements == 0" class="alert alert-info w-100 mb-2"
|
||||||
|
role="alert">
|
||||||
|
{{messagePrefix + '.no-members-yet' | translate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ng-container>
|
@@ -0,0 +1,241 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { async, ComponentFixture, fakeAsync, flush, inject, TestBed, tick } from '@angular/core/testing';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { BrowserModule, By } from '@angular/platform-browser';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { RestResponse } from '../../../../../core/cache/response.models';
|
||||||
|
import { PaginatedList } from '../../../../../core/data/paginated-list';
|
||||||
|
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||||
|
import { EPersonDataService } from '../../../../../core/eperson/eperson-data.service';
|
||||||
|
import { GroupDataService } from '../../../../../core/eperson/group-data.service';
|
||||||
|
import { EPerson } from '../../../../../core/eperson/models/eperson.model';
|
||||||
|
import { Group } from '../../../../../core/eperson/models/group.model';
|
||||||
|
import { PageInfo } from '../../../../../core/shared/page-info.model';
|
||||||
|
import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service';
|
||||||
|
import { getMockFormBuilderService } from '../../../../../shared/mocks/mock-form-builder-service';
|
||||||
|
import { MockRouter } from '../../../../../shared/mocks/mock-router';
|
||||||
|
import { getMockTranslateService } from '../../../../../shared/mocks/mock-translate.service';
|
||||||
|
import { NotificationsService } from '../../../../../shared/notifications/notifications.service';
|
||||||
|
import { EPersonMock, EPersonMock2 } from '../../../../../shared/testing/eperson-mock';
|
||||||
|
import { GroupMock, GroupMock2 } from '../../../../../shared/testing/group-mock';
|
||||||
|
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
|
||||||
|
import { NotificationsServiceStub } from '../../../../../shared/testing/notifications-service-stub';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils';
|
||||||
|
import { MembersListComponent } from './members-list.component';
|
||||||
|
|
||||||
|
describe('MembersListComponent', () => {
|
||||||
|
let component: MembersListComponent;
|
||||||
|
let fixture: ComponentFixture<MembersListComponent>;
|
||||||
|
let translateService: TranslateService;
|
||||||
|
let builderService: FormBuilderService;
|
||||||
|
let ePersonDataServiceStub: any;
|
||||||
|
let groupsDataServiceStub: any;
|
||||||
|
let activeGroup;
|
||||||
|
let allEPersons;
|
||||||
|
let allGroups;
|
||||||
|
let epersonMembers;
|
||||||
|
let subgroupMembers;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
activeGroup = GroupMock;
|
||||||
|
epersonMembers = [EPersonMock2];
|
||||||
|
subgroupMembers = [GroupMock2];
|
||||||
|
allEPersons = [EPersonMock, EPersonMock2];
|
||||||
|
allGroups = [GroupMock, GroupMock2];
|
||||||
|
ePersonDataServiceStub = {
|
||||||
|
activeGroup: activeGroup,
|
||||||
|
epersonMembers: epersonMembers,
|
||||||
|
subgroupMembers: subgroupMembers,
|
||||||
|
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()))
|
||||||
|
},
|
||||||
|
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||||
|
if (query === '') {
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), allEPersons))
|
||||||
|
}
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||||
|
},
|
||||||
|
clearEPersonRequests() {
|
||||||
|
// empty
|
||||||
|
},
|
||||||
|
clearLinkRequests() {
|
||||||
|
// empty
|
||||||
|
},
|
||||||
|
getEPeoplePageRouterLink(): string {
|
||||||
|
return '/admin/access-control/epeople';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
groupsDataServiceStub = {
|
||||||
|
activeGroup: activeGroup,
|
||||||
|
epersonMembers: epersonMembers,
|
||||||
|
subgroupMembers: subgroupMembers,
|
||||||
|
allGroups: allGroups,
|
||||||
|
getActiveGroup(): Observable<Group> {
|
||||||
|
return observableOf(activeGroup);
|
||||||
|
},
|
||||||
|
getEPersonMembers() {
|
||||||
|
return this.epersonMembers;
|
||||||
|
},
|
||||||
|
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
|
if (query === '') {
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), this.allGroups))
|
||||||
|
}
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||||
|
},
|
||||||
|
addMemberToGroup(parentGroup, eperson: EPerson): Observable<RestResponse> {
|
||||||
|
this.epersonMembers = [...this.epersonMembers, eperson];
|
||||||
|
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||||
|
},
|
||||||
|
clearGroupsRequests() {
|
||||||
|
// empty
|
||||||
|
},
|
||||||
|
clearGroupLinkRequests() {
|
||||||
|
// empty
|
||||||
|
},
|
||||||
|
getGroupEditPageRouterLink(group: Group): string {
|
||||||
|
return '/admin/access-control/groups/' + group.id;
|
||||||
|
},
|
||||||
|
deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable<RestResponse> {
|
||||||
|
this.epersonMembers = this.epersonMembers.find((eperson: EPerson) => {
|
||||||
|
if (eperson.id !== epersonToDelete.id) {
|
||||||
|
return eperson;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.epersonMembers === undefined) {
|
||||||
|
this.epersonMembers = []
|
||||||
|
}
|
||||||
|
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
builderService = getMockFormBuilderService();
|
||||||
|
translateService = getMockTranslateService();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||||
|
TranslateModule.forRoot({
|
||||||
|
loader: {
|
||||||
|
provide: TranslateLoader,
|
||||||
|
useClass: MockTranslateLoader
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
declarations: [MembersListComponent],
|
||||||
|
providers: [MembersListComponent,
|
||||||
|
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||||
|
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||||
|
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||||
|
{ provide: FormBuilderService, useValue: builderService },
|
||||||
|
{ provide: Router, useValue: new MockRouter() },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(MembersListComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
afterEach(fakeAsync(() => {
|
||||||
|
fixture.destroy();
|
||||||
|
flush();
|
||||||
|
component = null;
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should create MembersListComponent', inject([MembersListComponent], (comp: MembersListComponent) => {
|
||||||
|
expect(comp).toBeDefined();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should show list of eperson members of current active group', () => {
|
||||||
|
const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child'));
|
||||||
|
expect(epersonIdsFound.length).toEqual(1);
|
||||||
|
epersonMembers.map((eperson: EPerson) => {
|
||||||
|
expect(epersonIdsFound.find((foundEl) => {
|
||||||
|
return (foundEl.nativeElement.textContent.trim() === eperson.uuid);
|
||||||
|
})).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search', () => {
|
||||||
|
describe('when searching without query', () => {
|
||||||
|
let epersonsFound;
|
||||||
|
beforeEach(fakeAsync(() => {
|
||||||
|
component.search({ scope: 'metadata', query: '' });
|
||||||
|
tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should display all epersons', () => {
|
||||||
|
expect(epersonsFound.length).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('if eperson is already a eperson', () => {
|
||||||
|
it('should have delete button, else it should have add button', () => {
|
||||||
|
activeGroup.epersons.map((eperson: EPerson) => {
|
||||||
|
epersonsFound.map((foundEPersonRowElement) => {
|
||||||
|
if (foundEPersonRowElement.debugElement !== undefined) {
|
||||||
|
const epersonId = foundEPersonRowElement.debugElement.query(By.css('td:first-child'));
|
||||||
|
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||||
|
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
|
if (epersonId.nativeElement.textContent === eperson.id) {
|
||||||
|
expect(addButton).toBeUndefined();
|
||||||
|
expect(deleteButton).toBeDefined();
|
||||||
|
} else {
|
||||||
|
expect(deleteButton).toBeUndefined();
|
||||||
|
expect(addButton).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('if first add button is pressed', () => {
|
||||||
|
beforeEach(fakeAsync(() => {
|
||||||
|
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
|
||||||
|
addButton.nativeElement.click();
|
||||||
|
tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}));
|
||||||
|
it('all groups in search member of selected group', () => {
|
||||||
|
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
||||||
|
expect(epersonsFound.length).toEqual(2);
|
||||||
|
epersonsFound.map((foundEPersonRowElement) => {
|
||||||
|
if (foundEPersonRowElement.debugElement !== undefined) {
|
||||||
|
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||||
|
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
|
expect(addButton).toBeUndefined();
|
||||||
|
expect(deleteButton).toBeDefined();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('if first delete button is pressed', () => {
|
||||||
|
beforeEach(fakeAsync(() => {
|
||||||
|
const addButton = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt'));
|
||||||
|
addButton.nativeElement.click();
|
||||||
|
tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}));
|
||||||
|
it('first eperson in search delete button, because now member', () => {
|
||||||
|
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
|
||||||
|
epersonsFound.map((foundEPersonRowElement) => {
|
||||||
|
if (foundEPersonRowElement.debugElement !== undefined) {
|
||||||
|
const addButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||||
|
const deleteButton = foundEPersonRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
|
expect(deleteButton).toBeUndefined();
|
||||||
|
expect(addButton).toBeDefined();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,247 @@
|
|||||||
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { FormBuilder } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { Observable, of as observableOf, Subscription } from 'rxjs';
|
||||||
|
import { map, mergeMap, take } from 'rxjs/operators';
|
||||||
|
import { RestResponse } from '../../../../../core/cache/response.models';
|
||||||
|
import { PaginatedList } from '../../../../../core/data/paginated-list';
|
||||||
|
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||||
|
import { EPersonDataService } from '../../../../../core/eperson/eperson-data.service';
|
||||||
|
import { GroupDataService } from '../../../../../core/eperson/group-data.service';
|
||||||
|
import { EPerson } from '../../../../../core/eperson/models/eperson.model';
|
||||||
|
import { Group } from '../../../../../core/eperson/models/group.model';
|
||||||
|
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../../core/shared/operators';
|
||||||
|
import { hasValue } from '../../../../../shared/empty.util';
|
||||||
|
import { NotificationsService } from '../../../../../shared/notifications/notifications.service';
|
||||||
|
import { PaginationComponentOptions } from '../../../../../shared/pagination/pagination-component-options.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-members-list',
|
||||||
|
templateUrl: './members-list.component.html'
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* The list of members in the edit group page
|
||||||
|
*/
|
||||||
|
export class MembersListComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
messagePrefix: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EPeople being displayed in search result, initially all members, after search result of search
|
||||||
|
*/
|
||||||
|
ePeopleSearch: Observable<RemoteData<PaginatedList<EPerson>>>;
|
||||||
|
/**
|
||||||
|
* List of EPeople members of currently active group being edited
|
||||||
|
*/
|
||||||
|
ePeopleMembersOfGroup: Observable<RemoteData<PaginatedList<EPerson>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination config used to display the list of EPeople that are result of EPeople search
|
||||||
|
*/
|
||||||
|
configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'search-members-list-pagination',
|
||||||
|
pageSize: 5,
|
||||||
|
currentPage: 1
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Pagination config used to display the list of EPerson Membes of active group being edited
|
||||||
|
*/
|
||||||
|
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'members-list-pagination',
|
||||||
|
pageSize: 5,
|
||||||
|
currentPage: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of subscriptions
|
||||||
|
*/
|
||||||
|
subs: Subscription[] = [];
|
||||||
|
|
||||||
|
// The search form
|
||||||
|
searchForm;
|
||||||
|
|
||||||
|
// Current search in edit group - epeople search form
|
||||||
|
currentSearchQuery: string;
|
||||||
|
currentSearchScope: string;
|
||||||
|
|
||||||
|
// Whether or not user has done a EPeople search yet
|
||||||
|
searchDone: boolean;
|
||||||
|
|
||||||
|
// current active group being edited
|
||||||
|
groupBeingEdited: Group;
|
||||||
|
|
||||||
|
constructor(private groupDataService: GroupDataService,
|
||||||
|
public ePersonDataService: EPersonDataService,
|
||||||
|
private translateService: TranslateService,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private router: Router) {
|
||||||
|
this.currentSearchQuery = '';
|
||||||
|
this.currentSearchScope = 'metadata';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.searchForm = this.formBuilder.group(({
|
||||||
|
scope: 'metadata',
|
||||||
|
query: '',
|
||||||
|
}));
|
||||||
|
this.subs.push(this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
|
||||||
|
if (activeGroup != null) {
|
||||||
|
this.groupBeingEdited = activeGroup;
|
||||||
|
this.forceUpdateEPeople(activeGroup);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event triggered when the user changes page on search result
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
onPageChangeSearch(event) {
|
||||||
|
this.configSearch.currentPage = event;
|
||||||
|
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event triggered when the user changes page on EPerson embers of active group
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
onPageChange(event) {
|
||||||
|
this.ePeopleMembersOfGroup = this.ePersonDataService.findAllByHref(this.groupBeingEdited._links.epersons.href, {
|
||||||
|
currentPage: event,
|
||||||
|
elementsPerPage: this.config.pageSize
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a given EPerson from the members list of the group currently being edited
|
||||||
|
* @param ePerson EPerson we want to delete as member from group that is currently being edited
|
||||||
|
*/
|
||||||
|
deleteMemberFromGroup(ePerson: EPerson) {
|
||||||
|
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||||
|
if (activeGroup != null) {
|
||||||
|
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson);
|
||||||
|
this.showNotifications('deleteMember', response, ePerson.name, activeGroup);
|
||||||
|
this.forceUpdateEPeople(activeGroup);
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a given EPerson to the members list of the group currently being edited
|
||||||
|
* @param ePerson EPerson we want to add as member to group that is currently being edited
|
||||||
|
*/
|
||||||
|
addMemberToGroup(ePerson: EPerson) {
|
||||||
|
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||||
|
if (activeGroup != null) {
|
||||||
|
const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson);
|
||||||
|
this.showNotifications('addMember', response, ePerson.name, activeGroup);
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.forceUpdateEPeople(this.groupBeingEdited, ePerson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the given ePerson is a member of the group currently being edited
|
||||||
|
* @param possibleMember EPerson that is a possible member (being tested) of the group currently being edited
|
||||||
|
*/
|
||||||
|
isMemberOfGroup(possibleMember: EPerson): Observable<boolean> {
|
||||||
|
return this.groupDataService.getActiveGroup().pipe(take(1),
|
||||||
|
mergeMap((group: Group) => {
|
||||||
|
if (group != null) {
|
||||||
|
return this.ePersonDataService.findAllByHref(group._links.epersons.href, {
|
||||||
|
currentPage: 0,
|
||||||
|
elementsPerPage: Number.MAX_SAFE_INTEGER
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
map((listEPeopleInGroup: PaginatedList<EPerson>) => listEPeopleInGroup.page.filter((ePersonInList: EPerson) => ePersonInList.id === possibleMember.id)),
|
||||||
|
map((epeople: EPerson[]) => epeople.length > 0))
|
||||||
|
} else {
|
||||||
|
return observableOf(false);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search in the EPeople by name, email or metadata
|
||||||
|
* @param data Contains scope and query param
|
||||||
|
*/
|
||||||
|
search(data: any) {
|
||||||
|
const query: string = data.query;
|
||||||
|
const scope: string = data.scope;
|
||||||
|
if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) {
|
||||||
|
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(this.groupBeingEdited));
|
||||||
|
this.currentSearchQuery = query;
|
||||||
|
this.configSearch.currentPage = 1;
|
||||||
|
}
|
||||||
|
if (scope != null && this.currentSearchScope !== scope && this.groupBeingEdited) {
|
||||||
|
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(this.groupBeingEdited));
|
||||||
|
this.currentSearchScope = scope;
|
||||||
|
this.configSearch.currentPage = 1;
|
||||||
|
}
|
||||||
|
this.searchDone = true;
|
||||||
|
this.ePeopleSearch = this.ePersonDataService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
|
||||||
|
currentPage: this.configSearch.currentPage,
|
||||||
|
elementsPerPage: this.configSearch.pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
|
||||||
|
* a new REST call
|
||||||
|
* @param activeGroup Group currently being edited
|
||||||
|
*/
|
||||||
|
public forceUpdateEPeople(activeGroup: Group, ePersonToUpdate?: EPerson) {
|
||||||
|
if (ePersonToUpdate != null) {
|
||||||
|
this.ePersonDataService.clearLinkRequests(ePersonToUpdate._links.groups.href);
|
||||||
|
}
|
||||||
|
this.ePersonDataService.clearLinkRequests(activeGroup._links.epersons.href);
|
||||||
|
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(activeGroup));
|
||||||
|
this.ePeopleMembersOfGroup = this.ePersonDataService.findAllByHref(activeGroup._links.epersons.href, {
|
||||||
|
currentPage: this.configSearch.currentPage,
|
||||||
|
elementsPerPage: this.configSearch.pageSize
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unsub all subscriptions
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a notification based on the success/failure of the request
|
||||||
|
* @param messageSuffix Suffix for message
|
||||||
|
* @param response RestResponse observable containing success/failure request
|
||||||
|
* @param nameObject Object request was about
|
||||||
|
* @param activeGroup Group currently being edited
|
||||||
|
*/
|
||||||
|
showNotifications(messageSuffix: string, response: Observable<RestResponse>, nameObject: string, activeGroup: Group) {
|
||||||
|
response.pipe(take(1)).subscribe((restResponse: RestResponse) => {
|
||||||
|
if (restResponse.isSuccessful) {
|
||||||
|
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject }));
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all input-fields to be empty and search all search
|
||||||
|
*/
|
||||||
|
clearFormAndResetResult() {
|
||||||
|
this.searchForm.patchValue({
|
||||||
|
query: '',
|
||||||
|
});
|
||||||
|
this.search({ query: '' });
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,117 @@
|
|||||||
|
<ng-container>
|
||||||
|
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
|
||||||
|
|
||||||
|
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
|
||||||
|
<button (click)="clearFormAndResetResult();"
|
||||||
|
class="btn btn-primary float-right">{{messagePrefix + '.button.see-all' | translate}}</button>
|
||||||
|
</h4>
|
||||||
|
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-group input-group">
|
||||||
|
<input type="text" name="query" id="query" formControlName="query"
|
||||||
|
class="form-control" aria-label="Search input">
|
||||||
|
<span class="input-group-append">
|
||||||
|
<button type="submit"
|
||||||
|
class="search-button btn btn-secondary">{{ messagePrefix + '.search.button' | translate }}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ds-pagination *ngIf="(groupsSearch | async)?.payload.totalElements > 0"
|
||||||
|
[paginationOptions]="configSearch"
|
||||||
|
[pageInfoState]="(groupsSearch | async)?.payload"
|
||||||
|
[collectionSize]="(groupsSearch | async)?.payload?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
(pageChange)="onPageChangeSearch($event)">
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="groupsSearch" class="table table-striped table-hover table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||||
|
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let group of (groupsSearch | async)?.payload?.page">
|
||||||
|
<td>{{group.id}}</td>
|
||||||
|
<td><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||||
|
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group edit-field">
|
||||||
|
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
||||||
|
(click)="deleteSubgroupFromGroup(group)"
|
||||||
|
class="btn btn-outline-danger btn-sm deleteButton"
|
||||||
|
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: group.name} }}">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</p>
|
||||||
|
|
||||||
|
<button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
|
||||||
|
(click)="addSubgroupToGroup(group)"
|
||||||
|
class="btn btn-outline-primary btn-sm addButton"
|
||||||
|
title="{{messagePrefix + '.table.edit.buttons.add' | translate: {name: group.name} }}">
|
||||||
|
<i class="fas fa-plus fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ds-pagination>
|
||||||
|
|
||||||
|
<div *ngIf="(groupsSearch | async)?.payload.totalElements == 0 && searchDone" class="alert alert-info w-100 mb-2"
|
||||||
|
role="alert">
|
||||||
|
{{messagePrefix + '.no-items' | translate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>{{messagePrefix + '.headSubgroups' | translate}}</h4>
|
||||||
|
|
||||||
|
<ds-pagination *ngIf="(subgroupsOfGroup | async)?.payload.totalElements > 0"
|
||||||
|
[paginationOptions]="config"
|
||||||
|
[pageInfoState]="(subgroupsOfGroup | async)?.payload"
|
||||||
|
[collectionSize]="(subgroupsOfGroup | async)?.payload?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
(pageChange)="onPageChange($event)">
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.id' | translate}}</th>
|
||||||
|
<th scope="col">{{messagePrefix + '.table.name' | translate}}</th>
|
||||||
|
<th>{{messagePrefix + '.table.edit' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let group of (subgroupsOfGroup | async)?.payload?.page">
|
||||||
|
<td>{{group.id}}</td>
|
||||||
|
<td><a (click)="groupDataService.startEditingNewGroup(group)"
|
||||||
|
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">{{group.name}}</a></td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group edit-field">
|
||||||
|
<button (click)="deleteSubgroupFromGroup(group)"
|
||||||
|
class="btn btn-outline-danger btn-sm deleteButton"
|
||||||
|
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: {name: group.name} }}">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ds-pagination>
|
||||||
|
|
||||||
|
<div *ngIf="(subgroupsOfGroup | async)?.payload.totalElements == 0" class="alert alert-info w-100 mb-2"
|
||||||
|
role="alert">
|
||||||
|
{{messagePrefix + '.no-subgroups-yet' | translate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ng-container>
|
@@ -0,0 +1,208 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { async, ComponentFixture, fakeAsync, flush, inject, TestBed, tick } from '@angular/core/testing';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { BrowserModule, By } from '@angular/platform-browser';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { RestResponse } from '../../../../../core/cache/response.models';
|
||||||
|
import { PaginatedList } from '../../../../../core/data/paginated-list';
|
||||||
|
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||||
|
import { GroupDataService } from '../../../../../core/eperson/group-data.service';
|
||||||
|
import { Group } from '../../../../../core/eperson/models/group.model';
|
||||||
|
import { PageInfo } from '../../../../../core/shared/page-info.model';
|
||||||
|
import { FormBuilderService } from '../../../../../shared/form/builder/form-builder.service';
|
||||||
|
import { getMockFormBuilderService } from '../../../../../shared/mocks/mock-form-builder-service';
|
||||||
|
import { MockRouter } from '../../../../../shared/mocks/mock-router';
|
||||||
|
import { getMockTranslateService } from '../../../../../shared/mocks/mock-translate.service';
|
||||||
|
import { NotificationsService } from '../../../../../shared/notifications/notifications.service';
|
||||||
|
import { GroupMock, GroupMock2 } from '../../../../../shared/testing/group-mock';
|
||||||
|
import { MockTranslateLoader } from '../../../../../shared/testing/mock-translate-loader';
|
||||||
|
import { NotificationsServiceStub } from '../../../../../shared/testing/notifications-service-stub';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils';
|
||||||
|
import { SubgroupsListComponent } from './subgroups-list.component';
|
||||||
|
|
||||||
|
describe('SubgroupsListComponent', () => {
|
||||||
|
let component: SubgroupsListComponent;
|
||||||
|
let fixture: ComponentFixture<SubgroupsListComponent>;
|
||||||
|
let translateService: TranslateService;
|
||||||
|
let builderService: FormBuilderService;
|
||||||
|
let ePersonDataServiceStub: any;
|
||||||
|
let groupsDataServiceStub: any;
|
||||||
|
let activeGroup;
|
||||||
|
let subgroups;
|
||||||
|
let allGroups;
|
||||||
|
let routerStub;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
activeGroup = GroupMock;
|
||||||
|
subgroups = [GroupMock2];
|
||||||
|
allGroups = [GroupMock, GroupMock2];
|
||||||
|
ePersonDataServiceStub = {};
|
||||||
|
groupsDataServiceStub = {
|
||||||
|
activeGroup: activeGroup,
|
||||||
|
subgroups: subgroups,
|
||||||
|
getActiveGroup(): Observable<Group> {
|
||||||
|
return observableOf(this.activeGroup);
|
||||||
|
},
|
||||||
|
getSubgroups(): Group {
|
||||||
|
return this.activeGroup;
|
||||||
|
},
|
||||||
|
findAllByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList<Group>(new PageInfo(), this.subgroups))
|
||||||
|
},
|
||||||
|
getGroupEditPageRouterLink(group: Group): string {
|
||||||
|
return '/admin/access-control/groups/' + group.id;
|
||||||
|
},
|
||||||
|
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
|
if (query === '') {
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), allGroups))
|
||||||
|
}
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||||
|
},
|
||||||
|
addSubGroupToGroup(parentGroup, subgroup: Group): Observable<RestResponse> {
|
||||||
|
this.subgroups = [...this.subgroups, subgroup];
|
||||||
|
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||||
|
},
|
||||||
|
clearGroupsRequests() {
|
||||||
|
// empty
|
||||||
|
},
|
||||||
|
clearGroupLinkRequests() {
|
||||||
|
// empty
|
||||||
|
},
|
||||||
|
deleteSubGroupFromGroup(parentGroup, subgroup: Group): Observable<RestResponse> {
|
||||||
|
this.subgroups = this.subgroups.find((group: Group) => {
|
||||||
|
if (group.id !== subgroup.id) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return observableOf(new RestResponse(true, 200, 'Success'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
routerStub = new MockRouter();
|
||||||
|
builderService = getMockFormBuilderService();
|
||||||
|
translateService = getMockTranslateService();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||||
|
TranslateModule.forRoot({
|
||||||
|
loader: {
|
||||||
|
provide: TranslateLoader,
|
||||||
|
useClass: MockTranslateLoader
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
declarations: [SubgroupsListComponent],
|
||||||
|
providers: [SubgroupsListComponent,
|
||||||
|
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||||
|
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||||
|
{ provide: FormBuilderService, useValue: builderService },
|
||||||
|
{ provide: Router, useValue: routerStub },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(SubgroupsListComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
afterEach(fakeAsync(() => {
|
||||||
|
fixture.destroy();
|
||||||
|
flush();
|
||||||
|
component = null;
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should create SubgroupsListComponent', inject([SubgroupsListComponent], (comp: SubgroupsListComponent) => {
|
||||||
|
expect(comp).toBeDefined();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should show list of subgroups of current active group', () => {
|
||||||
|
const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child'));
|
||||||
|
expect(groupIdsFound.length).toEqual(1);
|
||||||
|
activeGroup.subgroups.map((group: Group) => {
|
||||||
|
expect(groupIdsFound.find((foundEl) => {
|
||||||
|
return (foundEl.nativeElement.textContent.trim() === group.uuid);
|
||||||
|
})).toBeTruthy();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('if first group delete button is pressed', () => {
|
||||||
|
let groupsFound;
|
||||||
|
beforeEach(fakeAsync(() => {
|
||||||
|
const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton'));
|
||||||
|
addButton.triggerEventHandler('click', {
|
||||||
|
preventDefault: () => {/**/
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
}));
|
||||||
|
it('one less subgroup in list from 1 to 0 (of 2 total groups)', () => {
|
||||||
|
groupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr'));
|
||||||
|
expect(groupsFound.length).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search', () => {
|
||||||
|
describe('when searching with empty query', () => {
|
||||||
|
let groupsFound;
|
||||||
|
beforeEach(fakeAsync(() => {
|
||||||
|
component.search({ query: '' });
|
||||||
|
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should display all groups', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||||
|
expect(groupsFound.length).toEqual(2);
|
||||||
|
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||||
|
const groupIdsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child'));
|
||||||
|
allGroups.map((group: Group) => {
|
||||||
|
expect(groupIdsFound.find((foundEl) => {
|
||||||
|
return (foundEl.nativeElement.textContent.trim() === group.uuid);
|
||||||
|
})).toBeTruthy();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('if group is already a subgroup', () => {
|
||||||
|
it('should have delete button, else it should have add button', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
|
||||||
|
const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups;
|
||||||
|
if (getSubgroups !== undefined && getSubgroups.length > 0) {
|
||||||
|
groupsFound.map((foundGroupRowElement) => {
|
||||||
|
if (foundGroupRowElement.debugElement !== undefined) {
|
||||||
|
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||||
|
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
|
expect(addButton).toBeUndefined();
|
||||||
|
expect(deleteButton).toBeDefined();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
getSubgroups.map((group: Group) => {
|
||||||
|
groupsFound.map((foundGroupRowElement) => {
|
||||||
|
if (foundGroupRowElement.debugElement !== undefined) {
|
||||||
|
const groupId = foundGroupRowElement.debugElement.query(By.css('td:first-child'));
|
||||||
|
const addButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-plus'));
|
||||||
|
const deleteButton = foundGroupRowElement.debugElement.query(By.css('td:last-child .fa-trash-alt'));
|
||||||
|
if (groupId.nativeElement.textContent === group.id) {
|
||||||
|
expect(addButton).toBeUndefined();
|
||||||
|
expect(deleteButton).toBeDefined();
|
||||||
|
} else {
|
||||||
|
expect(deleteButton).toBeUndefined();
|
||||||
|
expect(addButton).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,253 @@
|
|||||||
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { FormBuilder } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { Observable, of as observableOf, Subscription } from 'rxjs';
|
||||||
|
import { map, mergeMap, take } from 'rxjs/operators';
|
||||||
|
import { RestResponse } from '../../../../../core/cache/response.models';
|
||||||
|
import { PaginatedList } from '../../../../../core/data/paginated-list';
|
||||||
|
import { RemoteData } from '../../../../../core/data/remote-data';
|
||||||
|
import { GroupDataService } from '../../../../../core/eperson/group-data.service';
|
||||||
|
import { Group } from '../../../../../core/eperson/models/group.model';
|
||||||
|
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../../core/shared/operators';
|
||||||
|
import { hasValue } from '../../../../../shared/empty.util';
|
||||||
|
import { NotificationsService } from '../../../../../shared/notifications/notifications.service';
|
||||||
|
import { PaginationComponentOptions } from '../../../../../shared/pagination/pagination-component-options.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-subgroups-list',
|
||||||
|
templateUrl: './subgroups-list.component.html'
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* The list of subgroups in the edit group page
|
||||||
|
*/
|
||||||
|
export class SubgroupsListComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
messagePrefix: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of search groups, initially all groups
|
||||||
|
*/
|
||||||
|
groupsSearch: Observable<RemoteData<PaginatedList<Group>>>;
|
||||||
|
/**
|
||||||
|
* List of all subgroups of group being edited
|
||||||
|
*/
|
||||||
|
subgroupsOfGroup: Observable<RemoteData<PaginatedList<Group>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of subscriptions
|
||||||
|
*/
|
||||||
|
subs: Subscription[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination config used to display the list of groups that are result of groups search
|
||||||
|
*/
|
||||||
|
configSearch: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'search-subgroups-list-pagination',
|
||||||
|
pageSize: 5,
|
||||||
|
currentPage: 1
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Pagination config used to display the list of subgroups of currently active group being edited
|
||||||
|
*/
|
||||||
|
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'subgroups-list-pagination',
|
||||||
|
pageSize: 5,
|
||||||
|
currentPage: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// The search form
|
||||||
|
searchForm;
|
||||||
|
|
||||||
|
// Current search in edit group - groups search form
|
||||||
|
currentSearchQuery: string;
|
||||||
|
|
||||||
|
// Whether or not user has done a Groups search yet
|
||||||
|
searchDone: boolean;
|
||||||
|
|
||||||
|
// current active group being edited
|
||||||
|
groupBeingEdited: Group;
|
||||||
|
|
||||||
|
constructor(public groupDataService: GroupDataService,
|
||||||
|
private translateService: TranslateService,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private router: Router) {
|
||||||
|
this.currentSearchQuery = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.searchForm = this.formBuilder.group(({
|
||||||
|
query: '',
|
||||||
|
}));
|
||||||
|
this.subs.push(this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
|
||||||
|
if (activeGroup != null) {
|
||||||
|
this.groupBeingEdited = activeGroup;
|
||||||
|
this.forceUpdateGroups(activeGroup);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event triggered when the user changes page on search result
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
onPageChangeSearch(event) {
|
||||||
|
this.configSearch.currentPage = event;
|
||||||
|
this.search({ query: this.currentSearchQuery });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event triggered when the user changes page on subgroups of active group
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
onPageChange(event) {
|
||||||
|
this.subgroupsOfGroup = this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, {
|
||||||
|
currentPage: event,
|
||||||
|
elementsPerPage: this.config.pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the given group is a subgroup of the group currently being edited
|
||||||
|
* @param possibleSubgroup Group that is a possible subgroup (being tested) of the group currently being edited
|
||||||
|
*/
|
||||||
|
isSubgroupOfGroup(possibleSubgroup: Group): Observable<boolean> {
|
||||||
|
return this.groupDataService.getActiveGroup().pipe(take(1),
|
||||||
|
mergeMap((activeGroup: Group) => {
|
||||||
|
if (activeGroup != null) {
|
||||||
|
if (activeGroup.uuid === possibleSubgroup.uuid) {
|
||||||
|
return observableOf(false);
|
||||||
|
} else {
|
||||||
|
return this.groupDataService.findAllByHref(activeGroup._links.subgroups.href, {
|
||||||
|
currentPage: 0,
|
||||||
|
elementsPerPage: Number.MAX_SAFE_INTEGER
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
map((listTotalGroups: PaginatedList<Group>) => listTotalGroups.page.filter((groupInList: Group) => groupInList.id === possibleSubgroup.id)),
|
||||||
|
map((groups: Group[]) => groups.length > 0))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return observableOf(false);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether or not the given group is the current group being edited
|
||||||
|
* @param group Group that is possibly the current group being edited
|
||||||
|
*/
|
||||||
|
isActiveGroup(group: Group): Observable<boolean> {
|
||||||
|
return this.groupDataService.getActiveGroup().pipe(take(1),
|
||||||
|
mergeMap((activeGroup: Group) => {
|
||||||
|
if (activeGroup != null && activeGroup.uuid === group.uuid) {
|
||||||
|
return observableOf(true);
|
||||||
|
}
|
||||||
|
return observableOf(false);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes given subgroup from the group currently being edited
|
||||||
|
* @param subgroup Group we want to delete from the subgroups of the group currently being edited
|
||||||
|
*/
|
||||||
|
deleteSubgroupFromGroup(subgroup: Group) {
|
||||||
|
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||||
|
if (activeGroup != null) {
|
||||||
|
const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup);
|
||||||
|
this.showNotifications('deleteSubgroup', response, subgroup.name, activeGroup);
|
||||||
|
this.forceUpdateGroups(activeGroup);
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds given subgroup to the group currently being edited
|
||||||
|
* @param subgroup Subgroup to add to group currently being edited
|
||||||
|
*/
|
||||||
|
addSubgroupToGroup(subgroup: Group) {
|
||||||
|
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||||
|
if (activeGroup != null) {
|
||||||
|
if (activeGroup.uuid !== subgroup.uuid) {
|
||||||
|
const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup);
|
||||||
|
this.showNotifications('addSubgroup', response, subgroup.name, activeGroup);
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.forceUpdateGroups(this.groupBeingEdited);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search in the groups (searches by group name and by uuid exact match)
|
||||||
|
* @param data Contains query param
|
||||||
|
*/
|
||||||
|
search(data: any) {
|
||||||
|
const query: string = data.query;
|
||||||
|
if (query != null && this.currentSearchQuery !== query) {
|
||||||
|
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(this.groupBeingEdited));
|
||||||
|
this.currentSearchQuery = query;
|
||||||
|
this.configSearch.currentPage = 1;
|
||||||
|
}
|
||||||
|
this.searchDone = true;
|
||||||
|
this.groupsSearch = this.groupDataService.searchGroups(this.currentSearchQuery, {
|
||||||
|
currentPage: this.configSearch.currentPage,
|
||||||
|
elementsPerPage: this.configSearch.pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-update the list of groups by first clearing the cache of results of this active groups' subgroups, then performing a new REST call
|
||||||
|
* @param activeGroup Group currently being edited
|
||||||
|
*/
|
||||||
|
public forceUpdateGroups(activeGroup: Group) {
|
||||||
|
this.groupDataService.clearGroupLinkRequests(activeGroup._links.subgroups.href);
|
||||||
|
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLink(activeGroup));
|
||||||
|
this.subgroupsOfGroup = this.groupDataService.findAllByHref(activeGroup._links.subgroups.href, {
|
||||||
|
currentPage: this.config.currentPage,
|
||||||
|
elementsPerPage: this.config.pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* unsub all subscriptions
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a notification based on the success/failure of the request
|
||||||
|
* @param messageSuffix Suffix for message
|
||||||
|
* @param response RestResponse observable containing success/failure request
|
||||||
|
* @param nameObject Object request was about
|
||||||
|
* @param activeGroup Group currently being edited
|
||||||
|
*/
|
||||||
|
showNotifications(messageSuffix: string, response: Observable<RestResponse>, nameObject: string, activeGroup: Group) {
|
||||||
|
response.pipe(take(1)).subscribe((restResponse: RestResponse) => {
|
||||||
|
if (restResponse.isSuccessful) {
|
||||||
|
this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject }));
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all input-fields to be empty and search all search
|
||||||
|
*/
|
||||||
|
clearFormAndResetResult() {
|
||||||
|
this.searchForm.patchValue({
|
||||||
|
query: '',
|
||||||
|
});
|
||||||
|
this.search({ query: '' });
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,49 @@
|
|||||||
|
import { Action } from '@ngrx/store';
|
||||||
|
import { Group } from '../../../core/eperson/models/group.model';
|
||||||
|
import { type } from '../../../shared/ngrx/type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each action type in an action group, make a simple
|
||||||
|
* enum object for all of this group's action types.
|
||||||
|
*
|
||||||
|
* The 'type' utility function coerces strings into string
|
||||||
|
* literal types and runs a simple check to guarantee all
|
||||||
|
* action types in the application are unique.
|
||||||
|
*/
|
||||||
|
export const GroupRegistryActionTypes = {
|
||||||
|
|
||||||
|
EDIT_GROUP: type('dspace/epeople-registry/EDIT_GROUP'),
|
||||||
|
CANCEL_EDIT_GROUP: type('dspace/epeople-registry/CANCEL_EDIT_GROUP'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* tslint:disable:max-classes-per-file */
|
||||||
|
/**
|
||||||
|
* Used to edit a Group in the Group registry
|
||||||
|
*/
|
||||||
|
export class GroupRegistryEditGroupAction implements Action {
|
||||||
|
type = GroupRegistryActionTypes.EDIT_GROUP;
|
||||||
|
|
||||||
|
group: Group;
|
||||||
|
|
||||||
|
constructor(group: Group) {
|
||||||
|
this.group = group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to cancel the editing of a Group in the Group registry
|
||||||
|
*/
|
||||||
|
export class GroupRegistryCancelGroupAction implements Action {
|
||||||
|
type = GroupRegistryActionTypes.CANCEL_EDIT_GROUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a type alias of all actions in this action group
|
||||||
|
* so that reducers can easily compose action types
|
||||||
|
* These are all the actions to perform on the EPeople registry state
|
||||||
|
*/
|
||||||
|
export type GroupRegistryAction
|
||||||
|
= GroupRegistryEditGroupAction
|
||||||
|
| GroupRegistryCancelGroupAction
|
@@ -0,0 +1,54 @@
|
|||||||
|
import { GroupMock } from '../../../shared/testing/group-mock';
|
||||||
|
import { GroupRegistryCancelGroupAction, GroupRegistryEditGroupAction } from './group-registry.actions';
|
||||||
|
import { groupRegistryReducer, GroupRegistryState } from './group-registry.reducers';
|
||||||
|
|
||||||
|
const initialState: GroupRegistryState = {
|
||||||
|
editGroup: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const editState: GroupRegistryState = {
|
||||||
|
editGroup: GroupMock,
|
||||||
|
};
|
||||||
|
|
||||||
|
class NullAction extends GroupRegistryEditGroupAction {
|
||||||
|
type = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('groupRegistryReducer', () => {
|
||||||
|
|
||||||
|
it('should return the current state when no valid actions have been made', () => {
|
||||||
|
const state = initialState;
|
||||||
|
const action = new NullAction();
|
||||||
|
const newState = groupRegistryReducer(state, action);
|
||||||
|
|
||||||
|
expect(newState).toEqual(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start with an initial state', () => {
|
||||||
|
const state = initialState;
|
||||||
|
const action = new NullAction();
|
||||||
|
const initState = groupRegistryReducer(undefined, action);
|
||||||
|
|
||||||
|
expect(initState).toEqual(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the current state to change the editGroup to a new group when GroupRegistryEditGroupAction is dispatched', () => {
|
||||||
|
const state = editState;
|
||||||
|
const action = new GroupRegistryEditGroupAction(GroupMock);
|
||||||
|
const newState = groupRegistryReducer(state, action);
|
||||||
|
|
||||||
|
expect(newState.editGroup).toEqual(GroupMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the current state to remove the editGroup from the state when GroupRegistryCancelGroupAction is dispatched', () => {
|
||||||
|
const state = editState;
|
||||||
|
const action = new GroupRegistryCancelGroupAction();
|
||||||
|
const newState = groupRegistryReducer(state, action);
|
||||||
|
|
||||||
|
expect(newState.editGroup).toEqual(null);
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,43 @@
|
|||||||
|
import { Group } from '../../../core/eperson/models/group.model';
|
||||||
|
import { GroupRegistryAction, GroupRegistryActionTypes, GroupRegistryEditGroupAction } from './group-registry.actions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata registry state.
|
||||||
|
* @interface GroupRegistryState
|
||||||
|
*/
|
||||||
|
export interface GroupRegistryState {
|
||||||
|
editGroup: Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initial state.
|
||||||
|
*/
|
||||||
|
const initialState: GroupRegistryState = {
|
||||||
|
editGroup: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reducer that handles GroupRegistryActions to modify Groups
|
||||||
|
* @param state The current GroupRegistryState
|
||||||
|
* @param action The GroupRegistryAction to perform on the state
|
||||||
|
*/
|
||||||
|
export function groupRegistryReducer(state = initialState, action: GroupRegistryAction): GroupRegistryState {
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
|
||||||
|
case GroupRegistryActionTypes.EDIT_GROUP: {
|
||||||
|
return Object.assign({}, state, {
|
||||||
|
editGroup: (action as GroupRegistryEditGroupAction).group
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case GroupRegistryActionTypes.CANCEL_EDIT_GROUP: {
|
||||||
|
return Object.assign({}, state, {
|
||||||
|
editGroup: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,84 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="groups-registry row">
|
||||||
|
<div class="col-12">
|
||||||
|
|
||||||
|
<h2 id="header" class="border-bottom pb-2">{{messagePrefix + 'head' | translate}}</h2>
|
||||||
|
|
||||||
|
<div class="button-row top d-flex pb-2">
|
||||||
|
<button class="mr-auto btn btn-success"
|
||||||
|
[routerLink]="['newGroup']">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{messagePrefix + 'button.add' | translate}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 id="search" class="border-bottom pb-2">{{messagePrefix + 'search.head' | translate}}
|
||||||
|
<button (click)="clearFormAndResetResult();"
|
||||||
|
class="btn btn-primary float-right">{{messagePrefix + 'button.see-all' | translate}}</button>
|
||||||
|
</h3>
|
||||||
|
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-group input-group">
|
||||||
|
<input type="text" name="query" id="query" formControlName="query"
|
||||||
|
class="form-control" aria-label="Search input">
|
||||||
|
<span class="input-group-append">
|
||||||
|
<button type="submit"
|
||||||
|
class="search-button btn btn-secondary">{{ messagePrefix + 'search.button' | translate }}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ds-pagination
|
||||||
|
*ngIf="(groups | async)?.payload?.totalElements > 0"
|
||||||
|
[paginationOptions]="config"
|
||||||
|
[pageInfoState]="(groups | async)?.payload"
|
||||||
|
[collectionSize]="(groups | async)?.payload?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
(pageChange)="onPageChange($event)">
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="groups" class="table table-striped table-hover table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{messagePrefix + 'table.id' | translate}}</th>
|
||||||
|
<th scope="col">{{messagePrefix + 'table.name' | translate}}</th>
|
||||||
|
<th scope="col">{{messagePrefix + 'table.members' | translate}}</th>
|
||||||
|
<!-- <th scope="col">{{messagePrefix + 'table.comcol' | translate}}</th>-->
|
||||||
|
<th>{{messagePrefix + 'table.edit' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let group of (groups | async)?.payload?.page">
|
||||||
|
<td>{{group.id}}</td>
|
||||||
|
<td>{{group.name}}</td>
|
||||||
|
<td>{{(getMembers(group) | async)?.payload?.totalElements + (getSubgroups(group) | async)?.payload?.totalElements}}</td>
|
||||||
|
<!-- <td>{{getOptionalComColFromName(group.name)}}</td>-->
|
||||||
|
<td>
|
||||||
|
<div class="btn-group edit-field">
|
||||||
|
<button [routerLink]="groupService.getGroupEditPageRouterLink(group)"
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: group.name} }}">
|
||||||
|
<i class="fas fa-edit fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<button *ngIf="!group?.permanent" (click)="deleteGroup(group)"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: group.name} }}">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ds-pagination>
|
||||||
|
|
||||||
|
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||||
|
{{messagePrefix + 'no-items' | translate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,139 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { async, ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { BrowserModule, By } from '@angular/platform-browser';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||||
|
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
||||||
|
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||||
|
import { Group } from '../../../core/eperson/models/group.model';
|
||||||
|
import { RouteService } from '../../../core/services/route.service';
|
||||||
|
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||||
|
import { MockRouter } from '../../../shared/mocks/mock-router';
|
||||||
|
import { MockTranslateLoader } from '../../../shared/mocks/mock-translate-loader';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { EPersonMock, EPersonMock2 } from '../../../shared/testing/eperson-mock';
|
||||||
|
import { GroupMock, GroupMock2 } from '../../../shared/testing/group-mock';
|
||||||
|
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
|
||||||
|
import { routeServiceStub } from '../../../shared/testing/route-service-stub';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils';
|
||||||
|
import { GroupsRegistryComponent } from './groups-registry.component';
|
||||||
|
|
||||||
|
describe('GroupRegistryComponent', () => {
|
||||||
|
let component: GroupsRegistryComponent;
|
||||||
|
let fixture: ComponentFixture<GroupsRegistryComponent>;
|
||||||
|
let ePersonDataServiceStub: any;
|
||||||
|
let groupsDataServiceStub: any;
|
||||||
|
|
||||||
|
let mockGroups;
|
||||||
|
let mockEPeople;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
mockGroups = [GroupMock, GroupMock2];
|
||||||
|
mockEPeople = [EPersonMock, EPersonMock2];
|
||||||
|
ePersonDataServiceStub = {
|
||||||
|
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||||
|
switch (href) {
|
||||||
|
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons':
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
|
||||||
|
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons':
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(null, [EPersonMock]));
|
||||||
|
default:
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
groupsDataServiceStub = {
|
||||||
|
allGroups: mockGroups,
|
||||||
|
findAllByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
|
switch (href) {
|
||||||
|
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups':
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
|
||||||
|
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/groups':
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(null, [GroupMock2]));
|
||||||
|
default:
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getGroupEditPageRouterLink(group: Group): string {
|
||||||
|
return '/admin/access-control/groups/' + group.id;
|
||||||
|
},
|
||||||
|
getGroupRegistryRouterLink(): string {
|
||||||
|
return '/admin/access-control/groups';
|
||||||
|
},
|
||||||
|
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
|
if (query === '') {
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(null, this.allGroups));
|
||||||
|
}
|
||||||
|
const result = this.allGroups.find((group: Group) => {
|
||||||
|
return (group.id.includes(query))
|
||||||
|
});
|
||||||
|
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [result]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||||
|
TranslateModule.forRoot({
|
||||||
|
loader: {
|
||||||
|
provide: TranslateLoader,
|
||||||
|
useClass: MockTranslateLoader
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
declarations: [GroupsRegistryComponent],
|
||||||
|
providers: [GroupsRegistryComponent,
|
||||||
|
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||||
|
{ provide: GroupDataService, useValue: groupsDataServiceStub },
|
||||||
|
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||||
|
{ provide: RouteService, useValue: routeServiceStub },
|
||||||
|
{ provide: Router, useValue: new MockRouter() },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(GroupsRegistryComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create GroupRegistryComponent', inject([GroupsRegistryComponent], (comp: GroupsRegistryComponent) => {
|
||||||
|
expect(comp).toBeDefined();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should display list of groups', () => {
|
||||||
|
const groupIdsFound = fixture.debugElement.queryAll(By.css('#groups tr td:first-child'));
|
||||||
|
expect(groupIdsFound.length).toEqual(2);
|
||||||
|
mockGroups.map((group: Group) => {
|
||||||
|
expect(groupIdsFound.find((foundEl) => {
|
||||||
|
return (foundEl.nativeElement.textContent.trim() === group.uuid);
|
||||||
|
})).toBeTruthy();
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search', () => {
|
||||||
|
describe('when searching with query', () => {
|
||||||
|
let groupIdsFound;
|
||||||
|
beforeEach(fakeAsync(() => {
|
||||||
|
component.search({ query: GroupMock2.id });
|
||||||
|
tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
groupIdsFound = fixture.debugElement.queryAll(By.css('#groups tr td:first-child'));
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should display search result', () => {
|
||||||
|
expect(groupIdsFound.length).toEqual(1);
|
||||||
|
expect(groupIdsFound.find((foundEl) => {
|
||||||
|
return (foundEl.nativeElement.textContent.trim() === GroupMock2.uuid);
|
||||||
|
})).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,154 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { FormBuilder } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||||
|
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
||||||
|
import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||||
|
import { Group } from '../../../core/eperson/models/group.model';
|
||||||
|
import { RouteService } from '../../../core/services/route.service';
|
||||||
|
import { hasValue } from '../../../shared/empty.util';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-groups-registry',
|
||||||
|
templateUrl: './groups-registry.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* A component used for managing all existing groups within the repository.
|
||||||
|
* The admin can create, edit or delete groups here.
|
||||||
|
*/
|
||||||
|
export class GroupsRegistryComponent implements OnInit {
|
||||||
|
|
||||||
|
messagePrefix = 'admin.access-control.groups.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination config used to display the list of groups
|
||||||
|
*/
|
||||||
|
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'groups-list-pagination',
|
||||||
|
pageSize: 5,
|
||||||
|
currentPage: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all the current groups within the repository or the result of the search
|
||||||
|
*/
|
||||||
|
groups: Observable<RemoteData<PaginatedList<Group>>>;
|
||||||
|
|
||||||
|
// The search form
|
||||||
|
searchForm;
|
||||||
|
|
||||||
|
// Current search in groups registry
|
||||||
|
currentSearchQuery: string;
|
||||||
|
|
||||||
|
constructor(private groupService: GroupDataService,
|
||||||
|
private ePersonDataService: EPersonDataService,
|
||||||
|
private translateService: TranslateService,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
protected routeService: RouteService,
|
||||||
|
private router: Router) {
|
||||||
|
this.currentSearchQuery = '';
|
||||||
|
this.searchForm = this.formBuilder.group(({
|
||||||
|
query: this.currentSearchQuery,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.search({ query: this.currentSearchQuery });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event triggered when the user changes page
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
onPageChange(event) {
|
||||||
|
this.config.currentPage = event;
|
||||||
|
this.search({ query: this.currentSearchQuery })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search in the groups (searches by group name and by uuid exact match)
|
||||||
|
* @param data Contains query param
|
||||||
|
*/
|
||||||
|
search(data: any) {
|
||||||
|
const query: string = data.query;
|
||||||
|
if (query != null && this.currentSearchQuery !== query) {
|
||||||
|
this.router.navigateByUrl(this.groupService.getGroupRegistryRouterLink());
|
||||||
|
this.currentSearchQuery = query;
|
||||||
|
this.config.currentPage = 1;
|
||||||
|
}
|
||||||
|
this.groups = this.groupService.searchGroups(this.currentSearchQuery.trim(), {
|
||||||
|
currentPage: this.config.currentPage,
|
||||||
|
elementsPerPage: this.config.pageSize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Group
|
||||||
|
*/
|
||||||
|
deleteGroup(group: Group) {
|
||||||
|
// TODO (backend)
|
||||||
|
console.log('TODO implement editGroup', group);
|
||||||
|
this.notificationsService.error('TODO implement deleteGroup (not yet implemented in backend)');
|
||||||
|
if (hasValue(group.id)) {
|
||||||
|
this.groupService.deleteGroup(group).pipe(take(1)).subscribe((success: boolean) => {
|
||||||
|
if (success) {
|
||||||
|
this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: group.name }));
|
||||||
|
this.forceUpdateGroup();
|
||||||
|
} else {
|
||||||
|
this.notificationsService.error(this.translateService.get(this.messagePrefix + 'notification.deleted.failure', { name: group.name }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-update the list of groups by first clearing the cache related to groups, then performing a new REST call
|
||||||
|
*/
|
||||||
|
public forceUpdateGroup() {
|
||||||
|
this.groupService.clearGroupsRequests();
|
||||||
|
this.search({ query: this.currentSearchQuery })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the members (epersons embedded value of a group)
|
||||||
|
* @param group
|
||||||
|
*/
|
||||||
|
getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||||
|
return this.ePersonDataService.findAllByHref(group._links.epersons.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the subgroups (groups embedded value of a group)
|
||||||
|
* @param group
|
||||||
|
*/
|
||||||
|
getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
|
return this.groupService.findAllByHref(group._links.subgroups.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all input-fields to be empty and search all search
|
||||||
|
*/
|
||||||
|
clearFormAndResetResult() {
|
||||||
|
this.searchForm.patchValue({
|
||||||
|
query: '',
|
||||||
|
});
|
||||||
|
this.search({ query: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract optional UUID from a group name => To be resolved to community or collection with link
|
||||||
|
* (Or will be resolved in backend and added to group object, tbd) //TODO
|
||||||
|
* @param groupName
|
||||||
|
*/
|
||||||
|
getOptionalComColFromName(groupName: string): string {
|
||||||
|
return this.groupService.getUUIDFromString(groupName);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,9 +1,9 @@
|
|||||||
import { RouterModule } from '@angular/router';
|
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
import { RouterModule } from '@angular/router';
|
||||||
import { getAdminModulePath } from '../app-routing.module';
|
import { getAdminModulePath } from '../app-routing.module';
|
||||||
import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component';
|
import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component';
|
||||||
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||||
|
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||||
|
|
||||||
const REGISTRIES_MODULE_PATH = 'registries';
|
const REGISTRIES_MODULE_PATH = 'registries';
|
||||||
const ACCESS_CONTROL_MODULE_PATH = 'access-control';
|
const ACCESS_CONTROL_MODULE_PATH = 'access-control';
|
||||||
@@ -12,6 +12,10 @@ export function getRegistriesModulePath() {
|
|||||||
return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString();
|
return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAccessControlModulePath() {
|
||||||
|
return new URLCombiner(getAdminModulePath(), ACCESS_CONTROL_MODULE_PATH).toString();
|
||||||
|
}
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild([
|
RouterModule.forChild([
|
||||||
@@ -28,8 +32,8 @@ export function getRegistriesModulePath() {
|
|||||||
resolve: { breadcrumb: I18nBreadcrumbResolver },
|
resolve: { breadcrumb: I18nBreadcrumbResolver },
|
||||||
component: AdminSearchPageComponent,
|
component: AdminSearchPageComponent,
|
||||||
data: { title: 'admin.search.title', breadcrumbKey: 'admin.search' }
|
data: { title: 'admin.search.title', breadcrumbKey: 'admin.search' }
|
||||||
},
|
}
|
||||||
])
|
]),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AdminRoutingModule {
|
export class AdminRoutingModule {
|
||||||
|
@@ -336,7 +336,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
|||||||
model: {
|
model: {
|
||||||
type: MenuItemType.LINK,
|
type: MenuItemType.LINK,
|
||||||
text: 'menu.section.access_control_groups',
|
text: 'menu.section.access_control_groups',
|
||||||
link: ''
|
link: '/admin/access-control/groups'
|
||||||
} as LinkMenuItemModel,
|
} as LinkMenuItemModel,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
30
src/app/+bitstream-page/bitstream-page-routing.module.ts
Normal file
30
src/app/+bitstream-page/bitstream-page-routing.module.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
|
||||||
|
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||||
|
import { BitstreamPageResolver } from './bitstream-page.resolver';
|
||||||
|
|
||||||
|
const EDIT_BITSTREAM_PATH = ':id/edit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routing module to help navigate Bitstream pages
|
||||||
|
*/
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forChild([
|
||||||
|
{
|
||||||
|
path: EDIT_BITSTREAM_PATH,
|
||||||
|
component: EditBitstreamPageComponent,
|
||||||
|
resolve: {
|
||||||
|
bitstream: BitstreamPageResolver
|
||||||
|
},
|
||||||
|
canActivate: [AuthenticatedGuard]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
BitstreamPageResolver,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class BitstreamPageRoutingModule {
|
||||||
|
}
|
21
src/app/+bitstream-page/bitstream-page.module.ts
Normal file
21
src/app/+bitstream-page/bitstream-page.module.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
|
||||||
|
import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This module handles all components that are necessary for Bitstream related pages
|
||||||
|
*/
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
SharedModule,
|
||||||
|
BitstreamPageRoutingModule
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
EditBitstreamPageComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class BitstreamPageModule {
|
||||||
|
}
|
31
src/app/+bitstream-page/bitstream-page.resolver.ts
Normal file
31
src/app/+bitstream-page/bitstream-page.resolver.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||||
|
import { RemoteData } from '../core/data/remote-data';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { find } from 'rxjs/operators';
|
||||||
|
import { hasValue } from '../shared/empty.util';
|
||||||
|
import { Bitstream } from '../core/shared/bitstream.model';
|
||||||
|
import { BitstreamDataService } from '../core/data/bitstream-data.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class represents a resolver that requests a specific bitstream before the route is activated
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BitstreamPageResolver implements Resolve<RemoteData<Bitstream>> {
|
||||||
|
constructor(private bitstreamService: BitstreamDataService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method for resolving a bitstream based on the parameters in the current route
|
||||||
|
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
|
||||||
|
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
|
||||||
|
* @returns Observable<<RemoteData<Item>> Emits the found bitstream based on the parameters in the current route,
|
||||||
|
* or an error if something went wrong
|
||||||
|
*/
|
||||||
|
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Bitstream>> {
|
||||||
|
return this.bitstreamService.findById(route.params.id)
|
||||||
|
.pipe(
|
||||||
|
find((RD) => hasValue(RD.error) || RD.hasSucceeded),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,29 @@
|
|||||||
|
<ng-container *ngVar="(bitstreamRD$ | async) as bitstreamRD">
|
||||||
|
<div class="container" *ngVar="(bitstreamFormatsRD$ | async) as formatsRD">
|
||||||
|
<div class="row" *ngIf="bitstreamRD?.hasSucceeded && formatsRD?.hasSucceeded">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<ds-thumbnail [thumbnail]="bitstreamRD?.payload"></ds-thumbnail>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-10">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h3>{{bitstreamRD?.payload?.name}} <span class="text-muted">({{bitstreamRD?.payload?.sizeBytes | dsFileSize}})</span></h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ds-form [formId]="'edit-bitstream-form-id'"
|
||||||
|
[formGroup]="formGroup"
|
||||||
|
[formModel]="formModel"
|
||||||
|
[formLayout]="formLayout"
|
||||||
|
[submitLabel]="'form.save'"
|
||||||
|
(submitForm)="onSubmit()"
|
||||||
|
(cancel)="onCancel()"
|
||||||
|
(dfChange)="onChange($event)"></ds-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error>
|
||||||
|
<ds-loading *ngIf="!bitstreamRD || !formatsRD || bitstreamRD?.isLoading || formatsRD?.isLoading"
|
||||||
|
message="{{'loading.bitstream' | translate}}"></ds-loading>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
@@ -0,0 +1,8 @@
|
|||||||
|
:host {
|
||||||
|
::ng-deep {
|
||||||
|
.switch {
|
||||||
|
position: absolute;
|
||||||
|
top: $spacer*2.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,216 @@
|
|||||||
|
import { EditBitstreamPageComponent } from './edit-bitstream-page.component';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||||
|
import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
|
||||||
|
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||||
|
import { NotificationType } from '../../shared/notifications/models/notification-type';
|
||||||
|
import { INotification, Notification } from '../../shared/notifications/models/notification.model';
|
||||||
|
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
|
||||||
|
import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level';
|
||||||
|
import { hasValue } from '../../shared/empty.util';
|
||||||
|
import { FormControl, FormGroup } from '@angular/forms';
|
||||||
|
import { PaginatedList } from '../../core/data/paginated-list';
|
||||||
|
import { PageInfo } from '../../core/shared/page-info.model';
|
||||||
|
import { FileSizePipe } from '../../shared/utils/file-size-pipe';
|
||||||
|
import { RestResponse } from '../../core/cache/response.models';
|
||||||
|
import { VarDirective } from '../../shared/utils/var.directive';
|
||||||
|
|
||||||
|
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
|
||||||
|
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
|
||||||
|
const successNotification: INotification = new Notification('id', NotificationType.Success, 'success');
|
||||||
|
|
||||||
|
let notificationsService: NotificationsService;
|
||||||
|
let formService: DynamicFormService;
|
||||||
|
let bitstreamService: BitstreamDataService;
|
||||||
|
let bitstreamFormatService: BitstreamFormatDataService;
|
||||||
|
let bitstream: Bitstream;
|
||||||
|
let selectedFormat: BitstreamFormat;
|
||||||
|
let allFormats: BitstreamFormat[];
|
||||||
|
|
||||||
|
describe('EditBitstreamPageComponent', () => {
|
||||||
|
let comp: EditBitstreamPageComponent;
|
||||||
|
let fixture: ComponentFixture<EditBitstreamPageComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
allFormats = [
|
||||||
|
Object.assign({
|
||||||
|
id: '1',
|
||||||
|
shortDescription: 'Unknown',
|
||||||
|
description: 'Unknown format',
|
||||||
|
supportLevel: BitstreamFormatSupportLevel.Unknown,
|
||||||
|
_links: {
|
||||||
|
self: { href: 'format-selflink-1' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Object.assign({
|
||||||
|
id: '2',
|
||||||
|
shortDescription: 'PNG',
|
||||||
|
description: 'Portable Network Graphics',
|
||||||
|
supportLevel: BitstreamFormatSupportLevel.Known,
|
||||||
|
_links: {
|
||||||
|
self: { href: 'format-selflink-2' }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Object.assign({
|
||||||
|
id: '3',
|
||||||
|
shortDescription: 'GIF',
|
||||||
|
description: 'Graphics Interchange Format',
|
||||||
|
supportLevel: BitstreamFormatSupportLevel.Known,
|
||||||
|
_links: {
|
||||||
|
self: { href: 'format-selflink-3' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
] as BitstreamFormat[];
|
||||||
|
selectedFormat = allFormats[1];
|
||||||
|
notificationsService = jasmine.createSpyObj('notificationsService',
|
||||||
|
{
|
||||||
|
info: infoNotification,
|
||||||
|
warning: warningNotification,
|
||||||
|
success: successNotification
|
||||||
|
}
|
||||||
|
);
|
||||||
|
formService = Object.assign({
|
||||||
|
createFormGroup: (fModel: DynamicFormControlModel[]) => {
|
||||||
|
const controls = {};
|
||||||
|
if (hasValue(fModel)) {
|
||||||
|
fModel.forEach((controlModel) => {
|
||||||
|
controls[controlModel.id] = new FormControl((controlModel as any).value);
|
||||||
|
});
|
||||||
|
return new FormGroup(controls);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bitstream = Object.assign(new Bitstream(), {
|
||||||
|
metadata: {
|
||||||
|
'dc.description': [
|
||||||
|
{
|
||||||
|
value: 'Bitstream description'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'dc.title': [
|
||||||
|
{
|
||||||
|
value: 'Bitstream title'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
format: observableOf(new RemoteData(false, false, true, null, selectedFormat)),
|
||||||
|
_links: {
|
||||||
|
self: 'bitstream-selflink'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bitstreamService = jasmine.createSpyObj('bitstreamService', {
|
||||||
|
findById: observableOf(new RemoteData(false, false, true, null, bitstream)),
|
||||||
|
update: observableOf(new RemoteData(false, false, true, null, bitstream)),
|
||||||
|
updateFormat: observableOf(new RestResponse(true, 200, 'OK')),
|
||||||
|
commitUpdates: {},
|
||||||
|
patch: {}
|
||||||
|
});
|
||||||
|
bitstreamFormatService = jasmine.createSpyObj('bitstreamFormatService', {
|
||||||
|
findAll: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), allFormats)))
|
||||||
|
});
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||||
|
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
|
||||||
|
providers: [
|
||||||
|
{ provide: NotificationsService, useValue: notificationsService },
|
||||||
|
{ provide: DynamicFormService, useValue: formService },
|
||||||
|
{ provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: new RemoteData(false, false, true, null, bitstream) }), snapshot: { queryParams: {} } } },
|
||||||
|
{ provide: BitstreamDataService, useValue: bitstreamService },
|
||||||
|
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
|
||||||
|
ChangeDetectorRef
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(EditBitstreamPageComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('on startup', () => {
|
||||||
|
let rawForm;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rawForm = comp.formGroup.getRawValue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fill in the bitstream\'s title', () => {
|
||||||
|
expect(rawForm.fileNamePrimaryContainer.fileName).toEqual(bitstream.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fill in the bitstream\'s description', () => {
|
||||||
|
expect(rawForm.descriptionContainer.description).toEqual(bitstream.firstMetadataValue('dc.description'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select the correct format', () => {
|
||||||
|
expect(rawForm.formatContainer.selectedFormat).toEqual(selectedFormat.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put the \"New Format\" input on invisible', () => {
|
||||||
|
expect(comp.formLayout.newFormat.grid.host).toContain('invisible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when an unknown format is selected', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.updateNewFormatLayout(allFormats[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove the invisible class from the \"New Format\" input', () => {
|
||||||
|
expect(comp.formLayout.newFormat.grid.host).not.toContain('invisible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onSubmit', () => {
|
||||||
|
describe('when selected format hasn\'t changed', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.onSubmit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call update', () => {
|
||||||
|
expect(bitstreamService.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should commit the updates', () => {
|
||||||
|
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when selected format has changed', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.formGroup.patchValue({
|
||||||
|
formatContainer: {
|
||||||
|
selectedFormat: allFormats[2].id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fixture.detectChanges();
|
||||||
|
comp.onSubmit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call update', () => {
|
||||||
|
expect(bitstreamService.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call updateFormat', () => {
|
||||||
|
expect(bitstreamService.updateFormat).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should commit the updates', () => {
|
||||||
|
expect(bitstreamService.commitUpdates).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,524 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { filter, map, switchMap } from 'rxjs/operators';
|
||||||
|
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
|
||||||
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
|
import {
|
||||||
|
DynamicFormControlModel,
|
||||||
|
DynamicFormGroupModel,
|
||||||
|
DynamicFormLayout,
|
||||||
|
DynamicFormService,
|
||||||
|
DynamicInputModel,
|
||||||
|
DynamicSelectModel,
|
||||||
|
DynamicTextAreaModel
|
||||||
|
} from '@ng-dynamic-forms/core';
|
||||||
|
import { FormGroup } from '@angular/forms';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||||
|
import {
|
||||||
|
getAllSucceededRemoteData, getAllSucceededRemoteDataPayload,
|
||||||
|
getFirstSucceededRemoteDataPayload,
|
||||||
|
getRemoteDataPayload,
|
||||||
|
getSucceededRemoteData
|
||||||
|
} from '../../core/shared/operators';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
|
||||||
|
import { BitstreamFormat } from '../../core/shared/bitstream-format.model';
|
||||||
|
import { BitstreamFormatSupportLevel } from '../../core/shared/bitstream-format-support-level';
|
||||||
|
import { RestResponse } from '../../core/cache/response.models';
|
||||||
|
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||||
|
import { Metadata } from '../../core/shared/metadata.utils';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
|
import { PaginatedList } from '../../core/data/paginated-list';
|
||||||
|
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||||
|
import { getItemEditPath } from '../../+item-page/item-page-routing.module';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-edit-bitstream-page',
|
||||||
|
styleUrls: ['./edit-bitstream-page.component.scss'],
|
||||||
|
templateUrl: './edit-bitstream-page.component.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Page component for editing a bitstream
|
||||||
|
*/
|
||||||
|
export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bitstream's remote data observable
|
||||||
|
* Tracks changes and updates the view
|
||||||
|
*/
|
||||||
|
bitstreamRD$: Observable<RemoteData<Bitstream>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The formats their remote data observable
|
||||||
|
* Tracks changes and updates the view
|
||||||
|
*/
|
||||||
|
bitstreamFormatsRD$: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bitstream to edit
|
||||||
|
*/
|
||||||
|
bitstream: Bitstream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The originally selected format
|
||||||
|
*/
|
||||||
|
originalFormat: BitstreamFormat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all available bitstream formats
|
||||||
|
*/
|
||||||
|
formats: BitstreamFormat[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string} Key prefix used to generate form messages
|
||||||
|
*/
|
||||||
|
KEY_PREFIX = 'bitstream.edit.form.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string} Key suffix used to generate form labels
|
||||||
|
*/
|
||||||
|
LABEL_KEY_SUFFIX = '.label';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string} Key suffix used to generate form labels
|
||||||
|
*/
|
||||||
|
HINT_KEY_SUFFIX = '.hint';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {string} Key prefix used to generate notification messages
|
||||||
|
*/
|
||||||
|
NOTIFICATIONS_PREFIX = 'bitstream.edit.notifications.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for fetching all bitstream formats
|
||||||
|
*/
|
||||||
|
findAllOptions = { elementsPerPage: 9999 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dynamic Input Model for the file's name
|
||||||
|
*/
|
||||||
|
fileNameModel = new DynamicInputModel({
|
||||||
|
id: 'fileName',
|
||||||
|
name: 'fileName',
|
||||||
|
required: true,
|
||||||
|
validators: {
|
||||||
|
required: null
|
||||||
|
},
|
||||||
|
errorMessages: {
|
||||||
|
required: 'You must provide a file name for the bitstream'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dynamic Switch Model for the file's name
|
||||||
|
*/
|
||||||
|
primaryBitstreamModel = new DynamicCustomSwitchModel({
|
||||||
|
id: 'primaryBitstream',
|
||||||
|
name: 'primaryBitstream'
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dynamic TextArea Model for the file's description
|
||||||
|
*/
|
||||||
|
descriptionModel = new DynamicTextAreaModel({
|
||||||
|
id: 'description',
|
||||||
|
name: 'description',
|
||||||
|
rows: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dynamic Input Model for the file's embargo (disabled on this page)
|
||||||
|
*/
|
||||||
|
embargoModel = new DynamicInputModel({
|
||||||
|
id: 'embargo',
|
||||||
|
name: 'embargo',
|
||||||
|
disabled: true
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dynamic Input Model for the selected format
|
||||||
|
*/
|
||||||
|
selectedFormatModel = new DynamicSelectModel({
|
||||||
|
id: 'selectedFormat',
|
||||||
|
name: 'selectedFormat'
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Dynamic Input Model for supplying more format information
|
||||||
|
*/
|
||||||
|
newFormatModel = new DynamicInputModel({
|
||||||
|
id: 'newFormat',
|
||||||
|
name: 'newFormat'
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All input models in a simple array for easier iterations
|
||||||
|
*/
|
||||||
|
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.embargoModel, this.selectedFormatModel, this.newFormatModel];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dynamic form fields used for editing the information of a bitstream
|
||||||
|
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
|
||||||
|
*/
|
||||||
|
formModel: DynamicFormControlModel[] = [
|
||||||
|
new DynamicFormGroupModel({
|
||||||
|
id: 'fileNamePrimaryContainer',
|
||||||
|
group: [
|
||||||
|
this.fileNameModel,
|
||||||
|
this.primaryBitstreamModel
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
new DynamicFormGroupModel({
|
||||||
|
id: 'descriptionContainer',
|
||||||
|
group: [
|
||||||
|
this.descriptionModel
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
new DynamicFormGroupModel({
|
||||||
|
id: 'embargoContainer',
|
||||||
|
group: [
|
||||||
|
this.embargoModel
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
new DynamicFormGroupModel({
|
||||||
|
id: 'formatContainer',
|
||||||
|
group: [
|
||||||
|
this.selectedFormatModel,
|
||||||
|
this.newFormatModel
|
||||||
|
]
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base layout of the "Other Format" input
|
||||||
|
*/
|
||||||
|
newFormatBaseLayout = 'col col-sm-6 d-inline-block';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout used for structuring the form inputs
|
||||||
|
*/
|
||||||
|
formLayout: DynamicFormLayout = {
|
||||||
|
fileName: {
|
||||||
|
grid: {
|
||||||
|
host: 'col col-sm-8 d-inline-block'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
primaryBitstream: {
|
||||||
|
grid: {
|
||||||
|
host: 'col col-sm-4 d-inline-block switch'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
grid: {
|
||||||
|
host: 'col-12 d-inline-block'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
embargo: {
|
||||||
|
grid: {
|
||||||
|
host: 'col-12 d-inline-block'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedFormat: {
|
||||||
|
grid: {
|
||||||
|
host: 'col col-sm-6 d-inline-block'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
newFormat: {
|
||||||
|
grid: {
|
||||||
|
host: this.newFormatBaseLayout + ' invisible'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fileNamePrimaryContainer: {
|
||||||
|
grid: {
|
||||||
|
host: 'row position-relative'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
descriptionContainer: {
|
||||||
|
grid: {
|
||||||
|
host: 'row'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
embargoContainer: {
|
||||||
|
grid: {
|
||||||
|
host: 'row'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatContainer: {
|
||||||
|
grid: {
|
||||||
|
host: 'row'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form group of this form
|
||||||
|
*/
|
||||||
|
formGroup: FormGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the item the bitstream originates from
|
||||||
|
* Taken from the current query parameters when present
|
||||||
|
*/
|
||||||
|
itemId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array to track all subscriptions and unsubscribe them onDestroy
|
||||||
|
* @type {Array}
|
||||||
|
*/
|
||||||
|
protected subs: Subscription[] = [];
|
||||||
|
|
||||||
|
constructor(private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private location: Location,
|
||||||
|
private formService: DynamicFormService,
|
||||||
|
private translate: TranslateService,
|
||||||
|
private bitstreamService: BitstreamDataService,
|
||||||
|
private notificationsService: NotificationsService,
|
||||||
|
private bitstreamFormatService: BitstreamFormatDataService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the component
|
||||||
|
* - Create a FormGroup using the FormModel defined earlier
|
||||||
|
* - Subscribe on the route data to fetch the bitstream to edit and update the form values
|
||||||
|
* - Translate the form labels and hints
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.formGroup = this.formService.createFormGroup(this.formModel);
|
||||||
|
|
||||||
|
this.itemId = this.route.snapshot.queryParams.itemId;
|
||||||
|
this.bitstreamRD$ = this.route.data.pipe(map((data) => data.bitstream));
|
||||||
|
this.bitstreamFormatsRD$ = this.bitstreamFormatService.findAll(this.findAllOptions);
|
||||||
|
|
||||||
|
const bitstream$ = this.bitstreamRD$.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
switchMap((bitstream: Bitstream) => this.bitstreamService.findById(bitstream.id, followLink('format')).pipe(
|
||||||
|
getAllSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
filter((bs: Bitstream) => hasValue(bs)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const allFormats$ = this.bitstreamFormatsRD$.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.subs.push(
|
||||||
|
observableCombineLatest(
|
||||||
|
bitstream$,
|
||||||
|
allFormats$
|
||||||
|
).subscribe(([bitstream, allFormats]) => {
|
||||||
|
this.bitstream = bitstream as Bitstream;
|
||||||
|
this.formats = allFormats.page;
|
||||||
|
this.updateFormatModel();
|
||||||
|
this.updateForm(this.bitstream);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updateFieldTranslations();
|
||||||
|
|
||||||
|
this.subs.push(
|
||||||
|
this.translate.onLangChange
|
||||||
|
.subscribe(() => {
|
||||||
|
this.updateFieldTranslations();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current form values with bitstream properties
|
||||||
|
* @param bitstream
|
||||||
|
*/
|
||||||
|
updateForm(bitstream: Bitstream) {
|
||||||
|
this.formGroup.patchValue({
|
||||||
|
fileNamePrimaryContainer: {
|
||||||
|
fileName: bitstream.name,
|
||||||
|
primaryBitstream: false
|
||||||
|
},
|
||||||
|
descriptionContainer: {
|
||||||
|
description: bitstream.firstMetadataValue('dc.description')
|
||||||
|
},
|
||||||
|
formatContainer: {
|
||||||
|
newFormat: hasValue(bitstream.firstMetadata('dc.format')) ? bitstream.firstMetadata('dc.format').value : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.bitstream.format.pipe(
|
||||||
|
getAllSucceededRemoteDataPayload()
|
||||||
|
).subscribe((format: BitstreamFormat) => {
|
||||||
|
this.originalFormat = format;
|
||||||
|
this.formGroup.patchValue({
|
||||||
|
formatContainer: {
|
||||||
|
selectedFormat: format.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.updateNewFormatLayout(format.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the list of unknown format IDs an add options to the selectedFormatModel
|
||||||
|
*/
|
||||||
|
updateFormatModel() {
|
||||||
|
this.selectedFormatModel.options = this.formats.map((format: BitstreamFormat) =>
|
||||||
|
Object.assign({
|
||||||
|
value: format.id,
|
||||||
|
label: this.isUnknownFormat(format.id) ? this.translate.instant(this.KEY_PREFIX + 'selectedFormat.unknown') : format.shortDescription
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the layout of the "Other Format" input depending on the selected format
|
||||||
|
* @param selectedId
|
||||||
|
*/
|
||||||
|
updateNewFormatLayout(selectedId: string) {
|
||||||
|
if (this.isUnknownFormat(selectedId)) {
|
||||||
|
this.formLayout.newFormat.grid.host = this.newFormatBaseLayout;
|
||||||
|
} else {
|
||||||
|
this.formLayout.newFormat.grid.host = this.newFormatBaseLayout + ' invisible';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the provided format (id) part of the list of unknown formats?
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
isUnknownFormat(id: string): boolean {
|
||||||
|
const format = this.formats.find((f: BitstreamFormat) => f.id === id);
|
||||||
|
return hasValue(format) && format.supportLevel === BitstreamFormatSupportLevel.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to update translations of labels and hints on init and on language change
|
||||||
|
*/
|
||||||
|
private updateFieldTranslations() {
|
||||||
|
this.inputModels.forEach(
|
||||||
|
(fieldModel: DynamicFormControlModel) => {
|
||||||
|
this.updateFieldTranslation(fieldModel);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the translations of a DynamicFormControlModel
|
||||||
|
* @param fieldModel
|
||||||
|
*/
|
||||||
|
private updateFieldTranslation(fieldModel) {
|
||||||
|
fieldModel.label = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.LABEL_KEY_SUFFIX);
|
||||||
|
if (fieldModel.id !== this.primaryBitstreamModel.id) {
|
||||||
|
fieldModel.hint = this.translate.instant(this.KEY_PREFIX + fieldModel.id + this.HINT_KEY_SUFFIX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired whenever the form receives an update and changes the layout of the "Other Format" input, depending on the selected format
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
onChange(event) {
|
||||||
|
const model = event.model;
|
||||||
|
if (model.id === this.selectedFormatModel.id) {
|
||||||
|
this.updateNewFormatLayout(model.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for changes against the bitstream and send update requests to the REST API
|
||||||
|
*/
|
||||||
|
onSubmit() {
|
||||||
|
const updatedValues = this.formGroup.getRawValue();
|
||||||
|
const updatedBitstream = this.formToBitstream(updatedValues);
|
||||||
|
const selectedFormat = this.formats.find((f: BitstreamFormat) => f.id === updatedValues.formatContainer.selectedFormat);
|
||||||
|
const isNewFormat = selectedFormat.id !== this.originalFormat.id;
|
||||||
|
|
||||||
|
let bitstream$;
|
||||||
|
|
||||||
|
if (isNewFormat) {
|
||||||
|
bitstream$ = this.bitstreamService.updateFormat(this.bitstream, selectedFormat).pipe(
|
||||||
|
switchMap((formatResponse: RestResponse) => {
|
||||||
|
if (hasValue(formatResponse) && !formatResponse.isSuccessful) {
|
||||||
|
this.notificationsService.error(
|
||||||
|
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.format.title'),
|
||||||
|
formatResponse.statusText
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return this.bitstreamService.findById(this.bitstream.id).pipe(
|
||||||
|
getFirstSucceededRemoteDataPayload()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bitstream$ = observableOf(this.bitstream);
|
||||||
|
}
|
||||||
|
|
||||||
|
bitstream$.pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
return this.bitstreamService.update(updatedBitstream).pipe(
|
||||||
|
getFirstSucceededRemoteDataPayload()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
).subscribe(() => {
|
||||||
|
this.bitstreamService.commitUpdates();
|
||||||
|
this.notificationsService.success(
|
||||||
|
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.title'),
|
||||||
|
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'saved.content')
|
||||||
|
);
|
||||||
|
this.navigateToItemEditBitstreams();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse form data to an updated bitstream object
|
||||||
|
* @param rawForm Raw form data
|
||||||
|
*/
|
||||||
|
formToBitstream(rawForm): Bitstream {
|
||||||
|
const updatedBitstream = cloneDeep(this.bitstream);
|
||||||
|
const newMetadata = updatedBitstream.metadata;
|
||||||
|
// TODO: Set bitstream to primary when supported
|
||||||
|
const primary = rawForm.fileNamePrimaryContainer.primaryBitstream;
|
||||||
|
Metadata.setFirstValue(newMetadata, 'dc.title', rawForm.fileNamePrimaryContainer.fileName);
|
||||||
|
Metadata.setFirstValue(newMetadata, 'dc.description', rawForm.descriptionContainer.description);
|
||||||
|
if (isNotEmpty(rawForm.formatContainer.newFormat)) {
|
||||||
|
Metadata.setFirstValue(newMetadata, 'dc.format', rawForm.formatContainer.newFormat);
|
||||||
|
}
|
||||||
|
updatedBitstream.metadata = newMetadata;
|
||||||
|
return updatedBitstream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the form and return to the previous page
|
||||||
|
*/
|
||||||
|
onCancel() {
|
||||||
|
this.navigateToItemEditBitstreams();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the item ID is present, navigate back to the item's edit bitstreams page, otherwise go back to the previous
|
||||||
|
* page the user came from
|
||||||
|
*/
|
||||||
|
navigateToItemEditBitstreams() {
|
||||||
|
if (hasValue(this.itemId)) {
|
||||||
|
this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']);
|
||||||
|
} else {
|
||||||
|
this.location.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from open subscriptions
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subs
|
||||||
|
.filter((subscription) => hasValue(subscription))
|
||||||
|
.forEach((subscription) => subscription.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,6 @@
|
|||||||
|
<ds-comcol-role
|
||||||
|
*ngFor="let comcolRole of getComcolRoles() | async"
|
||||||
|
[dso]="collection$ | async"
|
||||||
|
[comcolRole]="comcolRole"
|
||||||
|
>
|
||||||
|
</ds-comcol-role>
|
||||||
|
@@ -0,0 +1,121 @@
|
|||||||
|
import { ComponentFixture, TestBed} from '@angular/core/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { CollectionRolesComponent } from './collection-roles.component';
|
||||||
|
import { Collection } from '../../../core/shared/collection.model';
|
||||||
|
import { SharedModule } from '../../../shared/shared.module';
|
||||||
|
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
||||||
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
|
||||||
|
describe('CollectionRolesComponent', () => {
|
||||||
|
|
||||||
|
let fixture: ComponentFixture<CollectionRolesComponent>;
|
||||||
|
let comp: CollectionRolesComponent;
|
||||||
|
let de: DebugElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
|
||||||
|
const route = {
|
||||||
|
parent: {
|
||||||
|
data: observableOf({
|
||||||
|
dso: new RemoteData(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
Object.assign(new Collection(), {
|
||||||
|
_links: {
|
||||||
|
'irrelevant': {
|
||||||
|
href: 'irrelevant link',
|
||||||
|
},
|
||||||
|
'adminGroup': {
|
||||||
|
href: 'adminGroup link',
|
||||||
|
},
|
||||||
|
'submittersGroup': {
|
||||||
|
href: 'submittersGroup link',
|
||||||
|
},
|
||||||
|
'itemReadGroup': {
|
||||||
|
href: 'itemReadGroup link',
|
||||||
|
},
|
||||||
|
'bitstreamReadGroup': {
|
||||||
|
href: 'bitstreamReadGroup link',
|
||||||
|
},
|
||||||
|
'workflowGroups/test': {
|
||||||
|
href: 'test workflow group link',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestService = {
|
||||||
|
hasByHrefObservable: () => observableOf(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupDataService = {
|
||||||
|
findByHref: () => observableOf(new RemoteData(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
200,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
SharedModule,
|
||||||
|
RouterTestingModule.withRoutes([]),
|
||||||
|
TranslateModule.forRoot(),
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
CollectionRolesComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: ActivatedRoute, useValue: route },
|
||||||
|
{ provide: RequestService, useValue: requestService },
|
||||||
|
{ provide: GroupDataService, useValue: groupDataService },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(CollectionRolesComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
de = fixture.debugElement;
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a collection admin role component', () => {
|
||||||
|
expect(de.query(By.css('ds-comcol-role .collection-admin')))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a submitters role component', () => {
|
||||||
|
expect(de.query(By.css('ds-comcol-role .submitters')))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a default item read role component', () => {
|
||||||
|
expect(de.query(By.css('ds-comcol-role .item_read')))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a default bitstream read role component', () => {
|
||||||
|
expect(de.query(By.css('ds-comcol-role .bitstream_read')))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a test workflow role component', () => {
|
||||||
|
expect(de.query(By.css('ds-comcol-role .test')))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@@ -1,4 +1,11 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { first, map } from 'rxjs/operators';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { Collection } from '../../../core/shared/collection.model';
|
||||||
|
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators';
|
||||||
|
import { ComcolRole } from '../../../shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component for managing a collection's roles
|
* Component for managing a collection's roles
|
||||||
@@ -7,6 +14,48 @@ import { Component } from '@angular/core';
|
|||||||
selector: 'ds-collection-roles',
|
selector: 'ds-collection-roles',
|
||||||
templateUrl: './collection-roles.component.html',
|
templateUrl: './collection-roles.component.html',
|
||||||
})
|
})
|
||||||
export class CollectionRolesComponent {
|
export class CollectionRolesComponent implements OnInit {
|
||||||
/* TODO: Implement Collection Edit - Roles */
|
|
||||||
|
dsoRD$: Observable<RemoteData<Collection>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collection to manage, as an observable.
|
||||||
|
*/
|
||||||
|
get collection$(): Observable<Collection> {
|
||||||
|
return this.dsoRD$.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The different roles for the collection, as an observable.
|
||||||
|
*/
|
||||||
|
getComcolRoles(): Observable<ComcolRole[]> {
|
||||||
|
return this.collection$.pipe(
|
||||||
|
map((collection) =>
|
||||||
|
[
|
||||||
|
ComcolRole.COLLECTION_ADMIN,
|
||||||
|
ComcolRole.SUBMITTERS,
|
||||||
|
ComcolRole.ITEM_READ,
|
||||||
|
ComcolRole.BITSTREAM_READ,
|
||||||
|
...Object.keys(collection._links)
|
||||||
|
.filter((link) => link.startsWith('workflowGroups/'))
|
||||||
|
.map((link) => new ComcolRole(link.substr('workflowGroups/'.length), link)),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected route: ActivatedRoute,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.dsoRD$ = this.route.parent.data.pipe(
|
||||||
|
first(),
|
||||||
|
map((data) => data.dso),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,6 @@
|
|||||||
|
<ds-comcol-role
|
||||||
|
*ngFor="let comcolRole of getComcolRoles()"
|
||||||
|
[dso]="community$ | async"
|
||||||
|
[comcolRole]="comcolRole"
|
||||||
|
>
|
||||||
|
</ds-comcol-role>
|
||||||
|
@@ -0,0 +1,89 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { CommunityRolesComponent } from './community-roles.component';
|
||||||
|
import { Community } from '../../../core/shared/community.model';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
|
import { GroupDataService } from '../../../core/eperson/group-data.service';
|
||||||
|
import { SharedModule } from '../../../shared/shared.module';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
|
||||||
|
describe('CommunityRolesComponent', () => {
|
||||||
|
|
||||||
|
let fixture: ComponentFixture<CommunityRolesComponent>;
|
||||||
|
let comp: CommunityRolesComponent;
|
||||||
|
let de: DebugElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
|
||||||
|
const route = {
|
||||||
|
parent: {
|
||||||
|
data: observableOf({
|
||||||
|
dso: new RemoteData(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
Object.assign(new Community(), {
|
||||||
|
_links: {
|
||||||
|
irrelevant: {
|
||||||
|
href: 'irrelevant link',
|
||||||
|
},
|
||||||
|
adminGroup: {
|
||||||
|
href: 'adminGroup link',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestService = {
|
||||||
|
hasByHrefObservable: () => observableOf(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupDataService = {
|
||||||
|
findByHref: () => observableOf(new RemoteData(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
200,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
SharedModule,
|
||||||
|
RouterTestingModule.withRoutes([]),
|
||||||
|
TranslateModule.forRoot(),
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
CommunityRolesComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: ActivatedRoute, useValue: route },
|
||||||
|
{ provide: RequestService, useValue: requestService },
|
||||||
|
{ provide: GroupDataService, useValue: groupDataService },
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(CommunityRolesComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
de = fixture.debugElement;
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a community admin role component', () => {
|
||||||
|
expect(de.query(By.css('ds-comcol-role .community-admin')))
|
||||||
|
.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@@ -1,4 +1,11 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { first, map } from 'rxjs/operators';
|
||||||
|
import { Community } from '../../../core/shared/community.model';
|
||||||
|
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators';
|
||||||
|
import { ComcolRole } from '../../../shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component for managing a community's roles
|
* Component for managing a community's roles
|
||||||
@@ -7,6 +14,38 @@ import { Component } from '@angular/core';
|
|||||||
selector: 'ds-community-roles',
|
selector: 'ds-community-roles',
|
||||||
templateUrl: './community-roles.component.html',
|
templateUrl: './community-roles.component.html',
|
||||||
})
|
})
|
||||||
export class CommunityRolesComponent {
|
export class CommunityRolesComponent implements OnInit {
|
||||||
/* TODO: Implement Community Edit - Roles */
|
|
||||||
|
dsoRD$: Observable<RemoteData<Community>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The community to manage, as an observable.
|
||||||
|
*/
|
||||||
|
get community$(): Observable<Community> {
|
||||||
|
return this.dsoRD$.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The different roles for the community.
|
||||||
|
*/
|
||||||
|
getComcolRoles(): ComcolRole[] {
|
||||||
|
return [
|
||||||
|
ComcolRole.COMMUNITY_ADMIN,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected route: ActivatedRoute,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.dsoRD$ = this.route.parent.data.pipe(
|
||||||
|
first(),
|
||||||
|
map((data) => data.dso),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,41 @@
|
|||||||
|
<div class="container" *ngVar="(bundlesRD$ | async)?.payload?.page as bundles">
|
||||||
|
<ng-container *ngIf="bundles">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-4">
|
||||||
|
<h2>{{'item.bitstreams.upload.title' | translate}}</h2>
|
||||||
|
<ng-container *ngVar="(itemRD$ | async)?.payload as item">
|
||||||
|
<div *ngIf="item">
|
||||||
|
<span class="font-weight-bold">{{'item.bitstreams.upload.item' | translate}}</span>
|
||||||
|
<span>{{item.name}}</span>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="font-weight-bold">{{'item.bitstreams.upload.bundle' | translate}}</label>
|
||||||
|
<ds-dso-input-suggestions #f id="search-form"
|
||||||
|
[suggestions]="bundles"
|
||||||
|
[placeholder]="'item.bitstreams.upload.bundle.placeholder' | translate"
|
||||||
|
[action]="getCurrentUrl()"
|
||||||
|
[name]="'bundle-select'"
|
||||||
|
[debounceTime]="50"
|
||||||
|
[(ngModel)]="selectedBundleName"
|
||||||
|
(typeSuggestion)="bundleNameChange()"
|
||||||
|
(clickSuggestion)="onClick($event)"
|
||||||
|
(click)="f.open()"
|
||||||
|
ngDefaultControl>
|
||||||
|
</ds-dso-input-suggestions>
|
||||||
|
<button *ngIf="!selectedBundleId && selectedBundleName?.length > 0" class="btn btn-success" (click)="createBundle()">
|
||||||
|
<i class="fa fa-plus"></i> {{ 'item.bitstreams.upload.bundle.new' | translate }}
|
||||||
|
</button>
|
||||||
|
<ds-uploader class="w-100" *ngIf="selectedBundleId"
|
||||||
|
[dropMsg]="'item.bitstreams.upload.drop-message'"
|
||||||
|
[dropOverDocumentMsg]="'item.bitstreams.upload.drop-message'"
|
||||||
|
[enableDragOverDocument]="true"
|
||||||
|
[uploadFilesOptions]="uploadFilesOptions"
|
||||||
|
(onCompleteItem)="onCompleteItem($event)"
|
||||||
|
(onUploadError)="onUploadError()"></ds-uploader>
|
||||||
|
<button class="btn btn-outline-secondary" (click)="onCancel()">{{'item.bitstreams.upload.cancel' | translate}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
@@ -0,0 +1,236 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
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 { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { UploadBitstreamComponent } from './upload-bitstream.component';
|
||||||
|
import { AuthService } from '../../../core/auth/auth.service';
|
||||||
|
import { AuthServiceStub } from '../../../shared/testing/auth-service-stub';
|
||||||
|
import { Item } from '../../../core/shared/item.model';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import {
|
||||||
|
createPaginatedList,
|
||||||
|
createSuccessfulRemoteDataObject,
|
||||||
|
createSuccessfulRemoteDataObject$
|
||||||
|
} from '../../../shared/testing/utils';
|
||||||
|
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||||
|
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
|
||||||
|
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||||
|
import { Bitstream } from '../../../core/shared/bitstream.model';
|
||||||
|
import { BundleDataService } from '../../../core/data/bundle-data.service';
|
||||||
|
import { Bundle } from '../../../core/shared/bundle.model';
|
||||||
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
|
|
||||||
|
describe('UploadBistreamComponent', () => {
|
||||||
|
let comp: UploadBitstreamComponent;
|
||||||
|
let fixture: ComponentFixture<UploadBitstreamComponent>;
|
||||||
|
|
||||||
|
const bundle = Object.assign(new Bundle(), {
|
||||||
|
id: 'bundle',
|
||||||
|
uuid: 'bundle',
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
{
|
||||||
|
value: 'bundleName',
|
||||||
|
language: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
_links: {
|
||||||
|
self: { href: 'bundle-selflink' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const customName = 'Custom Name';
|
||||||
|
const createdBundle = Object.assign(new Bundle(), {
|
||||||
|
id: 'created-bundle',
|
||||||
|
uuid: 'created-bundle',
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
{
|
||||||
|
value: customName,
|
||||||
|
language: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
_links: {
|
||||||
|
self: { href: 'created-bundle-selflink' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const itemName = 'fake-name';
|
||||||
|
const mockItem = Object.assign(new Item(), {
|
||||||
|
id: 'fake-id',
|
||||||
|
handle: 'fake/handle',
|
||||||
|
metadata: {
|
||||||
|
'dc.title': [
|
||||||
|
{
|
||||||
|
language: null,
|
||||||
|
value: itemName
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
bundles: createSuccessfulRemoteDataObject$(createPaginatedList([bundle]))
|
||||||
|
});
|
||||||
|
let routeStub;
|
||||||
|
const routerStub = new RouterStub();
|
||||||
|
const restEndpoint = 'fake-rest-endpoint';
|
||||||
|
const mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
|
||||||
|
getBitstreamsEndpoint: observableOf(restEndpoint),
|
||||||
|
createBundle: createSuccessfulRemoteDataObject$(createdBundle)
|
||||||
|
});
|
||||||
|
const bundleService = jasmine.createSpyObj('bundleService', {
|
||||||
|
getBitstreamsEndpoint: observableOf(restEndpoint),
|
||||||
|
findById: createSuccessfulRemoteDataObject$(bundle)
|
||||||
|
});
|
||||||
|
const authToken = 'fake-auth-token';
|
||||||
|
const authServiceStub = Object.assign(new AuthServiceStub(), {
|
||||||
|
buildAuthHeader: () => authToken
|
||||||
|
});
|
||||||
|
const notificationsServiceStub = new NotificationsServiceStub();
|
||||||
|
const uploaderComponent = jasmine.createSpyObj('uploaderComponent', ['ngOnInit', 'ngAfterViewInit']);
|
||||||
|
const requestService = jasmine.createSpyObj('requestService', {
|
||||||
|
removeByHrefSubstring: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when a file is uploaded', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
createUploadBitstreamTestingModule({});
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
loadFixtureAndComp();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and it fails, calling onUploadError', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.onUploadError();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display an error notification', () => {
|
||||||
|
expect(notificationsServiceStub.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and it succeeds, calling onCompleteItem', () => {
|
||||||
|
const createdBitstream = Object.assign(new Bitstream(), {
|
||||||
|
id: 'fake-bitstream'
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.onCompleteItem(createdBitstream);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate the user to the next page', () => {
|
||||||
|
expect(routerStub.navigate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when a bundle url parameter is present', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
createUploadBitstreamTestingModule({
|
||||||
|
bundle: bundle.id
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
loadFixtureAndComp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the selected id to the bundle\'s id', () => {
|
||||||
|
expect(comp.selectedBundleId).toEqual(bundle.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the selected name to the bundle\'s name', () => {
|
||||||
|
expect(comp.selectedBundleName).toEqual(bundle.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and bundle name changed', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.bundleNameChange();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear out the selected id', () => {
|
||||||
|
expect(comp.selectedBundleId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when a name is filled in, but no ID is selected', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
createUploadBitstreamTestingModule({});
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
loadFixtureAndComp();
|
||||||
|
comp.selectedBundleName = customName;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createBundle', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.createBundle();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new bundle', () => {
|
||||||
|
expect(mockItemDataService.createBundle).toHaveBeenCalledWith(mockItem.id, customName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the selected id to the id of the new bundle', () => {
|
||||||
|
expect(comp.selectedBundleId).toEqual(createdBundle.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a success notification', () => {
|
||||||
|
expect(notificationsServiceStub.success).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup an UploadBitstreamComponent testing module with custom queryParams for the route
|
||||||
|
* @param queryParams
|
||||||
|
*/
|
||||||
|
function createUploadBitstreamTestingModule(queryParams) {
|
||||||
|
routeStub = {
|
||||||
|
data: observableOf({
|
||||||
|
item: createSuccessfulRemoteDataObject(mockItem)
|
||||||
|
}),
|
||||||
|
queryParams: observableOf(queryParams),
|
||||||
|
snapshot: {
|
||||||
|
queryParams: queryParams,
|
||||||
|
params: {
|
||||||
|
id: mockItem.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
|
||||||
|
declarations: [UploadBitstreamComponent, VarDirective],
|
||||||
|
providers: [
|
||||||
|
{ provide: ActivatedRoute, useValue: routeStub },
|
||||||
|
{ provide: Router, useValue: routerStub },
|
||||||
|
{ provide: ItemDataService, useValue: mockItemDataService },
|
||||||
|
{ provide: NotificationsService, useValue: notificationsServiceStub },
|
||||||
|
{ provide: AuthService, useValue: authServiceStub },
|
||||||
|
{ provide: BundleDataService, useValue: bundleService },
|
||||||
|
{ provide: RequestService, useValue: requestService }
|
||||||
|
], schemas: [
|
||||||
|
NO_ERRORS_SCHEMA
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the TestBed's fixture and component
|
||||||
|
*/
|
||||||
|
function loadFixtureAndComp() {
|
||||||
|
fixture = TestBed.createComponent(UploadBitstreamComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
comp.uploaderComponent = uploaderComponent;
|
||||||
|
fixture.detectChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,218 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { Item } from '../../../core/shared/item.model';
|
||||||
|
import { map, switchMap, take } from 'rxjs/operators';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { UploaderOptions } from '../../../shared/uploader/uploader-options.model';
|
||||||
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
|
import { hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
|
||||||
|
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
|
import { AuthService } from '../../../core/auth/auth.service';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { getBitstreamModulePath } from '../../../app-routing.module';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { Bundle } from '../../../core/shared/bundle.model';
|
||||||
|
import { BundleDataService } from '../../../core/data/bundle-data.service';
|
||||||
|
import {
|
||||||
|
getFirstSucceededRemoteDataPayload
|
||||||
|
} from '../../../core/shared/operators';
|
||||||
|
import { UploaderComponent } from '../../../shared/uploader/uploader.component';
|
||||||
|
import { getItemEditPath } from '../../item-page-routing.module';
|
||||||
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-upload-bitstream',
|
||||||
|
templateUrl: './upload-bitstream.component.html'
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Page component for uploading a bitstream to an item
|
||||||
|
*/
|
||||||
|
export class UploadBitstreamComponent implements OnInit, OnDestroy {
|
||||||
|
/**
|
||||||
|
* The file uploader component
|
||||||
|
*/
|
||||||
|
@ViewChild(UploaderComponent, {static: false}) uploaderComponent: UploaderComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the item to upload a bitstream to
|
||||||
|
*/
|
||||||
|
itemId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item to upload a bitstream to
|
||||||
|
*/
|
||||||
|
itemRD$: Observable<RemoteData<Item>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item's bundles
|
||||||
|
*/
|
||||||
|
bundlesRD$: Observable<RemoteData<PaginatedList<Bundle>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the currently selected bundle to upload a bitstream to
|
||||||
|
*/
|
||||||
|
selectedBundleId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the currently selected bundle to upload a bitstream to
|
||||||
|
*/
|
||||||
|
selectedBundleName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The uploader configuration options
|
||||||
|
* @type {UploaderOptions}
|
||||||
|
*/
|
||||||
|
uploadFilesOptions: UploaderOptions = Object.assign(new UploaderOptions(), {
|
||||||
|
// URL needs to contain something to not produce any errors. This will be replaced once a bundle has been selected.
|
||||||
|
url: 'placeholder',
|
||||||
|
authToken: null,
|
||||||
|
disableMultipart: false,
|
||||||
|
itemAlias: null
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix for all i18n notification messages within this component
|
||||||
|
*/
|
||||||
|
NOTIFICATIONS_PREFIX = 'item.bitstreams.upload.notifications.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array to track all subscriptions and unsubscribe them onDestroy
|
||||||
|
* @type {Array}
|
||||||
|
*/
|
||||||
|
subs: Subscription[] = [];
|
||||||
|
|
||||||
|
constructor(protected route: ActivatedRoute,
|
||||||
|
protected router: Router,
|
||||||
|
protected itemService: ItemDataService,
|
||||||
|
protected bundleService: BundleDataService,
|
||||||
|
protected authService: AuthService,
|
||||||
|
protected notificationsService: NotificationsService,
|
||||||
|
protected translate: TranslateService,
|
||||||
|
protected requestService: RequestService) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize component properties:
|
||||||
|
* itemRD$ Fetched from the current route data (populated by BitstreamPageResolver)
|
||||||
|
* bundlesRD$ List of bundles on the item
|
||||||
|
* selectedBundleId Starts off by checking if the route's queryParams contain a "bundle" parameter. If none is found,
|
||||||
|
* the ID of the first bundle in the list is selected.
|
||||||
|
* Calls setUploadUrl after setting the selected bundle
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.itemId = this.route.snapshot.params.id;
|
||||||
|
this.itemRD$ = this.route.data.pipe(map((data) => data.item));
|
||||||
|
this.bundlesRD$ = this.itemRD$.pipe(
|
||||||
|
switchMap((itemRD: RemoteData<Item>) => itemRD.payload.bundles)
|
||||||
|
);
|
||||||
|
this.selectedBundleId = this.route.snapshot.queryParams.bundle;
|
||||||
|
if (isNotEmpty(this.selectedBundleId)) {
|
||||||
|
this.bundleService.findById(this.selectedBundleId).pipe(
|
||||||
|
getFirstSucceededRemoteDataPayload()
|
||||||
|
).subscribe((bundle: Bundle) => {
|
||||||
|
this.selectedBundleName = bundle.name;
|
||||||
|
});
|
||||||
|
this.setUploadUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new bundle with the filled in name on the current item
|
||||||
|
*/
|
||||||
|
createBundle() {
|
||||||
|
this.itemService.createBundle(this.itemId, this.selectedBundleName).pipe(
|
||||||
|
getFirstSucceededRemoteDataPayload()
|
||||||
|
).subscribe((bundle: Bundle) => {
|
||||||
|
this.selectedBundleId = bundle.id;
|
||||||
|
this.notificationsService.success(
|
||||||
|
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'bundle.created.title'),
|
||||||
|
this.translate.instant(this.NOTIFICATIONS_PREFIX + 'bundle.created.content')
|
||||||
|
);
|
||||||
|
this.setUploadUrl();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user changed the bundle name
|
||||||
|
* Reset the bundle ID
|
||||||
|
*/
|
||||||
|
bundleNameChange() {
|
||||||
|
this.selectedBundleId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the upload url to match the selected bundle ID
|
||||||
|
*/
|
||||||
|
setUploadUrl() {
|
||||||
|
this.bundleService.getBitstreamsEndpoint(this.selectedBundleId).pipe(take(1)).subscribe((href: string) => {
|
||||||
|
this.uploadFilesOptions.url = href;
|
||||||
|
if (isEmpty(this.uploadFilesOptions.authToken)) {
|
||||||
|
this.uploadFilesOptions.authToken = this.authService.buildAuthHeader();
|
||||||
|
}
|
||||||
|
// Re-initialize the uploader component to ensure the latest changes to the options are applied
|
||||||
|
if (this.uploaderComponent) {
|
||||||
|
this.uploaderComponent.ngOnInit();
|
||||||
|
this.uploaderComponent.ngAfterViewInit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request was successful, redirect the user to the new bitstream's edit page
|
||||||
|
* @param bitstream
|
||||||
|
*/
|
||||||
|
public onCompleteItem(bitstream) {
|
||||||
|
// Clear cached requests for this bundle's bitstreams to ensure lists on all pages are up-to-date
|
||||||
|
this.bundleService.getBitstreamsEndpoint(this.selectedBundleId).pipe(take(1)).subscribe((href: string) => {
|
||||||
|
this.requestService.removeByHrefSubstring(href);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bring over the item ID as a query parameter
|
||||||
|
const queryParams = { itemId: this.itemId };
|
||||||
|
this.router.navigate([getBitstreamModulePath(), bitstream.id, 'edit'], { queryParams: queryParams });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request was unsuccessful, display an error notification
|
||||||
|
*/
|
||||||
|
public onUploadError() {
|
||||||
|
this.notificationsService.error(null, this.translate.get(this.NOTIFICATIONS_PREFIX + 'upload.failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user selected a bundle from the input suggestions
|
||||||
|
* Set the bundle ID and Name properties, as well as the upload URL
|
||||||
|
* @param bundle
|
||||||
|
*/
|
||||||
|
onClick(bundle: Bundle) {
|
||||||
|
this.selectedBundleId = bundle.id;
|
||||||
|
this.selectedBundleName = bundle.name;
|
||||||
|
this.setUploadUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When cancel is clicked, navigate back to the item's edit bitstreams page
|
||||||
|
*/
|
||||||
|
onCancel() {
|
||||||
|
this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string} the current URL
|
||||||
|
*/
|
||||||
|
getCurrentUrl() {
|
||||||
|
return this.router.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from all open subscriptions when the component is destroyed
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.subs
|
||||||
|
.filter((subscription) => hasValue(subscription))
|
||||||
|
.forEach((subscription) => subscription.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -9,13 +9,13 @@ import { NotificationsService } from '../../../shared/notifications/notification
|
|||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { first, map } from 'rxjs/operators';
|
import { first, map } from 'rxjs/operators';
|
||||||
import { RemoteData } from '../../../core/data/remote-data';
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { AbstractTrackableComponent } from '../../../shared/trackable/abstract-trackable.component';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
/**
|
/**
|
||||||
* Abstract component for managing object updates of an item
|
* Abstract component for managing object updates of an item
|
||||||
*/
|
*/
|
||||||
export abstract class AbstractItemUpdateComponent implements OnInit {
|
export class AbstractItemUpdateComponent extends AbstractTrackableComponent implements OnInit {
|
||||||
/**
|
/**
|
||||||
* The item to display the edit page for
|
* The item to display the edit page for
|
||||||
*/
|
*/
|
||||||
@@ -25,19 +25,6 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
|||||||
* Should be initialized in the initializeUpdates method of the child component
|
* Should be initialized in the initializeUpdates method of the child component
|
||||||
*/
|
*/
|
||||||
updates$: Observable<FieldUpdates>;
|
updates$: Observable<FieldUpdates>;
|
||||||
/**
|
|
||||||
* The current url of this page
|
|
||||||
*/
|
|
||||||
url: string;
|
|
||||||
/**
|
|
||||||
* Prefix for this component's notification translate keys
|
|
||||||
* Should be initialized in the initializeNotificationsPrefix method of the child component
|
|
||||||
*/
|
|
||||||
notificationsPrefix;
|
|
||||||
/**
|
|
||||||
* The time span for being able to undo discarding changes
|
|
||||||
*/
|
|
||||||
discardTimeOut: number;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected itemService: ItemDataService,
|
protected itemService: ItemDataService,
|
||||||
@@ -47,7 +34,7 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
|||||||
protected translateService: TranslateService,
|
protected translateService: TranslateService,
|
||||||
protected route: ActivatedRoute
|
protected route: ActivatedRoute
|
||||||
) {
|
) {
|
||||||
|
super(objectUpdatesService, notificationsService, translateService)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +47,7 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
|||||||
map((data: RemoteData<Item>) => data.payload)
|
map((data: RemoteData<Item>) => data.payload)
|
||||||
).subscribe((item: Item) => {
|
).subscribe((item: Item) => {
|
||||||
this.item = item;
|
this.item = item;
|
||||||
|
this.postItemInit();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.discardTimeOut = environment.item.edit.undoTimeout;
|
this.discardTimeOut = environment.item.edit.undoTimeout;
|
||||||
@@ -80,19 +68,44 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the values and updates of the current item's fields
|
* Actions to perform after the item has been initialized
|
||||||
|
* Abstract method: Should be overwritten in the sub class
|
||||||
*/
|
*/
|
||||||
abstract initializeUpdates(): void;
|
postItemInit(): void {
|
||||||
|
// Overwrite in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the values and updates of the current item's fields
|
||||||
|
* Abstract method: Should be overwritten in the sub class
|
||||||
|
*/
|
||||||
|
initializeUpdates(): void {
|
||||||
|
// Overwrite in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the prefix for notification messages
|
* Initialize the prefix for notification messages
|
||||||
|
* Abstract method: Should be overwritten in the sub class
|
||||||
*/
|
*/
|
||||||
abstract initializeNotificationsPrefix(): void;
|
initializeNotificationsPrefix(): void {
|
||||||
|
// Overwrite in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends all initial values of this item to the object updates service
|
* Sends all initial values of this item to the object updates service
|
||||||
|
* Abstract method: Should be overwritten in the sub class
|
||||||
*/
|
*/
|
||||||
abstract initializeOriginalFields(): void;
|
initializeOriginalFields(): void {
|
||||||
|
// Overwrite in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the current changes
|
||||||
|
* Abstract method: Should be overwritten in the sub class
|
||||||
|
*/
|
||||||
|
submit(): void {
|
||||||
|
// Overwrite in subclasses
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prevent unnecessary rerendering so fields don't lose focus
|
* Prevent unnecessary rerendering so fields don't lose focus
|
||||||
@@ -101,13 +114,6 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
|||||||
return update && update.field ? update.field.uuid : undefined;
|
return update && update.field ? update.field.uuid : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether or not there are currently updates for this item
|
|
||||||
*/
|
|
||||||
hasChanges(): Observable<boolean> {
|
|
||||||
return this.objectUpdatesService.hasUpdates(this.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current page is entirely valid
|
* Check if the current page is entirely valid
|
||||||
*/
|
*/
|
||||||
@@ -130,49 +136,4 @@ export abstract class AbstractItemUpdateComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit the current changes
|
|
||||||
*/
|
|
||||||
abstract submit(): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request the object updates service to discard all current changes to this item
|
|
||||||
* Shows a notification to remind the user that they can undo this
|
|
||||||
*/
|
|
||||||
discard() {
|
|
||||||
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut });
|
|
||||||
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request the object updates service to undo discarding all changes to this item
|
|
||||||
*/
|
|
||||||
reinstate() {
|
|
||||||
this.objectUpdatesService.reinstateFieldUpdates(this.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether or not the item is currently reinstatable
|
|
||||||
*/
|
|
||||||
isReinstatable(): Observable<boolean> {
|
|
||||||
return this.objectUpdatesService.isReinstatable(this.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get translated notification title
|
|
||||||
* @param key
|
|
||||||
*/
|
|
||||||
protected getNotificationTitle(key: string) {
|
|
||||||
return this.translateService.instant(this.notificationsPrefix + key + '.title');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get translated notification content
|
|
||||||
* @param key
|
|
||||||
*/
|
|
||||||
protected getNotificationContent(key: string) {
|
|
||||||
return this.translateService.instant(this.notificationsPrefix + key + '.content');
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -15,12 +15,19 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component';
|
|||||||
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
||||||
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
|
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
|
||||||
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
||||||
|
import { ItemEditBitstreamComponent } from './item-bitstreams/item-edit-bitstream/item-edit-bitstream.component';
|
||||||
import { SearchPageModule } from '../../+search-page/search-page.module';
|
import { SearchPageModule } from '../../+search-page/search-page.module';
|
||||||
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
|
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
|
||||||
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 { AbstractItemUpdateComponent } from './abstract-item-update/abstract-item-update.component';
|
||||||
import { ItemMoveComponent } from './item-move/item-move.component';
|
import { ItemMoveComponent } from './item-move/item-move.component';
|
||||||
|
import { ItemEditBitstreamBundleComponent } from './item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component';
|
||||||
|
import { BundleDataService } from '../../core/data/bundle-data.service';
|
||||||
|
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||||
|
import { ItemEditBitstreamDragHandleComponent } from './item-bitstreams/item-edit-bitstream-drag-handle/item-edit-bitstream-drag-handle.component';
|
||||||
|
import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component';
|
||||||
import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component';
|
import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component';
|
||||||
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
|
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
|
||||||
|
|
||||||
@@ -32,12 +39,14 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
EditItemPageRoutingModule,
|
EditItemPageRoutingModule,
|
||||||
SearchPageModule
|
SearchPageModule,
|
||||||
|
DragDropModule
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
EditItemPageComponent,
|
EditItemPageComponent,
|
||||||
ItemOperationComponent,
|
ItemOperationComponent,
|
||||||
AbstractSimpleItemActionComponent,
|
AbstractSimpleItemActionComponent,
|
||||||
|
AbstractItemUpdateComponent,
|
||||||
ModifyItemOverviewComponent,
|
ModifyItemOverviewComponent,
|
||||||
ItemWithdrawComponent,
|
ItemWithdrawComponent,
|
||||||
ItemReinstateComponent,
|
ItemReinstateComponent,
|
||||||
@@ -50,11 +59,19 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version
|
|||||||
ItemBitstreamsComponent,
|
ItemBitstreamsComponent,
|
||||||
ItemVersionHistoryComponent,
|
ItemVersionHistoryComponent,
|
||||||
EditInPlaceFieldComponent,
|
EditInPlaceFieldComponent,
|
||||||
|
ItemEditBitstreamComponent,
|
||||||
|
ItemEditBitstreamBundleComponent,
|
||||||
|
PaginatedDragAndDropBitstreamListComponent,
|
||||||
|
EditInPlaceFieldComponent,
|
||||||
EditRelationshipComponent,
|
EditRelationshipComponent,
|
||||||
EditRelationshipListComponent,
|
EditRelationshipListComponent,
|
||||||
ItemCollectionMapperComponent,
|
ItemCollectionMapperComponent,
|
||||||
ItemMoveComponent,
|
ItemMoveComponent,
|
||||||
|
ItemEditBitstreamDragHandleComponent,
|
||||||
VirtualMetadataComponent,
|
VirtualMetadataComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
BundleDataService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class EditItemPageModule {
|
export class EditItemPageModule {
|
||||||
|
@@ -1,3 +1,68 @@
|
|||||||
<div>
|
<div class="item-bitstreams" *ngVar="(bundles$ | async) as bundles">
|
||||||
|
<div class="button-row top d-flex mt-2">
|
||||||
|
<button class="mr-auto btn btn-success"
|
||||||
|
[routerLink]="['/items/', item.id, 'bitstreams', 'new']"><i
|
||||||
|
class="fas fa-upload"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.upload-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger mr-1" *ngIf="!(isReinstatable() | async)"
|
||||||
|
[disabled]="!(hasChanges() | async) || submitting"
|
||||||
|
(click)="discard()"><i
|
||||||
|
class="fas fa-times"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.discard-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning mr-1" *ngIf="isReinstatable() | async"
|
||||||
|
(click)="reinstate()"><i
|
||||||
|
class="fas fa-undo-alt"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.reinstate-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || submitting"
|
||||||
|
(click)="submit()"><i
|
||||||
|
class="fas fa-save"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.save-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="item && bundles?.length > 0" class="container table-bordered mt-4">
|
||||||
|
<div class="row header-row font-weight-bold">
|
||||||
|
<div class="{{columnSizes.columns[0].buildClasses()}} row-element">
|
||||||
|
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
|
||||||
|
{{'item.edit.bitstreams.headers.name' | translate}}
|
||||||
|
</div>
|
||||||
|
<div class="{{columnSizes.columns[1].buildClasses()}} row-element">{{'item.edit.bitstreams.headers.description' | translate}}</div>
|
||||||
|
<div class="{{columnSizes.columns[2].buildClasses()}} text-center row-element">{{'item.edit.bitstreams.headers.format' | translate}}</div>
|
||||||
|
<div class="{{columnSizes.columns[3].buildClasses()}} text-center row-element">{{'item.edit.bitstreams.headers.actions' | translate}}</div>
|
||||||
|
</div>
|
||||||
|
<ds-item-edit-bitstream-bundle *ngFor="let bundle of bundles"
|
||||||
|
[bundle]="bundle"
|
||||||
|
[item]="item"
|
||||||
|
[columnSizes]="columnSizes">
|
||||||
|
</ds-item-edit-bitstream-bundle>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="bundles?.length === 0"
|
||||||
|
class="alert alert-info w-100 d-inline-block mt-4" role="alert">
|
||||||
|
{{'item.edit.bitstreams.empty' | translate}}
|
||||||
|
</div>
|
||||||
|
<ds-loading *ngIf="!bundles" message="{{'loading.bitstreams' | translate}}"></ds-loading>
|
||||||
|
|
||||||
|
<div class="button-row bottom">
|
||||||
|
<div class="mt-4 float-right">
|
||||||
|
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||||
|
[disabled]="!(hasChanges() | async) || submitting"
|
||||||
|
(click)="discard()"><i
|
||||||
|
class="fas fa-times"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.discard-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||||
|
(click)="reinstate()"><i
|
||||||
|
class="fas fa-undo-alt"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.reinstate-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || submitting"
|
||||||
|
(click)="submit()"><i
|
||||||
|
class="fas fa-save"></i>
|
||||||
|
<span class="d-none d-sm-inline"> {{"item.edit.bitstreams.save-button" | translate}}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -0,0 +1,42 @@
|
|||||||
|
.header-row {
|
||||||
|
color: $table-dark-color;
|
||||||
|
background-color: $table-dark-bg;
|
||||||
|
border-color: $table-dark-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bundle-row {
|
||||||
|
color: $table-head-color;
|
||||||
|
background-color: $table-head-bg;
|
||||||
|
border-color: $table-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-element {
|
||||||
|
padding: 12px;
|
||||||
|
padding: 0.75em;
|
||||||
|
border-bottom: $table-border-width solid $table-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
visibility: hidden;
|
||||||
|
&:hover {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .bitstream-row:hover .drag-handle {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdk-drag-preview {
|
||||||
|
margin-left: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdk-drag-placeholder {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdk-drag-animating {
|
||||||
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
@@ -0,0 +1,224 @@
|
|||||||
|
import { Bitstream } from '../../../core/shared/bitstream.model';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||||
|
import { Item } from '../../../core/shared/item.model';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ItemBitstreamsComponent } from './item-bitstreams.component';
|
||||||
|
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { GLOBAL_CONFIG } from '../../../../config';
|
||||||
|
import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
|
||||||
|
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||||
|
import { INotification, Notification } from '../../../shared/notifications/models/notification.model';
|
||||||
|
import { NotificationType } from '../../../shared/notifications/models/notification-type';
|
||||||
|
import { BitstreamDataService } from '../../../core/data/bitstream-data.service';
|
||||||
|
import { getMockRequestService } from '../../../shared/mocks/mock-request.service';
|
||||||
|
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
|
||||||
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
|
import { ObjectValuesPipe } from '../../../shared/utils/object-values-pipe';
|
||||||
|
import { VarDirective } from '../../../shared/utils/var.directive';
|
||||||
|
import { BundleDataService } from '../../../core/data/bundle-data.service';
|
||||||
|
import { Bundle } from '../../../core/shared/bundle.model';
|
||||||
|
import { RestResponse } from '../../../core/cache/response.models';
|
||||||
|
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
|
||||||
|
|
||||||
|
let comp: ItemBitstreamsComponent;
|
||||||
|
let fixture: ComponentFixture<ItemBitstreamsComponent>;
|
||||||
|
|
||||||
|
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
|
||||||
|
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
|
||||||
|
const successNotification: INotification = new Notification('id', NotificationType.Success, 'success');
|
||||||
|
const bitstream1 = Object.assign(new Bitstream(), {
|
||||||
|
id: 'bitstream1',
|
||||||
|
uuid: 'bitstream1'
|
||||||
|
});
|
||||||
|
const bitstream2 = Object.assign(new Bitstream(), {
|
||||||
|
id: 'bitstream2',
|
||||||
|
uuid: 'bitstream2'
|
||||||
|
});
|
||||||
|
const fieldUpdate1 = {
|
||||||
|
field: bitstream1,
|
||||||
|
changeType: undefined
|
||||||
|
};
|
||||||
|
const fieldUpdate2 = {
|
||||||
|
field: bitstream2,
|
||||||
|
changeType: FieldChangeType.REMOVE
|
||||||
|
};
|
||||||
|
const bundle = Object.assign(new Bundle(), {
|
||||||
|
id: 'bundle1',
|
||||||
|
uuid: 'bundle1',
|
||||||
|
_links: {
|
||||||
|
self: { href: 'bundle1-selflink' }
|
||||||
|
},
|
||||||
|
bitstreams: createMockRDPaginatedObs([bitstream1, bitstream2])
|
||||||
|
});
|
||||||
|
const moveOperations = [
|
||||||
|
{
|
||||||
|
op: 'move',
|
||||||
|
from: '/0',
|
||||||
|
path: '/1'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const date = new Date();
|
||||||
|
const url = 'thisUrl';
|
||||||
|
let item: Item;
|
||||||
|
let itemService: ItemDataService;
|
||||||
|
let objectUpdatesService: ObjectUpdatesService;
|
||||||
|
let router: any;
|
||||||
|
let route: ActivatedRoute;
|
||||||
|
let notificationsService: NotificationsService;
|
||||||
|
let bitstreamService: BitstreamDataService;
|
||||||
|
let objectCache: ObjectCacheService;
|
||||||
|
let requestService: RequestService;
|
||||||
|
let searchConfig: SearchConfigurationService;
|
||||||
|
let bundleService: BundleDataService;
|
||||||
|
|
||||||
|
describe('ItemBitstreamsComponent', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||||
|
{
|
||||||
|
getFieldUpdates: observableOf({
|
||||||
|
[bitstream1.uuid]: fieldUpdate1,
|
||||||
|
[bitstream2.uuid]: fieldUpdate2,
|
||||||
|
}),
|
||||||
|
getFieldUpdatesExclusive: observableOf({
|
||||||
|
[bitstream1.uuid]: fieldUpdate1,
|
||||||
|
[bitstream2.uuid]: fieldUpdate2,
|
||||||
|
}),
|
||||||
|
saveAddFieldUpdate: {},
|
||||||
|
discardFieldUpdates: {},
|
||||||
|
discardAllFieldUpdates: {},
|
||||||
|
reinstateFieldUpdates: observableOf(true),
|
||||||
|
initialize: {},
|
||||||
|
getUpdatedFields: observableOf([bitstream1, bitstream2]),
|
||||||
|
getLastModified: observableOf(date),
|
||||||
|
hasUpdates: observableOf(true),
|
||||||
|
isReinstatable: observableOf(false),
|
||||||
|
isValidPage: observableOf(true),
|
||||||
|
getMoveOperations: observableOf(moveOperations)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
router = Object.assign(new RouterStub(), {
|
||||||
|
url: url
|
||||||
|
});
|
||||||
|
notificationsService = jasmine.createSpyObj('notificationsService',
|
||||||
|
{
|
||||||
|
info: infoNotification,
|
||||||
|
warning: warningNotification,
|
||||||
|
success: successNotification
|
||||||
|
}
|
||||||
|
);
|
||||||
|
bitstreamService = jasmine.createSpyObj('bitstreamService', {
|
||||||
|
deleteAndReturnResponse: jasmine.createSpy('deleteAndReturnResponse')
|
||||||
|
});
|
||||||
|
objectCache = jasmine.createSpyObj('objectCache', {
|
||||||
|
remove: jasmine.createSpy('remove')
|
||||||
|
});
|
||||||
|
requestService = getMockRequestService();
|
||||||
|
searchConfig = Object.assign( {
|
||||||
|
paginatedSearchOptions: observableOf({})
|
||||||
|
});
|
||||||
|
|
||||||
|
item = Object.assign(new Item(), {
|
||||||
|
uuid: 'item',
|
||||||
|
id: 'item',
|
||||||
|
_links: {
|
||||||
|
self: { href: 'item-selflink' }
|
||||||
|
},
|
||||||
|
bundles: createMockRDPaginatedObs([bundle]),
|
||||||
|
lastModified: date
|
||||||
|
});
|
||||||
|
itemService = Object.assign( {
|
||||||
|
getBitstreams: () => createMockRDPaginatedObs([bitstream1, bitstream2]),
|
||||||
|
findById: () => createMockRDObs(item),
|
||||||
|
getBundles: () => createMockRDPaginatedObs([bundle])
|
||||||
|
});
|
||||||
|
route = Object.assign({
|
||||||
|
parent: {
|
||||||
|
data: observableOf({ item: createMockRD(item) })
|
||||||
|
},
|
||||||
|
url: url
|
||||||
|
});
|
||||||
|
bundleService = jasmine.createSpyObj('bundleService', {
|
||||||
|
patch: observableOf(new RestResponse(true, 200, 'OK'))
|
||||||
|
});
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot()],
|
||||||
|
declarations: [ItemBitstreamsComponent, ObjectValuesPipe, VarDirective],
|
||||||
|
providers: [
|
||||||
|
{ provide: ItemDataService, useValue: itemService },
|
||||||
|
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: ActivatedRoute, useValue: route },
|
||||||
|
{ provide: NotificationsService, useValue: notificationsService },
|
||||||
|
{ provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any },
|
||||||
|
{ provide: BitstreamDataService, useValue: bitstreamService },
|
||||||
|
{ provide: ObjectCacheService, useValue: objectCache },
|
||||||
|
{ provide: RequestService, useValue: requestService },
|
||||||
|
{ provide: SearchConfigurationService, useValue: searchConfig },
|
||||||
|
{ provide: BundleDataService, useValue: bundleService },
|
||||||
|
ChangeDetectorRef
|
||||||
|
], schemas: [
|
||||||
|
NO_ERRORS_SCHEMA
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ItemBitstreamsComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
comp.url = url;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when submit is called', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.submit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call deleteAndReturnResponse on the bitstreamService for the marked field', () => {
|
||||||
|
expect(bitstreamService.deleteAndReturnResponse).toHaveBeenCalledWith(bitstream2.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => {
|
||||||
|
expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send out a patch for the move operations', () => {
|
||||||
|
expect(bundleService.patch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('discard', () => {
|
||||||
|
it('should discard ALL field updates', () => {
|
||||||
|
comp.discard();
|
||||||
|
expect(objectUpdatesService.discardAllFieldUpdates).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reinstate', () => {
|
||||||
|
it('should reinstate field updates on the bundle', () => {
|
||||||
|
comp.reinstate();
|
||||||
|
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(bundle.self);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createMockRDPaginatedObs(list: any[]) {
|
||||||
|
return createMockRDObs(new PaginatedList(new PageInfo(), list));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockRDObs(obj: any) {
|
||||||
|
return observableOf(createMockRD(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockRD(obj: any) {
|
||||||
|
return new RemoteData(false, false, true, null, obj);
|
||||||
|
}
|
@@ -1,4 +1,34 @@
|
|||||||
import { Component } from '@angular/core';
|
import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core';
|
||||||
|
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
|
||||||
|
import { filter, map, switchMap, take, tap } from 'rxjs/operators';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { Subscription } from 'rxjs/internal/Subscription';
|
||||||
|
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||||
|
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
|
||||||
|
import { BitstreamDataService } from '../../../core/data/bitstream-data.service';
|
||||||
|
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
|
||||||
|
import { zip as observableZip, combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
|
||||||
|
import { ErrorResponse, RestResponse } from '../../../core/cache/response.models';
|
||||||
|
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
|
||||||
|
import { RequestService } from '../../../core/data/request.service';
|
||||||
|
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators';
|
||||||
|
import { Item } from '../../../core/shared/item.model';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { Bundle } from '../../../core/shared/bundle.model';
|
||||||
|
import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer';
|
||||||
|
import { Bitstream } from '../../../core/shared/bitstream.model';
|
||||||
|
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
|
||||||
|
import { Operation } from 'fast-json-patch';
|
||||||
|
import { MoveOperation } from 'fast-json-patch/lib/core';
|
||||||
|
import { BundleDataService } from '../../../core/data/bundle-data.service';
|
||||||
|
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
|
||||||
|
import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||||
|
import { ResponsiveTableSizes } from '../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ds-item-bitstreams',
|
selector: 'ds-item-bitstreams',
|
||||||
@@ -8,6 +38,273 @@ import { Component } from '@angular/core';
|
|||||||
/**
|
/**
|
||||||
* Component for displaying an item's bitstreams edit page
|
* Component for displaying an item's bitstreams edit page
|
||||||
*/
|
*/
|
||||||
export class ItemBitstreamsComponent {
|
export class ItemBitstreamsComponent extends AbstractItemUpdateComponent implements OnDestroy {
|
||||||
/* TODO implement */
|
|
||||||
|
/**
|
||||||
|
* The currently listed bundles
|
||||||
|
*/
|
||||||
|
bundles$: Observable<Bundle[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page options to use for fetching the bundles
|
||||||
|
*/
|
||||||
|
bundlesOptions = {
|
||||||
|
id: 'bundles-pagination-options',
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 9999
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bootstrap sizes used for the columns within this table
|
||||||
|
*/
|
||||||
|
columnSizes = new ResponsiveTableSizes([
|
||||||
|
// Name column
|
||||||
|
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
|
||||||
|
// Description column
|
||||||
|
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
|
||||||
|
// Format column
|
||||||
|
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
|
||||||
|
// Actions column
|
||||||
|
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Are we currently submitting the changes?
|
||||||
|
* Used to disable any action buttons until the submit finishes
|
||||||
|
*/
|
||||||
|
submitting = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subscription that checks when the item is deleted in cache and reloads the item by sending a new request
|
||||||
|
* This is used to update the item in cache after bitstreams are deleted
|
||||||
|
*/
|
||||||
|
itemUpdateSubscription: Subscription;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public itemService: ItemDataService,
|
||||||
|
public objectUpdatesService: ObjectUpdatesService,
|
||||||
|
public router: Router,
|
||||||
|
public notificationsService: NotificationsService,
|
||||||
|
public translateService: TranslateService,
|
||||||
|
@Inject(GLOBAL_CONFIG) public EnvConfig: GlobalConfig,
|
||||||
|
public route: ActivatedRoute,
|
||||||
|
public bitstreamService: BitstreamDataService,
|
||||||
|
public objectCache: ObjectCacheService,
|
||||||
|
public requestService: RequestService,
|
||||||
|
public cdRef: ChangeDetectorRef,
|
||||||
|
public bundleService: BundleDataService
|
||||||
|
) {
|
||||||
|
super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up and initialize all fields
|
||||||
|
*/
|
||||||
|
ngOnInit(): void {
|
||||||
|
super.ngOnInit();
|
||||||
|
this.initializeItemUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions to perform after the item has been initialized
|
||||||
|
*/
|
||||||
|
postItemInit(): void {
|
||||||
|
this.bundles$ = this.itemService.getBundles(this.item.id, new PaginatedSearchOptions({pagination: this.bundlesOptions})).pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload(),
|
||||||
|
map((bundlePage: PaginatedList<Bundle>) => bundlePage.page)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the notification messages prefix
|
||||||
|
*/
|
||||||
|
initializeNotificationsPrefix(): void {
|
||||||
|
this.notificationsPrefix = 'item.edit.bitstreams.notifications.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the item (and view) when it's removed in the request cache
|
||||||
|
* Also re-initialize the original fields and updates
|
||||||
|
*/
|
||||||
|
initializeItemUpdate(): void {
|
||||||
|
this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe(
|
||||||
|
filter((exists: boolean) => !exists),
|
||||||
|
switchMap(() => this.itemService.findById(this.item.uuid)),
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
).subscribe((itemRD: RemoteData<Item>) => {
|
||||||
|
if (hasValue(itemRD)) {
|
||||||
|
this.item = itemRD.payload;
|
||||||
|
this.postItemInit();
|
||||||
|
this.initializeOriginalFields();
|
||||||
|
this.initializeUpdates();
|
||||||
|
this.cdRef.detectChanges();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit the current changes
|
||||||
|
* Bitstreams that were dragged around send out a patch request with move operations to the rest API
|
||||||
|
* Bitstreams marked as deleted send out a delete request to the rest API
|
||||||
|
* Display notifications and reset the current item/updates
|
||||||
|
*/
|
||||||
|
submit() {
|
||||||
|
this.submitting = true;
|
||||||
|
const bundlesOnce$ = this.bundles$.pipe(take(1));
|
||||||
|
|
||||||
|
// Fetch all move operations for each bundle
|
||||||
|
const moveOperations$ = bundlesOnce$.pipe(
|
||||||
|
switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) =>
|
||||||
|
this.objectUpdatesService.getMoveOperations(bundle.self).pipe(
|
||||||
|
take(1),
|
||||||
|
map((operations: MoveOperation[]) => [...operations.map((operation: MoveOperation) => Object.assign(operation, {
|
||||||
|
from: `/_links/bitstreams${operation.from}/href`,
|
||||||
|
path: `/_links/bitstreams${operation.path}/href`
|
||||||
|
}))])
|
||||||
|
)
|
||||||
|
)))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send out an immediate patch request for each bundle
|
||||||
|
const patchResponses$ = observableCombineLatest(bundlesOnce$, moveOperations$).pipe(
|
||||||
|
switchMap(([bundles, moveOperationList]: [Bundle[], Operation[][]]) =>
|
||||||
|
observableZip(...bundles.map((bundle: Bundle, index: number) => {
|
||||||
|
if (isNotEmpty(moveOperationList[index])) {
|
||||||
|
return this.bundleService.patch(bundle, moveOperationList[index]);
|
||||||
|
} else {
|
||||||
|
return observableOf(undefined);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch all removed bitstreams from the object update service
|
||||||
|
const removedBitstreams$ = bundlesOnce$.pipe(
|
||||||
|
switchMap((bundles: Bundle[]) => observableZip(
|
||||||
|
...bundles.map((bundle: Bundle) => this.objectUpdatesService.getFieldUpdates(bundle.self, [], true))
|
||||||
|
)),
|
||||||
|
map((fieldUpdates: FieldUpdates[]) => ([] as FieldUpdate[]).concat(
|
||||||
|
...fieldUpdates.map((updates: FieldUpdates) => Object.values(updates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE))
|
||||||
|
)),
|
||||||
|
map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send out delete requests for all deleted bitstreams
|
||||||
|
const removedResponses$ = removedBitstreams$.pipe(
|
||||||
|
take(1),
|
||||||
|
switchMap((removedBistreams: Bitstream[]) => {
|
||||||
|
if (isNotEmpty(removedBistreams)) {
|
||||||
|
return observableZip(...removedBistreams.map((bitstream: Bitstream) => this.bitstreamService.deleteAndReturnResponse(bitstream.id)));
|
||||||
|
} else {
|
||||||
|
return observableOf(undefined);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Perform the setup actions from above in order and display notifications
|
||||||
|
patchResponses$.pipe(
|
||||||
|
switchMap((responses: RestResponse[]) => {
|
||||||
|
this.displayNotifications('item.edit.bitstreams.notifications.move', responses);
|
||||||
|
return removedResponses$
|
||||||
|
}),
|
||||||
|
take(1)
|
||||||
|
).subscribe((responses: RestResponse[]) => {
|
||||||
|
this.displayNotifications('item.edit.bitstreams.notifications.remove', responses);
|
||||||
|
this.reset();
|
||||||
|
this.submitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display notifications
|
||||||
|
* - Error notification for each failed response with their message
|
||||||
|
* - Success notification in case there's at least one successful response
|
||||||
|
* @param key The i18n key for the notification messages
|
||||||
|
* @param responses The returned responses to display notifications for
|
||||||
|
*/
|
||||||
|
displayNotifications(key: string, responses: RestResponse[]) {
|
||||||
|
if (isNotEmpty(responses)) {
|
||||||
|
const failedResponses = responses.filter((response: RestResponse) => hasValue(response) && !response.isSuccessful);
|
||||||
|
const successfulResponses = responses.filter((response: RestResponse) => hasValue(response) && response.isSuccessful);
|
||||||
|
|
||||||
|
failedResponses.forEach((response: ErrorResponse) => {
|
||||||
|
this.notificationsService.error(this.translateService.instant(`${key}.failed.title`), response.errorMessage);
|
||||||
|
});
|
||||||
|
if (successfulResponses.length > 0) {
|
||||||
|
this.notificationsService.success(this.translateService.instant(`${key}.saved.title`), this.translateService.instant(`${key}.saved.content`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the object updates service to discard all current changes to this item
|
||||||
|
* Shows a notification to remind the user that they can undo this
|
||||||
|
*/
|
||||||
|
discard() {
|
||||||
|
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), {timeOut: this.discardTimeOut});
|
||||||
|
this.objectUpdatesService.discardAllFieldUpdates(this.url, undoNotification);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request the object updates service to undo discarding all changes to this item
|
||||||
|
*/
|
||||||
|
reinstate() {
|
||||||
|
this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => {
|
||||||
|
bundles.forEach((bundle: Bundle) => {
|
||||||
|
this.objectUpdatesService.reinstateFieldUpdates(bundle.self);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether or not the object is currently reinstatable
|
||||||
|
*/
|
||||||
|
isReinstatable(): Observable<boolean> {
|
||||||
|
return this.bundles$.pipe(
|
||||||
|
switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => this.objectUpdatesService.isReinstatable(bundle.self)))),
|
||||||
|
map((reinstatable: boolean[]) => reinstatable.includes(true))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether or not there are currently updates for this object
|
||||||
|
*/
|
||||||
|
hasChanges(): Observable<boolean> {
|
||||||
|
return this.bundles$.pipe(
|
||||||
|
switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => this.objectUpdatesService.hasUpdates(bundle.self)))),
|
||||||
|
map((hasChanges: boolean[]) => hasChanges.includes(true))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De-cache the current item (it should automatically reload due to itemUpdateSubscription)
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.refreshItemCache();
|
||||||
|
this.initializeItemUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the current item's cache from object- and request-cache
|
||||||
|
*/
|
||||||
|
refreshItemCache() {
|
||||||
|
this.bundles$.pipe(take(1)).subscribe((bundles: Bundle[]) => {
|
||||||
|
bundles.forEach((bundle: Bundle) => {
|
||||||
|
this.objectCache.remove(bundle.self);
|
||||||
|
this.requestService.removeByHrefSubstring(bundle.self);
|
||||||
|
});
|
||||||
|
this.objectCache.remove(this.item.self);
|
||||||
|
this.requestService.removeByHrefSubstring(this.item.self);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from open subscriptions whenever the component gets destroyed
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.itemUpdateSubscription) {
|
||||||
|
this.itemUpdateSubscription.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,21 @@
|
|||||||
|
<ng-template #bundleView>
|
||||||
|
<div class="row bundle-row">
|
||||||
|
<div class="{{bundleNameColumn.buildClasses()}} font-weight-bold row-element d-flex">
|
||||||
|
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
|
||||||
|
<div class="float-left d-flex align-items-center">
|
||||||
|
{{'item.edit.bitstreams.bundle.name' | translate:{ name: bundle.name } }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="{{columnSizes.columns[3].buildClasses()}} text-center row-element">
|
||||||
|
<div class="btn-group bundle-action-buttons">
|
||||||
|
<button [routerLink]="['/items/', item.id, 'bitstreams', 'new']"
|
||||||
|
[queryParams]="{bundle: bundle.id}"
|
||||||
|
class="btn btn-outline-success btn-sm"
|
||||||
|
title="{{'item.edit.bitstreams.bundle.edit.buttons.upload' | translate}}">
|
||||||
|
<i class="fas fa-upload fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ds-paginated-drag-and-drop-bitstream-list [bundle]="bundle" [columnSizes]="columnSizes"></ds-paginated-drag-and-drop-bitstream-list>
|
||||||
|
</ng-template>
|
@@ -0,0 +1,58 @@
|
|||||||
|
import { ItemEditBitstreamBundleComponent } from './item-edit-bitstream-bundle.component';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { NO_ERRORS_SCHEMA, ViewContainerRef } from '@angular/core';
|
||||||
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { Bundle } from '../../../../core/shared/bundle.model';
|
||||||
|
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||||
|
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||||
|
|
||||||
|
describe('ItemEditBitstreamBundleComponent', () => {
|
||||||
|
let comp: ItemEditBitstreamBundleComponent;
|
||||||
|
let fixture: ComponentFixture<ItemEditBitstreamBundleComponent>;
|
||||||
|
let viewContainerRef: ViewContainerRef;
|
||||||
|
|
||||||
|
const columnSizes = new ResponsiveTableSizes([
|
||||||
|
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
|
||||||
|
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
|
||||||
|
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
|
||||||
|
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const item = Object.assign(new Item(), {
|
||||||
|
id: 'item-1',
|
||||||
|
uuid: 'item-1'
|
||||||
|
});
|
||||||
|
const bundle = Object.assign(new Bundle(), {
|
||||||
|
id: 'bundle-1',
|
||||||
|
uuid: 'bundle-1',
|
||||||
|
_links: {
|
||||||
|
self: { href: 'bundle-1-selflink' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot()],
|
||||||
|
declarations: [ItemEditBitstreamBundleComponent],
|
||||||
|
schemas: [
|
||||||
|
NO_ERRORS_SCHEMA
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ItemEditBitstreamBundleComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
comp.item = item;
|
||||||
|
comp.bundle = bundle;
|
||||||
|
comp.columnSizes = columnSizes;
|
||||||
|
viewContainerRef = (comp as any).viewContainerRef;
|
||||||
|
spyOn(viewContainerRef, 'createEmbeddedView');
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an embedded view of the component', () => {
|
||||||
|
expect(viewContainerRef.createEmbeddedView).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,52 @@
|
|||||||
|
import { Component, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
|
||||||
|
import { Bundle } from '../../../../core/shared/bundle.model';
|
||||||
|
import { Item } from '../../../../core/shared/item.model';
|
||||||
|
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||||
|
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-item-edit-bitstream-bundle',
|
||||||
|
styleUrls: ['../item-bitstreams.component.scss'],
|
||||||
|
templateUrl: './item-edit-bitstream-bundle.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component that displays a single bundle of an item on the item bitstreams edit page
|
||||||
|
* Creates an embedded view of the contents. This is to ensure the table structure won't break.
|
||||||
|
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-bundle element)
|
||||||
|
*/
|
||||||
|
export class ItemEditBitstreamBundleComponent implements OnInit {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view on the bundle information and bitstreams
|
||||||
|
*/
|
||||||
|
@ViewChild('bundleView', {static: true}) bundleView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bundle to display bitstreams for
|
||||||
|
*/
|
||||||
|
@Input() bundle: Bundle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The item the bundle belongs to
|
||||||
|
*/
|
||||||
|
@Input() item: Item;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bootstrap sizes used for the columns within this table
|
||||||
|
*/
|
||||||
|
@Input() columnSizes: ResponsiveTableSizes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bootstrap sizes used for the Bundle Name column
|
||||||
|
* This column stretches over the first 3 columns and thus is a combination of their sizes processed in ngOnInit
|
||||||
|
*/
|
||||||
|
bundleNameColumn: ResponsiveColumnSizes;
|
||||||
|
|
||||||
|
constructor(private viewContainerRef: ViewContainerRef) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.bundleNameColumn = this.columnSizes.combineColumns(0, 2);
|
||||||
|
this.viewContainerRef.createEmbeddedView(this.bundleView);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,30 @@
|
|||||||
|
<ds-pagination *ngIf="(objectsRD$ | async)?.payload"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
[hidePaginationDetail]="true"
|
||||||
|
[paginationOptions]="options"
|
||||||
|
[pageInfoState]="(objectsRD$ | async)?.payload"
|
||||||
|
[collectionSize]="(objectsRD$ | async)?.payload?.totalElements"
|
||||||
|
[disableRouteParameterUpdate]="true"
|
||||||
|
(pageChange)="switchPage($event)">
|
||||||
|
<div [id]="bundle.id" class="bundle-bitstreams-list"
|
||||||
|
[ngClass]="{'mb-3': (objectsRD$ | async)?.payload?.totalElements > pageSize}"
|
||||||
|
*ngVar="((updates$ | async) | dsObjectValues) as updateValues" cdkDropList (cdkDropListDropped)="drop($event)">
|
||||||
|
<div class="row bitstream-row" *ngFor="let updateValue of updateValues" cdkDrag
|
||||||
|
[id]="updateValue.field.uuid"
|
||||||
|
[ngClass]="{
|
||||||
|
'table-warning': updateValue.changeType === 0,
|
||||||
|
'table-danger': updateValue.changeType === 2,
|
||||||
|
'table-success': updateValue.changeType === 1,
|
||||||
|
'bg-white': updateValue.changeType === undefined
|
||||||
|
}">
|
||||||
|
<ds-item-edit-bitstream [fieldUpdate]="updateValue"
|
||||||
|
[bundleUrl]="bundle.self"
|
||||||
|
[columnSizes]="columnSizes">
|
||||||
|
<div class="d-flex align-items-center" slot="drag-handle" cdkDragHandle>
|
||||||
|
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
|
||||||
|
</div>
|
||||||
|
</ds-item-edit-bitstream>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ds-pagination>
|
@@ -0,0 +1,132 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { Bundle } from '../../../../../core/shared/bundle.model';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { PaginatedDragAndDropBitstreamListComponent } from './paginated-drag-and-drop-bitstream-list.component';
|
||||||
|
import { VarDirective } from '../../../../../shared/utils/var.directive';
|
||||||
|
import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe';
|
||||||
|
import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { BundleDataService } from '../../../../../core/data/bundle-data.service';
|
||||||
|
import { createMockRDObs } from '../../item-bitstreams.component.spec';
|
||||||
|
import { Bitstream } from '../../../../../core/shared/bitstream.model';
|
||||||
|
import { BitstreamFormat } from '../../../../../core/shared/bitstream-format.model';
|
||||||
|
import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { take } from 'rxjs/operators';
|
||||||
|
import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||||
|
import { ResponsiveColumnSizes } from '../../../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||||
|
|
||||||
|
describe('PaginatedDragAndDropBitstreamListComponent', () => {
|
||||||
|
let comp: PaginatedDragAndDropBitstreamListComponent;
|
||||||
|
let fixture: ComponentFixture<PaginatedDragAndDropBitstreamListComponent>;
|
||||||
|
let objectUpdatesService: ObjectUpdatesService;
|
||||||
|
let bundleService: BundleDataService;
|
||||||
|
|
||||||
|
const columnSizes = new ResponsiveTableSizes([
|
||||||
|
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
|
||||||
|
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
|
||||||
|
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
|
||||||
|
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bundle = Object.assign(new Bundle(), {
|
||||||
|
id: 'bundle-1',
|
||||||
|
uuid: 'bundle-1',
|
||||||
|
_links: {
|
||||||
|
self: { href: 'bundle-1-selflink' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const date = new Date();
|
||||||
|
const format = Object.assign(new BitstreamFormat(), {
|
||||||
|
shortDescription: 'PDF'
|
||||||
|
});
|
||||||
|
const bitstream1 = Object.assign(new Bitstream(), {
|
||||||
|
uuid: 'bitstreamUUID1',
|
||||||
|
name: 'Fake Bitstream 1',
|
||||||
|
bundleName: 'ORIGINAL',
|
||||||
|
description: 'Description',
|
||||||
|
format: createMockRDObs(format)
|
||||||
|
});
|
||||||
|
const fieldUpdate1 = {
|
||||||
|
field: bitstream1,
|
||||||
|
changeType: undefined
|
||||||
|
};
|
||||||
|
const bitstream2 = Object.assign(new Bitstream(), {
|
||||||
|
uuid: 'bitstreamUUID2',
|
||||||
|
name: 'Fake Bitstream 2',
|
||||||
|
bundleName: 'ORIGINAL',
|
||||||
|
description: 'Description',
|
||||||
|
format: createMockRDObs(format)
|
||||||
|
});
|
||||||
|
const fieldUpdate2 = {
|
||||||
|
field: bitstream2,
|
||||||
|
changeType: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||||
|
{
|
||||||
|
getFieldUpdates: observableOf({
|
||||||
|
[bitstream1.uuid]: fieldUpdate1,
|
||||||
|
[bitstream2.uuid]: fieldUpdate2,
|
||||||
|
}),
|
||||||
|
getFieldUpdatesExclusive: observableOf({
|
||||||
|
[bitstream1.uuid]: fieldUpdate1,
|
||||||
|
[bitstream2.uuid]: fieldUpdate2,
|
||||||
|
}),
|
||||||
|
getFieldUpdatesByCustomOrder: observableOf({
|
||||||
|
[bitstream1.uuid]: fieldUpdate1,
|
||||||
|
[bitstream2.uuid]: fieldUpdate2,
|
||||||
|
}),
|
||||||
|
saveMoveFieldUpdate: {},
|
||||||
|
saveRemoveFieldUpdate: {},
|
||||||
|
removeSingleFieldUpdate: {},
|
||||||
|
saveAddFieldUpdate: {},
|
||||||
|
discardFieldUpdates: {},
|
||||||
|
reinstateFieldUpdates: observableOf(true),
|
||||||
|
initialize: {},
|
||||||
|
getUpdatedFields: observableOf([bitstream1, bitstream2]),
|
||||||
|
getLastModified: observableOf(date),
|
||||||
|
hasUpdates: observableOf(true),
|
||||||
|
isReinstatable: observableOf(false),
|
||||||
|
isValidPage: observableOf(true),
|
||||||
|
initializeWithCustomOrder: {},
|
||||||
|
addPageToCustomOrder: {}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
bundleService = jasmine.createSpyObj('bundleService', {
|
||||||
|
getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2]))
|
||||||
|
});
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot()],
|
||||||
|
declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective, ObjectValuesPipe],
|
||||||
|
providers: [
|
||||||
|
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
|
||||||
|
{ provide: BundleDataService, useValue: bundleService }
|
||||||
|
], schemas: [
|
||||||
|
NO_ERRORS_SCHEMA
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(PaginatedDragAndDropBitstreamListComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
comp.bundle = bundle;
|
||||||
|
comp.columnSizes = columnSizes;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize the objectsRD$', (done) => {
|
||||||
|
comp.objectsRD$.pipe(take(1)).subscribe((objects) => {
|
||||||
|
expect(objects.payload.page).toEqual([bitstream1, bitstream2]);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize the URL', () => {
|
||||||
|
expect(comp.url).toEqual(bundle.self);
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,63 @@
|
|||||||
|
import { AbstractPaginatedDragAndDropListComponent } from '../../../../../shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component';
|
||||||
|
import { Component, ElementRef, Input, OnInit } from '@angular/core';
|
||||||
|
import { Bundle } from '../../../../../core/shared/bundle.model';
|
||||||
|
import { Bitstream } from '../../../../../core/shared/bitstream.model';
|
||||||
|
import { ObjectUpdatesService } from '../../../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { BundleDataService } from '../../../../../core/data/bundle-data.service';
|
||||||
|
import { switchMap } from 'rxjs/operators';
|
||||||
|
import { PaginatedSearchOptions } from '../../../../../shared/search/paginated-search-options.model';
|
||||||
|
import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||||
|
import { followLink } from '../../../../../shared/utils/follow-link-config.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-paginated-drag-and-drop-bitstream-list',
|
||||||
|
styleUrls: ['../../item-bitstreams.component.scss'],
|
||||||
|
templateUrl: './paginated-drag-and-drop-bitstream-list.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* A component listing edit-bitstream rows for each bitstream within the given bundle.
|
||||||
|
* This component makes use of the AbstractPaginatedDragAndDropListComponent, allowing for users to drag and drop
|
||||||
|
* bitstreams within the paginated list. To drag and drop a bitstream between two pages, drag the row on top of the
|
||||||
|
* page number you want the bitstream to end up at. Doing so will add the bitstream to the top of that page.
|
||||||
|
*/
|
||||||
|
export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginatedDragAndDropListComponent<Bitstream> implements OnInit {
|
||||||
|
/**
|
||||||
|
* The bundle to display bitstreams for
|
||||||
|
*/
|
||||||
|
@Input() bundle: Bundle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bootstrap sizes used for the columns within this table
|
||||||
|
*/
|
||||||
|
@Input() columnSizes: ResponsiveTableSizes;
|
||||||
|
|
||||||
|
constructor(protected objectUpdatesService: ObjectUpdatesService,
|
||||||
|
protected elRef: ElementRef,
|
||||||
|
protected bundleService: BundleDataService) {
|
||||||
|
super(objectUpdatesService, elRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
super.ngOnInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the bitstreams observable depending on currentPage$
|
||||||
|
*/
|
||||||
|
initializeObjectsRD(): void {
|
||||||
|
this.objectsRD$ = this.currentPage$.pipe(
|
||||||
|
switchMap((page: number) => this.bundleService.getBitstreams(
|
||||||
|
this.bundle.id,
|
||||||
|
new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}),
|
||||||
|
followLink('format')
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the URL used for the field-update store, in this case the bundle's self-link
|
||||||
|
*/
|
||||||
|
initializeURL(): void {
|
||||||
|
this.url = this.bundle.self;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,5 @@
|
|||||||
|
<ng-template #handleView>
|
||||||
|
<div class="drag-handle text-muted float-left p-1 mr-2">
|
||||||
|
<i class="fas fa-grip-vertical fa-fw" [title]="'item.edit.bitstreams.edit.buttons.drag' | translate"></i>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
@@ -0,0 +1,26 @@
|
|||||||
|
import { Component, OnInit, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-item-edit-bitstream-drag-handle',
|
||||||
|
styleUrls: ['../item-bitstreams.component.scss'],
|
||||||
|
templateUrl: './item-edit-bitstream-drag-handle.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component displaying a drag handle for the item-edit-bitstream page
|
||||||
|
* Creates an embedded view of the contents
|
||||||
|
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream-drag-handle element)
|
||||||
|
*/
|
||||||
|
export class ItemEditBitstreamDragHandleComponent implements OnInit {
|
||||||
|
/**
|
||||||
|
* The view on the drag-handle
|
||||||
|
*/
|
||||||
|
@ViewChild('handleView', {static: true}) handleView;
|
||||||
|
|
||||||
|
constructor(private viewContainerRef: ViewContainerRef) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.viewContainerRef.createEmbeddedView(this.handleView);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,43 @@
|
|||||||
|
<ng-template #bitstreamView>
|
||||||
|
<div class="{{columnSizes.columns[0].buildClasses()}} row-element d-flex">
|
||||||
|
<ng-content select="[slot=drag-handle]"></ng-content>
|
||||||
|
<div class="float-left d-flex align-items-center">
|
||||||
|
{{ bitstreamName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="{{columnSizes.columns[1].buildClasses()}} row-element d-flex align-items-center">
|
||||||
|
<div class="w-100">
|
||||||
|
{{ bitstream?.firstMetadataValue('dc.description') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="{{columnSizes.columns[2].buildClasses()}} row-element d-flex align-items-center">
|
||||||
|
<div class="text-center w-100">
|
||||||
|
{{ (format$ | async)?.shortDescription }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="{{columnSizes.columns[3].buildClasses()}} row-element d-flex align-items-center">
|
||||||
|
<div class="text-center w-100">
|
||||||
|
<div class="btn-group relationship-action-buttons">
|
||||||
|
<a [href]="bitstream?._links?.content?.href"
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
title="{{'item.edit.bitstreams.edit.buttons.download' | translate}}">
|
||||||
|
<i class="fas fa-download fa-fw"></i>
|
||||||
|
</a>
|
||||||
|
<button [routerLink]="['/bitstreams/', bitstream.id, 'edit']" class="btn btn-outline-primary btn-sm"
|
||||||
|
title="{{'item.edit.bitstreams.edit.buttons.edit' | translate}}">
|
||||||
|
<i class="fas fa-edit fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<button [disabled]="!canRemove()" (click)="remove()"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
title="{{'item.edit.bitstreams.edit.buttons.remove' | translate}}">
|
||||||
|
<i class="fas fa-trash-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<button [disabled]="!canUndo()" (click)="undo()"
|
||||||
|
class="btn btn-outline-warning btn-sm"
|
||||||
|
title="{{'item.edit.bitstreams.edit.buttons.undo' | translate}}">
|
||||||
|
<i class="fas fa-undo-alt fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
@@ -0,0 +1,119 @@
|
|||||||
|
import { ItemEditBitstreamComponent } from './item-edit-bitstream.component';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { Bitstream } from '../../../../core/shared/bitstream.model';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { VarDirective } from '../../../../shared/utils/var.directive';
|
||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { createMockRDObs } from '../item-bitstreams.component.spec';
|
||||||
|
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
|
||||||
|
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||||
|
import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes';
|
||||||
|
|
||||||
|
let comp: ItemEditBitstreamComponent;
|
||||||
|
let fixture: ComponentFixture<ItemEditBitstreamComponent>;
|
||||||
|
|
||||||
|
const columnSizes = new ResponsiveTableSizes([
|
||||||
|
new ResponsiveColumnSizes(2, 2, 3, 4, 4),
|
||||||
|
new ResponsiveColumnSizes(2, 3, 3, 3, 3),
|
||||||
|
new ResponsiveColumnSizes(2, 2, 2, 2, 2),
|
||||||
|
new ResponsiveColumnSizes(6, 5, 4, 3, 3)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const format = Object.assign(new BitstreamFormat(), {
|
||||||
|
shortDescription: 'PDF'
|
||||||
|
});
|
||||||
|
const bitstream = Object.assign(new Bitstream(), {
|
||||||
|
uuid: 'bitstreamUUID',
|
||||||
|
name: 'Fake Bitstream',
|
||||||
|
bundleName: 'ORIGINAL',
|
||||||
|
description: 'Description',
|
||||||
|
format: createMockRDObs(format)
|
||||||
|
});
|
||||||
|
const fieldUpdate = {
|
||||||
|
field: bitstream,
|
||||||
|
changeType: undefined
|
||||||
|
};
|
||||||
|
const date = new Date();
|
||||||
|
const url = 'thisUrl';
|
||||||
|
|
||||||
|
let objectUpdatesService: ObjectUpdatesService;
|
||||||
|
|
||||||
|
describe('ItemEditBitstreamComponent', () => {
|
||||||
|
beforeEach(async(() => {
|
||||||
|
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||||
|
{
|
||||||
|
getFieldUpdates: observableOf({
|
||||||
|
[bitstream.uuid]: fieldUpdate,
|
||||||
|
}),
|
||||||
|
getFieldUpdatesExclusive: observableOf({
|
||||||
|
[bitstream.uuid]: fieldUpdate,
|
||||||
|
}),
|
||||||
|
saveRemoveFieldUpdate: {},
|
||||||
|
removeSingleFieldUpdate: {},
|
||||||
|
saveAddFieldUpdate: {},
|
||||||
|
discardFieldUpdates: {},
|
||||||
|
reinstateFieldUpdates: observableOf(true),
|
||||||
|
initialize: {},
|
||||||
|
getUpdatedFields: observableOf([bitstream]),
|
||||||
|
getLastModified: observableOf(date),
|
||||||
|
hasUpdates: observableOf(true),
|
||||||
|
isReinstatable: observableOf(false),
|
||||||
|
isValidPage: observableOf(true)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [TranslateModule.forRoot()],
|
||||||
|
declarations: [ItemEditBitstreamComponent, VarDirective],
|
||||||
|
providers: [
|
||||||
|
{ provide: ObjectUpdatesService, useValue: objectUpdatesService }
|
||||||
|
], schemas: [
|
||||||
|
NO_ERRORS_SCHEMA
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ItemEditBitstreamComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
comp.fieldUpdate = fieldUpdate;
|
||||||
|
comp.bundleUrl = url;
|
||||||
|
comp.columnSizes = columnSizes;
|
||||||
|
comp.ngOnChanges(undefined);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when remove is called', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call saveRemoveFieldUpdate on objectUpdatesService', () => {
|
||||||
|
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, bitstream);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when undo is called', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
comp.undo();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call removeSingleFieldUpdate on objectUpdatesService', () => {
|
||||||
|
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, bitstream.uuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when canRemove is called', () => {
|
||||||
|
it('should return true', () => {
|
||||||
|
expect(comp.canRemove()).toEqual(true)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when canUndo is called', () => {
|
||||||
|
it('should return false', () => {
|
||||||
|
expect(comp.canUndo()).toEqual(false)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,110 @@
|
|||||||
|
import { Component, Input, OnChanges, OnInit, SimpleChanges, ViewChild, ViewContainerRef } from '@angular/core';
|
||||||
|
import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
|
||||||
|
import { Bitstream } from '../../../../core/shared/bitstream.model';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||||
|
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model';
|
||||||
|
import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||||
|
import { ResponsiveTableSizes } from '../../../../shared/responsive-table-sizes/responsive-table-sizes';
|
||||||
|
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-item-edit-bitstream',
|
||||||
|
styleUrls: ['../item-bitstreams.component.scss'],
|
||||||
|
templateUrl: './item-edit-bitstream.component.html',
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Component that displays a single bitstream of an item on the edit page
|
||||||
|
* Creates an embedded view of the contents
|
||||||
|
* (which means it'll be added to the parents html without a wrapping ds-item-edit-bitstream element)
|
||||||
|
*/
|
||||||
|
export class ItemEditBitstreamComponent implements OnChanges, OnInit {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view on the bitstream
|
||||||
|
*/
|
||||||
|
@ViewChild('bitstreamView', {static: true}) bitstreamView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current field, value and state of the bitstream
|
||||||
|
*/
|
||||||
|
@Input() fieldUpdate: FieldUpdate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The url of the bundle
|
||||||
|
*/
|
||||||
|
@Input() bundleUrl: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bootstrap sizes used for the columns within this table
|
||||||
|
*/
|
||||||
|
@Input() columnSizes: ResponsiveTableSizes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bitstream of this field
|
||||||
|
*/
|
||||||
|
bitstream: Bitstream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bitstream's name
|
||||||
|
*/
|
||||||
|
bitstreamName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The format of the bitstream
|
||||||
|
*/
|
||||||
|
format$: Observable<BitstreamFormat>;
|
||||||
|
|
||||||
|
constructor(private objectUpdatesService: ObjectUpdatesService,
|
||||||
|
private dsoNameService: DSONameService,
|
||||||
|
private viewContainerRef: ViewContainerRef) {
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.viewContainerRef.createEmbeddedView(this.bitstreamView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current bitstream and its format on changes
|
||||||
|
* @param changes
|
||||||
|
*/
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
this.bitstream = cloneDeep(this.fieldUpdate.field) as Bitstream;
|
||||||
|
this.bitstreamName = this.dsoNameService.getName(this.bitstream);
|
||||||
|
this.format$ = this.bitstream.format.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
|
getRemoteDataPayload()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a new remove update for this field to the object updates service
|
||||||
|
*/
|
||||||
|
remove(): void {
|
||||||
|
this.objectUpdatesService.saveRemoveFieldUpdate(this.bundleUrl, this.bitstream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the current update for this field in the object updates service
|
||||||
|
*/
|
||||||
|
undo(): void {
|
||||||
|
this.objectUpdatesService.removeSingleFieldUpdate(this.bundleUrl, this.bitstream.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user should be allowed to remove this field
|
||||||
|
*/
|
||||||
|
canRemove(): boolean {
|
||||||
|
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user should be allowed to cancel the update to this field
|
||||||
|
*/
|
||||||
|
canUndo(): boolean {
|
||||||
|
return this.fieldUpdate.changeType >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -59,8 +59,8 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
|||||||
* Initialize the values and updates of the current item's metadata fields
|
* Initialize the values and updates of the current item's metadata fields
|
||||||
*/
|
*/
|
||||||
public initializeUpdates(): void {
|
public initializeUpdates(): void {
|
||||||
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships());
|
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the prefix for notification messages
|
* Initialize the prefix for notification messages
|
||||||
@@ -81,7 +81,7 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
|||||||
* Sends all initial values of this item to the object updates service
|
* Sends all initial values of this item to the object updates service
|
||||||
*/
|
*/
|
||||||
public initializeOriginalFields() {
|
public initializeOriginalFields() {
|
||||||
this.objectUpdatesService.initialize(this.url, this.getMetadataAsListExcludingRelationships(), this.item.lastModified);
|
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -10,6 +10,7 @@ import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
|||||||
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
|
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
|
||||||
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
|
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
|
||||||
import { LinkService } from '../core/cache/builders/link.service';
|
import { LinkService } from '../core/cache/builders/link.service';
|
||||||
|
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
|
||||||
|
|
||||||
export function getItemPageRoute(itemId: string) {
|
export function getItemPageRoute(itemId: string) {
|
||||||
return new URLCombiner(getItemModulePath(), itemId).toString();
|
return new URLCombiner(getItemModulePath(), itemId).toString();
|
||||||
@@ -20,6 +21,7 @@ export function getItemEditPath(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_EDIT_PATH = 'edit';
|
const ITEM_EDIT_PATH = 'edit';
|
||||||
|
const UPLOAD_BITSTREAM_PATH = 'bitstreams/new';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -45,6 +47,11 @@ const ITEM_EDIT_PATH = 'edit';
|
|||||||
path: ITEM_EDIT_PATH,
|
path: ITEM_EDIT_PATH,
|
||||||
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
|
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
|
||||||
canActivate: [AuthenticatedGuard]
|
canActivate: [AuthenticatedGuard]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: UPLOAD_BITSTREAM_PATH,
|
||||||
|
component: UploadBitstreamComponent,
|
||||||
|
canActivate: [AuthenticatedGuard]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
@@ -26,6 +26,7 @@ import { MetadataRepresentationListComponent } from './simple/metadata-represent
|
|||||||
import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component';
|
import { RelatedEntitiesSearchComponent } from './simple/related-entities/related-entities-search/related-entities-search.component';
|
||||||
import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component';
|
import { MetadataValuesComponent } from './field-components/metadata-values/metadata-values.component';
|
||||||
import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component';
|
import { MetadataFieldWrapperComponent } from './field-components/metadata-field-wrapper/metadata-field-wrapper.component';
|
||||||
|
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
|
||||||
import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component';
|
import { TabbedRelatedEntitiesSearchComponent } from './simple/related-entities/tabbed-related-entities-search/tabbed-related-entities-search.component';
|
||||||
import { StatisticsModule } from '../statistics/statistics.module';
|
import { StatisticsModule } from '../statistics/statistics.module';
|
||||||
import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component';
|
import { AbstractIncrementalListComponent } from './simple/abstract-incremental-list/abstract-incremental-list.component';
|
||||||
@@ -58,6 +59,7 @@ import { AbstractIncrementalListComponent } from './simple/abstract-incremental-
|
|||||||
GenericItemPageFieldComponent,
|
GenericItemPageFieldComponent,
|
||||||
MetadataRepresentationListComponent,
|
MetadataRepresentationListComponent,
|
||||||
RelatedEntitiesSearchComponent,
|
RelatedEntitiesSearchComponent,
|
||||||
|
UploadBitstreamComponent,
|
||||||
TabbedRelatedEntitiesSearchComponent,
|
TabbedRelatedEntitiesSearchComponent,
|
||||||
AbstractIncrementalListComponent,
|
AbstractIncrementalListComponent,
|
||||||
],
|
],
|
||||||
|
@@ -28,6 +28,10 @@ const COMMUNITY_MODULE_PATH = 'communities';
|
|||||||
export function getCommunityModulePath() {
|
export function getCommunityModulePath() {
|
||||||
return `/${COMMUNITY_MODULE_PATH}`;
|
return `/${COMMUNITY_MODULE_PATH}`;
|
||||||
}
|
}
|
||||||
|
const BITSTREAM_MODULE_PATH = 'bitstreams';
|
||||||
|
export function getBitstreamModulePath() {
|
||||||
|
return `/${BITSTREAM_MODULE_PATH}`;
|
||||||
|
}
|
||||||
|
|
||||||
const ADMIN_MODULE_PATH = 'admin';
|
const ADMIN_MODULE_PATH = 'admin';
|
||||||
|
|
||||||
@@ -63,6 +67,7 @@ export function getDSOPath(dso: DSpaceObject): string {
|
|||||||
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
|
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
|
||||||
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
|
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
|
||||||
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
|
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
|
||||||
|
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' },
|
||||||
{
|
{
|
||||||
path: 'mydspace',
|
path: 'mydspace',
|
||||||
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
|
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
|
||||||
|
@@ -4,6 +4,10 @@ import {
|
|||||||
ePeopleRegistryReducer,
|
ePeopleRegistryReducer,
|
||||||
EPeopleRegistryState
|
EPeopleRegistryState
|
||||||
} from './+admin/admin-access-control/epeople-registry/epeople-registry.reducers';
|
} from './+admin/admin-access-control/epeople-registry/epeople-registry.reducers';
|
||||||
|
import {
|
||||||
|
groupRegistryReducer,
|
||||||
|
GroupRegistryState
|
||||||
|
} from './+admin/admin-access-control/group-registry/group-registry.reducers';
|
||||||
import {
|
import {
|
||||||
metadataRegistryReducer,
|
metadataRegistryReducer,
|
||||||
MetadataRegistryState
|
MetadataRegistryState
|
||||||
@@ -47,6 +51,7 @@ export interface AppState {
|
|||||||
relationshipLists: NameVariantListsState;
|
relationshipLists: NameVariantListsState;
|
||||||
communityList: CommunityListState;
|
communityList: CommunityListState;
|
||||||
epeopleRegistry: EPeopleRegistryState;
|
epeopleRegistry: EPeopleRegistryState;
|
||||||
|
groupRegistry: GroupRegistryState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appReducers: ActionReducerMap<AppState> = {
|
export const appReducers: ActionReducerMap<AppState> = {
|
||||||
@@ -66,6 +71,7 @@ export const appReducers: ActionReducerMap<AppState> = {
|
|||||||
relationshipLists: nameVariantReducer,
|
relationshipLists: nameVariantReducer,
|
||||||
communityList: CommunityListReducer,
|
communityList: CommunityListReducer,
|
||||||
epeopleRegistry: ePeopleRegistryReducer,
|
epeopleRegistry: ePeopleRegistryReducer,
|
||||||
|
groupRegistry: groupRegistryReducer,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const routerStateSelector = (state: AppState) => state.router;
|
export const routerStateSelector = (state: AppState) => state.router;
|
||||||
|
@@ -68,6 +68,8 @@ function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBActi
|
|||||||
const actionEntry = action.payload as ServerSyncBufferEntry;
|
const actionEntry = action.payload as ServerSyncBufferEntry;
|
||||||
if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) {
|
if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) {
|
||||||
return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) });
|
return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) });
|
||||||
|
} else {
|
||||||
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -135,12 +135,16 @@ import { PoolTask } from './tasks/models/pool-task-object.model';
|
|||||||
import { TaskObject } from './tasks/models/task-object.model';
|
import { TaskObject } from './tasks/models/task-object.model';
|
||||||
import { PoolTaskDataService } from './tasks/pool-task-data.service';
|
import { PoolTaskDataService } from './tasks/pool-task-data.service';
|
||||||
import { TaskResponseParsingService } from './tasks/task-response-parsing.service';
|
import { TaskResponseParsingService } from './tasks/task-response-parsing.service';
|
||||||
|
import { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service';
|
||||||
|
import { BitstreamDataService } from './data/bitstream-data.service';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { storeModuleConfig } from '../app.reducer';
|
import { storeModuleConfig } from '../app.reducer';
|
||||||
import { VersionDataService } from './data/version-data.service';
|
import { VersionDataService } from './data/version-data.service';
|
||||||
import { VersionHistoryDataService } from './data/version-history-data.service';
|
import { VersionHistoryDataService } from './data/version-history-data.service';
|
||||||
import { Version } from './shared/version.model';
|
import { Version } from './shared/version.model';
|
||||||
import { VersionHistory } from './shared/version-history.model';
|
import { VersionHistory } from './shared/version-history.model';
|
||||||
|
import { WorkflowActionDataService } from './data/workflow-action-data.service';
|
||||||
|
import { WorkflowAction } from './tasks/models/workflow-action-object.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When not in production, endpoint responses can be mocked for testing purposes
|
* When not in production, endpoint responses can be mocked for testing purposes
|
||||||
@@ -231,6 +235,7 @@ const PROVIDERS = [
|
|||||||
DSpaceObjectDataService,
|
DSpaceObjectDataService,
|
||||||
DSOChangeAnalyzer,
|
DSOChangeAnalyzer,
|
||||||
DefaultChangeAnalyzer,
|
DefaultChangeAnalyzer,
|
||||||
|
ArrayMoveChangeAnalyzer,
|
||||||
ObjectSelectService,
|
ObjectSelectService,
|
||||||
CSSVariableService,
|
CSSVariableService,
|
||||||
MenuService,
|
MenuService,
|
||||||
@@ -242,6 +247,7 @@ const PROVIDERS = [
|
|||||||
TaskResponseParsingService,
|
TaskResponseParsingService,
|
||||||
ClaimedTaskDataService,
|
ClaimedTaskDataService,
|
||||||
PoolTaskDataService,
|
PoolTaskDataService,
|
||||||
|
BitstreamDataService,
|
||||||
EntityTypeService,
|
EntityTypeService,
|
||||||
ContentSourceResponseParsingService,
|
ContentSourceResponseParsingService,
|
||||||
SearchService,
|
SearchService,
|
||||||
@@ -257,6 +263,7 @@ const PROVIDERS = [
|
|||||||
VersionHistoryDataService,
|
VersionHistoryDataService,
|
||||||
LicenseDataService,
|
LicenseDataService,
|
||||||
ItemTypeDataService,
|
ItemTypeDataService,
|
||||||
|
WorkflowActionDataService,
|
||||||
// register AuthInterceptor as HttpInterceptor
|
// register AuthInterceptor as HttpInterceptor
|
||||||
{
|
{
|
||||||
provide: HTTP_INTERCEPTORS,
|
provide: HTTP_INTERCEPTORS,
|
||||||
@@ -306,7 +313,8 @@ export const models =
|
|||||||
ExternalSource,
|
ExternalSource,
|
||||||
ExternalSourceEntry,
|
ExternalSourceEntry,
|
||||||
Version,
|
Version,
|
||||||
VersionHistory
|
VersionHistory,
|
||||||
|
WorkflowAction
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
107
src/app/core/data/array-move-change-analyzer.service.spec.ts
Normal file
107
src/app/core/data/array-move-change-analyzer.service.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service';
|
||||||
|
import { moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
|
import { Operation } from 'fast-json-patch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper class for creating move tests
|
||||||
|
* Define a "from" and "to" index to move objects within the array before comparing
|
||||||
|
*/
|
||||||
|
class MoveTest {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
|
||||||
|
constructor(from: number, to: number) {
|
||||||
|
this.from = from;
|
||||||
|
this.to = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ArrayMoveChangeAnalyzer', () => {
|
||||||
|
const comparator = new ArrayMoveChangeAnalyzer<string>();
|
||||||
|
|
||||||
|
let originalArray = [];
|
||||||
|
|
||||||
|
describe('when all values are defined', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
originalArray = [
|
||||||
|
'98700118-d65d-4636-b1d0-dba83fc932e1',
|
||||||
|
'4d7d0798-a8fa-45b8-b4fc-deb2819606c8',
|
||||||
|
'e56eb99e-2f7c-4bee-9b3f-d3dcc83386b1',
|
||||||
|
'0f608168-cdfc-46b0-92ce-889f7d3ac684',
|
||||||
|
'546f9f5c-15dc-4eec-86fe-648007ac9e1c'
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
testMove([
|
||||||
|
{ op: 'move', from: '/2', path: '/4' },
|
||||||
|
], new MoveTest(2, 4));
|
||||||
|
|
||||||
|
testMove([
|
||||||
|
{ op: 'move', from: '/0', path: '/3' },
|
||||||
|
], new MoveTest(0, 3));
|
||||||
|
|
||||||
|
testMove([
|
||||||
|
{ op: 'move', from: '/0', path: '/3' },
|
||||||
|
{ op: 'move', from: '/2', path: '/1' }
|
||||||
|
], new MoveTest(0, 3), new MoveTest(1, 2));
|
||||||
|
|
||||||
|
testMove([
|
||||||
|
{ op: 'move', from: '/0', path: '/1' },
|
||||||
|
{ op: 'move', from: '/3', path: '/4' }
|
||||||
|
], new MoveTest(0, 1), new MoveTest(3, 4));
|
||||||
|
|
||||||
|
testMove([], new MoveTest(0, 4), new MoveTest(4, 0));
|
||||||
|
|
||||||
|
testMove([
|
||||||
|
{ op: 'move', from: '/0', path: '/3' },
|
||||||
|
{ op: 'move', from: '/2', path: '/1' }
|
||||||
|
], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when some values are undefined (index 2 and 3)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
originalArray = [
|
||||||
|
'98700118-d65d-4636-b1d0-dba83fc932e1',
|
||||||
|
'4d7d0798-a8fa-45b8-b4fc-deb2819606c8',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'546f9f5c-15dc-4eec-86fe-648007ac9e1c'
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// It can't create a move operation for undefined values, so it should create move operations for the defined values instead
|
||||||
|
testMove([
|
||||||
|
{ op: 'move', from: '/4', path: '/3' },
|
||||||
|
], new MoveTest(2, 4));
|
||||||
|
|
||||||
|
// Moving a defined value should result in the same operations
|
||||||
|
testMove([
|
||||||
|
{ op: 'move', from: '/0', path: '/3' },
|
||||||
|
], new MoveTest(0, 3));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function for creating a move test
|
||||||
|
*
|
||||||
|
* @param expectedOperations An array of expected operations after comparing the original array with the array
|
||||||
|
* created using the provided MoveTests
|
||||||
|
* @param moves An array of MoveTest objects telling the test where to move objects before comparing
|
||||||
|
*/
|
||||||
|
function testMove(expectedOperations: Operation[], ...moves: MoveTest[]) {
|
||||||
|
describe(`move ${moves.map((move) => `${move.from} to ${move.to}`).join(' and ')}`, () => {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const movedArray = [...originalArray];
|
||||||
|
moves.forEach((move) => {
|
||||||
|
moveItemInArray(movedArray, move.from, move.to);
|
||||||
|
});
|
||||||
|
result = comparator.diff(originalArray, movedArray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the expected move operations', () => {
|
||||||
|
expect(result).toEqual(expectedOperations);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
37
src/app/core/data/array-move-change-analyzer.service.ts
Normal file
37
src/app/core/data/array-move-change-analyzer.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { MoveOperation } from 'fast-json-patch/lib/core';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
|
import { hasValue } from '../../shared/empty.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A class to determine move operations between two arrays
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ArrayMoveChangeAnalyzer<T> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two arrays detecting and returning move operations
|
||||||
|
*
|
||||||
|
* @param array1 The original array
|
||||||
|
* @param array2 The custom array to compare with the original
|
||||||
|
*/
|
||||||
|
diff(array1: T[], array2: T[]): MoveOperation[] {
|
||||||
|
const result = [];
|
||||||
|
const moved = [...array1];
|
||||||
|
array1.forEach((value: T, index: number) => {
|
||||||
|
if (hasValue(value)) {
|
||||||
|
const otherIndex = array2.indexOf(value);
|
||||||
|
const movedIndex = moved.indexOf(value);
|
||||||
|
if (index !== otherIndex && movedIndex !== otherIndex) {
|
||||||
|
moveItemInArray(moved, movedIndex, otherIndex);
|
||||||
|
result.push(Object.assign({
|
||||||
|
op: 'move',
|
||||||
|
from: '/' + movedIndex,
|
||||||
|
path: '/' + otherIndex
|
||||||
|
}) as MoveOperation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
58
src/app/core/data/bitstream-data.service.spec.ts
Normal file
58
src/app/core/data/bitstream-data.service.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { BitstreamDataService } from './bitstream-data.service';
|
||||||
|
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||||
|
import { RequestService } from './request.service';
|
||||||
|
import { Bitstream } from '../shared/bitstream.model';
|
||||||
|
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
|
||||||
|
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
import { BitstreamFormatDataService } from './bitstream-format-data.service';
|
||||||
|
import { of as observableOf } from 'rxjs/internal/observable/of';
|
||||||
|
import { BitstreamFormat } from '../shared/bitstream-format.model';
|
||||||
|
import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level';
|
||||||
|
import { PutRequest } from './request.models';
|
||||||
|
|
||||||
|
describe('BitstreamDataService', () => {
|
||||||
|
let service: BitstreamDataService;
|
||||||
|
let objectCache: ObjectCacheService;
|
||||||
|
let requestService: RequestService;
|
||||||
|
let halService: HALEndpointService;
|
||||||
|
let bitstreamFormatService: BitstreamFormatDataService;
|
||||||
|
const bitstreamFormatHref = 'rest-api/bitstreamformats';
|
||||||
|
|
||||||
|
const bitstream = Object.assign(new Bitstream(), {
|
||||||
|
uuid: 'fake-bitstream',
|
||||||
|
_links: {
|
||||||
|
self: { href: 'fake-bitstream-self' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const format = Object.assign(new BitstreamFormat(), {
|
||||||
|
id: '2',
|
||||||
|
shortDescription: 'PNG',
|
||||||
|
description: 'Portable Network Graphics',
|
||||||
|
supportLevel: BitstreamFormatSupportLevel.Known
|
||||||
|
});
|
||||||
|
const url = 'fake-bitstream-url';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
objectCache = jasmine.createSpyObj('objectCache', {
|
||||||
|
remove: jasmine.createSpy('remove')
|
||||||
|
});
|
||||||
|
requestService = getMockRequestService();
|
||||||
|
halService = Object.assign(new HALEndpointServiceStub(url));
|
||||||
|
bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', {
|
||||||
|
getBrowseEndpoint: observableOf(bitstreamFormatHref)
|
||||||
|
});
|
||||||
|
|
||||||
|
service = new BitstreamDataService(requestService, null, null, null, objectCache, halService, null, null, null, null, bitstreamFormatService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when updating the bitstream\'s format', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
service.updateFormat(bitstream, format);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure a put request', () => {
|
||||||
|
expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PutRequest));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -1,8 +1,8 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
import { map, switchMap } from 'rxjs/operators';
|
import { map, switchMap, take } from 'rxjs/operators';
|
||||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||||
@@ -22,8 +22,14 @@ import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
|||||||
import { PaginatedList } from './paginated-list';
|
import { PaginatedList } from './paginated-list';
|
||||||
import { RemoteData } from './remote-data';
|
import { RemoteData } from './remote-data';
|
||||||
import { RemoteDataError } from './remote-data-error';
|
import { RemoteDataError } from './remote-data-error';
|
||||||
import { FindListOptions } from './request.models';
|
import { FindListOptions, PutRequest } from './request.models';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
|
import { BitstreamFormatDataService } from './bitstream-format-data.service';
|
||||||
|
import { BitstreamFormat } from '../shared/bitstream-format.model';
|
||||||
|
import { RestResponse } from '../cache/response.models';
|
||||||
|
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
|
||||||
|
import { configureRequest, getResponseFromEntry } from '../shared/operators';
|
||||||
|
import { combineLatest as observableCombineLatest } from 'rxjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A service to retrieve {@link Bitstream}s from the REST API
|
* A service to retrieve {@link Bitstream}s from the REST API
|
||||||
@@ -50,6 +56,7 @@ export class BitstreamDataService extends DataService<Bitstream> {
|
|||||||
protected http: HttpClient,
|
protected http: HttpClient,
|
||||||
protected comparator: DSOChangeAnalyzer<Bitstream>,
|
protected comparator: DSOChangeAnalyzer<Bitstream>,
|
||||||
protected bundleService: BundleDataService,
|
protected bundleService: BundleDataService,
|
||||||
|
protected bitstreamFormatService: BitstreamFormatDataService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -167,4 +174,37 @@ export class BitstreamDataService extends DataService<Bitstream> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the format of a bitstream
|
||||||
|
* @param bitstream
|
||||||
|
* @param format
|
||||||
|
*/
|
||||||
|
updateFormat(bitstream: Bitstream, format: BitstreamFormat): Observable<RestResponse> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
const bitstreamHref$ = this.getBrowseEndpoint().pipe(
|
||||||
|
map((href: string) => `${href}/${bitstream.id}`),
|
||||||
|
switchMap((href: string) => this.halService.getEndpoint('format', href))
|
||||||
|
);
|
||||||
|
const formatHref$ = this.bitstreamFormatService.getBrowseEndpoint().pipe(
|
||||||
|
map((href: string) => `${href}/${format.id}`)
|
||||||
|
);
|
||||||
|
observableCombineLatest([bitstreamHref$, formatHref$]).pipe(
|
||||||
|
map(([bitstreamHref, formatHref]) => {
|
||||||
|
const options: HttpOptions = Object.create({});
|
||||||
|
let headers = new HttpHeaders();
|
||||||
|
headers = headers.append('Content-Type', 'text/uri-list');
|
||||||
|
options.headers = headers;
|
||||||
|
return new PutRequest(requestId, bitstreamHref, formatHref, options);
|
||||||
|
}),
|
||||||
|
configureRequest(this.requestService),
|
||||||
|
take(1)
|
||||||
|
).subscribe(() => {
|
||||||
|
this.requestService.removeByHrefSubstring(bitstream.self + '/format');
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.requestService.getByUUID(requestId).pipe(
|
||||||
|
getResponseFromEntry()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Observable } from 'rxjs/internal/Observable';
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
import { map } from 'rxjs/operators';
|
import { map, switchMap, take } from 'rxjs/operators';
|
||||||
import { hasValue } from '../../shared/empty.util';
|
import { hasValue } from '../../shared/empty.util';
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||||
@@ -18,8 +18,10 @@ import { DataService } from './data.service';
|
|||||||
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||||
import { PaginatedList } from './paginated-list';
|
import { PaginatedList } from './paginated-list';
|
||||||
import { RemoteData } from './remote-data';
|
import { RemoteData } from './remote-data';
|
||||||
import { FindListOptions } from './request.models';
|
import { FindListOptions, GetRequest } from './request.models';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
|
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
|
||||||
|
import { Bitstream } from '../shared/bitstream.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A service to retrieve {@link Bundle}s from the REST API
|
* A service to retrieve {@link Bundle}s from the REST API
|
||||||
@@ -30,6 +32,7 @@ import { RequestService } from './request.service';
|
|||||||
@dataService(BUNDLE)
|
@dataService(BUNDLE)
|
||||||
export class BundleDataService extends DataService<Bundle> {
|
export class BundleDataService extends DataService<Bundle> {
|
||||||
protected linkPath = 'bundles';
|
protected linkPath = 'bundles';
|
||||||
|
protected bitstreamsEndpoint = 'bitstreams';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
@@ -81,4 +84,34 @@ export class BundleDataService extends DataService<Bundle> {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the bitstreams endpoint for a bundle
|
||||||
|
* @param bundleId
|
||||||
|
*/
|
||||||
|
getBitstreamsEndpoint(bundleId: string): Observable<string> {
|
||||||
|
return this.getBrowseEndpoint().pipe(
|
||||||
|
switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a bundle's bitstreams using paginated search options
|
||||||
|
* @param bundleId The bundle's ID
|
||||||
|
* @param searchOptions The search options to use
|
||||||
|
* @param linksToFollow The {@link FollowLinkConfig}s for the request
|
||||||
|
*/
|
||||||
|
getBitstreams(bundleId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array<FollowLinkConfig<Bitstream>>): Observable<RemoteData<PaginatedList<Bitstream>>> {
|
||||||
|
const hrefObs = this.getBitstreamsEndpoint(bundleId).pipe(
|
||||||
|
map((href) => searchOptions ? searchOptions.toRestUrl(href) : href)
|
||||||
|
);
|
||||||
|
hrefObs.pipe(
|
||||||
|
take(1)
|
||||||
|
).subscribe((href) => {
|
||||||
|
const request = new GetRequest(this.requestService.generateRequestId(), href);
|
||||||
|
this.requestService.configure(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.rdbService.buildList<Bitstream>(hrefObs, ...linksToFollow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -14,7 +14,7 @@ import {
|
|||||||
take,
|
take,
|
||||||
tap
|
tap
|
||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
||||||
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
|
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||||
@@ -44,7 +44,8 @@ import {
|
|||||||
FindByIDRequest,
|
FindByIDRequest,
|
||||||
FindListOptions,
|
FindListOptions,
|
||||||
FindListRequest,
|
FindListRequest,
|
||||||
GetRequest, PatchRequest
|
GetRequest,
|
||||||
|
PatchRequest
|
||||||
} from './request.models';
|
} from './request.models';
|
||||||
import { RequestEntry } from './request.reducer';
|
import { RequestEntry } from './request.reducer';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
@@ -475,6 +476,39 @@ export abstract class DataService<T extends CacheableObject> {
|
|||||||
* @return an observable that emits true when the deletion was successful, false when it failed
|
* @return an observable that emits true when the deletion was successful, false when it failed
|
||||||
*/
|
*/
|
||||||
delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<boolean> {
|
delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<boolean> {
|
||||||
|
const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata);
|
||||||
|
|
||||||
|
return this.requestService.getByUUID(requestId).pipe(
|
||||||
|
find((request: RequestEntry) => request.completed),
|
||||||
|
map((request: RequestEntry) => request.response.isSuccessful)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an existing DSpace Object on the server
|
||||||
|
* @param dsoID The DSpace Object' id to be removed
|
||||||
|
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
|
||||||
|
* metadata should be saved as real metadata
|
||||||
|
* Return an observable of the completed response
|
||||||
|
*/
|
||||||
|
deleteAndReturnResponse(dsoID: string, copyVirtualMetadata?: string[]): Observable<RestResponse> {
|
||||||
|
const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata);
|
||||||
|
|
||||||
|
return this.requestService.getByUUID(requestId).pipe(
|
||||||
|
hasValueOperator(),
|
||||||
|
find((request: RequestEntry) => request.completed),
|
||||||
|
map((request: RequestEntry) => request.response)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an existing DSpace Object on the server
|
||||||
|
* @param dsoID The DSpace Object' id to be removed
|
||||||
|
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
|
||||||
|
* metadata should be saved as real metadata
|
||||||
|
* Return the delete request's ID
|
||||||
|
*/
|
||||||
|
private deleteAndReturnRequestId(dsoID: string, copyVirtualMetadata?: string[]): string {
|
||||||
const requestId = this.requestService.generateRequestId();
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
|
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
|
||||||
@@ -495,10 +529,7 @@ export abstract class DataService<T extends CacheableObject> {
|
|||||||
})
|
})
|
||||||
).subscribe();
|
).subscribe();
|
||||||
|
|
||||||
return this.requestService.getByUUID(requestId).pipe(
|
return requestId;
|
||||||
find((request: RequestEntry) => request.completed),
|
|
||||||
map((request: RequestEntry) => request.response.isSuccessful)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -47,6 +47,9 @@ describe('ItemDataService', () => {
|
|||||||
return cold('a', { a: itemEndpoint });
|
return cold('a', { a: itemEndpoint });
|
||||||
}
|
}
|
||||||
} as HALEndpointService;
|
} as HALEndpointService;
|
||||||
|
const bundleService = jasmine.createSpyObj('bundleService', {
|
||||||
|
findByHref: {}
|
||||||
|
});
|
||||||
|
|
||||||
const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39';
|
const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39';
|
||||||
const options = Object.assign(new FindListOptions(), {
|
const options = Object.assign(new FindListOptions(), {
|
||||||
@@ -87,7 +90,8 @@ describe('ItemDataService', () => {
|
|||||||
halEndpointService,
|
halEndpointService,
|
||||||
notificationsService,
|
notificationsService,
|
||||||
http,
|
http,
|
||||||
comparator
|
comparator,
|
||||||
|
bundleService
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,4 +216,20 @@ describe('ItemDataService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createBundle', () => {
|
||||||
|
const itemId = '3de6ea60-ec39-419b-ae6f-065930ac1429';
|
||||||
|
const bundleName = 'ORIGINAL';
|
||||||
|
let result;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = initTestService();
|
||||||
|
spyOn(requestService, 'configure');
|
||||||
|
result = service.createBundle(itemId, bundleName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure a POST request', () => {
|
||||||
|
result.subscribe(() => expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PostRequest)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
|
|||||||
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 { distinctUntilChanged, filter, find, map, switchMap, tap } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators';
|
||||||
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
import { BrowseService } from '../browse/browse.service';
|
import { BrowseService } from '../browse/browse.service';
|
||||||
@@ -32,6 +32,7 @@ import { RemoteData } from './remote-data';
|
|||||||
import {
|
import {
|
||||||
DeleteRequest,
|
DeleteRequest,
|
||||||
FindListOptions,
|
FindListOptions,
|
||||||
|
GetRequest,
|
||||||
MappedCollectionsRequest,
|
MappedCollectionsRequest,
|
||||||
PatchRequest,
|
PatchRequest,
|
||||||
PostRequest,
|
PostRequest,
|
||||||
@@ -40,6 +41,10 @@ import {
|
|||||||
} from './request.models';
|
} from './request.models';
|
||||||
import { RequestEntry } from './request.reducer';
|
import { RequestEntry } from './request.reducer';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
|
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
|
||||||
|
import { Bundle } from '../shared/bundle.model';
|
||||||
|
import { MetadataMap } from '../shared/metadata.models';
|
||||||
|
import { BundleDataService } from './bundle-data.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@dataService(ITEM)
|
@dataService(ITEM)
|
||||||
@@ -56,6 +61,7 @@ export class ItemDataService extends DataService<Item> {
|
|||||||
protected notificationsService: NotificationsService,
|
protected notificationsService: NotificationsService,
|
||||||
protected http: HttpClient,
|
protected http: HttpClient,
|
||||||
protected comparator: DSOChangeAnalyzer<Item>,
|
protected comparator: DSOChangeAnalyzer<Item>,
|
||||||
|
protected bundleService: BundleDataService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -219,6 +225,76 @@ export class ItemDataService extends DataService<Item> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the endpoint for an item's bundles
|
||||||
|
* @param itemId
|
||||||
|
*/
|
||||||
|
public getBundlesEndpoint(itemId: string): Observable<string> {
|
||||||
|
return this.halService.getEndpoint(this.linkPath).pipe(
|
||||||
|
switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an item's bundles using paginated search options
|
||||||
|
* @param itemId The item's ID
|
||||||
|
* @param searchOptions The search options to use
|
||||||
|
*/
|
||||||
|
public getBundles(itemId: string, searchOptions?: PaginatedSearchOptions): Observable<RemoteData<PaginatedList<Bundle>>> {
|
||||||
|
const hrefObs = this.getBundlesEndpoint(itemId).pipe(
|
||||||
|
map((href) => searchOptions ? searchOptions.toRestUrl(href) : href)
|
||||||
|
);
|
||||||
|
hrefObs.pipe(
|
||||||
|
take(1)
|
||||||
|
).subscribe((href) => {
|
||||||
|
const request = new GetRequest(this.requestService.generateRequestId(), href);
|
||||||
|
this.requestService.configure(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.rdbService.buildList<Bundle>(hrefObs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new bundle on an item
|
||||||
|
* @param itemId The item's ID
|
||||||
|
* @param bundleName The new bundle's name
|
||||||
|
* @param metadata Optional metadata for the bundle
|
||||||
|
*/
|
||||||
|
public createBundle(itemId: string, bundleName: string, metadata?: MetadataMap): Observable<RemoteData<Bundle>> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
const hrefObs = this.getBundlesEndpoint(itemId);
|
||||||
|
|
||||||
|
const bundleJson = {
|
||||||
|
name: bundleName,
|
||||||
|
metadata: metadata ? metadata : {}
|
||||||
|
};
|
||||||
|
|
||||||
|
hrefObs.pipe(
|
||||||
|
take(1)
|
||||||
|
).subscribe((href) => {
|
||||||
|
const options: HttpOptions = Object.create({});
|
||||||
|
let headers = new HttpHeaders();
|
||||||
|
headers = headers.append('Content-Type', 'application/json');
|
||||||
|
options.headers = headers;
|
||||||
|
const request = new PostRequest(requestId, href, JSON.stringify(bundleJson), options);
|
||||||
|
this.requestService.configure(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selfLink$ = this.requestService.getByUUID(requestId).pipe(
|
||||||
|
getResponseFromEntry(),
|
||||||
|
map((response: any) => {
|
||||||
|
if (isNotEmpty(response.resourceSelfLinks)) {
|
||||||
|
return response.resourceSelfLinks[0];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
distinctUntilChanged()
|
||||||
|
) as Observable<string>;
|
||||||
|
|
||||||
|
return selfLink$.pipe(
|
||||||
|
switchMap((selfLink: string) => this.bundleService.findByHref(selfLink)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the endpoint to move the item
|
* Get the endpoint to move the item
|
||||||
* @param itemId
|
* @param itemId
|
||||||
|
@@ -8,6 +8,7 @@ import {INotification} from '../../../shared/notifications/models/notification.m
|
|||||||
*/
|
*/
|
||||||
export const ObjectUpdatesActionTypes = {
|
export const ObjectUpdatesActionTypes = {
|
||||||
INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'),
|
INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'),
|
||||||
|
ADD_PAGE_TO_CUSTOM_ORDER: type('dspace/core/cache/object-updates/ADD_PAGE_TO_CUSTOM_ORDER'),
|
||||||
SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'),
|
SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'),
|
||||||
SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'),
|
SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'),
|
||||||
ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'),
|
ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'),
|
||||||
@@ -15,7 +16,9 @@ export const ObjectUpdatesActionTypes = {
|
|||||||
DISCARD: type('dspace/core/cache/object-updates/DISCARD'),
|
DISCARD: type('dspace/core/cache/object-updates/DISCARD'),
|
||||||
REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'),
|
REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'),
|
||||||
REMOVE: type('dspace/core/cache/object-updates/REMOVE'),
|
REMOVE: type('dspace/core/cache/object-updates/REMOVE'),
|
||||||
|
REMOVE_ALL: type('dspace/core/cache/object-updates/REMOVE_ALL'),
|
||||||
REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'),
|
REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'),
|
||||||
|
MOVE: type('dspace/core/cache/object-updates/MOVE'),
|
||||||
};
|
};
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
@@ -26,7 +29,8 @@ export const ObjectUpdatesActionTypes = {
|
|||||||
export enum FieldChangeType {
|
export enum FieldChangeType {
|
||||||
UPDATE = 0,
|
UPDATE = 0,
|
||||||
ADD = 1,
|
ADD = 1,
|
||||||
REMOVE = 2
|
REMOVE = 2,
|
||||||
|
MOVE = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +41,10 @@ export class InitializeFieldsAction implements Action {
|
|||||||
payload: {
|
payload: {
|
||||||
url: string,
|
url: string,
|
||||||
fields: Identifiable[],
|
fields: Identifiable[],
|
||||||
lastModified: Date
|
lastModified: Date,
|
||||||
|
order: string[],
|
||||||
|
pageSize: number,
|
||||||
|
page: number
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,13 +54,49 @@ export class InitializeFieldsAction implements Action {
|
|||||||
* the unique url of the page for which the fields are being initialized
|
* the unique url of the page for which the fields are being initialized
|
||||||
* @param fields The identifiable fields of which the updates are kept track of
|
* @param fields The identifiable fields of which the updates are kept track of
|
||||||
* @param lastModified The last modified date of the object that belongs to the page
|
* @param lastModified The last modified date of the object that belongs to the page
|
||||||
|
* @param order A custom order to keep track of objects moving around
|
||||||
|
* @param pageSize The page size used to fill empty pages for the custom order
|
||||||
|
* @param page The first page to populate in the custom order
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
url: string,
|
url: string,
|
||||||
fields: Identifiable[],
|
fields: Identifiable[],
|
||||||
lastModified: Date
|
lastModified: Date,
|
||||||
|
order: string[] = [],
|
||||||
|
pageSize: number = 9999,
|
||||||
|
page: number = 0
|
||||||
) {
|
) {
|
||||||
this.payload = { url, fields, lastModified };
|
this.payload = { url, fields, lastModified, order, pageSize, page };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ngrx action to initialize a new page's fields in the ObjectUpdates state
|
||||||
|
*/
|
||||||
|
export class AddPageToCustomOrderAction implements Action {
|
||||||
|
type = ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER;
|
||||||
|
payload: {
|
||||||
|
url: string,
|
||||||
|
fields: Identifiable[],
|
||||||
|
order: string[],
|
||||||
|
page: number
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new AddPageToCustomOrderAction
|
||||||
|
*
|
||||||
|
* @param url The unique url of the page for which the fields are being added
|
||||||
|
* @param fields The identifiable fields of which the updates are kept track of
|
||||||
|
* @param order A custom order to keep track of objects moving around
|
||||||
|
* @param page The page to populate in the custom order
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
url: string,
|
||||||
|
fields: Identifiable[],
|
||||||
|
order: string[] = [],
|
||||||
|
page: number = 0
|
||||||
|
) {
|
||||||
|
this.payload = { url, fields, order, page };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +223,8 @@ export class DiscardObjectUpdatesAction implements Action {
|
|||||||
type = ObjectUpdatesActionTypes.DISCARD;
|
type = ObjectUpdatesActionTypes.DISCARD;
|
||||||
payload: {
|
payload: {
|
||||||
url: string,
|
url: string,
|
||||||
notification: INotification
|
notification: INotification,
|
||||||
|
discardAll: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,12 +233,14 @@ export class DiscardObjectUpdatesAction implements Action {
|
|||||||
* @param url
|
* @param url
|
||||||
* the unique url of the page for which the changes should be discarded
|
* the unique url of the page for which the changes should be discarded
|
||||||
* @param notification The notification that is raised when changes are discarded
|
* @param notification The notification that is raised when changes are discarded
|
||||||
|
* @param discardAll discard all
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
url: string,
|
url: string,
|
||||||
notification: INotification
|
notification: INotification,
|
||||||
|
discardAll = false
|
||||||
) {
|
) {
|
||||||
this.payload = { url, notification };
|
this.payload = { url, notification, discardAll };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +288,13 @@ export class RemoveObjectUpdatesAction implements Action {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ngrx action to remove all previously discarded updates in the ObjectUpdates state
|
||||||
|
*/
|
||||||
|
export class RemoveAllObjectUpdatesAction implements Action {
|
||||||
|
type = ObjectUpdatesActionTypes.REMOVE_ALL;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid
|
* An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid
|
||||||
*/
|
*/
|
||||||
@@ -267,6 +320,43 @@ export class RemoveFieldUpdateAction implements Action {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid
|
||||||
|
*/
|
||||||
|
export class MoveFieldUpdateAction implements Action {
|
||||||
|
type = ObjectUpdatesActionTypes.MOVE;
|
||||||
|
payload: {
|
||||||
|
url: string,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
fromPage: number,
|
||||||
|
toPage: number,
|
||||||
|
field?: Identifiable
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new RemoveObjectUpdatesAction
|
||||||
|
*
|
||||||
|
* @param url
|
||||||
|
* the unique url of the page for which a field's change should be removed
|
||||||
|
* @param from The index of the object to move
|
||||||
|
* @param to The index to move the object to
|
||||||
|
* @param fromPage The page to move the object from
|
||||||
|
* @param toPage The page to move the object to
|
||||||
|
* @param field Optional field to add to the fieldUpdates list (useful when we want to track updates across multiple pages)
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
url: string,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
fromPage: number,
|
||||||
|
toPage: number,
|
||||||
|
field?: Identifiable
|
||||||
|
) {
|
||||||
|
this.payload = { url, from, to, fromPage, toPage, field };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* tslint:enable:max-classes-per-file */
|
/* tslint:enable:max-classes-per-file */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -279,6 +369,9 @@ export type ObjectUpdatesAction
|
|||||||
| ReinstateObjectUpdatesAction
|
| ReinstateObjectUpdatesAction
|
||||||
| RemoveObjectUpdatesAction
|
| RemoveObjectUpdatesAction
|
||||||
| RemoveFieldUpdateAction
|
| RemoveFieldUpdateAction
|
||||||
|
| MoveFieldUpdateAction
|
||||||
|
| AddPageToCustomOrderAction
|
||||||
|
| RemoveAllObjectUpdatesAction
|
||||||
| SelectVirtualMetadataAction
|
| SelectVirtualMetadataAction
|
||||||
| SetEditableFieldUpdateAction
|
| SetEditableFieldUpdateAction
|
||||||
| SetValidFieldUpdateAction;
|
| SetValidFieldUpdateAction;
|
||||||
|
@@ -3,12 +3,12 @@ import { Actions, Effect, ofType } from '@ngrx/effects';
|
|||||||
import {
|
import {
|
||||||
DiscardObjectUpdatesAction,
|
DiscardObjectUpdatesAction,
|
||||||
ObjectUpdatesAction,
|
ObjectUpdatesAction,
|
||||||
ObjectUpdatesActionTypes,
|
ObjectUpdatesActionTypes, RemoveAllObjectUpdatesAction,
|
||||||
RemoveObjectUpdatesAction
|
RemoveObjectUpdatesAction
|
||||||
} from './object-updates.actions';
|
} from './object-updates.actions';
|
||||||
import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators';
|
import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators';
|
||||||
import { of as observableOf, race as observableRace, Subject } from 'rxjs';
|
import { of as observableOf, race as observableRace, Subject } from 'rxjs';
|
||||||
import { hasNoValue } from '../../../shared/empty.util';
|
import { hasNoValue, hasValue } from '../../../shared/empty.util';
|
||||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||||
import { INotification } from '../../../shared/notifications/models/notification.model';
|
import { INotification } from '../../../shared/notifications/models/notification.model';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
NotificationsActionTypes,
|
NotificationsActionTypes,
|
||||||
RemoveNotificationAction
|
RemoveNotificationAction
|
||||||
} from '../../../shared/notifications/notifications.actions';
|
} from '../../../shared/notifications/notifications.actions';
|
||||||
|
import { Action } from '@ngrx/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NGRX effects for ObjectUpdatesActions
|
* NGRX effects for ObjectUpdatesActions
|
||||||
@@ -53,13 +54,14 @@ export class ObjectUpdatesEffects {
|
|||||||
.pipe(
|
.pipe(
|
||||||
ofType(...Object.values(ObjectUpdatesActionTypes)),
|
ofType(...Object.values(ObjectUpdatesActionTypes)),
|
||||||
map((action: ObjectUpdatesAction) => {
|
map((action: ObjectUpdatesAction) => {
|
||||||
const url: string = action.payload.url;
|
if (hasValue((action as any).payload)) {
|
||||||
|
const url: string = (action as any).payload.url;
|
||||||
if (hasNoValue(this.actionMap$[url])) {
|
if (hasNoValue(this.actionMap$[url])) {
|
||||||
this.actionMap$[url] = new Subject<ObjectUpdatesAction>();
|
this.actionMap$[url] = new Subject<ObjectUpdatesAction>();
|
||||||
}
|
}
|
||||||
this.actionMap$[url].next(action);
|
this.actionMap$[url].next(action);
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,9 +93,15 @@ export class ObjectUpdatesEffects {
|
|||||||
const url: string = action.payload.url;
|
const url: string = action.payload.url;
|
||||||
const notification: INotification = action.payload.notification;
|
const notification: INotification = action.payload.notification;
|
||||||
const timeOut = notification.options.timeOut;
|
const timeOut = notification.options.timeOut;
|
||||||
|
|
||||||
|
let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url);
|
||||||
|
if (action.payload.discardAll) {
|
||||||
|
removeAction = new RemoveAllObjectUpdatesAction();
|
||||||
|
}
|
||||||
|
|
||||||
return observableRace(
|
return observableRace(
|
||||||
// Either wait for the delay and perform a remove action
|
// Either wait for the delay and perform a remove action
|
||||||
observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)),
|
observableOf(removeAction).pipe(delay(timeOut)),
|
||||||
// Or wait for a a user action
|
// Or wait for a a user action
|
||||||
this.actionMap$[url].pipe(
|
this.actionMap$[url].pipe(
|
||||||
take(1),
|
take(1),
|
||||||
@@ -106,19 +114,19 @@ export class ObjectUpdatesEffects {
|
|||||||
return { type: 'NO_ACTION' }
|
return { type: 'NO_ACTION' }
|
||||||
}
|
}
|
||||||
// If someone performed another action, assume the user does not want to reinstate and remove all changes
|
// If someone performed another action, assume the user does not want to reinstate and remove all changes
|
||||||
return new RemoveObjectUpdatesAction(action.payload.url);
|
return removeAction
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
this.notificationActionMap$[notification.id].pipe(
|
this.notificationActionMap$[notification.id].pipe(
|
||||||
filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION),
|
filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION),
|
||||||
map(() => {
|
map(() => {
|
||||||
return new RemoveObjectUpdatesAction(action.payload.url);
|
return removeAction;
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
this.notificationActionMap$[this.allIdentifier].pipe(
|
this.notificationActionMap$[this.allIdentifier].pipe(
|
||||||
filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS),
|
filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS),
|
||||||
map(() => {
|
map(() => {
|
||||||
return new RemoveObjectUpdatesAction(action.payload.url);
|
return removeAction;
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import * as deepFreeze from 'deep-freeze';
|
import * as deepFreeze from 'deep-freeze';
|
||||||
import {
|
import {
|
||||||
AddFieldUpdateAction,
|
AddFieldUpdateAction, AddPageToCustomOrderAction,
|
||||||
DiscardObjectUpdatesAction,
|
DiscardObjectUpdatesAction,
|
||||||
FieldChangeType,
|
FieldChangeType,
|
||||||
InitializeFieldsAction,
|
InitializeFieldsAction, MoveFieldUpdateAction,
|
||||||
ReinstateObjectUpdatesAction,
|
ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction,
|
||||||
RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction,
|
RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction,
|
||||||
SetEditableFieldUpdateAction, SetValidFieldUpdateAction
|
SetEditableFieldUpdateAction, SetValidFieldUpdateAction
|
||||||
} from './object-updates.actions';
|
} from './object-updates.actions';
|
||||||
@@ -85,6 +85,16 @@ describe('objectUpdatesReducer', () => {
|
|||||||
virtualMetadataSources: {
|
virtualMetadataSources: {
|
||||||
[relationship.uuid]: {[identifiable1.uuid]: true}
|
[relationship.uuid]: {[identifiable1.uuid]: true}
|
||||||
},
|
},
|
||||||
|
customOrder: {
|
||||||
|
initialOrderPages: [
|
||||||
|
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||||
|
],
|
||||||
|
newOrderPages: [
|
||||||
|
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||||
|
],
|
||||||
|
pageSize: 10,
|
||||||
|
changed: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,6 +121,16 @@ describe('objectUpdatesReducer', () => {
|
|||||||
virtualMetadataSources: {
|
virtualMetadataSources: {
|
||||||
[relationship.uuid]: {[identifiable1.uuid]: true}
|
[relationship.uuid]: {[identifiable1.uuid]: true}
|
||||||
},
|
},
|
||||||
|
customOrder: {
|
||||||
|
initialOrderPages: [
|
||||||
|
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||||
|
],
|
||||||
|
newOrderPages: [
|
||||||
|
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||||
|
],
|
||||||
|
pageSize: 10,
|
||||||
|
changed: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[url + OBJECT_UPDATES_TRASH_PATH]: {
|
[url + OBJECT_UPDATES_TRASH_PATH]: {
|
||||||
fieldStates: {
|
fieldStates: {
|
||||||
@@ -145,6 +165,16 @@ describe('objectUpdatesReducer', () => {
|
|||||||
virtualMetadataSources: {
|
virtualMetadataSources: {
|
||||||
[relationship.uuid]: {[identifiable1.uuid]: true}
|
[relationship.uuid]: {[identifiable1.uuid]: true}
|
||||||
},
|
},
|
||||||
|
customOrder: {
|
||||||
|
initialOrderPages: [
|
||||||
|
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||||
|
],
|
||||||
|
newOrderPages: [
|
||||||
|
{ order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] }
|
||||||
|
],
|
||||||
|
pageSize: 10,
|
||||||
|
changed: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,7 +243,7 @@ describe('objectUpdatesReducer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => {
|
it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => {
|
||||||
const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate);
|
const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate, [identifiable1.uuid, identifiable3.uuid], 10, 0);
|
||||||
|
|
||||||
const expectedState = {
|
const expectedState = {
|
||||||
[url]: {
|
[url]: {
|
||||||
@@ -231,7 +261,17 @@ describe('objectUpdatesReducer', () => {
|
|||||||
},
|
},
|
||||||
fieldUpdates: {},
|
fieldUpdates: {},
|
||||||
virtualMetadataSources: {},
|
virtualMetadataSources: {},
|
||||||
lastModified: modDate
|
lastModified: modDate,
|
||||||
|
customOrder: {
|
||||||
|
initialOrderPages: [
|
||||||
|
{ order: [identifiable1.uuid, identifiable3.uuid] }
|
||||||
|
],
|
||||||
|
newOrderPages: [
|
||||||
|
{ order: [identifiable1.uuid, identifiable3.uuid] }
|
||||||
|
],
|
||||||
|
pageSize: 10,
|
||||||
|
changed: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const newState = objectUpdatesReducer(testState, action);
|
const newState = objectUpdatesReducer(testState, action);
|
||||||
@@ -283,10 +323,44 @@ describe('objectUpdatesReducer', () => {
|
|||||||
expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined();
|
expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should remove all updates from the state when the REMOVE_ALL action is dispatched', () => {
|
||||||
|
const action = new RemoveAllObjectUpdatesAction();
|
||||||
|
|
||||||
|
const newState = objectUpdatesReducer(discardedTestState, action as any);
|
||||||
|
expect(newState[url].fieldUpdates).toBeUndefined();
|
||||||
|
expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => {
|
it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => {
|
||||||
const action = new RemoveFieldUpdateAction(url, uuid);
|
const action = new RemoveFieldUpdateAction(url, uuid);
|
||||||
|
|
||||||
const newState = objectUpdatesReducer(testState, action);
|
const newState = objectUpdatesReducer(testState, action);
|
||||||
expect(newState[url].fieldUpdates[uuid]).toBeUndefined();
|
expect(newState[url].fieldUpdates[uuid]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should move the custom order from the state when the MOVE action is dispatched', () => {
|
||||||
|
const action = new MoveFieldUpdateAction(url, 0, 1, 0, 0);
|
||||||
|
|
||||||
|
const newState = objectUpdatesReducer(testState, action);
|
||||||
|
expect(newState[url].customOrder.newOrderPages[0].order[0]).toEqual(testState[url].customOrder.newOrderPages[0].order[1]);
|
||||||
|
expect(newState[url].customOrder.newOrderPages[0].order[1]).toEqual(testState[url].customOrder.newOrderPages[0].order[0]);
|
||||||
|
expect(newState[url].customOrder.changed).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a new page to the custom order and add empty pages in between when the ADD_PAGE_TO_CUSTOM_ORDER action is dispatched', () => {
|
||||||
|
const identifiable4 = {
|
||||||
|
uuid: 'a23eae5a-7857-4ef9-8e52-989436ad2955',
|
||||||
|
key: 'dc.description.abstract',
|
||||||
|
language: null,
|
||||||
|
value: 'Extra value'
|
||||||
|
};
|
||||||
|
const action = new AddPageToCustomOrderAction(url, [identifiable4], [identifiable4.uuid], 2);
|
||||||
|
|
||||||
|
const newState = objectUpdatesReducer(testState, action);
|
||||||
|
// Confirm the page in between the two pages (index 1) has been filled with 10 (page size) undefined values
|
||||||
|
expect(newState[url].customOrder.newOrderPages[1].order.length).toEqual(10);
|
||||||
|
expect(newState[url].customOrder.newOrderPages[1].order[0]).toBeUndefined();
|
||||||
|
// Verify the new page is correct
|
||||||
|
expect(newState[url].customOrder.newOrderPages[2].order[0]).toEqual(identifiable4.uuid);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
AddFieldUpdateAction,
|
AddFieldUpdateAction, AddPageToCustomOrderAction,
|
||||||
DiscardObjectUpdatesAction,
|
DiscardObjectUpdatesAction,
|
||||||
FieldChangeType,
|
FieldChangeType,
|
||||||
InitializeFieldsAction,
|
InitializeFieldsAction, MoveFieldUpdateAction,
|
||||||
ObjectUpdatesAction,
|
ObjectUpdatesAction,
|
||||||
ObjectUpdatesActionTypes,
|
ObjectUpdatesActionTypes,
|
||||||
ReinstateObjectUpdatesAction,
|
ReinstateObjectUpdatesAction,
|
||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
SetValidFieldUpdateAction,
|
SetValidFieldUpdateAction,
|
||||||
SelectVirtualMetadataAction,
|
SelectVirtualMetadataAction,
|
||||||
} from './object-updates.actions';
|
} from './object-updates.actions';
|
||||||
import { hasNoValue, hasValue } from '../../../shared/empty.util';
|
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
|
||||||
|
import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
|
||||||
|
import { from } from 'rxjs/internal/observable/from';
|
||||||
import {Relationship} from '../../shared/item-relationships/relationship.model';
|
import {Relationship} from '../../shared/item-relationships/relationship.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +48,7 @@ export interface Identifiable {
|
|||||||
/**
|
/**
|
||||||
* The state of a single field update
|
* The state of a single field update
|
||||||
*/
|
*/
|
||||||
export interface FieldUpdate {
|
export interface FieldUpdate {
|
||||||
field: Identifiable,
|
field: Identifiable,
|
||||||
changeType: FieldChangeType
|
changeType: FieldChangeType
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,20 @@ export interface DeleteRelationship extends Relationship {
|
|||||||
keepRightVirtualMetadata: boolean,
|
keepRightVirtualMetadata: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom order given to the list of objects
|
||||||
|
*/
|
||||||
|
export interface CustomOrder {
|
||||||
|
initialOrderPages: OrderPage[],
|
||||||
|
newOrderPages: OrderPage[],
|
||||||
|
pageSize: number;
|
||||||
|
changed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderPage {
|
||||||
|
order: string[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The updated state of a single page
|
* The updated state of a single page
|
||||||
*/
|
*/
|
||||||
@@ -89,6 +105,7 @@ export interface ObjectUpdatesEntry {
|
|||||||
fieldUpdates: FieldUpdates;
|
fieldUpdates: FieldUpdates;
|
||||||
virtualMetadataSources: VirtualMetadataSources;
|
virtualMetadataSources: VirtualMetadataSources;
|
||||||
lastModified: Date;
|
lastModified: Date;
|
||||||
|
customOrder: CustomOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,6 +138,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
|
|||||||
case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: {
|
case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: {
|
||||||
return initializeFieldsUpdate(state, action as InitializeFieldsAction);
|
return initializeFieldsUpdate(state, action as InitializeFieldsAction);
|
||||||
}
|
}
|
||||||
|
case ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER: {
|
||||||
|
return addPageToCustomOrder(state, action as AddPageToCustomOrderAction);
|
||||||
|
}
|
||||||
case ObjectUpdatesActionTypes.ADD_FIELD: {
|
case ObjectUpdatesActionTypes.ADD_FIELD: {
|
||||||
return addFieldUpdate(state, action as AddFieldUpdateAction);
|
return addFieldUpdate(state, action as AddFieldUpdateAction);
|
||||||
}
|
}
|
||||||
@@ -136,6 +156,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
|
|||||||
case ObjectUpdatesActionTypes.REMOVE: {
|
case ObjectUpdatesActionTypes.REMOVE: {
|
||||||
return removeObjectUpdates(state, action as RemoveObjectUpdatesAction);
|
return removeObjectUpdates(state, action as RemoveObjectUpdatesAction);
|
||||||
}
|
}
|
||||||
|
case ObjectUpdatesActionTypes.REMOVE_ALL: {
|
||||||
|
return removeAllObjectUpdates(state);
|
||||||
|
}
|
||||||
case ObjectUpdatesActionTypes.REMOVE_FIELD: {
|
case ObjectUpdatesActionTypes.REMOVE_FIELD: {
|
||||||
return removeFieldUpdate(state, action as RemoveFieldUpdateAction);
|
return removeFieldUpdate(state, action as RemoveFieldUpdateAction);
|
||||||
}
|
}
|
||||||
@@ -145,6 +168,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates
|
|||||||
case ObjectUpdatesActionTypes.SET_VALID_FIELD: {
|
case ObjectUpdatesActionTypes.SET_VALID_FIELD: {
|
||||||
return setValidFieldUpdate(state, action as SetValidFieldUpdateAction);
|
return setValidFieldUpdate(state, action as SetValidFieldUpdateAction);
|
||||||
}
|
}
|
||||||
|
case ObjectUpdatesActionTypes.MOVE: {
|
||||||
|
return moveFieldUpdate(state, action as MoveFieldUpdateAction);
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -160,18 +186,50 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
|
|||||||
const url: string = action.payload.url;
|
const url: string = action.payload.url;
|
||||||
const fields: Identifiable[] = action.payload.fields;
|
const fields: Identifiable[] = action.payload.fields;
|
||||||
const lastModifiedServer: Date = action.payload.lastModified;
|
const lastModifiedServer: Date = action.payload.lastModified;
|
||||||
|
const order = action.payload.order;
|
||||||
|
const pageSize = action.payload.pageSize;
|
||||||
|
const page = action.payload.page;
|
||||||
const fieldStates = createInitialFieldStates(fields);
|
const fieldStates = createInitialFieldStates(fields);
|
||||||
|
const initialOrderPages = addOrderToPages([], order, pageSize, page);
|
||||||
const newPageState = Object.assign(
|
const newPageState = Object.assign(
|
||||||
{},
|
{},
|
||||||
state[url],
|
state[url],
|
||||||
{ fieldStates: fieldStates },
|
{ fieldStates: fieldStates },
|
||||||
{ fieldUpdates: {} },
|
{ fieldUpdates: {} },
|
||||||
{ virtualMetadataSources: {} },
|
{ virtualMetadataSources: {} },
|
||||||
{ lastModified: lastModifiedServer }
|
{ lastModified: lastModifiedServer },
|
||||||
|
{ customOrder: {
|
||||||
|
initialOrderPages: initialOrderPages,
|
||||||
|
newOrderPages: initialOrderPages,
|
||||||
|
pageSize: pageSize,
|
||||||
|
changed: false }
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return Object.assign({}, state, { [url]: newPageState });
|
return Object.assign({}, state, { [url]: newPageState });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a page of objects to the state of a specific url and update a specific page of the custom order
|
||||||
|
* @param state The current state
|
||||||
|
* @param action The action to perform on the current state
|
||||||
|
*/
|
||||||
|
function addPageToCustomOrder(state: any, action: AddPageToCustomOrderAction) {
|
||||||
|
const url: string = action.payload.url;
|
||||||
|
const fields: Identifiable[] = action.payload.fields;
|
||||||
|
const fieldStates = createInitialFieldStates(fields);
|
||||||
|
const order = action.payload.order;
|
||||||
|
const page = action.payload.page;
|
||||||
|
const pageState: ObjectUpdatesEntry = state[url] || {};
|
||||||
|
const newPageState = Object.assign({}, pageState, {
|
||||||
|
fieldStates: Object.assign({}, pageState.fieldStates, fieldStates),
|
||||||
|
customOrder: Object.assign({}, pageState.customOrder, {
|
||||||
|
newOrderPages: addOrderToPages(pageState.customOrder.newOrderPages, order, pageState.customOrder.pageSize, page),
|
||||||
|
initialOrderPages: addOrderToPages(pageState.customOrder.initialOrderPages, order, pageState.customOrder.pageSize, page)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return Object.assign({}, state, { [url]: newPageState });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new update for a specific field to the store
|
* Add a new update for a specific field to the store
|
||||||
* @param state The current state
|
* @param state The current state
|
||||||
@@ -252,7 +310,24 @@ function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction)
|
|||||||
* @param action The action to perform on the current state
|
* @param action The action to perform on the current state
|
||||||
*/
|
*/
|
||||||
function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) {
|
function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) {
|
||||||
const url: string = action.payload.url;
|
if (action.payload.discardAll) {
|
||||||
|
let newState = Object.assign({}, state);
|
||||||
|
Object.keys(state).filter((path: string) => !path.endsWith(OBJECT_UPDATES_TRASH_PATH)).forEach((path: string) => {
|
||||||
|
newState = discardObjectUpdatesFor(path, newState);
|
||||||
|
});
|
||||||
|
return newState;
|
||||||
|
} else {
|
||||||
|
const url: string = action.payload.url;
|
||||||
|
return discardObjectUpdatesFor(url, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discard all updates for a specific action's url in the store
|
||||||
|
* @param url The action's url
|
||||||
|
* @param state The current state
|
||||||
|
*/
|
||||||
|
function discardObjectUpdatesFor(url: string, state: any) {
|
||||||
const pageState: ObjectUpdatesEntry = state[url];
|
const pageState: ObjectUpdatesEntry = state[url];
|
||||||
const newFieldStates = {};
|
const newFieldStates = {};
|
||||||
Object.keys(pageState.fieldStates).forEach((uuid: string) => {
|
Object.keys(pageState.fieldStates).forEach((uuid: string) => {
|
||||||
@@ -263,9 +338,19 @@ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const newCustomOrder = Object.assign({}, pageState.customOrder);
|
||||||
|
if (pageState.customOrder.changed) {
|
||||||
|
const initialOrder = pageState.customOrder.initialOrderPages;
|
||||||
|
if (isNotEmpty(initialOrder)) {
|
||||||
|
newCustomOrder.newOrderPages = initialOrder;
|
||||||
|
newCustomOrder.changed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const discardedPageState = Object.assign({}, pageState, {
|
const discardedPageState = Object.assign({}, pageState, {
|
||||||
fieldUpdates: {},
|
fieldUpdates: {},
|
||||||
fieldStates: newFieldStates
|
fieldStates: newFieldStates,
|
||||||
|
customOrder: newCustomOrder
|
||||||
});
|
});
|
||||||
return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState });
|
return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState });
|
||||||
}
|
}
|
||||||
@@ -305,6 +390,18 @@ function removeObjectUpdatesByURL(state: any, url: string) {
|
|||||||
return newState;
|
return newState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all updates in the store
|
||||||
|
* @param state The current state
|
||||||
|
*/
|
||||||
|
function removeAllObjectUpdates(state: any) {
|
||||||
|
const newState = Object.assign({}, state);
|
||||||
|
Object.keys(state).filter((path: string) => path.endsWith(OBJECT_UPDATES_TRASH_PATH)).forEach((path: string) => {
|
||||||
|
delete newState[path];
|
||||||
|
});
|
||||||
|
return newState;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discard the update for a specific action's url and field UUID in the store
|
* Discard the update for a specific action's url and field UUID in the store
|
||||||
* @param state The current state
|
* @param state The current state
|
||||||
@@ -407,3 +504,121 @@ function createInitialFieldStates(fields: Identifiable[]) {
|
|||||||
uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState);
|
uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState);
|
||||||
return fieldStates;
|
return fieldStates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to add a list of objects to an existing FieldStates object
|
||||||
|
* @param fieldStates FieldStates to add states to
|
||||||
|
* @param fields Identifiable objects The list of objects to add to the FieldStates
|
||||||
|
*/
|
||||||
|
function addFieldStates(fieldStates: FieldStates, fields: Identifiable[]) {
|
||||||
|
const uuids = fields.map((field: Identifiable) => field.uuid);
|
||||||
|
uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState);
|
||||||
|
return fieldStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an object within the custom order of a page state
|
||||||
|
* @param state The current state
|
||||||
|
* @param action The move action to perform
|
||||||
|
*/
|
||||||
|
function moveFieldUpdate(state: any, action: MoveFieldUpdateAction) {
|
||||||
|
const url = action.payload.url;
|
||||||
|
const fromIndex = action.payload.from;
|
||||||
|
const toIndex = action.payload.to;
|
||||||
|
const fromPage = action.payload.fromPage;
|
||||||
|
const toPage = action.payload.toPage;
|
||||||
|
const field = action.payload.field;
|
||||||
|
|
||||||
|
const pageState: ObjectUpdatesEntry = state[url];
|
||||||
|
const initialOrderPages = pageState.customOrder.initialOrderPages;
|
||||||
|
const customOrderPages = [...pageState.customOrder.newOrderPages];
|
||||||
|
|
||||||
|
// Create a copy of the custom orders for the from- and to-pages
|
||||||
|
const fromPageOrder = [...customOrderPages[fromPage].order];
|
||||||
|
const toPageOrder = [...customOrderPages[toPage].order];
|
||||||
|
if (fromPage === toPage) {
|
||||||
|
if (isNotEmpty(customOrderPages[fromPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex]) && isNotEmpty(customOrderPages[fromPage].order[toIndex])) {
|
||||||
|
// Move an item from one index to another within the same page
|
||||||
|
moveItemInArray(fromPageOrder, fromIndex, toIndex);
|
||||||
|
// Update the custom order for this page
|
||||||
|
customOrderPages[fromPage] = { order: fromPageOrder };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isNotEmpty(customOrderPages[fromPage]) && hasValue(customOrderPages[toPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex])) {
|
||||||
|
// Move an item from one index of one page to an index in another page
|
||||||
|
transferArrayItem(fromPageOrder, toPageOrder, fromIndex, toIndex);
|
||||||
|
// Update the custom order for both pages
|
||||||
|
customOrderPages[fromPage] = { order: fromPageOrder };
|
||||||
|
customOrderPages[toPage] = { order: toPageOrder };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a field update if it doesn't exist for this field yet
|
||||||
|
let fieldUpdate = {};
|
||||||
|
if (hasValue(field)) {
|
||||||
|
fieldUpdate = pageState.fieldUpdates[field.uuid];
|
||||||
|
if (hasNoValue(fieldUpdate)) {
|
||||||
|
fieldUpdate = { field: field, changeType: undefined }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the store's state with new values and return
|
||||||
|
return Object.assign({}, state, { [url]: Object.assign({}, pageState, {
|
||||||
|
fieldUpdates: Object.assign({}, pageState.fieldUpdates, hasValue(field) ? { [field.uuid]: fieldUpdate } : {}),
|
||||||
|
customOrder: Object.assign({}, pageState.customOrder, { newOrderPages: customOrderPages, changed: checkForOrderChanges(initialOrderPages, customOrderPages) })
|
||||||
|
})})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two lists of OrderPage objects and return whether there's at least one change in the order of objects within
|
||||||
|
* @param initialOrderPages The initial list of OrderPages
|
||||||
|
* @param customOrderPages The changed list of OrderPages
|
||||||
|
*/
|
||||||
|
function checkForOrderChanges(initialOrderPages: OrderPage[], customOrderPages: OrderPage[]) {
|
||||||
|
let changed = false;
|
||||||
|
initialOrderPages.forEach((orderPage: OrderPage, page: number) => {
|
||||||
|
if (isNotEmpty(orderPage) && isNotEmpty(orderPage.order) && isNotEmpty(customOrderPages[page]) && isNotEmpty(customOrderPages[page].order)) {
|
||||||
|
orderPage.order.forEach((id: string, index: number) => {
|
||||||
|
if (id !== customOrderPages[page].order[index]) {
|
||||||
|
changed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a custom order page by providing the list of all pages, a list of UUIDs, pageSize and the page to populate
|
||||||
|
* @param initialPages The initial list of OrderPage objects
|
||||||
|
* @param order The list of UUIDs to create a page for
|
||||||
|
* @param pageSize The pageSize used to populate empty spacer pages
|
||||||
|
* @param page The index of the page to add
|
||||||
|
*/
|
||||||
|
function addOrderToPages(initialPages: OrderPage[], order: string[], pageSize: number, page: number): OrderPage[] {
|
||||||
|
const result = [...initialPages];
|
||||||
|
const orderPage: OrderPage = { order: order };
|
||||||
|
if (page < result.length) {
|
||||||
|
// The page we're trying to add already exists in the list. Overwrite it.
|
||||||
|
result[page] = orderPage;
|
||||||
|
} else if (page === result.length) {
|
||||||
|
// The page we're trying to add is the next page in the list, add it.
|
||||||
|
result.push(orderPage);
|
||||||
|
} else {
|
||||||
|
// The page we're trying to add is at least one page ahead of the list, fill the list with empty pages before adding the page.
|
||||||
|
const emptyOrder = [];
|
||||||
|
for (let i = 0; i < pageSize; i++) {
|
||||||
|
emptyOrder.push(undefined);
|
||||||
|
}
|
||||||
|
const emptyOrderPage: OrderPage = { order: emptyOrder };
|
||||||
|
for (let i = result.length; i < page; i++) {
|
||||||
|
result.push(emptyOrderPage);
|
||||||
|
}
|
||||||
|
result.push(orderPage);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
@@ -2,6 +2,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { CoreState } from '../../core.reducers';
|
import { CoreState } from '../../core.reducers';
|
||||||
import { ObjectUpdatesService } from './object-updates.service';
|
import { ObjectUpdatesService } from './object-updates.service';
|
||||||
import {
|
import {
|
||||||
|
AddPageToCustomOrderAction,
|
||||||
DiscardObjectUpdatesAction,
|
DiscardObjectUpdatesAction,
|
||||||
FieldChangeType,
|
FieldChangeType,
|
||||||
InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction,
|
InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction,
|
||||||
@@ -12,6 +13,8 @@ import { Notification } from '../../../shared/notifications/models/notification.
|
|||||||
import { NotificationType } from '../../../shared/notifications/models/notification-type';
|
import { NotificationType } from '../../../shared/notifications/models/notification-type';
|
||||||
import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer';
|
import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer';
|
||||||
import {Relationship} from '../../shared/item-relationships/relationship.model';
|
import {Relationship} from '../../shared/item-relationships/relationship.model';
|
||||||
|
import { MoveOperation } from 'fast-json-patch/lib/core';
|
||||||
|
import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service';
|
||||||
|
|
||||||
describe('ObjectUpdatesService', () => {
|
describe('ObjectUpdatesService', () => {
|
||||||
let service: ObjectUpdatesService;
|
let service: ObjectUpdatesService;
|
||||||
@@ -44,7 +47,7 @@ describe('ObjectUpdatesService', () => {
|
|||||||
};
|
};
|
||||||
store = new Store<CoreState>(undefined, undefined, undefined);
|
store = new Store<CoreState>(undefined, undefined, undefined);
|
||||||
spyOn(store, 'dispatch');
|
spyOn(store, 'dispatch');
|
||||||
service = (new ObjectUpdatesService(store));
|
service = new ObjectUpdatesService(store, new ArrayMoveChangeAnalyzer<string>());
|
||||||
|
|
||||||
spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry));
|
spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry));
|
||||||
spyOn(service as any, 'getFieldState').and.callFake((uuid) => {
|
spyOn(service as any, 'getFieldState').and.callFake((uuid) => {
|
||||||
@@ -60,6 +63,25 @@ describe('ObjectUpdatesService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('initializeWithCustomOrder', () => {
|
||||||
|
const pageSize = 20;
|
||||||
|
const page = 0;
|
||||||
|
|
||||||
|
it('should dispatch an INITIALIZE action with the correct URL, initial identifiables, last modified , custom order, page size and page', () => {
|
||||||
|
service.initializeWithCustomOrder(url, identifiables, modDate, pageSize, page);
|
||||||
|
expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate, identifiables.map((identifiable) => identifiable.uuid), pageSize, page));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addPageToCustomOrder', () => {
|
||||||
|
const page = 2;
|
||||||
|
|
||||||
|
it('should dispatch an ADD_PAGE_TO_CUSTOM_ORDER action with the correct URL, identifiables, custom order and page number to add', () => {
|
||||||
|
service.addPageToCustomOrder(url, identifiables, page);
|
||||||
|
expect(store.dispatch).toHaveBeenCalledWith(new AddPageToCustomOrderAction(url, identifiables, identifiables.map((identifiable) => identifiable.uuid), page));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getFieldUpdates', () => {
|
describe('getFieldUpdates', () => {
|
||||||
it('should return the list of all fields, including their update if there is one', () => {
|
it('should return the list of all fields, including their update if there is one', () => {
|
||||||
const result$ = service.getFieldUpdates(url, identifiables);
|
const result$ = service.getFieldUpdates(url, identifiables);
|
||||||
@@ -77,6 +99,66 @@ describe('ObjectUpdatesService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getFieldUpdatesExclusive', () => {
|
||||||
|
it('should return the list of all fields, including their update if there is one, excluding updates that aren\'t part of the initial values provided', (done) => {
|
||||||
|
const result$ = service.getFieldUpdatesExclusive(url, identifiables);
|
||||||
|
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url);
|
||||||
|
|
||||||
|
const expectedResult = {
|
||||||
|
[identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE },
|
||||||
|
[identifiable2.uuid]: { field: identifiable2, changeType: undefined }
|
||||||
|
};
|
||||||
|
|
||||||
|
result$.subscribe((result) => {
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFieldUpdatesByCustomOrder', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const fieldStates = {
|
||||||
|
[identifiable1.uuid]: { editable: false, isNew: false, isValid: true },
|
||||||
|
[identifiable2.uuid]: { editable: true, isNew: false, isValid: false },
|
||||||
|
[identifiable3.uuid]: { editable: true, isNew: true, isValid: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const customOrder = {
|
||||||
|
initialOrderPages: [{
|
||||||
|
order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid]
|
||||||
|
}],
|
||||||
|
newOrderPages: [{
|
||||||
|
order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid]
|
||||||
|
}],
|
||||||
|
pageSize: 20,
|
||||||
|
changed: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectEntry = {
|
||||||
|
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
(service as any).getObjectEntry.and.returnValue(observableOf(objectEntry))
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the list of all fields, including their update if there is one, ordered by their custom order', (done) => {
|
||||||
|
const result$ = service.getFieldUpdatesByCustomOrder(url, identifiables);
|
||||||
|
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url);
|
||||||
|
|
||||||
|
const expectedResult = {
|
||||||
|
[identifiable2.uuid]: { field: identifiable2, changeType: undefined },
|
||||||
|
[identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD },
|
||||||
|
[identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }
|
||||||
|
};
|
||||||
|
|
||||||
|
result$.subscribe((result) => {
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isEditable', () => {
|
describe('isEditable', () => {
|
||||||
it('should return false if this identifiable is currently not editable in the store', () => {
|
it('should return false if this identifiable is currently not editable in the store', () => {
|
||||||
const result$ = service.isEditable(url, identifiable1.uuid);
|
const result$ = service.isEditable(url, identifiable1.uuid);
|
||||||
@@ -192,7 +274,11 @@ describe('ObjectUpdatesService', () => {
|
|||||||
});
|
});
|
||||||
describe('when updates are emtpy', () => {
|
describe('when updates are emtpy', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(service as any).getObjectEntry.and.returnValue(observableOf({}))
|
(service as any).getObjectEntry.and.returnValue(observableOf({
|
||||||
|
customOrder: {
|
||||||
|
changed: false
|
||||||
|
}
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when there are no updates', () => {
|
it('should return false when there are no updates', () => {
|
||||||
@@ -259,4 +345,45 @@ describe('ObjectUpdatesService', () => {
|
|||||||
expect(store.dispatch).toHaveBeenCalledWith(new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true));
|
expect(store.dispatch).toHaveBeenCalledWith(new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getMoveOperations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const fieldStates = {
|
||||||
|
[identifiable1.uuid]: { editable: false, isNew: false, isValid: true },
|
||||||
|
[identifiable2.uuid]: { editable: true, isNew: false, isValid: false },
|
||||||
|
[identifiable3.uuid]: { editable: true, isNew: true, isValid: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const customOrder = {
|
||||||
|
initialOrderPages: [{
|
||||||
|
order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid]
|
||||||
|
}],
|
||||||
|
newOrderPages: [{
|
||||||
|
order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid]
|
||||||
|
}],
|
||||||
|
pageSize: 20,
|
||||||
|
changed: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectEntry = {
|
||||||
|
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
(service as any).getObjectEntry.and.returnValue(observableOf(objectEntry))
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the expected move operations', (done) => {
|
||||||
|
const result$ = service.getMoveOperations(url);
|
||||||
|
|
||||||
|
const expectedResult = [
|
||||||
|
{ op: 'move', from: '/0', path: '/2' }
|
||||||
|
] as MoveOperation[];
|
||||||
|
|
||||||
|
result$.subscribe((result) => {
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -8,15 +8,16 @@ import {
|
|||||||
Identifiable,
|
Identifiable,
|
||||||
OBJECT_UPDATES_TRASH_PATH,
|
OBJECT_UPDATES_TRASH_PATH,
|
||||||
ObjectUpdatesEntry,
|
ObjectUpdatesEntry,
|
||||||
ObjectUpdatesState,
|
ObjectUpdatesState, OrderPage,
|
||||||
VirtualMetadataSource
|
VirtualMetadataSource
|
||||||
} from './object-updates.reducer';
|
} from './object-updates.reducer';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
AddFieldUpdateAction,
|
AddFieldUpdateAction, AddPageToCustomOrderAction,
|
||||||
DiscardObjectUpdatesAction,
|
DiscardObjectUpdatesAction,
|
||||||
FieldChangeType,
|
FieldChangeType,
|
||||||
InitializeFieldsAction,
|
InitializeFieldsAction,
|
||||||
|
MoveFieldUpdateAction,
|
||||||
ReinstateObjectUpdatesAction,
|
ReinstateObjectUpdatesAction,
|
||||||
RemoveFieldUpdateAction,
|
RemoveFieldUpdateAction,
|
||||||
SelectVirtualMetadataAction,
|
SelectVirtualMetadataAction,
|
||||||
@@ -26,6 +27,9 @@ import {
|
|||||||
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
|
||||||
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
|
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
|
||||||
import { INotification } from '../../../shared/notifications/models/notification.model';
|
import { INotification } from '../../../shared/notifications/models/notification.model';
|
||||||
|
import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service';
|
||||||
|
import { MoveOperation } from 'fast-json-patch/lib/core';
|
||||||
|
import { flatten } from '@angular/compiler';
|
||||||
|
|
||||||
function objectUpdatesStateSelector(): MemoizedSelector<CoreState, ObjectUpdatesState> {
|
function objectUpdatesStateSelector(): MemoizedSelector<CoreState, ObjectUpdatesState> {
|
||||||
return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']);
|
return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']);
|
||||||
@@ -48,7 +52,8 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ObjectUpdatesService {
|
export class ObjectUpdatesService {
|
||||||
constructor(private store: Store<CoreState>) {
|
constructor(private store: Store<CoreState>,
|
||||||
|
private comparator: ArrayMoveChangeAnalyzer<string>) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +67,28 @@ export class ObjectUpdatesService {
|
|||||||
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified));
|
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to dispatch an InitializeFieldsAction to the store and keeping track of the order objects are stored
|
||||||
|
* @param url The page's URL for which the changes are being mapped
|
||||||
|
* @param fields The initial fields for the page's object
|
||||||
|
* @param lastModified The date the object was last modified
|
||||||
|
* @param pageSize The page size to use for adding pages to the custom order
|
||||||
|
* @param page The first page to populate the custom order with
|
||||||
|
*/
|
||||||
|
initializeWithCustomOrder(url, fields: Identifiable[], lastModified: Date, pageSize = 9999, page = 0): void {
|
||||||
|
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, fields.map((field) => field.uuid), pageSize, page));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to dispatch an AddPageToCustomOrderAction, adding a new page to an already existing custom order tracking
|
||||||
|
* @param url The URL for which the changes are being mapped
|
||||||
|
* @param fields The fields to add a new page for
|
||||||
|
* @param page The page number (starting from index 0)
|
||||||
|
*/
|
||||||
|
addPageToCustomOrder(url, fields: Identifiable[], page: number): void {
|
||||||
|
this.store.dispatch(new AddPageToCustomOrderAction(url, fields, fields.map((field) => field.uuid), page));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to dispatch an AddFieldUpdateAction to the store
|
* Method to dispatch an AddFieldUpdateAction to the store
|
||||||
* @param url The page's URL for which the changes are saved
|
* @param url The page's URL for which the changes are saved
|
||||||
@@ -94,14 +121,15 @@ export class ObjectUpdatesService {
|
|||||||
* a FieldUpdates object
|
* a FieldUpdates object
|
||||||
* @param url The URL of the page for which the FieldUpdates should be requested
|
* @param url The URL of the page for which the FieldUpdates should be requested
|
||||||
* @param initialFields The initial values of the fields
|
* @param initialFields The initial values of the fields
|
||||||
|
* @param ignoreStates Ignore the fieldStates to loop over the fieldUpdates instead
|
||||||
*/
|
*/
|
||||||
getFieldUpdates(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> {
|
getFieldUpdates(url: string, initialFields: Identifiable[], ignoreStates?: boolean): Observable<FieldUpdates> {
|
||||||
const objectUpdates = this.getObjectEntry(url);
|
const objectUpdates = this.getObjectEntry(url);
|
||||||
return objectUpdates.pipe(
|
return objectUpdates.pipe(
|
||||||
switchMap((objectEntry) => {
|
switchMap((objectEntry) => {
|
||||||
const fieldUpdates: FieldUpdates = {};
|
const fieldUpdates: FieldUpdates = {};
|
||||||
if (hasValue(objectEntry)) {
|
if (hasValue(objectEntry)) {
|
||||||
Object.keys(objectEntry.fieldStates).forEach((uuid) => {
|
Object.keys(ignoreStates ? objectEntry.fieldUpdates : objectEntry.fieldStates).forEach((uuid) => {
|
||||||
fieldUpdates[uuid] = objectEntry.fieldUpdates[uuid];
|
fieldUpdates[uuid] = objectEntry.fieldUpdates[uuid];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -138,6 +166,31 @@ export class ObjectUpdatesService {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that combines the state's updates with the initial values (when there's no update),
|
||||||
|
* sorted by their custom order to create a FieldUpdates object
|
||||||
|
* @param url The URL of the page for which the FieldUpdates should be requested
|
||||||
|
* @param initialFields The initial values of the fields
|
||||||
|
* @param page The page to retrieve
|
||||||
|
*/
|
||||||
|
getFieldUpdatesByCustomOrder(url: string, initialFields: Identifiable[], page = 0): Observable<FieldUpdates> {
|
||||||
|
const objectUpdates = this.getObjectEntry(url);
|
||||||
|
return objectUpdates.pipe(map((objectEntry) => {
|
||||||
|
const fieldUpdates: FieldUpdates = {};
|
||||||
|
if (hasValue(objectEntry) && hasValue(objectEntry.customOrder) && isNotEmpty(objectEntry.customOrder.newOrderPages) && page < objectEntry.customOrder.newOrderPages.length) {
|
||||||
|
for (const uuid of objectEntry.customOrder.newOrderPages[page].order) {
|
||||||
|
let fieldUpdate = objectEntry.fieldUpdates[uuid];
|
||||||
|
if (isEmpty(fieldUpdate)) {
|
||||||
|
const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid);
|
||||||
|
fieldUpdate = {field: identifiable, changeType: undefined};
|
||||||
|
}
|
||||||
|
fieldUpdates[uuid] = fieldUpdate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fieldUpdates;
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to check if a specific field is currently editable in the store
|
* Method to check if a specific field is currently editable in the store
|
||||||
* @param url The URL of the page on which the field resides
|
* @param url The URL of the page on which the field resides
|
||||||
@@ -207,6 +260,19 @@ export class ObjectUpdatesService {
|
|||||||
this.saveFieldUpdate(url, field, FieldChangeType.UPDATE);
|
this.saveFieldUpdate(url, field, FieldChangeType.UPDATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatches a MoveFieldUpdateAction
|
||||||
|
* @param url The page's URL for which the changes are saved
|
||||||
|
* @param from The index of the object to move
|
||||||
|
* @param to The index to move the object to
|
||||||
|
* @param fromPage The page to move the object from
|
||||||
|
* @param toPage The page to move the object to
|
||||||
|
* @param field Optional field to add to the fieldUpdates list (useful if we want to track updates across multiple pages)
|
||||||
|
*/
|
||||||
|
saveMoveFieldUpdate(url: string, from: number, to: number, fromPage = 0, toPage = 0, field?: Identifiable) {
|
||||||
|
this.store.dispatch(new MoveFieldUpdateAction(url, from, to, fromPage, toPage, field));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the virtual metadata of a given item is selected to be saved as real metadata
|
* Check whether the virtual metadata of a given item is selected to be saved as real metadata
|
||||||
* @param url The URL of the page on which the field resides
|
* @param url The URL of the page on which the field resides
|
||||||
@@ -264,6 +330,15 @@ export class ObjectUpdatesService {
|
|||||||
this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification));
|
this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to dispatch a DiscardObjectUpdatesAction to the store with discardAll set to true
|
||||||
|
* @param url The page's URL for which the changes should be discarded
|
||||||
|
* @param undoNotification The notification which is should possibly be canceled
|
||||||
|
*/
|
||||||
|
discardAllFieldUpdates(url: string, undoNotification: INotification) {
|
||||||
|
this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification, true));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to dispatch an ReinstateObjectUpdatesAction to the store
|
* Method to dispatch an ReinstateObjectUpdatesAction to the store
|
||||||
* @param url The page's URL for which the changes should be reinstated
|
* @param url The page's URL for which the changes should be reinstated
|
||||||
@@ -312,7 +387,7 @@ export class ObjectUpdatesService {
|
|||||||
* @param url The page's url to check for in the store
|
* @param url The page's url to check for in the store
|
||||||
*/
|
*/
|
||||||
hasUpdates(url: string): Observable<boolean> {
|
hasUpdates(url: string): Observable<boolean> {
|
||||||
return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates)));
|
return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && (isNotEmpty(objectEntry.fieldUpdates) || objectEntry.customOrder.changed)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -330,4 +405,19 @@ export class ObjectUpdatesService {
|
|||||||
getLastModified(url: string): Observable<Date> {
|
getLastModified(url: string): Observable<Date> {
|
||||||
return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified));
|
return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get move operations based on the custom order
|
||||||
|
* @param url The page's url
|
||||||
|
*/
|
||||||
|
getMoveOperations(url: string): Observable<MoveOperation[]> {
|
||||||
|
return this.getObjectEntry(url).pipe(
|
||||||
|
map((objectEntry) => objectEntry.customOrder),
|
||||||
|
map((customOrder) => this.comparator.diff(
|
||||||
|
flatten(customOrder.initialOrderPages.map((orderPage: OrderPage) => orderPage.order)),
|
||||||
|
flatten(customOrder.newOrderPages.map((orderPage: OrderPage) => orderPage.order)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -22,6 +22,7 @@ import {
|
|||||||
} from './request.models';
|
} from './request.models';
|
||||||
import { RequestEntry } from './request.reducer';
|
import { RequestEntry } from './request.reducer';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
|
import { parseJsonSchemaToCommandDescription } from '@angular/cli/utilities/json-schema';
|
||||||
|
|
||||||
describe('RequestService', () => {
|
describe('RequestService', () => {
|
||||||
let scheduler: TestScheduler;
|
let scheduler: TestScheduler;
|
||||||
@@ -139,13 +140,21 @@ describe('RequestService', () => {
|
|||||||
describe('getByUUID', () => {
|
describe('getByUUID', () => {
|
||||||
describe('if the request with the specified UUID exists in the store', () => {
|
describe('if the request with the specified UUID exists in the store', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
let callCounter = 0;
|
||||||
|
const responses = [
|
||||||
|
cold('a', { // A direct hit in the request cache
|
||||||
|
a: {
|
||||||
|
completed: true
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
cold('b', { b: undefined }), // No hit in the index
|
||||||
|
cold('c', { c: undefined }) // So no mapped hit in the request cache
|
||||||
|
];
|
||||||
selectSpy.and.callFake(() => {
|
selectSpy.and.callFake(() => {
|
||||||
return () => {
|
return () => {
|
||||||
return () => hot('a', {
|
const response = responses[callCounter];
|
||||||
a: {
|
callCounter++;
|
||||||
completed: true
|
return () => response;
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -162,11 +171,19 @@ describe('RequestService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('if the request with the specified UUID doesn\'t exist in the store', () => {
|
describe(`if the request with the specified UUID doesn't exist in the store `, () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
let callCounter = 0;
|
||||||
|
const responses = [
|
||||||
|
cold('a', { a: undefined }), // No direct hit in the request cache
|
||||||
|
cold('b', { b: undefined }), // No hit in the index
|
||||||
|
cold('c', { c: undefined }), // So no mapped hit in the request cache
|
||||||
|
];
|
||||||
selectSpy.and.callFake(() => {
|
selectSpy.and.callFake(() => {
|
||||||
return () => {
|
return () => {
|
||||||
return () => hot('a', { a: undefined });
|
const response = responses[callCounter];
|
||||||
|
callCounter++;
|
||||||
|
return () => response;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -174,11 +191,43 @@ describe('RequestService', () => {
|
|||||||
it('should return an Observable of undefined', () => {
|
it('should return an Observable of undefined', () => {
|
||||||
const result = service.getByUUID(testUUID);
|
const result = service.getByUUID(testUUID);
|
||||||
|
|
||||||
scheduler.expectObservable(result).toBe('b', { b: undefined });
|
scheduler.expectObservable(result).toBe('a', { a: undefined });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
describe(`if the request with the specified UUID wasn't sent, because it was already cached`, () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
let callCounter = 0;
|
||||||
|
const responses = [
|
||||||
|
cold('a', { a: undefined }), // No direct hit in the request cache with that UUID
|
||||||
|
cold('b', { b: 'otherRequestUUID' }), // A hit in the index, which returns the uuid of the cached request
|
||||||
|
cold('c', { // the call to retrieve the cached request using the UUID from the index
|
||||||
|
c: {
|
||||||
|
completed: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
selectSpy.and.callFake(() => {
|
||||||
|
return () => {
|
||||||
|
const response = responses[callCounter];
|
||||||
|
callCounter++;
|
||||||
|
return () => response;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`it should return the cached request`, () => {
|
||||||
|
const result = service.getByUUID(testUUID);
|
||||||
|
|
||||||
|
scheduler.expectObservable(result).toBe('c', {
|
||||||
|
c: {
|
||||||
|
completed: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
describe('getByHref', () => {
|
describe('getByHref', () => {
|
||||||
describe('when the request with the specified href exists in the store', () => {
|
describe('when the request with the specified href exists in the store', () => {
|
||||||
|
@@ -2,10 +2,10 @@ import { Injectable } from '@angular/core';
|
|||||||
import { HttpHeaders } from '@angular/common/http';
|
import { HttpHeaders } from '@angular/common/http';
|
||||||
|
|
||||||
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
|
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
|
||||||
import { Observable, race as observableRace } from 'rxjs';
|
import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
|
||||||
import { filter, map, mergeMap, take } from 'rxjs/operators';
|
import { filter, map, mergeMap, take, switchMap, startWith } from 'rxjs/operators';
|
||||||
import { cloneDeep, remove } from 'lodash';
|
import { cloneDeep, remove } from 'lodash';
|
||||||
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
|
import { hasValue, isEmpty, isNotEmpty, hasValueOperator } from '../../shared/empty.util';
|
||||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||||
import { CoreState } from '../core.reducers';
|
import { CoreState } from '../core.reducers';
|
||||||
@@ -110,15 +110,19 @@ export class RequestService {
|
|||||||
* Retrieve a RequestEntry based on their uuid
|
* Retrieve a RequestEntry based on their uuid
|
||||||
*/
|
*/
|
||||||
getByUUID(uuid: string): Observable<RequestEntry> {
|
getByUUID(uuid: string): Observable<RequestEntry> {
|
||||||
return observableRace(
|
return observableCombineLatest([
|
||||||
this.store.pipe(select(entryFromUUIDSelector(uuid))),
|
this.store.pipe(
|
||||||
|
select(entryFromUUIDSelector(uuid))
|
||||||
|
),
|
||||||
this.store.pipe(
|
this.store.pipe(
|
||||||
select(originalRequestUUIDFromRequestUUIDSelector(uuid)),
|
select(originalRequestUUIDFromRequestUUIDSelector(uuid)),
|
||||||
mergeMap((originalUUID) => {
|
switchMap((originalUUID) => {
|
||||||
return this.store.pipe(select(entryFromUUIDSelector(originalUUID)))
|
return this.store.pipe(select(entryFromUUIDSelector(originalUUID)))
|
||||||
},
|
},
|
||||||
))
|
),
|
||||||
).pipe(
|
),
|
||||||
|
]).pipe(
|
||||||
|
map((entries: RequestEntry[]) => entries.find((entry: RequestEntry) => hasValue(entry))),
|
||||||
map((entry: RequestEntry) => {
|
map((entry: RequestEntry) => {
|
||||||
// Headers break after being retrieved from the store (because of lazy initialization)
|
// Headers break after being retrieved from the store (because of lazy initialization)
|
||||||
// Combining them with a new object fixes this issue
|
// Combining them with a new object fixes this issue
|
||||||
@@ -137,7 +141,13 @@ export class RequestService {
|
|||||||
getByHref(href: string): Observable<RequestEntry> {
|
getByHref(href: string): Observable<RequestEntry> {
|
||||||
return this.store.pipe(
|
return this.store.pipe(
|
||||||
select(uuidFromHrefSelector(href)),
|
select(uuidFromHrefSelector(href)),
|
||||||
mergeMap((uuid: string) => this.getByUUID(uuid))
|
mergeMap((uuid: string) => {
|
||||||
|
if (isNotEmpty(uuid)) {
|
||||||
|
return this.getByUUID(uuid);
|
||||||
|
} else {
|
||||||
|
return [undefined];
|
||||||
|
}
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
41
src/app/core/data/workflow-action-data.service.ts
Normal file
41
src/app/core/data/workflow-action-data.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { DataService } from './data.service';
|
||||||
|
import { WorkflowAction } from '../tasks/models/workflow-action-object.model';
|
||||||
|
import { RequestService } from './request.service';
|
||||||
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { CoreState } from '../core.reducers';
|
||||||
|
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||||
|
import { FindListOptions } from './request.models';
|
||||||
|
import { Observable } from 'rxjs/internal/Observable';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { dataService } from '../cache/builders/build-decorators';
|
||||||
|
import { WORKFLOW_ACTION } from '../tasks/models/workflow-action-object.resource-type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service responsible for fetching/sending data from/to the REST API on the workflowactions endpoint
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
@dataService(WORKFLOW_ACTION)
|
||||||
|
export class WorkflowActionDataService extends DataService<WorkflowAction> {
|
||||||
|
protected linkPath = 'workflowactions';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected requestService: RequestService,
|
||||||
|
protected rdbService: RemoteDataBuildService,
|
||||||
|
protected store: Store<CoreState>,
|
||||||
|
protected objectCache: ObjectCacheService,
|
||||||
|
protected halService: HALEndpointService,
|
||||||
|
protected notificationsService: NotificationsService,
|
||||||
|
protected http: HttpClient,
|
||||||
|
protected comparator: DefaultChangeAnalyzer<WorkflowAction>) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable<string> {
|
||||||
|
return this.halService.getEndpoint(this.linkPath);
|
||||||
|
}
|
||||||
|
}
|
@@ -10,7 +10,6 @@ import { Observable } from 'rxjs/internal/Observable';
|
|||||||
import { TestScheduler } from 'rxjs/testing';
|
import { TestScheduler } from 'rxjs/testing';
|
||||||
import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions';
|
import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions';
|
||||||
import { SearchParam } from '../cache/models/search-param.model';
|
import { SearchParam } from '../cache/models/search-param.model';
|
||||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
|
||||||
import { CoreState } from '../core.reducers';
|
import { CoreState } from '../core.reducers';
|
||||||
import { ChangeAnalyzer } from '../data/change-analyzer';
|
import { ChangeAnalyzer } from '../data/change-analyzer';
|
||||||
import { PaginatedList } from '../data/paginated-list';
|
import { PaginatedList } from '../data/paginated-list';
|
||||||
@@ -36,43 +35,15 @@ describe('EPersonDataService', () => {
|
|||||||
let requestService: RequestService;
|
let requestService: RequestService;
|
||||||
let scheduler: TestScheduler;
|
let scheduler: TestScheduler;
|
||||||
|
|
||||||
const epeople = [EPersonMock, EPersonMock2];
|
let epeople;
|
||||||
|
|
||||||
const restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson';
|
let restEndpointURL;
|
||||||
const epersonsEndpoint = `${restEndpointURL}/epersons`;
|
let epersonsEndpoint;
|
||||||
let halService: any = new HALEndpointServiceStub(restEndpointURL);
|
let halService: any;
|
||||||
const epeople$ = createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [epeople]));
|
let epeople$;
|
||||||
const rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons': epeople$ });
|
let rdbService;
|
||||||
const objectCache = Object.assign({
|
|
||||||
/* tslint:disable:no-empty */
|
|
||||||
remove: () => {
|
|
||||||
},
|
|
||||||
hasBySelfLinkObservable: () => observableOf(false)
|
|
||||||
/* tslint:enable:no-empty */
|
|
||||||
}) as ObjectCacheService;
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
let getRequestEntry$;
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
StoreModule.forRoot({}),
|
|
||||||
TranslateModule.forRoot({
|
|
||||||
loader: {
|
|
||||||
provide: TranslateLoader,
|
|
||||||
useClass: TranslateLoaderMock
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
declarations: [],
|
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
|
||||||
});
|
|
||||||
|
|
||||||
const getRequestEntry$ = (successful: boolean) => {
|
|
||||||
return observableOf({
|
|
||||||
completed: true,
|
|
||||||
response: { isSuccessful: successful, payload: epeople } as any
|
|
||||||
} as RequestEntry)
|
|
||||||
};
|
|
||||||
|
|
||||||
function initTestService() {
|
function initTestService() {
|
||||||
return new EPersonDataService(
|
return new EPersonDataService(
|
||||||
@@ -87,7 +58,39 @@ describe('EPersonDataService', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
getRequestEntry$ = (successful: boolean) => {
|
||||||
|
return observableOf({
|
||||||
|
completed: true,
|
||||||
|
response: { isSuccessful: successful, payload: epeople } as any
|
||||||
|
} as RequestEntry)
|
||||||
|
};
|
||||||
|
restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson';
|
||||||
|
epersonsEndpoint = `${restEndpointURL}/epersons`;
|
||||||
|
epeople = [EPersonMock, EPersonMock2];
|
||||||
|
epeople$ = createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [epeople]));
|
||||||
|
rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons': epeople$ });
|
||||||
|
halService = new HALEndpointServiceStub(restEndpointURL);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
StoreModule.forRoot({}),
|
||||||
|
TranslateModule.forRoot({
|
||||||
|
loader: {
|
||||||
|
provide: TranslateLoader,
|
||||||
|
useClass: TranslateLoaderMock
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
declarations: [],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
init();
|
||||||
requestService = getMockRequestService(getRequestEntry$(true));
|
requestService = getMockRequestService(getRequestEntry$(true));
|
||||||
store = new Store<CoreState>(undefined, undefined, undefined);
|
store = new Store<CoreState>(undefined, undefined, undefined);
|
||||||
service = initTestService();
|
service = initTestService();
|
||||||
|
@@ -181,7 +181,7 @@ export class EPersonDataService extends DataService<EPerson> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method that clears a cached EPerson request and returns its REST url
|
* Method that clears a cached EPerson request
|
||||||
*/
|
*/
|
||||||
public clearEPersonRequests(): void {
|
public clearEPersonRequests(): void {
|
||||||
this.getBrowseEndpoint().pipe(take(1)).subscribe((link: string) => {
|
this.getBrowseEndpoint().pipe(take(1)).subscribe((link: string) => {
|
||||||
@@ -189,6 +189,13 @@ export class EPersonDataService extends DataService<EPerson> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that clears a link's requests in cache
|
||||||
|
*/
|
||||||
|
public clearLinkRequests(href: string): void {
|
||||||
|
this.requestService.removeByHrefSubstring(href);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Method to retrieve the eperson that is currently being edited
|
* Method to retrieve the eperson that is currently being edited
|
||||||
*/
|
*/
|
||||||
@@ -219,4 +226,27 @@ export class EPersonDataService extends DataService<EPerson> {
|
|||||||
return this.delete(ePerson.id);
|
return this.delete(ePerson.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change which ePerson is being edited and return the link for EPeople edit page
|
||||||
|
* @param ePerson New EPerson to edit
|
||||||
|
*/
|
||||||
|
public startEditingNewEPerson(ePerson: EPerson): string {
|
||||||
|
this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => {
|
||||||
|
if (ePerson === activeEPerson) {
|
||||||
|
this.cancelEditEPerson();
|
||||||
|
} else {
|
||||||
|
this.editEPerson(ePerson);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return '/admin/access-control/epeople';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get EPeople admin page
|
||||||
|
* @param ePerson New EPerson to edit
|
||||||
|
*/
|
||||||
|
public getEPeoplePageRouterLink(): string {
|
||||||
|
return '/admin/access-control/epeople';
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
198
src/app/core/eperson/group-data.service.spec.ts
Normal file
198
src/app/core/eperson/group-data.service.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { HttpHeaders } from '@angular/common/http';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { Store, StoreModule } from '@ngrx/store';
|
||||||
|
import { of as observableOf } from 'rxjs';
|
||||||
|
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { compare, Operation } from 'fast-json-patch';
|
||||||
|
import {
|
||||||
|
GroupRegistryCancelGroupAction,
|
||||||
|
GroupRegistryEditGroupAction
|
||||||
|
} from '../../+admin/admin-access-control/group-registry/group-registry.actions';
|
||||||
|
import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/mock-remote-data-build.service';
|
||||||
|
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
|
||||||
|
import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader';
|
||||||
|
import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson-mock';
|
||||||
|
import { GroupMock, GroupMock2 } from '../../shared/testing/group-mock';
|
||||||
|
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
|
||||||
|
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
|
||||||
|
import { SearchParam } from '../cache/models/search-param.model';
|
||||||
|
import { CoreState } from '../core.reducers';
|
||||||
|
import { ChangeAnalyzer } from '../data/change-analyzer';
|
||||||
|
import { PaginatedList } from '../data/paginated-list';
|
||||||
|
import { DeleteByIDRequest, DeleteRequest, FindListOptions, PostRequest } from '../data/request.models';
|
||||||
|
import { RequestEntry } from '../data/request.reducer';
|
||||||
|
import { RequestService } from '../data/request.service';
|
||||||
|
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
|
||||||
|
import { Item } from '../shared/item.model';
|
||||||
|
import { PageInfo } from '../shared/page-info.model';
|
||||||
|
import { GroupDataService } from './group-data.service';
|
||||||
|
|
||||||
|
describe('GroupDataService', () => {
|
||||||
|
let service: GroupDataService;
|
||||||
|
let store: Store<CoreState>;
|
||||||
|
let requestService: RequestService;
|
||||||
|
|
||||||
|
let restEndpointURL;
|
||||||
|
let groupsEndpoint;
|
||||||
|
let groups;
|
||||||
|
let groups$;
|
||||||
|
let halService;
|
||||||
|
let rdbService;
|
||||||
|
|
||||||
|
let getRequestEntry$;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
getRequestEntry$ = (successful: boolean) => {
|
||||||
|
return observableOf({
|
||||||
|
completed: true,
|
||||||
|
response: { isSuccessful: successful, payload: groups } as any
|
||||||
|
} as RequestEntry)
|
||||||
|
};
|
||||||
|
restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson';
|
||||||
|
groupsEndpoint = `${restEndpointURL}/groups`;
|
||||||
|
groups = [GroupMock, GroupMock2];
|
||||||
|
groups$ = createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), groups));
|
||||||
|
rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups': groups$ });
|
||||||
|
halService = new HALEndpointServiceStub(restEndpointURL);
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
StoreModule.forRoot({}),
|
||||||
|
TranslateModule.forRoot({
|
||||||
|
loader: {
|
||||||
|
provide: TranslateLoader,
|
||||||
|
useClass: MockTranslateLoader
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
declarations: [],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTestService() {
|
||||||
|
return new GroupDataService(
|
||||||
|
new DummyChangeAnalyzer() as any,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
requestService,
|
||||||
|
rdbService,
|
||||||
|
store,
|
||||||
|
null,
|
||||||
|
halService,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
init();
|
||||||
|
requestService = getMockRequestService(getRequestEntry$(true));
|
||||||
|
store = new Store<CoreState>(undefined, undefined, undefined);
|
||||||
|
service = initTestService();
|
||||||
|
spyOn(store, 'dispatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchGroups', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
spyOn(service, 'searchBy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search with empty query', () => {
|
||||||
|
service.searchGroups('');
|
||||||
|
const options = Object.assign(new FindListOptions(), {
|
||||||
|
searchParams: [Object.assign(new SearchParam('query', ''))]
|
||||||
|
});
|
||||||
|
expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search with query', () => {
|
||||||
|
service.searchGroups('test');
|
||||||
|
const options = Object.assign(new FindListOptions(), {
|
||||||
|
searchParams: [Object.assign(new SearchParam('query', 'test'))]
|
||||||
|
});
|
||||||
|
expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteGroup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
service.deleteGroup(GroupMock2).subscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send DeleteRequest', () => {
|
||||||
|
const expected = new DeleteByIDRequest(requestService.generateRequestId(), groupsEndpoint + '/' + GroupMock2.uuid, GroupMock2.uuid);
|
||||||
|
expect(requestService.configure).toHaveBeenCalledWith(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addSubGroupToGroup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
service.addSubGroupToGroup(GroupMock, GroupMock2).subscribe();
|
||||||
|
});
|
||||||
|
it('should send PostRequest to eperson/groups/group-id/subgroups endpoint with new subgroup link in body', () => {
|
||||||
|
let headers = new HttpHeaders();
|
||||||
|
const options: HttpOptions = Object.create({});
|
||||||
|
headers = headers.append('Content-Type', 'text/uri-list');
|
||||||
|
options.headers = headers;
|
||||||
|
const expected = new PostRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.subgroupsEndpoint, GroupMock2.self, options);
|
||||||
|
expect(requestService.configure).toHaveBeenCalledWith(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteSubGroupFromGroup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
service.deleteSubGroupFromGroup(GroupMock, GroupMock2).subscribe();
|
||||||
|
});
|
||||||
|
it('should send DeleteRequest to eperson/groups/group-id/subgroups/group-id endpoint', () => {
|
||||||
|
const expected = new DeleteRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.subgroupsEndpoint + '/' + GroupMock2.id);
|
||||||
|
expect(requestService.configure).toHaveBeenCalledWith(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addMemberToGroup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
service.addMemberToGroup(GroupMock, EPersonMock2).subscribe();
|
||||||
|
});
|
||||||
|
it('should send PostRequest to eperson/groups/group-id/epersons endpoint with new eperson member in body', () => {
|
||||||
|
let headers = new HttpHeaders();
|
||||||
|
const options: HttpOptions = Object.create({});
|
||||||
|
headers = headers.append('Content-Type', 'text/uri-list');
|
||||||
|
options.headers = headers;
|
||||||
|
const expected = new PostRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.ePersonsEndpoint, EPersonMock2.self, options);
|
||||||
|
expect(requestService.configure).toHaveBeenCalledWith(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteMemberFromGroup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
service.deleteMemberFromGroup(GroupMock, EPersonMock).subscribe();
|
||||||
|
});
|
||||||
|
it('should send DeleteRequest to eperson/groups/group-id/epersons/eperson-id endpoint', () => {
|
||||||
|
const expected = new DeleteRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.ePersonsEndpoint + '/' + EPersonMock.id);
|
||||||
|
expect(requestService.configure).toHaveBeenCalledWith(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editGroup', () => {
|
||||||
|
it('should dispatch a EDIT_GROUP action with the groupp to start editing', () => {
|
||||||
|
service.editGroup(GroupMock);
|
||||||
|
expect(store.dispatch).toHaveBeenCalledWith(new GroupRegistryEditGroupAction(GroupMock));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelEditGroup', () => {
|
||||||
|
it('should dispatch a CANCEL_EDIT_GROUP action', () => {
|
||||||
|
service.cancelEditGroup();
|
||||||
|
expect(store.dispatch).toHaveBeenCalledWith(new GroupRegistryCancelGroupAction());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
|
||||||
|
diff(object1: Item, object2: Item): Operation[] {
|
||||||
|
return compare((object1 as any).metadata, (object2 as any).metadata);
|
||||||
|
}
|
||||||
|
}
|
@@ -1,28 +1,52 @@
|
|||||||
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
|
||||||
|
|
||||||
import { Store } from '@ngrx/store';
|
import { createSelector, select, Store } from '@ngrx/store';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { filter, map, take } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, map, switchMap, take, tap } from 'rxjs/operators';
|
||||||
|
import {
|
||||||
|
GroupRegistryCancelGroupAction,
|
||||||
|
GroupRegistryEditGroupAction
|
||||||
|
} from '../../+admin/admin-access-control/group-registry/group-registry.actions';
|
||||||
|
import { GroupRegistryState } from '../../+admin/admin-access-control/group-registry/group-registry.reducers';
|
||||||
|
import { AppState } from '../../app.reducer';
|
||||||
|
import { hasValue } from '../../shared/empty.util';
|
||||||
|
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||||
|
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||||
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
|
import { SearchParam } from '../cache/models/search-param.model';
|
||||||
|
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||||
|
import { RestResponse } from '../cache/response.models';
|
||||||
import { DataService } from '../data/data.service';
|
import { DataService } from '../data/data.service';
|
||||||
|
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||||
|
import { PaginatedList } from '../data/paginated-list';
|
||||||
|
import { RemoteData } from '../data/remote-data';
|
||||||
|
import {
|
||||||
|
CreateRequest,
|
||||||
|
DeleteRequest,
|
||||||
|
FindListOptions,
|
||||||
|
FindListRequest,
|
||||||
|
PostRequest
|
||||||
|
} from '../data/request.models';
|
||||||
|
|
||||||
import { RequestService } from '../data/request.service';
|
import { RequestService } from '../data/request.service';
|
||||||
import { FindListOptions } from '../data/request.models';
|
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
|
||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
import { configureRequest, getResponseFromEntry} from '../shared/operators';
|
||||||
|
import { EPerson } from './models/eperson.model';
|
||||||
import { Group } from './models/group.model';
|
import { Group } from './models/group.model';
|
||||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
|
||||||
import { CoreState } from '../core.reducers';
|
|
||||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
|
||||||
import { SearchParam } from '../cache/models/search-param.model';
|
|
||||||
import { RemoteData } from '../data/remote-data';
|
|
||||||
import { PaginatedList } from '../data/paginated-list';
|
|
||||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
|
||||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
|
||||||
import { dataService } from '../cache/builders/build-decorators';
|
import { dataService } from '../cache/builders/build-decorators';
|
||||||
import { GROUP } from './models/group.resource-type';
|
import { GROUP } from './models/group.resource-type';
|
||||||
|
import { DSONameService } from '../breadcrumbs/dso-name.service';
|
||||||
|
import { Community } from '../shared/community.model';
|
||||||
|
import { Collection } from '../shared/collection.model';
|
||||||
|
import { ComcolRole } from '../../shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role';
|
||||||
|
|
||||||
|
const groupRegistryStateSelector = (state: AppState) => state.groupRegistry;
|
||||||
|
const editGroupSelector = createSelector(groupRegistryStateSelector, (groupRegistryState: GroupRegistryState) => groupRegistryState.editGroup);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides methods to retrieve eperson group resources.
|
* Provides methods to retrieve eperson group resources from the REST API & Group related CRUD actions.
|
||||||
*/
|
*/
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -31,6 +55,8 @@ import { GROUP } from './models/group.resource-type';
|
|||||||
export class GroupDataService extends DataService<Group> {
|
export class GroupDataService extends DataService<Group> {
|
||||||
protected linkPath = 'groups';
|
protected linkPath = 'groups';
|
||||||
protected browseEndpoint = '';
|
protected browseEndpoint = '';
|
||||||
|
public ePersonsEndpoint = 'epersons';
|
||||||
|
public subgroupsEndpoint = 'subgroups';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected comparator: DSOChangeAnalyzer<Group>,
|
protected comparator: DSOChangeAnalyzer<Group>,
|
||||||
@@ -38,13 +64,52 @@ export class GroupDataService extends DataService<Group> {
|
|||||||
protected notificationsService: NotificationsService,
|
protected notificationsService: NotificationsService,
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
protected rdbService: RemoteDataBuildService,
|
protected rdbService: RemoteDataBuildService,
|
||||||
protected store: Store<CoreState>,
|
protected store: Store<any>,
|
||||||
protected objectCache: ObjectCacheService,
|
protected objectCache: ObjectCacheService,
|
||||||
protected halService: HALEndpointService
|
protected halService: HALEndpointService,
|
||||||
|
protected nameService: DSONameService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all groups
|
||||||
|
* @param pagination The pagination info used to retrieve the groups
|
||||||
|
*/
|
||||||
|
public getGroups(options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Group>>): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
|
const hrefObs = this.getFindAllHref(options, this.linkPath, ...linksToFollow);
|
||||||
|
hrefObs.pipe(
|
||||||
|
filter((href: string) => hasValue(href)),
|
||||||
|
take(1))
|
||||||
|
.subscribe((href: string) => {
|
||||||
|
const request = new FindListRequest(this.requestService.generateRequestId(), href, options);
|
||||||
|
this.requestService.configure(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.rdbService.buildList<Group>(hrefObs) as Observable<RemoteData<PaginatedList<Group>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a search result list of groups, with certain query (searches in group name and by exact uuid)
|
||||||
|
* Endpoint used: /eperson/groups/search/byMetadata?query=<:name>
|
||||||
|
* @param query search query param
|
||||||
|
* @param options
|
||||||
|
* @param linksToFollow
|
||||||
|
*/
|
||||||
|
public searchGroups(query: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<Group>>): Observable<RemoteData<PaginatedList<Group>>> {
|
||||||
|
const searchParams = [new SearchParam('query', query)];
|
||||||
|
let findListOptions = new FindListOptions();
|
||||||
|
if (options) {
|
||||||
|
findListOptions = Object.assign(new FindListOptions(), options);
|
||||||
|
}
|
||||||
|
if (findListOptions.searchParams) {
|
||||||
|
findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams];
|
||||||
|
} else {
|
||||||
|
findListOptions.searchParams = searchParams;
|
||||||
|
}
|
||||||
|
return this.searchBy('byMetadata', findListOptions, ...linksToFollow);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the current user is member of to the indicated group
|
* Check if the current user is member of to the indicated group
|
||||||
*
|
*
|
||||||
@@ -59,10 +124,252 @@ export class GroupDataService extends DataService<Group> {
|
|||||||
options.searchParams = [new SearchParam('groupName', groupName)];
|
options.searchParams = [new SearchParam('groupName', groupName)];
|
||||||
|
|
||||||
return this.searchBy(searchHref, options).pipe(
|
return this.searchBy(searchHref, options).pipe(
|
||||||
filter((groups: RemoteData<PaginatedList<Group>>) => !groups.isResponsePending),
|
filter((groups: RemoteData<PaginatedList<Group>>) => !groups.isResponsePending),
|
||||||
take(1),
|
take(1),
|
||||||
map((groups: RemoteData<PaginatedList<Group>>) => groups.payload.totalElements > 0)
|
map((groups: RemoteData<PaginatedList<Group>>) => groups.payload.totalElements > 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to delete a group
|
||||||
|
* @param id The group id to delete
|
||||||
|
*/
|
||||||
|
public deleteGroup(group: Group): Observable<boolean> {
|
||||||
|
return this.delete(group.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or Update a group
|
||||||
|
* If the group contains an id, it is assumed the eperson already exists and is updated instead
|
||||||
|
* @param group The group to create or update
|
||||||
|
*/
|
||||||
|
public createOrUpdateGroup(group: Group): Observable<RemoteData<Group>> {
|
||||||
|
const isUpdate = hasValue(group.id);
|
||||||
|
if (isUpdate) {
|
||||||
|
return this.updateGroup(group);
|
||||||
|
} else {
|
||||||
|
return this.create(group, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* // TODO
|
||||||
|
* @param {DSpaceObject} ePerson The given object
|
||||||
|
*/
|
||||||
|
updateGroup(group: Group): Observable<RemoteData<Group>> {
|
||||||
|
// TODO
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds given subgroup as a subgroup to the given active group
|
||||||
|
* @param activeGroup Group we want to add subgroup to
|
||||||
|
* @param subgroup Group we want to add as subgroup to activeGroup
|
||||||
|
*/
|
||||||
|
addSubGroupToGroup(activeGroup: Group, subgroup: Group): Observable<RestResponse> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
const options: HttpOptions = Object.create({});
|
||||||
|
let headers = new HttpHeaders();
|
||||||
|
headers = headers.append('Content-Type', 'text/uri-list');
|
||||||
|
options.headers = headers;
|
||||||
|
const postRequest = new PostRequest(requestId, activeGroup.self + '/' + this.subgroupsEndpoint, subgroup.self, options);
|
||||||
|
this.requestService.configure(postRequest);
|
||||||
|
|
||||||
|
return this.fetchResponse(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a given subgroup from the subgroups of the given active group
|
||||||
|
* @param activeGroup Group we want to delete subgroup from
|
||||||
|
* @param subgroup Subgroup we want to delete from activeGroup
|
||||||
|
*/
|
||||||
|
deleteSubGroupFromGroup(activeGroup: Group, subgroup: Group): Observable<RestResponse> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
const deleteRequest = new DeleteRequest(requestId, activeGroup.self + '/' + this.subgroupsEndpoint + '/' + subgroup.id);
|
||||||
|
this.requestService.configure(deleteRequest);
|
||||||
|
|
||||||
|
return this.fetchResponse(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds given ePerson as member to given group
|
||||||
|
* @param activeGroup Group we want to add member to
|
||||||
|
* @param ePerson EPerson we want to add as member to given activeGroup
|
||||||
|
*/
|
||||||
|
addMemberToGroup(activeGroup: Group, ePerson: EPerson): Observable<RestResponse> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
const options: HttpOptions = Object.create({});
|
||||||
|
let headers = new HttpHeaders();
|
||||||
|
headers = headers.append('Content-Type', 'text/uri-list');
|
||||||
|
options.headers = headers;
|
||||||
|
const postRequest = new PostRequest(requestId, activeGroup.self + '/' + this.ePersonsEndpoint, ePerson.self, options);
|
||||||
|
this.requestService.configure(postRequest);
|
||||||
|
|
||||||
|
return this.fetchResponse(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a given ePerson from the members of the given active group
|
||||||
|
* @param activeGroup Group we want to delete member from
|
||||||
|
* @param ePerson EPerson we want to delete from members of given activeGroup
|
||||||
|
*/
|
||||||
|
deleteMemberFromGroup(activeGroup: Group, ePerson: EPerson): Observable<RestResponse> {
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
const deleteRequest = new DeleteRequest(requestId, activeGroup.self + '/' + this.ePersonsEndpoint + '/' + ePerson.id);
|
||||||
|
this.requestService.configure(deleteRequest);
|
||||||
|
|
||||||
|
return this.fetchResponse(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the restResponse from the requestService
|
||||||
|
* @param requestId
|
||||||
|
*/
|
||||||
|
protected fetchResponse(requestId: string): Observable<RestResponse> {
|
||||||
|
return this.requestService.getByUUID(requestId).pipe(
|
||||||
|
getResponseFromEntry(),
|
||||||
|
map((response: RestResponse) => {
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to retrieve the group that is currently being edited
|
||||||
|
*/
|
||||||
|
public getActiveGroup(): Observable<Group> {
|
||||||
|
return this.store.pipe(select(editGroupSelector))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to cancel editing a group, dispatches a cancel group action
|
||||||
|
*/
|
||||||
|
public cancelEditGroup() {
|
||||||
|
this.store.dispatch(new GroupRegistryCancelGroupAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to set the group being edited, dispatches an edit group action
|
||||||
|
* @param group The group to edit
|
||||||
|
*/
|
||||||
|
public editGroup(group: Group) {
|
||||||
|
this.store.dispatch(new GroupRegistryEditGroupAction(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that clears a cached groups request
|
||||||
|
*/
|
||||||
|
public clearGroupsRequests(): void {
|
||||||
|
this.getBrowseEndpoint().pipe(take(1)).subscribe((link: string) => {
|
||||||
|
this.requestService.removeByHrefSubstring(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method that clears a cached get subgroups of certain group request
|
||||||
|
*/
|
||||||
|
public clearGroupLinkRequests(href: string): void {
|
||||||
|
this.requestService.removeByHrefSubstring(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getGroupRegistryRouterLink(): string {
|
||||||
|
return '/admin/access-control/groups';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change which group is being edited and return the link for the edit page of the new group being edited
|
||||||
|
* @param newGroup New group to edit
|
||||||
|
*/
|
||||||
|
public startEditingNewGroup(newGroup: Group): string {
|
||||||
|
this.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
|
||||||
|
if (newGroup === activeGroup) {
|
||||||
|
this.cancelEditGroup()
|
||||||
|
} else {
|
||||||
|
this.editGroup(newGroup)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.getGroupEditPageRouterLinkWithID(newGroup.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Edit page of group
|
||||||
|
* @param group Group we want edit page for
|
||||||
|
*/
|
||||||
|
public getGroupEditPageRouterLink(group: Group): string {
|
||||||
|
return this.getGroupEditPageRouterLinkWithID(group.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Edit page of group
|
||||||
|
* @param groupID Group ID we want edit page for
|
||||||
|
*/
|
||||||
|
public getGroupEditPageRouterLinkWithID(groupId: string): string {
|
||||||
|
return '/admin/access-control/groups/' + groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract optional UUID from a string
|
||||||
|
* @param stringWithUUID String with possible UUID
|
||||||
|
*/
|
||||||
|
public getUUIDFromString(stringWithUUID: string): string {
|
||||||
|
let foundUUID = '';
|
||||||
|
const uuidMatches = stringWithUUID.match(/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/g);
|
||||||
|
if (uuidMatches != null) {
|
||||||
|
foundUUID = uuidMatches[0];
|
||||||
|
}
|
||||||
|
return foundUUID;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a group for a given role for a given community or collection.
|
||||||
|
*
|
||||||
|
* @param dso The community or collection for which to create a group
|
||||||
|
* @param link The REST endpoint to create the group
|
||||||
|
*/
|
||||||
|
createComcolGroup(dso: Community|Collection, link: string): Observable<RestResponse> {
|
||||||
|
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
const group = Object.assign(new Group(), {
|
||||||
|
metadata: {
|
||||||
|
'dc.description': [
|
||||||
|
{
|
||||||
|
value: `${this.nameService.getName(dso)} admin group`,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.requestService.configure(
|
||||||
|
new CreateRequest(
|
||||||
|
requestId,
|
||||||
|
link,
|
||||||
|
JSON.stringify(group),
|
||||||
|
));
|
||||||
|
|
||||||
|
return this.requestService.getByUUID(requestId).pipe(
|
||||||
|
getResponseFromEntry(),
|
||||||
|
tap(() => this.requestService.removeByHrefSubstring(link)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the group for a given role for a given community or collection.
|
||||||
|
*
|
||||||
|
* @param link The REST endpoint to delete the group
|
||||||
|
*/
|
||||||
|
deleteComcolGroup(link: string): Observable<RestResponse> {
|
||||||
|
|
||||||
|
const requestId = this.requestService.generateRequestId();
|
||||||
|
|
||||||
|
this.requestService.configure(
|
||||||
|
new DeleteRequest(
|
||||||
|
requestId,
|
||||||
|
link,
|
||||||
|
));
|
||||||
|
|
||||||
|
return this.requestService.getByUUID(requestId).pipe(
|
||||||
|
getResponseFromEntry(),
|
||||||
|
tap(() => this.requestService.removeByHrefSubstring(link)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -6,6 +6,8 @@ import { RemoteData } from '../../data/remote-data';
|
|||||||
|
|
||||||
import { DSpaceObject } from '../../shared/dspace-object.model';
|
import { DSpaceObject } from '../../shared/dspace-object.model';
|
||||||
import { HALLink } from '../../shared/hal-link.model';
|
import { HALLink } from '../../shared/hal-link.model';
|
||||||
|
import { EPerson } from './eperson.model';
|
||||||
|
import { EPERSON } from './eperson.resource-type';
|
||||||
import { GROUP } from './group.resource-type';
|
import { GROUP } from './group.resource-type';
|
||||||
|
|
||||||
@typedObject
|
@typedObject
|
||||||
@@ -13,6 +15,12 @@ import { GROUP } from './group.resource-type';
|
|||||||
export class Group extends DSpaceObject {
|
export class Group extends DSpaceObject {
|
||||||
static type = GROUP;
|
static type = GROUP;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A string representing the unique name of this Group
|
||||||
|
*/
|
||||||
|
@autoserialize
|
||||||
|
public name: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A string representing the unique handle of this Group
|
* A string representing the unique handle of this Group
|
||||||
*/
|
*/
|
||||||
@@ -31,7 +39,8 @@ export class Group extends DSpaceObject {
|
|||||||
@deserialize
|
@deserialize
|
||||||
_links: {
|
_links: {
|
||||||
self: HALLink;
|
self: HALLink;
|
||||||
groups: HALLink;
|
subgroups: HALLink;
|
||||||
|
epersons: HALLink;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,6 +48,13 @@ export class Group extends DSpaceObject {
|
|||||||
* Will be undefined unless the groups {@link HALLink} has been resolved.
|
* Will be undefined unless the groups {@link HALLink} has been resolved.
|
||||||
*/
|
*/
|
||||||
@link(GROUP, true)
|
@link(GROUP, true)
|
||||||
public groups?: Observable<RemoteData<PaginatedList<Group>>>;
|
public subgroups?: Observable<RemoteData<PaginatedList<Group>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of EPeople in this group
|
||||||
|
* Will be undefined unless the epersons {@link HALLink} has been resolved.
|
||||||
|
*/
|
||||||
|
@link(EPERSON, true)
|
||||||
|
public epersons?: Observable<RemoteData<PaginatedList<EPerson>>>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -54,7 +54,7 @@ export class Bitstream extends DSpaceObject implements HALResource {
|
|||||||
* The BitstreamFormat of this Bitstream
|
* The BitstreamFormat of this Bitstream
|
||||||
* Will be undefined unless the format {@link HALLink} has been resolved.
|
* Will be undefined unless the format {@link HALLink} has been resolved.
|
||||||
*/
|
*/
|
||||||
@link(BITSTREAM_FORMAT)
|
@link(BITSTREAM_FORMAT, false, 'format')
|
||||||
format?: Observable<RemoteData<BitstreamFormat>>;
|
format?: Observable<RemoteData<BitstreamFormat>>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -15,6 +15,8 @@ import { RESOURCE_POLICY } from './resource-policy.resource-type';
|
|||||||
import { COMMUNITY } from './community.resource-type';
|
import { COMMUNITY } from './community.resource-type';
|
||||||
import { Community } from './community.model';
|
import { Community } from './community.model';
|
||||||
import { ChildHALResource } from './child-hal-resource.model';
|
import { ChildHALResource } from './child-hal-resource.model';
|
||||||
|
import { GROUP } from '../eperson/models/group.resource-type';
|
||||||
|
import { Group } from '../eperson/models/group.model';
|
||||||
|
|
||||||
@typedObject
|
@typedObject
|
||||||
@inheritSerialization(DSpaceObject)
|
@inheritSerialization(DSpaceObject)
|
||||||
@@ -70,6 +72,12 @@ export class Collection extends DSpaceObject implements ChildHALResource {
|
|||||||
@link(COMMUNITY, false)
|
@link(COMMUNITY, false)
|
||||||
parentCommunity?: Observable<RemoteData<Community>>;
|
parentCommunity?: Observable<RemoteData<Community>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The administrators group of this community.
|
||||||
|
*/
|
||||||
|
@link(GROUP)
|
||||||
|
adminGroup?: Observable<RemoteData<Group>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The introductory text of this Collection
|
* The introductory text of this Collection
|
||||||
* Corresponds to the metadata field dc.description
|
* Corresponds to the metadata field dc.description
|
||||||
|
@@ -3,6 +3,8 @@ import { Observable } from 'rxjs';
|
|||||||
import { link, typedObject } from '../cache/builders/build-decorators';
|
import { link, typedObject } from '../cache/builders/build-decorators';
|
||||||
import { PaginatedList } from '../data/paginated-list';
|
import { PaginatedList } from '../data/paginated-list';
|
||||||
import { RemoteData } from '../data/remote-data';
|
import { RemoteData } from '../data/remote-data';
|
||||||
|
import { Group } from '../eperson/models/group.model';
|
||||||
|
import { GROUP } from '../eperson/models/group.resource-type';
|
||||||
import { Bitstream } from './bitstream.model';
|
import { Bitstream } from './bitstream.model';
|
||||||
import { BITSTREAM } from './bitstream.resource-type';
|
import { BITSTREAM } from './bitstream.resource-type';
|
||||||
import { Collection } from './collection.model';
|
import { Collection } from './collection.model';
|
||||||
@@ -32,6 +34,7 @@ export class Community extends DSpaceObject implements ChildHALResource {
|
|||||||
logo: HALLink;
|
logo: HALLink;
|
||||||
subcommunities: HALLink;
|
subcommunities: HALLink;
|
||||||
parentCommunity: HALLink;
|
parentCommunity: HALLink;
|
||||||
|
adminGroup: HALLink;
|
||||||
self: HALLink;
|
self: HALLink;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +66,12 @@ export class Community extends DSpaceObject implements ChildHALResource {
|
|||||||
@link(COMMUNITY, false)
|
@link(COMMUNITY, false)
|
||||||
parentCommunity?: Observable<RemoteData<Community>>;
|
parentCommunity?: Observable<RemoteData<Community>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The administrators group of this community.
|
||||||
|
*/
|
||||||
|
@link(GROUP)
|
||||||
|
adminGroup?: Observable<RemoteData<Group>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The introductory text of this Community
|
* The introductory text of this Community
|
||||||
* Corresponds to the metadata field dc.description
|
* Corresponds to the metadata field dc.description
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { autoserialize, autoserializeAs, deserialize, deserializeAs } from 'cerialize';
|
import { autoserialize, autoserializeAs, deserialize, deserializeAs } from 'cerialize';
|
||||||
import { hasNoValue, isUndefined } from '../../shared/empty.util';
|
import { hasNoValue, hasValue, isUndefined } from '../../shared/empty.util';
|
||||||
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
||||||
import { typedObject } from '../cache/builders/build-decorators';
|
import { typedObject } from '../cache/builders/build-decorators';
|
||||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||||
@@ -79,6 +79,9 @@ export class DSpaceObject extends ListableObject implements CacheableObject {
|
|||||||
* The name for this DSpaceObject
|
* The name for this DSpaceObject
|
||||||
*/
|
*/
|
||||||
set name(name) {
|
set name(name) {
|
||||||
|
if (hasValue(this.firstMetadata('dc.title'))) {
|
||||||
|
this.firstMetadata('dc.title').value = name;
|
||||||
|
}
|
||||||
this._name = name;
|
this._name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -7,6 +7,7 @@ import {
|
|||||||
MetadatumViewModel
|
MetadatumViewModel
|
||||||
} from './metadata.models';
|
} from './metadata.models';
|
||||||
import { Metadata } from './metadata.utils';
|
import { Metadata } from './metadata.utils';
|
||||||
|
import { beforeEach } from 'selenium-webdriver/testing';
|
||||||
|
|
||||||
const mdValue = (value: string, language?: string, authority?: string): MetadataValue => {
|
const mdValue = (value: string, language?: string, authority?: string): MetadataValue => {
|
||||||
return Object.assign(new MetadataValue(), { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language, place: 0, authority: isUndefined(authority) ? null : authority, confidence: undefined });
|
return Object.assign(new MetadataValue(), { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language, place: 0, authority: isUndefined(authority) ? null : authority, confidence: undefined });
|
||||||
@@ -216,4 +217,26 @@ describe('Metadata', () => {
|
|||||||
testToMetadataMap(multiViewModelList, multiMap);
|
testToMetadataMap(multiViewModelList, multiMap);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setFirstValue method', () => {
|
||||||
|
|
||||||
|
const metadataMap = {
|
||||||
|
'dc.description': [mdValue('Test description')],
|
||||||
|
'dc.title': [mdValue('Test title 1'), mdValue('Test title 2')]
|
||||||
|
};
|
||||||
|
|
||||||
|
const testSetFirstValue = (map: MetadataMap, key: string, value: string) => {
|
||||||
|
describe(`with field ${key} and value ${value}`, () => {
|
||||||
|
Metadata.setFirstValue(map, key, value);
|
||||||
|
it(`should set first value of ${key} to ${value}`, () => {
|
||||||
|
expect(map[key][0].value).toEqual(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
testSetFirstValue(metadataMap, 'dc.description', 'New Description');
|
||||||
|
testSetFirstValue(metadataMap, 'dc.title', 'New Title');
|
||||||
|
testSetFirstValue(metadataMap, 'dc.format', 'Completely new field and value');
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util';
|
import { isEmpty, isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util';
|
||||||
import {
|
import {
|
||||||
MetadataMapInterface,
|
MetadataMapInterface,
|
||||||
MetadataValue,
|
MetadataValue,
|
||||||
@@ -217,4 +217,19 @@ export class Metadata {
|
|||||||
});
|
});
|
||||||
return metadataMap;
|
return metadataMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the first value of a metadata by field key
|
||||||
|
* Creates a new MetadataValue if the field doesn't exist yet
|
||||||
|
* @param mdMap The map to add/change values in
|
||||||
|
* @param key The metadata field
|
||||||
|
* @param value The value to add
|
||||||
|
*/
|
||||||
|
public static setFirstValue(mdMap: MetadataMapInterface, key: string, value: string) {
|
||||||
|
if (isNotEmpty(mdMap[key])) {
|
||||||
|
mdMap[key][0].value = value;
|
||||||
|
} else {
|
||||||
|
mdMap[key] = [Object.assign(new MetadataValue(), { value: value })]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { filter, find, flatMap, map, tap } from 'rxjs/operators';
|
import { filter, find, flatMap, map, take, tap } from 'rxjs/operators';
|
||||||
import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
|
import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
|
||||||
import { SearchResult } from '../../shared/search/search-result.model';
|
import { SearchResult } from '../../shared/search/search-result.model';
|
||||||
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
|
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
|
||||||
@@ -207,3 +207,13 @@ export const getFirstOccurrence = () =>
|
|||||||
source.pipe(
|
source.pipe(
|
||||||
map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined }))
|
map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined }))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator for turning the current page of bitstreams into an array
|
||||||
|
*/
|
||||||
|
export const paginatedListToArray = () =>
|
||||||
|
<T extends DSpaceObject>(source: Observable<RemoteData<PaginatedList<T>>>): Observable<T[]> =>
|
||||||
|
source.pipe(
|
||||||
|
hasValueOperator(),
|
||||||
|
map((objectRD: RemoteData<PaginatedList<T>>) => objectRD.payload.page.filter((object: T) => hasValue(object)))
|
||||||
|
);
|
||||||
|
@@ -52,8 +52,7 @@ describe('ClaimedTaskDataService', () => {
|
|||||||
options.headers = headers;
|
options.headers = headers;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('approveTask', () => {
|
describe('submitTask', () => {
|
||||||
|
|
||||||
it('should call postToEndpoint method', () => {
|
it('should call postToEndpoint method', () => {
|
||||||
const scopeId = '1234';
|
const scopeId = '1234';
|
||||||
const body = {
|
const body = {
|
||||||
@@ -63,33 +62,13 @@ describe('ClaimedTaskDataService', () => {
|
|||||||
spyOn(service, 'postToEndpoint');
|
spyOn(service, 'postToEndpoint');
|
||||||
requestService.uriEncodeBody.and.returnValue(body);
|
requestService.uriEncodeBody.and.returnValue(body);
|
||||||
|
|
||||||
service.approveTask(scopeId);
|
service.submitTask(scopeId, body);
|
||||||
|
|
||||||
expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rejectTask', () => {
|
|
||||||
|
|
||||||
it('should call postToEndpoint method', () => {
|
|
||||||
const scopeId = '1234';
|
|
||||||
const reason = 'test reject';
|
|
||||||
const body = {
|
|
||||||
submit_reject: 'true',
|
|
||||||
reason
|
|
||||||
};
|
|
||||||
|
|
||||||
spyOn(service, 'postToEndpoint');
|
|
||||||
requestService.uriEncodeBody.and.returnValue(body);
|
|
||||||
|
|
||||||
service.rejectTask(reason, scopeId);
|
|
||||||
|
|
||||||
expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options);
|
expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('returnToPoolTask', () => {
|
describe('returnToPoolTask', () => {
|
||||||
|
|
||||||
it('should call deleteById method', () => {
|
it('should call deleteById method', () => {
|
||||||
const scopeId = '1234';
|
const scopeId = '1234';
|
||||||
|
|
||||||
|
@@ -35,7 +35,6 @@ export class ClaimedTaskDataService extends TasksService<ClaimedTask> {
|
|||||||
*
|
*
|
||||||
* @param {RequestService} requestService
|
* @param {RequestService} requestService
|
||||||
* @param {RemoteDataBuildService} rdbService
|
* @param {RemoteDataBuildService} rdbService
|
||||||
* @param {NormalizedObjectBuildService} linkService
|
|
||||||
* @param {Store<CoreState>} store
|
* @param {Store<CoreState>} store
|
||||||
* @param {ObjectCacheService} objectCache
|
* @param {ObjectCacheService} objectCache
|
||||||
* @param {HALEndpointService} halService
|
* @param {HALEndpointService} halService
|
||||||
@@ -56,35 +55,16 @@ export class ClaimedTaskDataService extends TasksService<ClaimedTask> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Make a request to approve the given task
|
* Make a request for the given task
|
||||||
*
|
*
|
||||||
* @param scopeId
|
* @param scopeId
|
||||||
* The task id
|
* The task id
|
||||||
|
* @param body
|
||||||
|
* The request body
|
||||||
* @return {Observable<ProcessTaskResponse>}
|
* @return {Observable<ProcessTaskResponse>}
|
||||||
* Emit the server response
|
* Emit the server response
|
||||||
*/
|
*/
|
||||||
public approveTask(scopeId: string): Observable<ProcessTaskResponse> {
|
public submitTask(scopeId: string, body: any): Observable<ProcessTaskResponse> {
|
||||||
const body = {
|
|
||||||
submit_approve: 'true'
|
|
||||||
};
|
|
||||||
return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make a request to reject the given task
|
|
||||||
*
|
|
||||||
* @param reason
|
|
||||||
* The reason of reject
|
|
||||||
* @param scopeId
|
|
||||||
* The task id
|
|
||||||
* @return {Observable<ProcessTaskResponse>}
|
|
||||||
* Emit the server response
|
|
||||||
*/
|
|
||||||
public rejectTask(reason: string, scopeId: string): Observable<ProcessTaskResponse> {
|
|
||||||
const body = {
|
|
||||||
submit_reject: 'true',
|
|
||||||
reason
|
|
||||||
};
|
|
||||||
return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions());
|
return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -13,6 +13,8 @@ import { HALLink } from '../../shared/hal-link.model';
|
|||||||
import { WorkflowItem } from '../../submission/models/workflowitem.model';
|
import { WorkflowItem } from '../../submission/models/workflowitem.model';
|
||||||
import { TASK_OBJECT } from './task-object.resource-type';
|
import { TASK_OBJECT } from './task-object.resource-type';
|
||||||
import { WORKFLOWITEM } from '../../eperson/models/workflowitem.resource-type';
|
import { WORKFLOWITEM } from '../../eperson/models/workflowitem.resource-type';
|
||||||
|
import { WORKFLOW_ACTION } from './workflow-action-object.resource-type';
|
||||||
|
import { WorkflowAction } from './workflow-action-object.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An abstract model class for a TaskObject.
|
* An abstract model class for a TaskObject.
|
||||||
@@ -34,12 +36,6 @@ export class TaskObject extends DSpaceObject implements CacheableObject {
|
|||||||
@autoserialize
|
@autoserialize
|
||||||
step: string;
|
step: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* The task action type
|
|
||||||
*/
|
|
||||||
@autoserialize
|
|
||||||
action: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The {@link HALLink}s for this TaskObject
|
* The {@link HALLink}s for this TaskObject
|
||||||
*/
|
*/
|
||||||
@@ -49,6 +45,7 @@ export class TaskObject extends DSpaceObject implements CacheableObject {
|
|||||||
owner: HALLink;
|
owner: HALLink;
|
||||||
group: HALLink;
|
group: HALLink;
|
||||||
workflowitem: HALLink;
|
workflowitem: HALLink;
|
||||||
|
action: HALLink;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,4 +70,11 @@ export class TaskObject extends DSpaceObject implements CacheableObject {
|
|||||||
/* This was changed from 'WorkflowItem | Observable<RemoteData<WorkflowItem>>' to 'any' to prevent issues in templates with async */
|
/* This was changed from 'WorkflowItem | Observable<RemoteData<WorkflowItem>>' to 'any' to prevent issues in templates with async */
|
||||||
workflowitem?: any;
|
workflowitem?: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The task action type
|
||||||
|
* Will be undefined unless the group {@link HALLink} has been resolved.
|
||||||
|
*/
|
||||||
|
@link(WORKFLOW_ACTION, false, 'action')
|
||||||
|
action: Observable<RemoteData<WorkflowAction>>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
25
src/app/core/tasks/models/workflow-action-object.model.ts
Normal file
25
src/app/core/tasks/models/workflow-action-object.model.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { inheritSerialization, autoserialize } from 'cerialize';
|
||||||
|
import { typedObject } from '../../cache/builders/build-decorators';
|
||||||
|
import { DSpaceObject } from '../../shared/dspace-object.model';
|
||||||
|
import { WORKFLOW_ACTION } from './workflow-action-object.resource-type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A model class for a WorkflowAction
|
||||||
|
*/
|
||||||
|
@typedObject
|
||||||
|
@inheritSerialization(DSpaceObject)
|
||||||
|
export class WorkflowAction extends DSpaceObject {
|
||||||
|
static type = WORKFLOW_ACTION;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workflow action's identifier
|
||||||
|
*/
|
||||||
|
@autoserialize
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options available for this workflow action
|
||||||
|
*/
|
||||||
|
@autoserialize
|
||||||
|
options: string[];
|
||||||
|
}
|
@@ -0,0 +1,9 @@
|
|||||||
|
import { ResourceType } from '../../shared/resource-type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resource type for WorkflowAction
|
||||||
|
*
|
||||||
|
* Needs to be in a separate file to prevent circular
|
||||||
|
* dependencies in webpack.
|
||||||
|
*/
|
||||||
|
export const WORKFLOW_ACTION = new ResourceType('workflowaction');
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user