[DURACOM-191] align with main branch and migrated to be standalone new components

This commit is contained in:
Vlad Nouski
2023-12-11 11:08:22 +01:00
parent 8a7b459695
commit 1f15b21ba9
245 changed files with 10480 additions and 3191 deletions

View File

@@ -208,6 +208,9 @@ languages:
- code: pt-BR - code: pt-BR
label: Português do Brasil label: Português do Brasil
active: true active: true
- code: sr-lat
label: Srpski (lat)
active: true
- code: fi - code: fi
label: Suomi label: Suomi
active: true active: true
@@ -232,6 +235,9 @@ languages:
- code: el - code: el
label: Ελληνικά label: Ελληνικά
active: true active: true
- code: sr-cyr
label: Српски
active: true
- code: uk - code: uk
label: раї́нська label: раї́нська
active: true active: true
@@ -379,4 +385,4 @@ vocabularies:
# Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query. # Default collection/community sorting order at Advanced search, Create/update community and collection when there are not a query.
comcolSelectionSort: comcolSelectionSort:
sortField: 'dc.title' sortField: 'dc.title'
sortDirection: 'ASC' sortDirection: 'ASC'

View File

@@ -177,6 +177,8 @@ function generateViewEvent(uuid: string, dsoType: string): void {
[XSRF_REQUEST_HEADER] : csrfToken, [XSRF_REQUEST_HEADER] : csrfToken,
// use a known public IP address to avoid being seen as a "bot" // use a known public IP address to avoid being seen as a "bot"
'X-Forwarded-For': '1.1.1.1', 'X-Forwarded-For': '1.1.1.1',
// Use a user-agent of a Firefox browser on Windows. This again avoids being seen as a "bot"
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
}, },
//form: true, // indicates the body should be form urlencoded //form: true, // indicates the body should be form urlencoded
body: { targetId: uuid, targetType: dsoType }, body: { targetId: uuid, targetType: dsoType },

View File

@@ -15,14 +15,14 @@
"analyze": "webpack-bundle-analyzer dist/browser/stats.json", "analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build --configuration development", "build": "ng build --configuration development",
"build:stats": "ng build --stats-json", "build:stats": "ng build --stats-json",
"build:prod": "yarn run build:ssr", "build:prod": "cross-env NODE_ENV=production yarn run build:ssr",
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
"test": "ng test --source-map=true --watch=false --configuration test", "test": "ng test --source-map=true --watch=false --configuration test",
"test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"", "test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"",
"test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage", "test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
"lint": "ng lint", "lint": "ng lint",
"lint-fix": "ng lint --fix=true", "lint-fix": "ng lint --fix=true",
"e2e": "ng e2e", "e2e": "cross-env NODE_ENV=production ng e2e",
"clean:dev:config": "rimraf src/assets/config.json", "clean:dev:config": "rimraf src/assets/config.json",
"clean:coverage": "rimraf coverage", "clean:coverage": "rimraf coverage",
"clean:dist": "rimraf dist", "clean:dist": "rimraf dist",
@@ -82,7 +82,7 @@
"@types/grecaptcha": "^3.0.4", "@types/grecaptcha": "^3.0.4",
"angular-idle-preload": "3.0.0", "angular-idle-preload": "3.0.0",
"angulartics2": "^12.2.0", "angulartics2": "^12.2.0",
"axios": "^0.27.2", "axios": "^1.6.0",
"bootstrap": "^4.6.1", "bootstrap": "^4.6.1",
"cerialize": "0.1.18", "cerialize": "0.1.18",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",

View File

@@ -1,12 +1,22 @@
import { URLCombiner } from '../core/url-combiner/url-combiner'; import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getAccessControlModuleRoute } from '../app-routing-paths'; import { getAccessControlModuleRoute } from '../app-routing-paths';
export const GROUP_EDIT_PATH = 'groups'; export const EPERSON_PATH = 'epeople';
export function getEPersonsRoute(): string {
return new URLCombiner(getAccessControlModuleRoute(), EPERSON_PATH).toString();
}
export function getEPersonEditRoute(id: string): string {
return new URLCombiner(getEPersonsRoute(), id, 'edit').toString();
}
export const GROUP_PATH = 'groups';
export function getGroupsRoute() { export function getGroupsRoute() {
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString(); return new URLCombiner(getAccessControlModuleRoute(), GROUP_PATH).toString();
} }
export function getGroupEditRoute(id: string) { export function getGroupEditRoute(id: string) {
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString(); return new URLCombiner(getGroupsRoute(), id, 'edit').toString();
} }

View File

@@ -3,7 +3,7 @@ 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 { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { GROUP_EDIT_PATH } from './access-control-routing-paths'; import { EPERSON_PATH, GROUP_PATH } from './access-control-routing-paths';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { GroupPageGuard } from './group-registry/group-page.guard'; import { GroupPageGuard } from './group-registry/group-page.guard';
import { import {
@@ -13,12 +13,14 @@ import {
SiteAdministratorGuard SiteAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { BulkAccessComponent } from './bulk-access/bulk-access.component'; import { BulkAccessComponent } from './bulk-access/bulk-access.component';
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
import { EPersonResolver } from './epeople-registry/eperson-resolver.service';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{ {
path: 'epeople', path: EPERSON_PATH,
component: EPeopleRegistryComponent, component: EPeopleRegistryComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
@@ -27,7 +29,26 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component';
canActivate: [SiteAdministratorGuard] canActivate: [SiteAdministratorGuard]
}, },
{ {
path: GROUP_EDIT_PATH, path: `${EPERSON_PATH}/create`,
component: EPersonFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver,
},
data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' },
canActivate: [SiteAdministratorGuard],
},
{
path: `${EPERSON_PATH}/:id/edit`,
component: EPersonFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver,
ePerson: EPersonResolver,
},
data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' },
canActivate: [SiteAdministratorGuard],
},
{
path: GROUP_PATH,
component: GroupsRegistryComponent, component: GroupsRegistryComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
@@ -36,7 +57,7 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component';
canActivate: [GroupAdministratorGuard] canActivate: [GroupAdministratorGuard]
}, },
{ {
path: `${GROUP_EDIT_PATH}/newGroup`, path: `${GROUP_PATH}/create`,
component: GroupFormComponent, component: GroupFormComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
@@ -45,7 +66,7 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component';
canActivate: [GroupAdministratorGuard] canActivate: [GroupAdministratorGuard]
}, },
{ {
path: `${GROUP_EDIT_PATH}/:groupId`, path: `${GROUP_PATH}/:groupId/edit`,
component: GroupFormComponent, component: GroupFormComponent,
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver

View File

@@ -4,96 +4,91 @@
<div class="d-flex justify-content-between border-bottom mb-3"> <div class="d-flex justify-content-between border-bottom mb-3">
<h2 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h2> <h2 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h2>
<div *ngIf="!isEPersonFormShown"> <div>
<button class="mr-auto btn btn-success addEPerson-button" <button class="mr-auto btn btn-success addEPerson-button"
(click)="isEPersonFormShown = true"> [routerLink]="'create'">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline ml-1">{{labelPrefix + 'button.add' | translate}}</span> <span class="d-none d-sm-inline ml-1">{{labelPrefix + 'button.add' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>
<ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="reset()" <h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}}
(cancelForm)="isEPersonFormShown = false"></ds-eperson-form>
<div *ngIf="!isEPersonFormShown"> </h3>
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}} <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div>
</h3> <select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <option value="metadata">{{labelPrefix + 'search.scope.metadata' | translate}}</option>
<div> <option value="email">{{labelPrefix + 'search.scope.email' | translate}}</option>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope"> </select>
<option value="metadata">{{labelPrefix + 'search.scope.metadata' | translate}}</option> </div>
<option value="email">{{labelPrefix + 'search.scope.email' | translate}}</option> <div class="flex-grow-1 mr-3 ml-3">
</select> <div class="form-group input-group">
</div> <input type="text" name="query" id="query" formControlName="query"
<div class="flex-grow-1 mr-3 ml-3"> class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
<div class="form-group input-group"> [placeholder]="(labelPrefix + 'search.placeholder' | translate)">
<input type="text" name="query" id="query" formControlName="query" <span class="input-group-append">
class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
[placeholder]="(labelPrefix + 'search.placeholder' | translate)">
<span class="input-group-append">
<button type="submit" class="search-button btn btn-primary"> <button type="submit" class="search-button btn btn-primary">
<i class="fas fa-search"></i> {{ labelPrefix + 'search.button' | translate }} <i class="fas fa-search"></i> {{ labelPrefix + 'search.button' | translate }}
</button> </button>
</span> </span>
</div>
</div> </div>
<div>
<button (click)="clearFormAndResetResult();"
class="search-button btn btn-secondary">{{labelPrefix + 'button.see-all' | translate}}</button>
</div>
</form>
<ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
<ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)"
[paginationOptions]="config"
[pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="epeople" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{labelPrefix + 'table.id' | translate}}</th>
<th scope="col">{{labelPrefix + 'table.name' | translate}}</th>
<th scope="col">{{labelPrefix + 'table.email' | translate}}</th>
<th>{{labelPrefix + 'table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
<td>{{epersonDto.eperson.id}}</td>
<td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
<td>{{epersonDto.eperson.email}}</td>
<td>
<div class="btn-group edit-field">
<button (click)="toggleEditEPerson(epersonDto.eperson)"
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
{{labelPrefix + 'no-items' | translate}}
</div> </div>
<div>
<button (click)="clearFormAndResetResult();"
class="search-button btn btn-secondary">{{labelPrefix + 'button.see-all' | translate}}</button>
</div>
</form>
<ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
<ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)"
[paginationOptions]="config"
[pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="epeople" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col">{{labelPrefix + 'table.id' | translate}}</th>
<th scope="col">{{labelPrefix + 'table.name' | translate}}</th>
<th scope="col">{{labelPrefix + 'table.email' | translate}}</th>
<th>{{labelPrefix + 'table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
<td>{{epersonDto.eperson.id}}</td>
<td>{{ dsoNameService.getName(epersonDto.eperson) }}</td>
<td>{{epersonDto.eperson.email}}</td>
<td>
<div class="btn-group edit-field">
<button [routerLink]="getEditEPeoplePage(epersonDto.eperson.id)"
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button *ngIf="epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
class="delete-button btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: { name: dsoNameService.getName(epersonDto.eperson) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
{{labelPrefix + 'no-items' | translate}}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -214,36 +214,6 @@ describe('EPeopleRegistryComponent', () => {
}); });
}); });
describe('toggleEditEPerson', () => {
describe('when you click on first edit eperson button', () => {
beforeEach(fakeAsync(() => {
const editButtons = fixture.debugElement.queryAll(By.css('.access-control-editEPersonButton'));
editButtons[0].triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
fixture.detectChanges();
}));
it('editEPerson form is toggled', () => {
const ePeopleIds = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child'));
ePersonDataServiceStub.getActiveEPerson().subscribe((activeEPerson: EPerson) => {
if (ePeopleIds[0] && activeEPerson === ePeopleIds[0].nativeElement.textContent) {
expect(component.isEPersonFormShown).toEqual(false);
} else {
expect(component.isEPersonFormShown).toEqual(true);
}
});
});
it('EPerson search section is hidden', () => {
expect(fixture.debugElement.query(By.css('#search'))).toBeNull();
});
});
});
describe('deleteEPerson', () => { describe('deleteEPerson', () => {
describe('when you click on first delete eperson button', () => { describe('when you click on first delete eperson button', () => {
let ePeopleIdsFoundBeforeDelete; let ePeopleIdsFoundBeforeDelete;

View File

@@ -1,6 +1,6 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms';
import { Router } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
@@ -26,12 +26,14 @@ import { AsyncPipe, NgClass, NgForOf, NgIf } from '@angular/common';
import { EPersonFormComponent } from './eperson-form/eperson-form.component'; import { EPersonFormComponent } from './eperson-form/eperson-form.component';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { PaginationComponent } from '../../shared/pagination/pagination.component'; import { PaginationComponent } from '../../shared/pagination/pagination.component';
import { getEPersonEditRoute, getEPersonsRoute } from '../access-control-routing-paths';
@Component({ @Component({
selector: 'ds-epeople-registry', selector: 'ds-epeople-registry',
templateUrl: './epeople-registry.component.html', templateUrl: './epeople-registry.component.html',
imports: [ imports: [
TranslateModule, TranslateModule,
RouterModule,
AsyncPipe, AsyncPipe,
NgIf, NgIf,
EPersonFormComponent, EPersonFormComponent,
@@ -80,11 +82,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
currentPage: 1 currentPage: 1
}); });
/**
* Whether or not to show the EPerson form
*/
isEPersonFormShown: boolean;
// The search form // The search form
searchForm; searchForm;
@@ -130,17 +127,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
*/ */
initialisePage() { initialisePage() {
this.searching$.next(true); this.searching$.next(true);
this.isEPersonFormShown = false;
this.search({scope: this.currentSearchScope, query: this.currentSearchQuery}); 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;
}
}));
this.subs.push(this.ePeople$.pipe( this.subs.push(this.ePeople$.pipe(
switchMap((epeople: PaginatedList<EPerson>) => { switchMap((epeople: PaginatedList<EPerson>) => {
if (epeople.pageInfo.totalElements > 0) { if (epeople.pageInfo.totalElements > 0) {
return combineLatest([...epeople.page.map((eperson: EPerson) => { return combineLatest(epeople.page.map((eperson: EPerson) => {
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe( return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
map((authorized) => { map((authorized) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
@@ -149,7 +140,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
return epersonDtoModel; return epersonDtoModel;
}) })
); );
})]).pipe(map((dtos: EpersonDtoModel[]) => { })).pipe(map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epeople.pageInfo, dtos); return buildPaginatedList(epeople.pageInfo, dtos);
})); }));
} else { } else {
@@ -176,14 +167,14 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
const query: string = data.query; const query: string = data.query;
const scope: string = data.scope; const scope: string = data.scope;
if (query != null && this.currentSearchQuery !== query) { if (query != null && this.currentSearchQuery !== query) {
this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { void this.router.navigate([getEPersonsRoute()], {
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
this.currentSearchQuery = query; this.currentSearchQuery = query;
this.paginationService.resetPage(this.config.id); this.paginationService.resetPage(this.config.id);
} }
if (scope != null && this.currentSearchScope !== scope) { if (scope != null && this.currentSearchScope !== scope) {
this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], { void this.router.navigate([getEPersonsRoute()], {
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
this.currentSearchScope = scope; this.currentSearchScope = scope;
@@ -221,23 +212,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
return this.epersonService.getActiveEPerson(); 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 * Deletes EPerson, show notification on success/failure & updates EPeople list
*/ */
@@ -258,7 +232,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
if (restResponse.hasSucceeded) { if (restResponse.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: this.dsoNameService.getName(ePerson)})); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: this.dsoNameService.getName(ePerson)}));
} else { } else {
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.errorMessage); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { id: ePerson.id, statusCode: restResponse.statusCode, errorMessage: restResponse.errorMessage }));
} }
}); });
} }
@@ -280,16 +254,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
} }
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 * Reset all input-fields to be empty and search all search
*/ */
@@ -300,20 +264,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.search({query: ''}); this.search({query: ''});
} }
/** getEditEPeoplePage(id: string): string {
* This method will set everything to stale, which will cause the lists on this page to update. return getEPersonEditRoute(id);
*/
reset(): void {
this.epersonService.getBrowseEndpoint().pipe(
take(1),
switchMap((href: string) => {
return this.requestService.setStaleByHrefSubstring(href).pipe(
take(1),
);
})
).subscribe(()=>{
this.epersonService.cancelEditEPerson();
this.isEPersonFormShown = false;
});
} }
} }

View File

@@ -1,89 +1,97 @@
<div *ngIf="epersonService.getActiveEPerson() | async; then editheader; else createHeader"></div> <div class="container">
<div class="group-form row">
<div class="col-12">
<ng-template #createHeader> <div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div>
<h4>{{messagePrefix + '.create' | translate}}</h4>
</ng-template>
<ng-template #editheader> <ng-template #createHeader>
<h4>{{messagePrefix + '.edit' | translate}}</h4> <h2 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h2>
</ng-template> </ng-template>
<ds-form [formId]="formId" <ng-template #editHeader>
[formModel]="formModel" <h2 class="border-bottom pb-2">{{messagePrefix + '.edit' | translate}}</h2>
[formGroup]="formGroup" </ng-template>
[formLayout]="formLayout"
[displayCancel]="false"
[submitLabel]="submitLabel"
(submitForm)="onSubmit()">
<div before class="btn-group">
<button (click)="onCancel()" type="button"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div>
<div *ngIf="displayResetPassword" between class="btn-group">
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" type="button" (click)="resetPassword()">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button>
</div>
<div between class="btn-group ml-1">
<button *ngIf="!isImpersonated" class="btn btn-primary" type="button" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.impersonate' | translate}}
</button>
<button *ngIf="isImpersonated" class="btn btn-primary" type="button" (click)="stopImpersonating()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.stop-impersonating' | translate}}
</button>
</div>
<button after class="btn btn-danger delete-button" type="button" [disabled]="!(canDelete$ | async)" (click)="delete()">
<i class="fas fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
</button>
</ds-form>
<ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading> <ds-form [formId]="formId"
[formModel]="formModel"
[formGroup]="formGroup"
[formLayout]="formLayout"
[displayCancel]="false"
[submitLabel]="submitLabel"
(submitForm)="onSubmit()">
<div before class="btn-group">
<button (click)="onCancel()" type="button" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}
</button>
</div>
<div *ngIf="displayResetPassword" between class="btn-group">
<button class="btn btn-primary" [disabled]="!(canReset$ | async)" type="button" (click)="resetPassword()">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button>
</div>
<div *ngIf="canImpersonate$ | async" between class="btn-group">
<button *ngIf="!isImpersonated" class="btn btn-primary" type="button" (click)="impersonate()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.impersonate' | translate}}
</button>
<button *ngIf="isImpersonated" class="btn btn-primary" type="button" (click)="stopImpersonating()">
<i class="fa fa-user-secret"></i> {{'admin.access-control.epeople.actions.stop-impersonating' | translate}}
</button>
</div>
<button *ngIf="canDelete$ | async" after class="btn btn-danger delete-button" type="button" (click)="delete()">
<i class="fas fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
</button>
</ds-form>
<div *ngIf="epersonService.getActiveEPerson() | async"> <ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
<ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading> <div *ngIf="epersonService.getActiveEPerson() | async">
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
<ds-pagination <ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading>
*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"> <ds-pagination
<table id="groups" class="table table-striped table-hover table-bordered"> *ngIf="(groups | async)?.payload?.totalElements > 0"
<thead> [paginationOptions]="config"
<tr> [pageInfoState]="(groups | async)?.payload"
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th> [collectionSize]="(groups | async)?.payload?.totalElements"
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th> [hideGear]="true"
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th> [hidePagerWhenSinglePage]="true"
</tr> (pageChange)="onPageChange($event)">
</thead>
<tbody>
<tr *ngFor="let group of (groups | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupsDataService.startEditingNewGroup(group)"
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination> <div class="table-responsive">
<table id="groups" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (groups | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupsDataService.startEditingNewGroup(group)"
[routerLink]="[groupsDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName(undefined) }}</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert"> </ds-pagination>
<div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
<div> <div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]" <div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button> <div>
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]"
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -34,6 +34,10 @@ import { EpersonRegistrationService } from '../../../core/data/eperson-registrat
import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component';
import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { FormComponent } from '../../../shared/form/form.component'; import { FormComponent } from '../../../shared/form/form.component';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { ActivatedRoute, Router } from '@angular/router';
import { RouterStub } from '../../../shared/testing/router.stub';
import { ActivatedRouteStub } from '../../../shared/testing/active-router.stub';
describe('EPersonFormComponent', () => { describe('EPersonFormComponent', () => {
let component: EPersonFormComponent; let component: EPersonFormComponent;
@@ -46,6 +50,8 @@ describe('EPersonFormComponent', () => {
let authorizationService: AuthorizationDataService; let authorizationService: AuthorizationDataService;
let groupsDataService: GroupDataService; let groupsDataService: GroupDataService;
let epersonRegistrationService: EpersonRegistrationService; let epersonRegistrationService: EpersonRegistrationService;
let route: ActivatedRouteStub;
let router: RouterStub;
let paginationService; let paginationService;
@@ -109,6 +115,9 @@ describe('EPersonFormComponent', () => {
}, },
getEPersonByEmail(email): Observable<RemoteData<EPerson>> { getEPersonByEmail(email): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null); return createSuccessfulRemoteDataObject$(null);
},
findById(_id: string, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig<EPerson>[]): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null);
} }
}; };
builderService = Object.assign(getMockFormBuilderService(),{ builderService = Object.assign(getMockFormBuilderService(),{
@@ -185,6 +194,8 @@ describe('EPersonFormComponent', () => {
}); });
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();
route = new ActivatedRouteStub();
router = new RouterStub();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({ TranslateModule.forRoot({
@@ -203,6 +214,8 @@ describe('EPersonFormComponent', () => {
{ provide: PaginationService, useValue: paginationService }, { provide: PaginationService, useValue: paginationService },
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) }, { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) },
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService }, { provide: EpersonRegistrationService, useValue: epersonRegistrationService },
{ provide: ActivatedRoute, useValue: route },
{ provide: Router, useValue: router },
EPeopleRegistryComponent EPeopleRegistryComponent
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -268,24 +281,18 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
describe('firstName, lastName and email should be required', () => { describe('firstName, lastName and email should be required', () => {
it('form should be invalid because the firstName is required', waitForAsync(() => { it('form should be invalid because the firstName is required', () => {
fixture.whenStable().then(() => { expect(component.formGroup.controls.firstName.valid).toBeFalse();
expect(component.formGroup.controls.firstName.valid).toBeFalse(); expect(component.formGroup.controls.firstName.errors.required).toBeTrue();
expect(component.formGroup.controls.firstName.errors.required).toBeTrue(); });
}); it('form should be invalid because the lastName is required', () => {
})); expect(component.formGroup.controls.lastName.valid).toBeFalse();
it('form should be invalid because the lastName is required', waitForAsync(() => { expect(component.formGroup.controls.lastName.errors.required).toBeTrue();
fixture.whenStable().then(() => { });
expect(component.formGroup.controls.lastName.valid).toBeFalse(); it('form should be invalid because the email is required', () => {
expect(component.formGroup.controls.lastName.errors.required).toBeTrue(); expect(component.formGroup.controls.email.valid).toBeFalse();
}); expect(component.formGroup.controls.email.errors.required).toBeTrue();
})); });
it('form should be invalid because the email is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.required).toBeTrue();
});
}));
}); });
describe('after inserting information firstName,lastName and email not required', () => { describe('after inserting information firstName,lastName and email not required', () => {
@@ -295,24 +302,18 @@ describe('EPersonFormComponent', () => {
component.formGroup.controls.email.setValue('test@test.com'); component.formGroup.controls.email.setValue('test@test.com');
fixture.detectChanges(); fixture.detectChanges();
}); });
it('firstName should be valid because the firstName is set', waitForAsync(() => { it('firstName should be valid because the firstName is set', () => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.firstName.valid).toBeTrue(); expect(component.formGroup.controls.firstName.valid).toBeTrue();
expect(component.formGroup.controls.firstName.errors).toBeNull(); expect(component.formGroup.controls.firstName.errors).toBeNull();
}); });
})); it('lastName should be valid because the lastName is set', () => {
it('lastName should be valid because the lastName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.lastName.valid).toBeTrue(); expect(component.formGroup.controls.lastName.valid).toBeTrue();
expect(component.formGroup.controls.lastName.errors).toBeNull(); expect(component.formGroup.controls.lastName.errors).toBeNull();
}); });
})); it('email should be valid because the email is set', () => {
it('email should be valid because the email is set', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeTrue(); expect(component.formGroup.controls.email.valid).toBeTrue();
expect(component.formGroup.controls.email.errors).toBeNull(); expect(component.formGroup.controls.email.errors).toBeNull();
}); });
}));
}); });
@@ -321,12 +322,10 @@ describe('EPersonFormComponent', () => {
component.formGroup.controls.email.setValue('test@test'); component.formGroup.controls.email.setValue('test@test');
fixture.detectChanges(); fixture.detectChanges();
}); });
it('email should not be valid because the email pattern', waitForAsync(() => { it('email should not be valid because the email pattern', () => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse(); expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.pattern).toBeTruthy(); expect(component.formGroup.controls.email.errors.pattern).toBeTruthy();
}); });
}));
}); });
describe('after already utilized email', () => { describe('after already utilized email', () => {
@@ -341,12 +340,10 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('email should not be valid because email is already taken', waitForAsync(() => { it('email should not be valid because email is already taken', () => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.email.valid).toBeFalse(); expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy(); expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
}); });
}));
}); });
@@ -398,11 +395,9 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should emit a new eperson using the correct values', waitForAsync(() => { it('should emit a new eperson using the correct values', () => {
fixture.whenStable().then(() => { expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
expect(component.submitForm.emit).toHaveBeenCalledWith(expected); });
});
}));
}); });
describe('with an active eperson', () => { describe('with an active eperson', () => {
@@ -433,11 +428,9 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should emit the existing eperson using the correct values', waitForAsync(() => { it('should emit the existing eperson using the correct values', () => {
fixture.whenStable().then(() => { expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId); });
});
}));
}); });
}); });
@@ -496,16 +489,16 @@ describe('EPersonFormComponent', () => {
}); });
it('the delete button should be active if the eperson can be deleted', () => { it('the delete button should be visible if the ePerson can be deleted', () => {
const deleteButton = fixture.debugElement.query(By.css('.delete-button')); const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(false); expect(deleteButton).not.toBeNull();
}); });
it('the delete button should be disabled if the eperson cannot be deleted', () => { it('the delete button should be hidden if the ePerson cannot be deleted', () => {
component.canDelete$ = observableOf(false); component.canDelete$ = observableOf(false);
fixture.detectChanges(); fixture.detectChanges();
const deleteButton = fixture.debugElement.query(By.css('.delete-button')); const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(true); expect(deleteButton).toBeNull();
}); });
it('should call the epersonFormComponent delete when clicked on the button', () => { it('should call the epersonFormComponent delete when clicked on the button', () => {

View File

@@ -43,6 +43,8 @@ import { AsyncPipe, NgClass, NgIf } from '@angular/common';
import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component';
import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { getEPersonsRoute } from '../../access-control-routing-paths';
@Component({ @Component({
selector: 'ds-eperson-form', selector: 'ds-eperson-form',
@@ -210,6 +212,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
public requestService: RequestService, public requestService: RequestService,
private epersonRegistrationService: EpersonRegistrationService, private epersonRegistrationService: EpersonRegistrationService,
public dsoNameService: DSONameService, public dsoNameService: DSONameService,
protected route: ActivatedRoute,
protected router: Router,
) { ) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.epersonInitial = eperson; this.epersonInitial = eperson;
@@ -229,7 +233,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* This method will initialise the page * This method will initialise the page
*/ */
initialisePage() { initialisePage() {
this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData<EPerson>) => {
this.epersonService.editEPerson(ePersonRD.payload);
}));
observableCombineLatest([ observableCombineLatest([
this.translateService.get(`${this.messagePrefix}.firstName`), this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`), this.translateService.get(`${this.messagePrefix}.lastName`),
@@ -355,6 +361,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
onCancel() { onCancel() {
this.epersonService.cancelEditEPerson(); this.epersonService.cancelEditEPerson();
this.cancelForm.emit(); this.cancelForm.emit();
void this.router.navigate([getEPersonsRoute()]);
} }
/** /**
@@ -406,6 +413,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: this.dsoNameService.getName(ePersonToCreate) })); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: this.dsoNameService.getName(ePersonToCreate) }));
this.submitForm.emit(ePersonToCreate); this.submitForm.emit(ePersonToCreate);
this.epersonService.clearEPersonRequests();
void this.router.navigateByUrl(getEPersonsRoute());
} else { } else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: this.dsoNameService.getName(ePersonToCreate) })); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: this.dsoNameService.getName(ePersonToCreate) }));
this.cancelForm.emit(); this.cancelForm.emit();
@@ -445,6 +454,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
if (rd.hasSucceeded) { if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: this.dsoNameService.getName(editedEperson) })); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: this.dsoNameService.getName(editedEperson) }));
this.submitForm.emit(editedEperson); this.submitForm.emit(editedEperson);
void this.router.navigateByUrl(getEPersonsRoute());
} else { } else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: this.dsoNameService.getName(editedEperson) })); this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: this.dsoNameService.getName(editedEperson) }));
this.cancelForm.emit(); this.cancelForm.emit();
@@ -511,6 +521,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
).subscribe(({ restResponse, eperson }: { restResponse: RemoteData<NoContent> | null, eperson: EPerson }) => { ).subscribe(({ restResponse, eperson }: { restResponse: RemoteData<NoContent> | null, eperson: EPerson }) => {
if (restResponse?.hasSucceeded) { if (restResponse?.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) })); this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) }));
void this.router.navigate([getEPersonsRoute()]);
} else { } else {
this.notificationsService.error(`Error occurred when trying to delete EPerson with id: ${eperson?.id} with code: ${restResponse?.statusCode} and message: ${restResponse?.errorMessage}`); this.notificationsService.error(`Error occurred when trying to delete EPerson with id: ${eperson?.id} with code: ${restResponse?.statusCode} and message: ${restResponse?.errorMessage}`);
} }
@@ -557,16 +568,6 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
} }
} }
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
this.requestService.removeByHrefSubstring(eperson.self);
});
this.initialisePage();
}
/** /**
* Checks for the given ePerson if there is already an ePerson in the system with that email * Checks for the given ePerson if there is already an ePerson in the system with that email
* and shows notification if this is the case * and shows notification if this is the case

View File

@@ -0,0 +1,53 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { RemoteData } from '../../core/data/remote-data';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { ResolvedAction } from '../../core/resolving/resolver.actions';
import { EPersonDataService } from '../../core/eperson/eperson-data.service';
import { Store } from '@ngrx/store';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
export const EPERSON_EDIT_FOLLOW_LINKS: FollowLinkConfig<EPerson>[] = [
followLink('groups'),
];
/**
* This class represents a resolver that requests a specific {@link EPerson} before the route is activated
*/
@Injectable({
providedIn: 'root',
})
export class EPersonResolver implements Resolve<RemoteData<EPerson>> {
constructor(
protected ePersonService: EPersonDataService,
protected store: Store<any>,
) {
}
/**
* Method for resolving a {@link EPerson} based on the parameters in the current route
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns `Observable<<RemoteData<EPerson>>` Emits the found {@link EPerson} based on the parameters in the current
* route, or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<EPerson>> {
const ePersonRD$: Observable<RemoteData<EPerson>> = this.ePersonService.findById(route.params.id,
true,
false,
...EPERSON_EDIT_FOLLOW_LINKS,
).pipe(
getFirstCompletedRemoteData(),
);
ePersonRD$.subscribe((ePersonRD: RemoteData<EPerson>) => {
this.store.dispatch(new ResolvedAction(state.url, ePersonRD.payload));
});
return ePersonRD$;
}
}

View File

@@ -2,13 +2,13 @@
<div class="group-form row"> <div class="group-form row">
<div class="col-12"> <div class="col-12">
<div *ngIf="groupDataService.getActiveGroup() | async; then editheader; else createHeader"></div> <div *ngIf="groupDataService.getActiveGroup() | async; then editHeader; else createHeader"></div>
<ng-template #createHeader> <ng-template #createHeader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h2> <h2 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h2>
</ng-template> </ng-template>
<ng-template #editheader> <ng-template #editHeader>
<h2 class="border-bottom pb-2"> <h2 class="border-bottom pb-2">
<span <span
*dsContextHelp="{ *dsContextHelp="{
@@ -39,7 +39,7 @@
<button (click)="onCancel()" type="button" <button (click)="onCancel()" type="button"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button> class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</div> </div>
<div after *ngIf="groupBeingEdited != null" class="btn-group"> <div after *ngIf="(canEdit$ | async) && !groupBeingEdited.permanent" class="btn-group">
<button class="btn btn-danger delete-button" [disabled]="!(canEdit$ | async) || groupBeingEdited.permanent" <button class="btn btn-danger delete-button" [disabled]="!(canEdit$ | async) || groupBeingEdited.permanent"
(click)="delete()" type="button"> (click)="delete()" type="button">
<i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}} <i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}}

View File

@@ -10,7 +10,6 @@ import {
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { import {
ObservedValueOf,
combineLatest as observableCombineLatest, combineLatest as observableCombineLatest,
Observable, Observable,
of as observableOf, of as observableOf,
@@ -54,6 +53,7 @@ import { AsyncPipe, NgIf } from '@angular/common';
import { ContextHelpDirective } from '../../../shared/context-help.directive'; import { ContextHelpDirective } from '../../../shared/context-help.directive';
import { MembersListComponent } from './members-list/members-list.component'; import { MembersListComponent } from './members-list/members-list.component';
import { SubgroupsListComponent } from './subgroup-list/subgroups-list.component'; import { SubgroupsListComponent } from './subgroup-list/subgroups-list.component';
import { getGroupEditRoute, getGroupsRoute } from '../../access-control-routing-paths';
@Component({ @Component({
selector: 'ds-group-form', selector: 'ds-group-form',
@@ -182,19 +182,19 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.canEdit$ = this.groupDataService.getActiveGroup().pipe( this.canEdit$ = this.groupDataService.getActiveGroup().pipe(
hasValueOperator(), hasValueOperator(),
switchMap((group: Group) => { switchMap((group: Group) => {
return observableCombineLatest( return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.CanDelete, isNotEmpty(group) ? group.self : undefined), this.authorizationService.isAuthorized(FeatureID.CanDelete, isNotEmpty(group) ? group.self : undefined),
this.hasLinkedDSO(group), this.hasLinkedDSO(group),
(isAuthorized: ObservedValueOf<Observable<boolean>>, hasLinkedDSO: ObservedValueOf<Observable<boolean>>) => { ]).pipe(
return isAuthorized && !hasLinkedDSO; map(([isAuthorized, hasLinkedDSO]: [boolean, boolean]) => isAuthorized && !hasLinkedDSO),
}); );
}) }),
); );
observableCombineLatest( observableCombineLatest([
this.translateService.get(`${this.messagePrefix}.groupName`), this.translateService.get(`${this.messagePrefix}.groupName`),
this.translateService.get(`${this.messagePrefix}.groupCommunity`), this.translateService.get(`${this.messagePrefix}.groupCommunity`),
this.translateService.get(`${this.messagePrefix}.groupDescription`) this.translateService.get(`${this.messagePrefix}.groupDescription`)
).subscribe(([groupName, groupCommunity, groupDescription]) => { ]).subscribe(([groupName, groupCommunity, groupDescription]) => {
this.groupName = new DynamicInputModel({ this.groupName = new DynamicInputModel({
id: 'groupName', id: 'groupName',
label: groupName, label: groupName,
@@ -232,12 +232,12 @@ export class GroupFormComponent implements OnInit, OnDestroy {
} }
this.subs.push( this.subs.push(
observableCombineLatest( observableCombineLatest([
this.groupDataService.getActiveGroup(), this.groupDataService.getActiveGroup(),
this.canEdit$, this.canEdit$,
this.groupDataService.getActiveGroup() this.groupDataService.getActiveGroup()
.pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload()))) .pipe(filter((activeGroup) => hasValue(activeGroup)),switchMap((activeGroup) => this.getLinkedDSO(activeGroup).pipe(getFirstSucceededRemoteDataPayload())))
).subscribe(([activeGroup, canEdit, linkedObject]) => { ]).subscribe(([activeGroup, canEdit, linkedObject]) => {
if (activeGroup != null) { if (activeGroup != null) {
@@ -247,12 +247,14 @@ export class GroupFormComponent implements OnInit, OnDestroy {
this.groupBeingEdited = activeGroup; this.groupBeingEdited = activeGroup;
if (linkedObject?.name) { if (linkedObject?.name) {
this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity); if (!this.formGroup.controls.groupCommunity) {
this.formGroup.patchValue({ this.formBuilderService.insertFormGroupControl(1, this.formGroup, this.formModel, this.groupCommunity);
groupName: activeGroup.name, this.formGroup.patchValue({
groupCommunity: linkedObject?.name ?? '', groupName: activeGroup.name,
groupDescription: activeGroup.firstMetadataValue('dc.description'), groupCommunity: linkedObject?.name ?? '',
}); groupDescription: activeGroup.firstMetadataValue('dc.description'),
});
}
} else { } else {
this.formModel = [ this.formModel = [
this.groupName, this.groupName,
@@ -280,7 +282,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
onCancel() { onCancel() {
this.groupDataService.cancelEditGroup(); this.groupDataService.cancelEditGroup();
this.cancelForm.emit(); this.cancelForm.emit();
this.router.navigate([this.groupDataService.getGroupRegistryRouterLink()]); void this.router.navigate([getGroupsRoute()]);
} }
/** /**
@@ -327,7 +329,7 @@ export class GroupFormComponent implements OnInit, OnDestroy {
const groupSelfLink = rd.payload._links.self.href; const groupSelfLink = rd.payload._links.self.href;
this.setActiveGroupWithLink(groupSelfLink); this.setActiveGroupWithLink(groupSelfLink);
this.groupDataService.clearGroupsRequests(); this.groupDataService.clearGroupsRequests();
this.router.navigateByUrl(this.groupDataService.getGroupEditPageRouterLinkWithID(rd.payload.uuid)); void this.router.navigateByUrl(getGroupEditRoute(rd.payload.uuid));
} }
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.created.failure', { name: groupToCreate.name })); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.created.failure', { name: groupToCreate.name }));

View File

@@ -1,6 +1,60 @@
<ng-container> <ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3> <h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4>{{messagePrefix + '.headMembers' | translate}}</h4>
<ds-pagination *ngIf="(ePeopleMembersOfGroup | async)?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(ePeopleMembersOfGroup | async)"
[collectionSize]="(ePeopleMembersOfGroup | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let eperson of (ePeopleMembersOfGroup | async)?.page">
<td class="align-middle">{{eperson.id}}</td>
<td class="align-middle">
<a (click)="ePersonDataService.startEditingNewEPerson(eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(eperson) }}
</a>
</td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button (click)="deleteMemberFromGroup(eperson)"
[disabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(ePeopleMembersOfGroup | async) == undefined || (ePeopleMembersOfGroup | async)?.totalElements == 0" class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-members-yet' | translate}}
</div>
<h4 id="search" class="border-bottom pb-2"> <h4 id="search" class="border-bottom pb-2">
<span <span
*dsContextHelp="{ *dsContextHelp="{
@@ -15,14 +69,8 @@
</h4> </h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between"> <form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div> <div class="flex-grow-1 mr-3">
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope"> <div class="form-group input-group mr-3">
<option value="metadata">{{messagePrefix + '.search.scope.metadata' | translate}}</option>
<option value="email">{{messagePrefix + '.search.scope.email' | translate}}</option>
</select>
</div>
<div class="flex-grow-1 mr-3 ml-3">
<div class="form-group input-group">
<input type="text" name="query" id="query" formControlName="query" <input type="text" name="query" id="query" formControlName="query"
class="form-control" aria-label="Search input"> class="form-control" aria-label="Search input">
<span class="input-group-append"> <span class="input-group-append">
@@ -37,10 +85,10 @@
</div> </div>
</form> </form>
<ds-pagination *ngIf="(ePeopleSearchDtos | async)?.totalElements > 0" <ds-pagination *ngIf="(ePeopleSearch | async)?.totalElements > 0"
[paginationOptions]="configSearch" [paginationOptions]="configSearch"
[pageInfoState]="(ePeopleSearchDtos | async)" [pageInfoState]="(ePeopleSearch | async)"
[collectionSize]="(ePeopleSearchDtos | async)?.totalElements" [collectionSize]="(ePeopleSearch | async)?.totalElements"
[hideGear]="true" [hideGear]="true"
[hidePagerWhenSinglePage]="true"> [hidePagerWhenSinglePage]="true">
@@ -55,33 +103,24 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let ePerson of (ePeopleSearchDtos | async)?.page"> <tr *ngFor="let eperson of (ePeopleSearch | async)?.page">
<td class="align-middle">{{ePerson.eperson.id}}</td> <td class="align-middle">{{eperson.id}}</td>
<td class="align-middle"> <td class="align-middle">
<a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)" <a (click)="ePersonDataService.startEditingNewEPerson(eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]"> [routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(ePerson.eperson) }} {{ dsoNameService.getName(eperson) }}
</a> </a>
</td> </td>
<td class="align-middle"> <td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/> {{messagePrefix + '.table.email' | translate}}: {{ eperson.email ? eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }} {{messagePrefix + '.table.netid' | translate}}: {{ eperson.netid ? eperson.netid : '-' }}
</td> </td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button *ngIf="ePerson.memberOfGroup" <button (click)="addMemberToGroup(eperson)"
(click)="deleteMemberFromGroup(ePerson)"
[disabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i>
</button>
<button *ngIf="!ePerson.memberOfGroup"
(click)="addMemberToGroup(ePerson)"
[disabled]="actionConfig.add.disabled" [disabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]" [ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}"> title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i> <i [ngClass]="actionConfig.add.icon"></i>
</button> </button>
</div> </div>
@@ -93,72 +132,10 @@
</ds-pagination> </ds-pagination>
<div *ngIf="(ePeopleSearchDtos | async)?.totalElements == 0 && searchDone" <div *ngIf="(ePeopleSearch | async)?.totalElements == 0 && searchDone"
class="alert alert-info w-100 mb-2" class="alert alert-info w-100 mb-2"
role="alert"> role="alert">
{{messagePrefix + '.no-items' | translate}} {{messagePrefix + '.no-items' | translate}}
</div> </div>
<h4>{{messagePrefix + '.headMembers' | translate}}</h4>
<ds-pagination *ngIf="(ePeopleMembersOfGroupDtos | async)?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(ePeopleMembersOfGroupDtos | async)"
[collectionSize]="(ePeopleMembersOfGroupDtos | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="ePeopleMembersOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.identity' | translate}}</th>
<th class="align-middle">{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let ePerson of (ePeopleMembersOfGroupDtos | async)?.page">
<td class="align-middle">{{ePerson.eperson.id}}</td>
<td class="align-middle">
<a (click)="ePersonDataService.startEditingNewEPerson(ePerson.eperson)"
[routerLink]="[ePersonDataService.getEPeoplePageRouterLink()]">
{{ dsoNameService.getName(ePerson.eperson) }}
</a>
</td>
<td class="align-middle">
{{messagePrefix + '.table.email' | translate}}: {{ ePerson.eperson.email ? ePerson.eperson.email : '-' }}<br/>
{{messagePrefix + '.table.netid' | translate}}: {{ ePerson.eperson.netid ? ePerson.eperson.netid : '-' }}
</td>
<td class="align-middle">
<div class="btn-group edit-field">
<button *ngIf="ePerson.memberOfGroup"
(click)="deleteMemberFromGroup(ePerson)"
[disabled]="actionConfig.remove.disabled"
[ngClass]="['btn btn-sm', actionConfig.remove.css]"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
<i [ngClass]="actionConfig.remove.icon"></i>
</button>
<button *ngIf="!ePerson.memberOfGroup"
(click)="addMemberToGroup(ePerson)"
[disabled]="actionConfig.add.disabled"
[ngClass]="['btn btn-sm', actionConfig.add.css]"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(ePerson.eperson) } }}">
<i [ngClass]="actionConfig.add.icon"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(ePeopleMembersOfGroupDtos | async) == undefined || (ePeopleMembersOfGroupDtos | async)?.totalElements == 0" class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-members-yet' | translate}}
</div>
</ng-container> </ng-container>

View File

@@ -17,7 +17,7 @@ import { Group } from '../../../../core/eperson/models/group.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 { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock'; import { GroupMock } from '../../../../shared/testing/group-mock';
import { MembersListComponent } from './members-list.component'; import { MembersListComponent } from './members-list.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';
@@ -42,28 +42,26 @@ describe('MembersListComponent', () => {
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let groupsDataServiceStub: any; let groupsDataServiceStub: any;
let activeGroup; let activeGroup;
let allEPersons: EPerson[];
let allGroups: Group[];
let epersonMembers: EPerson[]; let epersonMembers: EPerson[];
let subgroupMembers: Group[]; let epersonNonMembers: EPerson[];
let paginationService; let paginationService;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
activeGroup = GroupMock; activeGroup = GroupMock;
epersonMembers = [EPersonMock2]; epersonMembers = [EPersonMock2];
subgroupMembers = [GroupMock2]; epersonNonMembers = [EPersonMock];
allEPersons = [EPersonMock, EPersonMock2];
allGroups = [GroupMock, GroupMock2];
ePersonDataServiceStub = { ePersonDataServiceStub = {
activeGroup: activeGroup, activeGroup: activeGroup,
epersonMembers: epersonMembers, epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers, epersonNonMembers: epersonNonMembers,
// This method is used to get all the current members
findListByHref(_href: string): Observable<RemoteData<PaginatedList<EPerson>>> { findListByHref(_href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()));
}, },
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> { // This method is used to search across *non-members*
searchNonMembers(query: string, group: string): Observable<RemoteData<PaginatedList<EPerson>>> {
if (query === '') { if (query === '') {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allEPersons)); return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), epersonNonMembers));
} }
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
}, },
@@ -80,22 +78,22 @@ describe('MembersListComponent', () => {
groupsDataServiceStub = { groupsDataServiceStub = {
activeGroup: activeGroup, activeGroup: activeGroup,
epersonMembers: epersonMembers, epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers, epersonNonMembers: epersonNonMembers,
allGroups: allGroups,
getActiveGroup(): Observable<Group> { getActiveGroup(): Observable<Group> {
return observableOf(activeGroup); return observableOf(activeGroup);
}, },
getEPersonMembers() { getEPersonMembers() {
return this.epersonMembers; return this.epersonMembers;
}, },
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> { addMemberToGroup(parentGroup, epersonToAdd: EPerson): Observable<RestResponse> {
if (query === '') { // Add eperson to list of members
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), this.allGroups)); this.epersonMembers = [...this.epersonMembers, epersonToAdd];
} // Remove eperson from list of non-members
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); this.epersonNonMembers.forEach( (eperson: EPerson, index: number) => {
}, if (eperson.id === epersonToAdd.id) {
addMemberToGroup(parentGroup, eperson: EPerson): Observable<RestResponse> { this.epersonNonMembers.splice(index, 1);
this.epersonMembers = [...this.epersonMembers, eperson]; }
});
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
}, },
clearGroupsRequests() { clearGroupsRequests() {
@@ -108,14 +106,14 @@ describe('MembersListComponent', () => {
return '/access-control/groups/' + group.id; return '/access-control/groups/' + group.id;
}, },
deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable<RestResponse> { deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable<RestResponse> {
this.epersonMembers = this.epersonMembers.find((eperson: EPerson) => { // Remove eperson from list of members
if (eperson.id !== epersonToDelete.id) { this.epersonMembers.forEach( (eperson: EPerson, index: number) => {
return eperson; if (eperson.id === epersonToDelete.id) {
this.epersonMembers.splice(index, 1);
} }
}); });
if (this.epersonMembers === undefined) { // Add eperson to list of non-members
this.epersonMembers = []; this.epersonNonMembers = [...this.epersonNonMembers, epersonToDelete];
}
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
} }
}; };
@@ -168,13 +166,37 @@ describe('MembersListComponent', () => {
expect(comp).toBeDefined(); expect(comp).toBeDefined();
})); }));
it('should show list of eperson members of current active group', () => { describe('current members list', () => {
const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child')); it('should show list of eperson members of current active group', () => {
expect(epersonIdsFound.length).toEqual(1); const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child'));
epersonMembers.map((eperson: EPerson) => { expect(epersonIdsFound.length).toEqual(1);
expect(epersonIdsFound.find((foundEl) => { epersonMembers.map((eperson: EPerson) => {
return (foundEl.nativeElement.textContent.trim() === eperson.uuid); expect(epersonIdsFound.find((foundEl) => {
})).toBeTruthy(); return (foundEl.nativeElement.textContent.trim() === eperson.uuid);
})).toBeTruthy();
});
});
it('should show a delete button next to each member', () => {
const epersonsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tbody tr'));
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
});
});
describe('if first delete button is pressed', () => {
beforeEach(() => {
const deleteButton: DebugElement = fixture.debugElement.query(By.css('#ePeopleMembersOfGroup tbody .fa-trash-alt'));
deleteButton.nativeElement.click();
fixture.detectChanges();
});
it('then no ePerson remains as a member of the active group.', () => {
const epersonsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tbody tr'));
expect(epersonsFound.length).toEqual(0);
});
}); });
}); });
@@ -182,76 +204,40 @@ describe('MembersListComponent', () => {
describe('when searching without query', () => { describe('when searching without query', () => {
let epersonsFound: DebugElement[]; let epersonsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
spyOn(component, 'isMemberOfGroup').and.callFake((ePerson: EPerson) => {
return observableOf(activeGroup.epersons.includes(ePerson));
});
component.search({ scope: 'metadata', query: '' }); component.search({ scope: 'metadata', query: '' });
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
// Stop using the fake spy function (because otherwise the clicking on the buttons will not change anything
// because they don't change the value of activeGroup.epersons)
jasmine.getEnv().allowRespy(true);
spyOn(component, 'isMemberOfGroup').and.callThrough();
})); }));
it('should display all epersons', () => { it('should display only non-members of the group', () => {
expect(epersonsFound.length).toEqual(2); const epersonIdsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr td:first-child'));
expect(epersonIdsFound.length).toEqual(1);
epersonNonMembers.map((eperson: EPerson) => {
expect(epersonIdsFound.find((foundEl) => {
return (foundEl.nativeElement.textContent.trim() === eperson.uuid);
})).toBeTruthy();
});
}); });
describe('if eperson is already a eperson', () => { it('should display an add button next to non-members, not a delete button', () => {
it('should have delete button, else it should have add button', () => { epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const memberIds: string[] = activeGroup.epersons.map((ePerson: EPerson) => ePerson.id); const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
epersonsFound.map((foundEPersonRowElement: DebugElement) => { const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
const epersonId: DebugElement = foundEPersonRowElement.query(By.css('td:first-child')); expect(addButton).not.toBeNull();
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus')); expect(deleteButton).toBeNull();
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
if (memberIds.includes(epersonId.nativeElement.textContent)) {
expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
} else {
expect(deleteButton).toBeNull();
expect(addButton).not.toBeNull();
}
});
}); });
}); });
describe('if first add button is pressed', () => { describe('if first add button is pressed', () => {
beforeEach(fakeAsync(() => { beforeEach(() => {
const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus')); const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
addButton.nativeElement.click(); addButton.nativeElement.click();
tick();
fixture.detectChanges(); fixture.detectChanges();
}));
it('then all the ePersons are member of the active group', () => {
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
expect(epersonsFound.length).toEqual(2);
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
});
}); });
}); it('then all (two) ePersons are member of the active group. No non-members left', () => {
describe('if first delete button is pressed', () => {
beforeEach(fakeAsync(() => {
const deleteButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-trash-alt'));
deleteButton.nativeElement.click();
tick();
fixture.detectChanges();
}));
it('then no ePerson is member of the active group', () => {
epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr')); epersonsFound = fixture.debugElement.queryAll(By.css('#epersonsSearch tbody tr'));
expect(epersonsFound.length).toEqual(2); expect(epersonsFound.length).toEqual(0);
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const addButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundEPersonRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(deleteButton).toBeNull();
expect(addButton).not.toBeNull();
});
}); });
}); });
}); });

View File

@@ -4,28 +4,23 @@ import { Router, RouterLink } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { import {
Observable, Observable,
of as observableOf,
Subscription, Subscription,
BehaviorSubject, BehaviorSubject
combineLatest as observableCombineLatest,
ObservedValueOf,
} from 'rxjs'; } from 'rxjs';
import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; 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 { 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 { Group } from '../../../../core/eperson/models/group.model';
import { import {
getFirstSucceededRemoteData,
getFirstCompletedRemoteData, getFirstCompletedRemoteData,
getAllCompletedRemoteData, getAllCompletedRemoteData,
getRemoteDataPayload getRemoteDataPayload
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { EpersonDtoModel } from '../../../../core/eperson/models/eperson-dto.model';
import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationService } from '../../../../core/pagination/pagination.service';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { ContextHelpDirective } from '../../../../shared/context-help.directive';
@@ -37,8 +32,8 @@ import { AsyncPipe, NgClass, NgForOf, NgIf } from '@angular/common';
*/ */
enum SubKey { enum SubKey {
ActiveGroup, ActiveGroup,
MembersDTO, Members,
SearchResultsDTO, SearchResults,
} }
/** /**
@@ -111,11 +106,11 @@ export class MembersListComponent implements OnInit, OnDestroy {
/** /**
* EPeople being displayed in search result, initially all members, after search result of search * EPeople being displayed in search result, initially all members, after search result of search
*/ */
ePeopleSearchDtos: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject<PaginatedList<EpersonDtoModel>>(undefined); ePeopleSearch: BehaviorSubject<PaginatedList<EPerson>> = new BehaviorSubject<PaginatedList<EPerson>>(undefined);
/** /**
* List of EPeople members of currently active group being edited * List of EPeople members of currently active group being edited
*/ */
ePeopleMembersOfGroupDtos: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject<PaginatedList<EpersonDtoModel>>(undefined); ePeopleMembersOfGroup: BehaviorSubject<PaginatedList<EPerson>> = new BehaviorSubject<PaginatedList<EPerson>>(undefined);
/** /**
* Pagination config used to display the list of EPeople that are result of EPeople search * Pagination config used to display the list of EPeople that are result of EPeople search
@@ -144,7 +139,6 @@ export class MembersListComponent implements OnInit, OnDestroy {
// Current search in edit group - epeople search form // Current search in edit group - epeople search form
currentSearchQuery: string; currentSearchQuery: string;
currentSearchScope: string;
// Whether or not user has done a EPeople search yet // Whether or not user has done a EPeople search yet
searchDone: boolean; searchDone: boolean;
@@ -163,18 +157,17 @@ export class MembersListComponent implements OnInit, OnDestroy {
public dsoNameService: DSONameService, public dsoNameService: DSONameService,
) { ) {
this.currentSearchQuery = ''; this.currentSearchQuery = '';
this.currentSearchScope = 'metadata';
} }
ngOnInit(): void { ngOnInit(): void {
this.searchForm = this.formBuilder.group(({ this.searchForm = this.formBuilder.group(({
scope: 'metadata',
query: '', query: '',
})); }));
this.subs.set(SubKey.ActiveGroup, this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => { this.subs.set(SubKey.ActiveGroup, this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
if (activeGroup != null) { if (activeGroup != null) {
this.groupBeingEdited = activeGroup; this.groupBeingEdited = activeGroup;
this.retrieveMembers(this.config.currentPage); this.retrieveMembers(this.config.currentPage);
this.search({query: ''});
} }
})); }));
} }
@@ -186,8 +179,8 @@ export class MembersListComponent implements OnInit, OnDestroy {
* @private * @private
*/ */
retrieveMembers(page: number): void { retrieveMembers(page: number): void {
this.unsubFrom(SubKey.MembersDTO); this.unsubFrom(SubKey.Members);
this.subs.set(SubKey.MembersDTO, this.subs.set(SubKey.Members,
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
switchMap((currentPagination) => { switchMap((currentPagination) => {
return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, { return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, {
@@ -204,49 +197,12 @@ export class MembersListComponent implements OnInit, OnDestroy {
return rd; return rd;
} }
}), }),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => { getRemoteDataPayload())
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { .subscribe((paginatedListOfEPersons: PaginatedList<EPerson>) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest( this.ePeopleMembersOfGroup.next(paginatedListOfEPersons);
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
epersonDtoModel.eperson = member;
epersonDtoModel.memberOfGroup = isMember;
return epersonDtoModel;
});
return dto$;
})]);
return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
}));
}))
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
this.ePeopleMembersOfGroupDtos.next(paginatedListOfDTOs);
})); }));
} }
/**
* Whether 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.findListByHref(group._links.epersons.href, {
currentPage: 1,
elementsPerPage: 9999
})
.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map((listEPeopleInGroup: PaginatedList<EPerson>) => listEPeopleInGroup.page.filter((ePersonInList: EPerson) => ePersonInList.id === possibleMember.id)),
map((epeople: EPerson[]) => epeople.length > 0));
} else {
return observableOf(false);
}
}));
}
/** /**
* Unsubscribe from a subscription if it's still subscribed, and remove it from the map of * Unsubscribe from a subscription if it's still subscribed, and remove it from the map of
* active subscriptions * active subscriptions
@@ -263,14 +219,18 @@ export class MembersListComponent implements OnInit, OnDestroy {
/** /**
* Deletes a given EPerson from the members list of the group currently being edited * 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 * @param eperson EPerson we want to delete as member from group that is currently being edited
*/ */
deleteMemberFromGroup(ePerson: EpersonDtoModel) { deleteMemberFromGroup(eperson: EPerson) {
ePerson.memberOfGroup = false;
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson); const response = this.groupDataService.deleteMemberFromGroup(activeGroup, eperson);
this.showNotifications('deleteMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup); this.showNotifications('deleteMember', response, this.dsoNameService.getName(eperson), activeGroup);
// Reload search results (if there is an active query).
// This will potentially add this deleted subgroup into the list of search results.
if (this.currentSearchQuery != null) {
this.search({query: this.currentSearchQuery});
}
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
} }
@@ -279,14 +239,18 @@ export class MembersListComponent implements OnInit, OnDestroy {
/** /**
* Adds a given EPerson to the members list of the group currently being edited * 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 * @param eperson EPerson we want to add as member to group that is currently being edited
*/ */
addMemberToGroup(ePerson: EpersonDtoModel) { addMemberToGroup(eperson: EPerson) {
ePerson.memberOfGroup = true;
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson.eperson); const response = this.groupDataService.addMemberToGroup(activeGroup, eperson);
this.showNotifications('addMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup); this.showNotifications('addMember', response, this.dsoNameService.getName(eperson), activeGroup);
// Reload search results (if there is an active query).
// This will potentially add this deleted subgroup into the list of search results.
if (this.currentSearchQuery != null) {
this.search({query: this.currentSearchQuery});
}
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
} }
@@ -294,37 +258,25 @@ export class MembersListComponent implements OnInit, OnDestroy {
} }
/** /**
* Search in the EPeople by name, email or metadata * Search all EPeople who are NOT a member of the current group by name, email or metadata
* @param data Contains scope and query param * @param data Contains query param
*/ */
search(data: any) { search(data: any) {
this.unsubFrom(SubKey.SearchResultsDTO); this.unsubFrom(SubKey.SearchResults);
this.subs.set(SubKey.SearchResultsDTO, this.subs.set(SubKey.SearchResults,
this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe(
switchMap((paginationOptions) => { switchMap((paginationOptions) => {
const query: string = data.query; const query: string = data.query;
const scope: string = data.scope;
if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) { if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) {
this.router.navigate([], {
queryParamsHandling: 'merge'
});
this.currentSearchQuery = query; this.currentSearchQuery = query;
this.paginationService.resetPage(this.configSearch.id); this.paginationService.resetPage(this.configSearch.id);
} }
if (scope != null && this.currentSearchScope !== scope && this.groupBeingEdited) {
this.router.navigate([], {
queryParamsHandling: 'merge'
});
this.currentSearchScope = scope;
this.paginationService.resetPage(this.configSearch.id);
}
this.searchDone = true; this.searchDone = true;
return this.ePersonDataService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { return this.ePersonDataService.searchNonMembers(this.currentSearchQuery, this.groupBeingEdited.id, {
currentPage: paginationOptions.currentPage, currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize elementsPerPage: paginationOptions.pageSize
}); }, false, true);
}), }),
getAllCompletedRemoteData(), getAllCompletedRemoteData(),
map((rd: RemoteData<any>) => { map((rd: RemoteData<any>) => {
@@ -334,23 +286,9 @@ export class MembersListComponent implements OnInit, OnDestroy {
return rd; return rd;
} }
}), }),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => { getRemoteDataPayload())
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { .subscribe((paginatedListOfEPersons: PaginatedList<EPerson>) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest( this.ePeopleSearch.next(paginatedListOfEPersons);
this.isMemberOfGroup(member), (isMember: ObservedValueOf<Observable<boolean>>) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
epersonDtoModel.eperson = member;
epersonDtoModel.memberOfGroup = isMember;
return epersonDtoModel;
});
return dto$;
})]);
return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epersonListRD.payload.pageInfo, dtos);
}));
}))
.subscribe((paginatedListOfDTOs: PaginatedList<EpersonDtoModel>) => {
this.ePeopleSearchDtos.next(paginatedListOfDTOs);
})); }));
} }

View File

@@ -1,6 +1,55 @@
<ng-container> <ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3> <h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4>{{messagePrefix + '.headSubgroups' | translate}}</h4>
<ds-pagination *ngIf="(subGroups$ | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(subGroups$ | async)?.payload"
[collectionSize]="(subGroups$ | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupDataService.startEditingNewGroup(group)"
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload)}}</td>
<td class="align-middle">
<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: dsoNameService.getName(group) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(subGroups$ | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-subgroups-yet' | translate}}
</div>
<h4 id="search" class="border-bottom pb-2"> <h4 id="search" class="border-bottom pb-2">
<span *dsContextHelp="{ <span *dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups', content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups',
@@ -62,17 +111,7 @@
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload) }}</td> <td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload) }}</td>
<td class="align-middle"> <td class="align-middle">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)" <button (click)="addSubgroupToGroup(group)"
(click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
<span *ngIf="(isActiveGroup(group) | async)">{{ messagePrefix + '.table.edit.currentGroup' | translate }}</span>
<button *ngIf="!(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
(click)="addSubgroupToGroup(group)"
class="btn btn-outline-primary btn-sm addButton" class="btn btn-outline-primary btn-sm addButton"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(group) } }}"> title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-plus fa-fw"></i> <i class="fas fa-plus fa-fw"></i>
@@ -90,53 +129,4 @@
{{messagePrefix + '.no-items' | translate}} {{messagePrefix + '.no-items' | translate}}
</div> </div>
<h4>{{messagePrefix + '.headSubgroups' | translate}}</h4>
<ds-pagination *ngIf="(subGroups$ | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(subGroups$ | async)?.payload"
[collectionSize]="(subGroups$ | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="table-responsive">
<table id="subgroupsOfGroup" class="table table-striped table-hover table-bordered">
<thead>
<tr>
<th scope="col" class="align-middle">{{messagePrefix + '.table.id' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.name' | translate}}</th>
<th scope="col" class="align-middle">{{messagePrefix + '.table.collectionOrCommunity' | translate}}</th>
<th>{{messagePrefix + '.table.edit' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let group of (subGroups$ | async)?.payload?.page">
<td class="align-middle">{{group.id}}</td>
<td class="align-middle">
<a (click)="groupDataService.startEditingNewGroup(group)"
[routerLink]="[groupDataService.getGroupEditPageRouterLink(group)]">
{{ dsoNameService.getName(group) }}
</a>
</td>
<td class="align-middle">{{ dsoNameService.getName((group.object | async)?.payload)}}</td>
<td class="align-middle">
<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: dsoNameService.getName(group) } }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="(subGroups$ | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2"
role="alert">
{{messagePrefix + '.no-subgroups-yet' | translate}}
</div>
</ng-container> </ng-container>

View File

@@ -1,12 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core'; import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, fakeAsync, flush, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
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, of as observableOf, BehaviorSubject } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { RestResponse } from '../../../../core/cache/response.models'; import { RestResponse } from '../../../../core/cache/response.models';
import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model'; import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
@@ -18,15 +18,13 @@ import { NotificationsService } from '../../../../shared/notifications/notificat
import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock'; import { GroupMock, GroupMock2 } from '../../../../shared/testing/group-mock';
import { SubgroupsListComponent } from './subgroups-list.component'; import { SubgroupsListComponent } from './subgroups-list.component';
import { import {
createSuccessfulRemoteDataObject$, createSuccessfulRemoteDataObject$
createSuccessfulRemoteDataObject
} from '../../../../shared/remote-data.utils'; } from '../../../../shared/remote-data.utils';
import { RouterMock } from '../../../../shared/mocks/router.mock'; import { RouterMock } from '../../../../shared/mocks/router.mock';
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/testing/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { map } from 'rxjs/operators';
import { PaginationService } from '../../../../core/pagination/pagination.service'; import { PaginationService } from '../../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service'; import { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
@@ -34,6 +32,7 @@ import { DSONameServiceMock } from '../../../../shared/mocks/dso-name.service.mo
import { ContextHelpDirective } from '../../../../shared/context-help.directive'; import { ContextHelpDirective } from '../../../../shared/context-help.directive';
import { PaginationComponent } from '../../../../shared/pagination/pagination.component'; import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub'; import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub';
import { EPersonMock2 } from 'src/app/shared/testing/eperson.mock';
describe('SubgroupsListComponent', () => { describe('SubgroupsListComponent', () => {
let component: SubgroupsListComponent; let component: SubgroupsListComponent;
@@ -42,57 +41,72 @@ describe('SubgroupsListComponent', () => {
let builderService: FormBuilderService; let builderService: FormBuilderService;
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let groupsDataServiceStub: any; let groupsDataServiceStub: any;
let activeGroup; let activeGroup: Group;
let subgroups: Group[]; let subgroups: Group[];
let allGroups: Group[]; let groupNonMembers: Group[];
let routerStub; let routerStub;
let paginationService; let paginationService;
// Define a new mock activegroup for all tests below
let mockActiveGroup: Group = Object.assign(new Group(), {
handle: null,
subgroups: [GroupMock2],
epersons: [EPersonMock2],
selfRegistered: false,
permanent: false,
_links: {
self: {
href: 'https://rest.api/server/api/eperson/groups/activegroupid',
},
subgroups: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/subgroups' },
object: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/object' },
epersons: { href: 'https://rest.api/server/api/eperson/groups/activegroupid/epersons' }
},
_name: 'activegroupname',
id: 'activegroupid',
uuid: 'activegroupid',
type: 'group',
});
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
activeGroup = GroupMock; activeGroup = mockActiveGroup;
subgroups = [GroupMock2]; subgroups = [GroupMock2];
allGroups = [GroupMock, GroupMock2]; groupNonMembers = [GroupMock];
ePersonDataServiceStub = {}; ePersonDataServiceStub = {};
groupsDataServiceStub = { groupsDataServiceStub = {
activeGroup: activeGroup, activeGroup: activeGroup,
subgroups$: new BehaviorSubject(subgroups), subgroups: subgroups,
groupNonMembers: groupNonMembers,
getActiveGroup(): Observable<Group> { getActiveGroup(): Observable<Group> {
return observableOf(this.activeGroup); return observableOf(this.activeGroup);
}, },
getSubgroups(): Group { getSubgroups(): Group {
return this.activeGroup; return this.subgroups;
}, },
findListByHref( // This method is used to get all the current subgroups
_href: string findListByHref(_href: string): Observable<RemoteData<PaginatedList<Group>>> {
): Observable<RemoteData<PaginatedList<Group>>> { return createSuccessfulRemoteDataObject$(buildPaginatedList<Group>(new PageInfo(), groupsDataServiceStub.getSubgroups()));
return this.subgroups$.pipe(
map((currentGroups: Group[]) => {
return createSuccessfulRemoteDataObject(
buildPaginatedList<Group>(new PageInfo(), currentGroups)
);
})
);
}, },
getGroupEditPageRouterLink(group: Group): string { getGroupEditPageRouterLink(group: Group): string {
return '/access-control/groups/' + group.id; return '/access-control/groups/' + group.id;
}, },
searchGroups( // This method is used to get all groups which are NOT currently a subgroup member
query: string searchNonMemberGroups(query: string, group: string): Observable<RemoteData<PaginatedList<Group>>> {
): Observable<RemoteData<PaginatedList<Group>>> {
if (query === '') { if (query === '') {
return createSuccessfulRemoteDataObject$( return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupNonMembers));
buildPaginatedList(new PageInfo(), allGroups)
);
} }
return createSuccessfulRemoteDataObject$( return createSuccessfulRemoteDataObject$(
buildPaginatedList(new PageInfo(), []) buildPaginatedList(new PageInfo(), [])
); );
}, },
addSubGroupToGroup( addSubGroupToGroup(parentGroup, subgroupToAdd: Group): Observable<RestResponse> {
parentGroup, // Add group to list of subgroups
subgroup: Group this.subgroups = [...this.subgroups, subgroupToAdd];
): Observable<RestResponse> { // Remove group from list of non-members
this.subgroups$.next([...this.subgroups$.getValue(), subgroup]); this.groupNonMembers.forEach( (group: Group, index: number) => {
if (group.id === subgroupToAdd.id) {
this.groupNonMembers.splice(index, 1);
}
});
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
}, },
clearGroupsRequests() { clearGroupsRequests() {
@@ -101,17 +115,15 @@ describe('SubgroupsListComponent', () => {
clearGroupLinkRequests() { clearGroupLinkRequests() {
// empty // empty
}, },
deleteSubGroupFromGroup( deleteSubGroupFromGroup(parentGroup, subgroupToDelete: Group): Observable<RestResponse> {
parentGroup, // Remove group from list of subgroups
subgroup: Group this.subgroups.forEach( (group: Group, index: number) => {
): Observable<RestResponse> { if (group.id === subgroupToDelete.id) {
this.subgroups$.next( this.subgroups.splice(index, 1);
this.subgroups$.getValue().filter((group: Group) => { }
if (group.id !== subgroup.id) { });
return group; // Add group to list of non-members
} this.groupNonMembers = [...this.groupNonMembers, subgroupToDelete];
})
);
return observableOf(new RestResponse(true, 200, 'Success')); return observableOf(new RestResponse(true, 200, 'Success'));
}, },
}; };
@@ -174,30 +186,38 @@ describe('SubgroupsListComponent', () => {
expect(comp).toBeDefined(); expect(comp).toBeDefined();
})); }));
it('should show list of subgroups of current active group', () => { describe('current subgroup list', () => {
const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child')); it('should show list of subgroups of current active group', () => {
expect(groupIdsFound.length).toEqual(1); const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child'));
activeGroup.subgroups.map((group: Group) => { expect(groupIdsFound.length).toEqual(1);
expect(groupIdsFound.find((foundEl) => { subgroups.map((group: Group) => {
return (foundEl.nativeElement.textContent.trim() === group.uuid); expect(groupIdsFound.find((foundEl) => {
})).toBeTruthy(); return (foundEl.nativeElement.textContent.trim() === group.uuid);
}); })).toBeTruthy();
}); });
});
describe('if first group delete button is pressed', () => {
let groupsFound: DebugElement[]; it('should show a delete button next to each subgroup', () => {
beforeEach(fakeAsync(() => { const subgroupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr'));
const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton')); subgroupsFound.map((foundGroupRowElement: DebugElement) => {
addButton.triggerEventHandler('click', { const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
preventDefault: () => {/**/ const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
} expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
});
});
describe('if first group delete button is pressed', () => {
let groupsFound: DebugElement[];
beforeEach(() => {
const deleteButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton'));
deleteButton.nativeElement.click();
fixture.detectChanges();
});
it('then no subgroup remains as a member of the active group', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr'));
expect(groupsFound.length).toEqual(0);
}); });
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);
}); });
}); });
@@ -206,54 +226,38 @@ describe('SubgroupsListComponent', () => {
let groupsFound: DebugElement[]; let groupsFound: DebugElement[];
beforeEach(fakeAsync(() => { beforeEach(fakeAsync(() => {
component.search({ query: '' }); component.search({ query: '' });
fixture.detectChanges();
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
})); }));
it('should display all groups', () => { it('should display only non-member groups (i.e. groups that are not a subgroup)', () => {
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: DebugElement[] = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child')); const groupIdsFound: DebugElement[] = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr td:first-child'));
allGroups.map((group: Group) => { expect(groupIdsFound.length).toEqual(1);
groupNonMembers.map((group: Group) => {
expect(groupIdsFound.find((foundEl: DebugElement) => { expect(groupIdsFound.find((foundEl: DebugElement) => {
return (foundEl.nativeElement.textContent.trim() === group.uuid); return (foundEl.nativeElement.textContent.trim() === group.uuid);
})).toBeTruthy(); })).toBeTruthy();
}); });
}); });
describe('if group is already a subgroup', () => { it('should display an add button next to non-member groups, not a delete button', () => {
it('should have delete button, else it should have add button', () => { groupsFound.map((foundGroupRowElement: DebugElement) => {
const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).not.toBeNull();
expect(deleteButton).toBeNull();
});
});
describe('if first add button is pressed', () => {
beforeEach(() => {
const addButton: DebugElement = fixture.debugElement.query(By.css('#groupsSearch tbody .fa-plus'));
addButton.nativeElement.click();
fixture.detectChanges(); fixture.detectChanges();
});
it('then all (two) Groups are subgroups of the active group. No non-members left', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr')); groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups; expect(groupsFound.length).toEqual(0);
if (getSubgroups !== undefined && getSubgroups.length > 0) {
groupsFound.map((foundGroupRowElement: DebugElement) => {
const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
expect(addButton).toBeNull();
if (activeGroup.id === groupId.nativeElement.textContent) {
expect(deleteButton).toBeNull();
} else {
expect(deleteButton).not.toBeNull();
}
});
} else {
const subgroupIds: string[] = activeGroup.subgroups.map((group: Group) => group.id);
groupsFound.map((foundGroupRowElement: DebugElement) => {
const groupId: DebugElement = foundGroupRowElement.query(By.css('td:first-child'));
const addButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-plus'));
const deleteButton: DebugElement = foundGroupRowElement.query(By.css('td:last-child .fa-trash-alt'));
if (subgroupIds.includes(groupId.nativeElement.textContent)) {
expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
} else {
expect(deleteButton).toBeNull();
expect(addButton).not.toBeNull();
}
});
}
}); });
}); });
}); });

View File

@@ -2,16 +2,15 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { map, mergeMap, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { Group } from '../../../../core/eperson/models/group.model'; import { Group } from '../../../../core/eperson/models/group.model';
import { import {
getFirstCompletedRemoteData, getAllCompletedRemoteData,
getFirstSucceededRemoteData, getFirstCompletedRemoteData
getRemoteDataPayload
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
@@ -117,6 +116,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup != null) { if (activeGroup != null) {
this.groupBeingEdited = activeGroup; this.groupBeingEdited = activeGroup;
this.retrieveSubGroups(); this.retrieveSubGroups();
this.search({query: ''});
} }
})); }));
} }
@@ -145,47 +145,6 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
})); }));
} }
/**
* 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.findListByHref(activeGroup._links.subgroups.href, {
currentPage: 1,
elementsPerPage: 9999
})
.pipe(
getFirstSucceededRemoteData(),
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 * 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 * @param subgroup Group we want to delete from the subgroups of the group currently being edited
@@ -195,6 +154,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup != null) { if (activeGroup != null) {
const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup); const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup);
this.showNotifications('deleteSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup); this.showNotifications('deleteSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup);
// Reload search results (if there is an active query).
// This will potentially add this deleted subgroup into the list of search results.
if (this.currentSearchQuery != null) {
this.search({query: this.currentSearchQuery});
}
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveGroup'));
} }
@@ -211,6 +175,11 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup.uuid !== subgroup.uuid) { if (activeGroup.uuid !== subgroup.uuid) {
const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup); const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup);
this.showNotifications('addSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup); this.showNotifications('addSubgroup', response, this.dsoNameService.getName(subgroup), activeGroup);
// Reload search results (if there is an active query).
// This will potentially remove this added subgroup from search results.
if (this.currentSearchQuery != null) {
this.search({query: this.currentSearchQuery});
}
} else { } else {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup')); this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.subgroupToAddIsActiveGroup'));
} }
@@ -221,28 +190,38 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
} }
/** /**
* Search in the groups (searches by group name and by uuid exact match) * Search all non-member groups (searches by group name and by uuid exact match). Used to search for
* groups that could be added to current group as a subgroup.
* @param data Contains query param * @param data Contains query param
*/ */
search(data: any) { 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.unsubFrom(SubKey.SearchResults); this.unsubFrom(SubKey.SearchResults);
this.subs.set(SubKey.SearchResults, this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe( this.subs.set(SubKey.SearchResults,
switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, { this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe(
currentPage: config.currentPage, switchMap((paginationOptions) => {
elementsPerPage: config.pageSize const query: string = data.query;
}, true, true, followLink('object') if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) {
)) this.currentSearchQuery = query;
).subscribe((rd: RemoteData<PaginatedList<Group>>) => { this.paginationService.resetPage(this.configSearch.id);
this.searchResults$.next(rd); }
})); this.searchDone = true;
return this.groupDataService.searchNonMemberGroups(this.currentSearchQuery, this.groupBeingEdited.id, {
currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize
}, false, true, followLink('object'));
}),
getAllCompletedRemoteData(),
map((rd: RemoteData<any>) => {
if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage }));
} else {
return rd;
}
}))
.subscribe((rd: RemoteData<PaginatedList<Group>>) => {
this.searchResults$.next(rd);
}));
} }
/** /**

View File

@@ -5,7 +5,7 @@
<h2 id="header" class="pb-2">{{messagePrefix + 'head' | translate}}</h2> <h2 id="header" class="pb-2">{{messagePrefix + 'head' | translate}}</h2>
<div> <div>
<button class="mr-auto btn btn-success" <button class="mr-auto btn btn-success"
[routerLink]="['newGroup']"> [routerLink]="'create'">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
<span class="d-none d-sm-inline ml-1">{{messagePrefix + 'button.add' | translate}}</span> <span class="d-none d-sm-inline ml-1">{{messagePrefix + 'button.add' | translate}}</span>
</button> </button>

View File

@@ -234,18 +234,28 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
/** /**
* Get the members (epersons embedded value of a group) * Get the members (epersons embedded value of a group)
* NOTE: At this time we only grab the *first* member in order to receive the `totalElements` value
* needed for our HTML template.
* @param group * @param group
*/ */
getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> { getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> {
return this.ePersonDataService.findListByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); return this.ePersonDataService.findListByHref(group._links.epersons.href, {
currentPage: 1,
elementsPerPage: 1,
}).pipe(getFirstSucceededRemoteData());
} }
/** /**
* Get the subgroups (groups embedded value of a group) * Get the subgroups (groups embedded value of a group)
* NOTE: At this time we only grab the *first* subgroup in order to receive the `totalElements` value
* needed for our HTML template.
* @param group * @param group
*/ */
getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> { getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> {
return this.groupService.findListByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); return this.groupService.findListByHref(group._links.subgroups.href, {
currentPage: 1,
elementsPerPage: 1,
}).pipe(getFirstSucceededRemoteData());
} }
/** /**

View File

@@ -3,7 +3,8 @@ import {
DynamicFormControlModel, DynamicFormControlModel,
DynamicFormGroupModel, DynamicFormGroupModel,
DynamicFormLayout, DynamicFormLayout,
DynamicInputModel DynamicInputModel,
DynamicTextAreaModel
} from '@ng-dynamic-forms/core'; } from '@ng-dynamic-forms/core';
import { UntypedFormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { RegistryService } from '../../../../core/registry/registry.service'; import { RegistryService } from '../../../../core/registry/registry.service';
@@ -60,7 +61,7 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
/** /**
* A dynamic input model for the scopeNote field * A dynamic input model for the scopeNote field
*/ */
scopeNote: DynamicInputModel; scopeNote: DynamicTextAreaModel;
/** /**
* A list of all dynamic input models * A list of all dynamic input models
@@ -141,11 +142,12 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
maxLength: 'error.validation.metadata.qualifier.max-length', maxLength: 'error.validation.metadata.qualifier.max-length',
}, },
}); });
this.scopeNote = new DynamicInputModel({ this.scopeNote = new DynamicTextAreaModel({
id: 'scopeNote', id: 'scopeNote',
label: scopenote, label: scopenote,
name: 'scopeNote', name: 'scopeNote',
required: false, required: false,
rows: 5,
}); });
this.formModel = [ this.formModel = [
new DynamicFormGroupModel( new DynamicFormGroupModel(

View File

@@ -41,7 +41,7 @@
</label> </label>
</td> </td>
<td class="selectable-row" (click)="editField(field)">{{field.id}}</td> <td class="selectable-row" (click)="editField(field)">{{field.id}}</td>
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier" class="mb-0">.</label>{{field.qualifier}}</td> <td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}{{field.qualifier ? '.' + field.qualifier : ''}}</td>
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td> <td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -14,12 +14,12 @@ import { NgClass } from '@angular/common';
* Represents a non-expandable section in the admin sidebar * Represents a non-expandable section in the admin sidebar
*/ */
@Component({ @Component({
/* eslint-disable @angular-eslint/component-selector */ selector: 'ds-admin-sidebar-section',
selector: 'li[ds-admin-sidebar-section]', templateUrl: './admin-sidebar-section.component.html',
templateUrl: './admin-sidebar-section.component.html', styleUrls: ['./admin-sidebar-section.component.scss'],
styleUrls: ['./admin-sidebar-section.component.scss'], standalone: true,
standalone: true, imports: [NgClass, RouterLink, TranslateModule]
imports: [NgClass, RouterLink, TranslateModule]
}) })
@rendersSectionForMenu(MenuID.ADMIN, false) @rendersSectionForMenu(MenuID.ADMIN, false)
export class AdminSidebarSectionComponent extends MenuSectionComponent implements OnInit { export class AdminSidebarSectionComponent extends MenuSectionComponent implements OnInit {

View File

@@ -26,10 +26,10 @@
</div> </div>
</li> </li>
<ng-container *ngFor="let section of (sections | async)"> <li *ngFor="let section of (sections | async)">
<ng-container <ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</ng-container> </li>
</ul> </ul>
</div> </div>
<div class="navbar-nav"> <div class="navbar-nav">

View File

@@ -17,13 +17,12 @@ import { NgClass, NgComponentOutlet, NgIf, NgFor, AsyncPipe } from '@angular/com
* Represents a expandable section in the sidebar * Represents a expandable section in the sidebar
*/ */
@Component({ @Component({
/* eslint-disable @angular-eslint/component-selector */ selector: 'ds-expandable-admin-sidebar-section',
selector: 'li[ds-expandable-admin-sidebar-section]', templateUrl: './expandable-admin-sidebar-section.component.html',
templateUrl: './expandable-admin-sidebar-section.component.html', styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
styleUrls: ['./expandable-admin-sidebar-section.component.scss'], animations: [rotate, slide, bgColor],
animations: [rotate, slide, bgColor], standalone: true,
standalone: true, imports: [NgClass, NgComponentOutlet, NgIf, NgFor, AsyncPipe, TranslateModule]
imports: [NgClass, NgComponentOutlet, NgIf, NgFor, AsyncPipe, TranslateModule]
}) })
@rendersSectionForMenu(MenuID.ADMIN, true) @rendersSectionForMenu(MenuID.ADMIN, true)

View File

@@ -1,6 +1,6 @@
<ng-container *ngVar="(breadcrumbs$ | async) as breadcrumbs"> <ng-container *ngVar="(breadcrumbs$ | async) as breadcrumbs">
<nav *ngIf="(showBreadcrumbs$ | async)" aria-label="breadcrumb" class="nav-breadcrumb"> <nav *ngIf="(showBreadcrumbs$ | async)" aria-label="breadcrumb" class="nav-breadcrumb">
<ol class="container breadcrumb"> <ol class="container breadcrumb my-0">
<ng-container <ng-container
*ngTemplateOutlet="breadcrumbs?.length > 0 ? breadcrumb : activeBreadcrumb; context: {text: 'home.breadcrumbs', url: '/'}"></ng-container> *ngTemplateOutlet="breadcrumbs?.length > 0 ? breadcrumb : activeBreadcrumb; context: {text: 'home.breadcrumbs', url: '/'}"></ng-container>
<ng-container *ngFor="let bc of breadcrumbs; let last = last;"> <ng-container *ngFor="let bc of breadcrumbs; let last = last;">

View File

@@ -4,9 +4,8 @@
.breadcrumb { .breadcrumb {
border-radius: 0; border-radius: 0;
margin-top: calc(-1 * var(--ds-content-spacing)); padding-bottom: calc(var(--ds-content-spacing) / 2);
padding-bottom: var(--ds-content-spacing / 3); padding-top: calc(var(--ds-content-spacing) / 2);
padding-top: var(--ds-content-spacing / 3);
background-color: var(--ds-breadcrumb-bg); background-color: var(--ds-breadcrumb-bg);
} }

View File

@@ -34,6 +34,7 @@ import {
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component'; import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { ThemedBrowseByComponent } from 'src/app/shared/browse-by/themed-browse-by.component';
@Component({ @Component({
selector: 'ds-browse-by-date-page', selector: 'ds-browse-by-date-page',
@@ -52,7 +53,8 @@ import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.comp
ThemedComcolPageBrowseByComponent, ThemedComcolPageBrowseByComponent,
BrowseByComponent, BrowseByComponent,
TranslateModule, TranslateModule,
ThemedLoadingComponent ThemedLoadingComponent,
ThemedBrowseByComponent
] ]
}) })
/** /**
@@ -119,11 +121,11 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
const lastItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC); const lastItemRD = this.browseService.getFirstItemFor(definition, scope, SortDirection.DESC);
this.subs.push( this.subs.push(
observableCombineLatest([firstItemRD, lastItemRD]).subscribe(([firstItem, lastItem]) => { observableCombineLatest([firstItemRD, lastItemRD]).subscribe(([firstItem, lastItem]) => {
let lowerLimit = this.getLimit(firstItem, metadataKeys, this.appConfig.browseBy.defaultLowerLimit); let lowerLimit: number = this.getLimit(firstItem, metadataKeys, this.appConfig.browseBy.defaultLowerLimit);
let upperLimit = this.getLimit(lastItem, metadataKeys, new Date().getUTCFullYear()); let upperLimit: number = this.getLimit(lastItem, metadataKeys, new Date().getUTCFullYear());
const options = []; const options: number[] = [];
const oneYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.oneYearLimit) / 5) * 5; const oneYearBreak: number = Math.floor((upperLimit - this.appConfig.browseBy.oneYearLimit) / 5) * 5;
const fiveYearBreak = Math.floor((upperLimit - this.appConfig.browseBy.fiveYearLimit) / 10) * 10; const fiveYearBreak: number = Math.floor((upperLimit - this.appConfig.browseBy.fiveYearLimit) / 10) * 10;
if (lowerLimit <= fiveYearBreak) { if (lowerLimit <= fiveYearBreak) {
lowerLimit -= 10; lowerLimit -= 10;
} else if (lowerLimit <= oneYearBreak) { } else if (lowerLimit <= oneYearBreak) {
@@ -131,7 +133,7 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
} else { } else {
lowerLimit -= 1; lowerLimit -= 1;
} }
let i = upperLimit; let i: number = upperLimit;
while (i > lowerLimit) { while (i > lowerLimit) {
options.push(i); options.push(i);
if (i <= fiveYearBreak) { if (i <= fiveYearBreak) {

View File

@@ -32,7 +32,7 @@
<section class="comcol-page-browse-section"> <section class="comcol-page-browse-section">
<div class="browse-by-metadata w-100"> <div class="browse-by-metadata w-100">
<ds-browse-by *ngIf="startsWithOptions" class="col-xs-12 w-100" <ds-themed-browse-by *ngIf="startsWithOptions" class="col-xs-12 w-100"
title="{{'browse.title' | translate: title="{{'browse.title' | translate:
{ {
collection: dsoNameService.getName((parent$ | async)?.payload), collection: dsoNameService.getName((parent$ | async)?.payload),
@@ -48,7 +48,7 @@
[startsWithOptions]="startsWithOptions" [startsWithOptions]="startsWithOptions"
(prev)="goPrev()" (prev)="goPrev()"
(next)="goNext()"> (next)="goNext()">
</ds-browse-by> </ds-themed-browse-by>
<ds-themed-loading *ngIf="!startsWithOptions" message="{{'loading.browse-by-page' | translate}}"></ds-themed-loading> <ds-themed-loading *ngIf="!startsWithOptions" message="{{'loading.browse-by-page' | translate}}"></ds-themed-loading>
</div> </div>
</section> </section>

View File

@@ -37,6 +37,7 @@ import {
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component'; import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { ThemedBrowseByComponent } from 'src/app/shared/browse-by/themed-browse-by.component';
export const BBM_PAGINATION_ID = 'bbm'; export const BBM_PAGINATION_ID = 'bbm';
@@ -53,6 +54,7 @@ export const BBM_PAGINATION_ID = 'bbm';
ThemedComcolPageHandleComponent, ThemedComcolPageHandleComponent,
ComcolPageContentComponent, ComcolPageContentComponent,
DsoEditMenuComponent, DsoEditMenuComponent,
ThemedBrowseByComponent,
ThemedComcolPageBrowseByComponent, ThemedComcolPageBrowseByComponent,
BrowseByComponent, BrowseByComponent,
TranslateModule, TranslateModule,
@@ -191,7 +193,11 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
this.value = ''; this.value = '';
} }
if (typeof params.startsWith === 'string'){ if (params.startsWith === undefined || params.startsWith === '') {
this.startsWith = undefined;
}
if (typeof params.startsWith === 'string'){
this.startsWith = params.startsWith.trim(); this.startsWith = params.startsWith.trim();
} }

View File

@@ -29,6 +29,7 @@ import {
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component'; import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { ThemedBrowseByComponent } from 'src/app/shared/browse-by/themed-browse-by.component';
@Component({ @Component({
selector: 'ds-browse-by-title-page', selector: 'ds-browse-by-title-page',
@@ -47,7 +48,8 @@ import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.comp
ThemedComcolPageBrowseByComponent, ThemedComcolPageBrowseByComponent,
BrowseByComponent, BrowseByComponent,
TranslateModule, TranslateModule,
ThemedLoadingComponent ThemedLoadingComponent,
ThemedBrowseByComponent
], ],
}) })
/** /**

View File

@@ -14,6 +14,7 @@ import { ThemedBrowseByTaxonomyPageComponent } from './browse-by-taxonomy-page/t
import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module'; import { SharedBrowseByModule } from '../shared/browse-by/shared-browse-by.module';
import { DsoPageModule } from '../shared/dso-page/dso-page.module'; import { DsoPageModule } from '../shared/dso-page/dso-page.module';
import { FormModule } from '../shared/form/form.module'; import { FormModule } from '../shared/form/form.module';
import { SharedModule } from '../shared/shared.module';
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator // put only entry components that use custom decorator
@@ -37,6 +38,7 @@ const ENTRY_COMPONENTS = [
FormModule, FormModule,
BrowseBySwitcherComponent, BrowseBySwitcherComponent,
ThemedBrowseBySwitcherComponent, ThemedBrowseBySwitcherComponent,
SharedModule,
...ENTRY_COMPONENTS ...ENTRY_COMPONENTS
], ],
exports: [ exports: [

View File

@@ -34,9 +34,6 @@
</ds-comcol-page-content> </ds-comcol-page-content>
</header> </header>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
<div class="pl-2 space-children-mr">
<ds-dso-page-subscription-button [dso]="collection"></ds-dso-page-subscription-button>
</div>
</div> </div>
<section class="comcol-page-browse-section"> <section class="comcol-page-browse-section">
<!-- Browse-By Links --> <!-- Browse-By Links -->

View File

@@ -44,9 +44,6 @@ import {
ThemedComcolPageHandleComponent ThemedComcolPageHandleComponent
} from '../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component'; } from '../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component';
import { DsoEditMenuComponent } from '../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { DsoEditMenuComponent } from '../shared/dso-page/dso-edit-menu/dso-edit-menu.component';
import {
DsoPageSubscriptionButtonComponent
} from '../shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component';
import { import {
ThemedComcolPageBrowseByComponent ThemedComcolPageBrowseByComponent
} from '../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component'; } from '../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component';
@@ -74,7 +71,6 @@ import { ObjectCollectionComponent } from '../shared/object-collection/object-co
ComcolPageLogoComponent, ComcolPageLogoComponent,
ThemedComcolPageHandleComponent, ThemedComcolPageHandleComponent,
DsoEditMenuComponent, DsoEditMenuComponent,
DsoPageSubscriptionButtonComponent,
ThemedComcolPageBrowseByComponent, ThemedComcolPageBrowseByComponent,
ObjectCollectionComponent ObjectCollectionComponent
], ],

View File

@@ -24,6 +24,7 @@ import { FlatNode } from './flat-node.model';
import { ShowMoreFlatNode } from './show-more-flat-node.model'; import { ShowMoreFlatNode } from './show-more-flat-node.model';
import { FindListOptions } from '../core/data/find-list-options.model'; import { FindListOptions } from '../core/data/find-list-options.model';
import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
import { v4 as uuidv4 } from 'uuid';
// Helper method to combine and flatten an array of observables of flatNode arrays // Helper method to combine and flatten an array of observables of flatNode arrays
export const combineAndFlatten = (obsList: Observable<FlatNode[]>[]): Observable<FlatNode[]> => export const combineAndFlatten = (obsList: Observable<FlatNode[]>[]): Observable<FlatNode[]> =>
@@ -186,7 +187,7 @@ export class CommunityListService {
return this.transformCommunity(community, level, parent, expandedNodes); return this.transformCommunity(community, level, parent, expandedNodes);
}); });
if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) { if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) {
obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])]; obsList = [...obsList, observableOf([showMoreFlatNode(`community-${uuidv4()}`, level, parent)])];
} }
return combineAndFlatten(obsList); return combineAndFlatten(obsList);
@@ -257,7 +258,7 @@ export class CommunityListService {
let nodes = rd.payload.page let nodes = rd.payload.page
.map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode)); .map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode));
if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) { if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) {
nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)]; nodes = [...nodes, showMoreFlatNode(`collection-${uuidv4()}`, level + 1, communityFlatNode)];
} }
return nodes; return nodes;
} else { } else {

View File

@@ -8,7 +8,7 @@
<span class="fa fa-chevron-right invisible" aria-hidden="true"></span> <span class="fa fa-chevron-right invisible" aria-hidden="true"></span>
</button> </button>
<div class="align-middle pt-2"> <div class="align-middle pt-2">
<button *ngIf="node!==loadingNode" (click)="getNextPage(node)" <button *ngIf="!(dataSource.loading$ | async)" (click)="getNextPage(node)"
class="btn btn-outline-primary btn-sm" role="button"> class="btn btn-outline-primary btn-sm" role="button">
<i class="fas fa-angle-down"></i> {{ 'communityList.showMore' | translate }} <i class="fas fa-angle-down"></i> {{ 'communityList.showMore' | translate }}
</button> </button>

View File

@@ -20,6 +20,7 @@ import { RouterLinkWithHref } from '@angular/router';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { TruncatableComponent } from '../../shared/truncatable/truncatable.component'; import { TruncatableComponent } from '../../shared/truncatable/truncatable.component';
import { TruncatablePartComponent } from '../../shared/truncatable/truncatable-part/truncatable-part.component'; import { TruncatablePartComponent } from '../../shared/truncatable/truncatable-part/truncatable-part.component';
import { v4 as uuidv4 } from 'uuid';
describe('CommunityListComponent', () => { describe('CommunityListComponent', () => {
let component: CommunityListComponent; let component: CommunityListComponent;
@@ -141,7 +142,7 @@ describe('CommunityListComponent', () => {
} }
if (expandedNodes === null || isEmpty(expandedNodes)) { if (expandedNodes === null || isEmpty(expandedNodes)) {
if (showMoreTopComNode) { if (showMoreTopComNode) {
return observableOf([...mockTopFlatnodesUnexpanded.slice(0, endPageIndex), showMoreFlatNode('community', 0, null)]); return observableOf([...mockTopFlatnodesUnexpanded.slice(0, endPageIndex), showMoreFlatNode(`community-${uuidv4()}`, 0, null)]);
} else { } else {
return observableOf(mockTopFlatnodesUnexpanded.slice(0, endPageIndex)); return observableOf(mockTopFlatnodesUnexpanded.slice(0, endPageIndex));
} }
@@ -168,21 +169,21 @@ describe('CommunityListComponent', () => {
const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage; const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage;
flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)]; flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)];
if (subComFlatnodes.length > endSubComIndex) { if (subComFlatnodes.length > endSubComIndex) {
flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)]; flatnodes = [...flatnodes, showMoreFlatNode(`community-${uuidv4()}`, topNode.level + 1, expandedParent)];
} }
} }
if (isNotEmpty(collFlatnodes)) { if (isNotEmpty(collFlatnodes)) {
const endColIndex = this.pageSize * expandedParent.currentCollectionPage; const endColIndex = this.pageSize * expandedParent.currentCollectionPage;
flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)]; flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)];
if (collFlatnodes.length > endColIndex) { if (collFlatnodes.length > endColIndex) {
flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)]; flatnodes = [...flatnodes, showMoreFlatNode(`collection-${uuidv4()}`, topNode.level + 1, expandedParent)];
} }
} }
} }
} }
}); });
if (showMoreTopComNode) { if (showMoreTopComNode) {
flatnodes = [...flatnodes, showMoreFlatNode('community', 0, null)]; flatnodes = [...flatnodes, showMoreFlatNode(`community-${uuidv4()}`, 0, null)];
} }
return observableOf(flatnodes); return observableOf(flatnodes);
} }

View File

@@ -92,7 +92,7 @@ export class CommunityListComponent implements OnInit, OnDestroy {
toggleExpanded(node: FlatNode) { toggleExpanded(node: FlatNode) {
this.loadingNode = node; this.loadingNode = node;
if (node.isExpanded) { if (node.isExpanded) {
this.expandedNodes = this.expandedNodes.filter((node2) => node2.name !== node.name); this.expandedNodes = this.expandedNodes.filter((node2) => node2.id !== node.id);
node.isExpanded = false; node.isExpanded = false;
} else { } else {
this.expandedNodes.push(node); this.expandedNodes.push(node);
@@ -119,19 +119,18 @@ export class CommunityListComponent implements OnInit, OnDestroy {
getNextPage(node: FlatNode): void { getNextPage(node: FlatNode): void {
this.loadingNode = node; this.loadingNode = node;
if (node.parent != null) { if (node.parent != null) {
if (node.id === 'collection') { if (node.id.startsWith('collection')) {
const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id);
parentNodeInExpandedNodes.currentCollectionPage++; parentNodeInExpandedNodes.currentCollectionPage++;
} }
if (node.id === 'community') { if (node.id.startsWith('community')) {
const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id);
parentNodeInExpandedNodes.currentCommunityPage++; parentNodeInExpandedNodes.currentCommunityPage++;
} }
this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
} else { } else {
this.paginationConfig.currentPage++; this.paginationConfig.currentPage++;
this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
} }
this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
} }
} }

View File

@@ -21,9 +21,6 @@
</ds-comcol-page-content> </ds-comcol-page-content>
</header> </header>
<ds-dso-edit-menu></ds-dso-edit-menu> <ds-dso-edit-menu></ds-dso-edit-menu>
<div class="pl-2 space-children-mr">
<ds-dso-page-subscription-button [dso]="communityPayload"></ds-dso-page-subscription-button>
</div>
</div> </div>
<section class="comcol-page-browse-section"> <section class="comcol-page-browse-section">

View File

@@ -35,9 +35,6 @@ import {
ThemedComcolPageBrowseByComponent ThemedComcolPageBrowseByComponent
} from '../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component'; } from '../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component';
import { DsoEditMenuComponent } from '../shared/dso-page/dso-edit-menu/dso-edit-menu.component'; import { DsoEditMenuComponent } from '../shared/dso-page/dso-edit-menu/dso-edit-menu.component';
import {
DsoPageSubscriptionButtonComponent
} from '../shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component';
import { import {
ThemedComcolPageHandleComponent ThemedComcolPageHandleComponent
} from '../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component'; } from '../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component';
@@ -62,7 +59,6 @@ import { VarDirective } from '../shared/utils/var.directive';
ThemedCollectionPageSubCollectionListComponent, ThemedCollectionPageSubCollectionListComponent,
ThemedComcolPageBrowseByComponent, ThemedComcolPageBrowseByComponent,
DsoEditMenuComponent, DsoEditMenuComponent,
DsoPageSubscriptionButtonComponent,
ThemedComcolPageHandleComponent, ThemedComcolPageHandleComponent,
ComcolPageLogoComponent, ComcolPageLogoComponent,
ComcolPageHeaderComponent, ComcolPageHeaderComponent,

View File

@@ -1,7 +1,7 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { Collection } from '../../core/shared/collection.model'; import { Collection } from '../../core/shared/collection.model';
@@ -66,6 +66,8 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro
*/ */
subCollectionsRDObs: BehaviorSubject<RemoteData<PaginatedList<Collection>>> = new BehaviorSubject<RemoteData<PaginatedList<Collection>>>({} as any); subCollectionsRDObs: BehaviorSubject<RemoteData<PaginatedList<Collection>>> = new BehaviorSubject<RemoteData<PaginatedList<Collection>>>({} as any);
subscriptions: Subscription[] = [];
constructor( constructor(
protected cds: CollectionDataService, protected cds: CollectionDataService,
protected paginationService: PaginationService, protected paginationService: PaginationService,
@@ -93,7 +95,7 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro
const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config);
const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig);
observableCombineLatest([pagination$, sort$]).pipe( this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe(
switchMap(([currentPagination, currentSort]) => { switchMap(([currentPagination, currentSort]) => {
return this.cds.findByParent(this.community.id, { return this.cds.findByParent(this.community.id, {
currentPage: currentPagination.currentPage, currentPage: currentPagination.currentPage,
@@ -103,11 +105,12 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro
}) })
).subscribe((results) => { ).subscribe((results) => {
this.subCollectionsRDObs.next(results); this.subCollectionsRDObs.next(results);
}); }));
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.paginationService.clearPagination(this.config.id); this.paginationService.clearPagination(this.config?.id);
this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe());
} }
} }

View File

@@ -1,7 +1,7 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; import { BehaviorSubject, combineLatest as observableCombineLatest, Subscription } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model'; import { Community } from '../../core/shared/community.model';
@@ -68,6 +68,8 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy
*/ */
subCommunitiesRDObs: BehaviorSubject<RemoteData<PaginatedList<Community>>> = new BehaviorSubject<RemoteData<PaginatedList<Community>>>({} as any); subCommunitiesRDObs: BehaviorSubject<RemoteData<PaginatedList<Community>>> = new BehaviorSubject<RemoteData<PaginatedList<Community>>>({} as any);
subscriptions: Subscription[] = [];
constructor( constructor(
protected cds: CommunityDataService, protected cds: CommunityDataService,
protected paginationService: PaginationService, protected paginationService: PaginationService,
@@ -95,7 +97,7 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy
const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config); const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config);
const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig); const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig);
observableCombineLatest([pagination$, sort$]).pipe( this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe(
switchMap(([currentPagination, currentSort]) => { switchMap(([currentPagination, currentSort]) => {
return this.cds.findByParent(this.community.id, { return this.cds.findByParent(this.community.id, {
currentPage: currentPagination.currentPage, currentPage: currentPagination.currentPage,
@@ -105,11 +107,12 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy
}) })
).subscribe((results) => { ).subscribe((results) => {
this.subCommunitiesRDObs.next(results); this.subCommunitiesRDObs.next(results);
}); }));
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.paginationService.clearPagination(this.config.id); this.paginationService.clearPagination(this.config?.id);
this.subscriptions.map((subscription: Subscription) => subscription.unsubscribe());
} }
} }

View File

@@ -7,6 +7,7 @@ import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
import { RouteEffects } from './services/route.effects'; import { RouteEffects } from './services/route.effects';
import { RouterEffects } from './router/router.effects'; import { RouterEffects } from './router/router.effects';
import { MenuEffects } from '../shared/menu/menu.effects';
export const coreEffects = [ export const coreEffects = [
RequestEffects, RequestEffects,
@@ -18,4 +19,5 @@ export const coreEffects = [
ObjectUpdatesEffects, ObjectUpdatesEffects,
RouteEffects, RouteEffects,
RouterEffects, RouterEffects,
MenuEffects,
]; ];

View File

@@ -1,4 +1,3 @@
/* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';

View File

@@ -11,6 +11,8 @@ import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { EMBED_SEPARATOR } from './base/base-data.service'; import { EMBED_SEPARATOR } from './base/base-data.service';
import { HardRedirectService } from '../services/hard-redirect.service'; import { HardRedirectService } from '../services/hard-redirect.service';
import { environment } from '../../../environments/environment.test';
import { AppConfig } from '../../../config/app-config.interface';
describe('DsoRedirectService', () => { describe('DsoRedirectService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
@@ -56,6 +58,7 @@ describe('DsoRedirectService', () => {
}); });
service = new DsoRedirectService( service = new DsoRedirectService(
environment as AppConfig,
requestService, requestService,
rdbService, rdbService,
objectCache, objectCache,
@@ -107,7 +110,7 @@ describe('DsoRedirectService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(redirectService.redirect).toHaveBeenCalledWith('/items/' + remoteData.payload.uuid, 301); expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/items/${remoteData.payload.uuid}`, 301);
}); });
it('should navigate to entities route with the corresponding entity type', () => { it('should navigate to entities route with the corresponding entity type', () => {
remoteData.payload.type = 'item'; remoteData.payload.type = 'item';
@@ -124,7 +127,7 @@ describe('DsoRedirectService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(redirectService.redirect).toHaveBeenCalledWith('/entities/publication/' + remoteData.payload.uuid, 301); expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/entities/publication/${remoteData.payload.uuid}`, 301);
}); });
it('should navigate to collections route', () => { it('should navigate to collections route', () => {
@@ -133,7 +136,7 @@ describe('DsoRedirectService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(redirectService.redirect).toHaveBeenCalledWith('/collections/' + remoteData.payload.uuid, 301); expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/collections/${remoteData.payload.uuid}`, 301);
}); });
it('should navigate to communities route', () => { it('should navigate to communities route', () => {
@@ -142,7 +145,7 @@ describe('DsoRedirectService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(redirectService.redirect).toHaveBeenCalledWith('/communities/' + remoteData.payload.uuid, 301); expect(redirectService.redirect).toHaveBeenCalledWith(`${environment.ui.nameSpace}/communities/${remoteData.payload.uuid}`, 301);
}); });
}); });

View File

@@ -6,7 +6,7 @@
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core'; import { Injectable, Inject } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
@@ -21,6 +21,7 @@ import { DSpaceObject } from '../shared/dspace-object.model';
import { IdentifiableDataService } from './base/identifiable-data.service'; import { IdentifiableDataService } from './base/identifiable-data.service';
import { getDSORoute } from '../../app-routing-paths'; import { getDSORoute } from '../../app-routing-paths';
import { HardRedirectService } from '../services/hard-redirect.service'; import { HardRedirectService } from '../services/hard-redirect.service';
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
const ID_ENDPOINT = 'pid'; const ID_ENDPOINT = 'pid';
const UUID_ENDPOINT = 'dso'; const UUID_ENDPOINT = 'dso';
@@ -70,6 +71,7 @@ export class DsoRedirectService {
private dataService: DsoByIdOrUUIDDataService; private dataService: DsoByIdOrUUIDDataService;
constructor( constructor(
@Inject(APP_CONFIG) protected appConfig: AppConfig,
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
@@ -98,7 +100,7 @@ export class DsoRedirectService {
let newRoute = getDSORoute(dso); let newRoute = getDSORoute(dso);
if (hasValue(newRoute)) { if (hasValue(newRoute)) {
// Use a "301 Moved Permanently" redirect for SEO purposes // Use a "301 Moved Permanently" redirect for SEO purposes
this.hardRedirectService.redirect(newRoute, 301); this.hardRedirectService.redirect(this.appConfig.ui.nameSpace.replace(/\/$/, '') + newRoute, 301);
} }
} }
} }

View File

@@ -71,7 +71,7 @@ export class AuthorizationDataService extends BaseDataService<Authorization> imp
return []; return [];
} }
}), }),
catchError(() => observableOf(false)), catchError(() => observableOf([])),
oneAuthorizationMatchesFeature(featureId) oneAuthorizationMatchesFeature(featureId)
); );
} }

View File

@@ -68,13 +68,13 @@ export const oneAuthorizationMatchesFeature = (featureID: FeatureID) =>
source.pipe( source.pipe(
switchMap((authorizations: Authorization[]) => { switchMap((authorizations: Authorization[]) => {
if (isNotEmpty(authorizations)) { if (isNotEmpty(authorizations)) {
return observableCombineLatest( return observableCombineLatest([
...authorizations ...authorizations
.filter((authorization: Authorization) => hasValue(authorization.feature)) .filter((authorization: Authorization) => hasValue(authorization.feature))
.map((authorization: Authorization) => authorization.feature.pipe( .map((authorization: Authorization) => authorization.feature.pipe(
getFirstSucceededRemoteDataPayload() getFirstSucceededRemoteDataPayload()
)) ))
); ]);
} else { } else {
return observableOf([]); return observableOf([]);
} }

View File

@@ -639,6 +639,45 @@ describe('RequestService', () => {
})); }));
}); });
describe('setStaleByHref', () => {
const uuid = 'c574a42c-4818-47ac-bbe1-6c3cd622c81f';
const href = 'https://rest.api/some/object';
const freshRE: any = {
request: { uuid, href },
state: RequestEntryState.Success
};
const staleRE: any = {
request: { uuid, href },
state: RequestEntryState.SuccessStale
};
it(`should call getByHref to retrieve the RequestEntry matching the href`, () => {
spyOn(service, 'getByHref').and.returnValue(observableOf(staleRE));
service.setStaleByHref(href);
expect(service.getByHref).toHaveBeenCalledWith(href);
});
it(`should dispatch a RequestStaleAction for the RequestEntry returned by getByHref`, (done: DoneFn) => {
spyOn(service, 'getByHref').and.returnValue(observableOf(staleRE));
spyOn(store, 'dispatch');
service.setStaleByHref(href).subscribe(() => {
const requestStaleAction = new RequestStaleAction(uuid);
requestStaleAction.lastUpdated = jasmine.any(Number) as any;
expect(store.dispatch).toHaveBeenCalledWith(requestStaleAction);
done();
});
});
it(`should emit true when the request in the store is stale`, () => {
spyOn(service, 'getByHref').and.returnValue(cold('a-b', {
a: freshRE,
b: staleRE
}));
const result$ = service.setStaleByHref(href);
expect(result$).toBeObservable(cold('--(c|)', { c: true }));
});
});
describe('setStaleByHrefSubstring', () => { describe('setStaleByHrefSubstring', () => {
let dispatchSpy: jasmine.Spy; let dispatchSpy: jasmine.Spy;
let getByUUIDSpy: jasmine.Spy; let getByUUIDSpy: jasmine.Spy;

View File

@@ -16,7 +16,7 @@ import {
RequestExecuteAction, RequestExecuteAction,
RequestStaleAction RequestStaleAction
} from './request.actions'; } from './request.actions';
import { GetRequest} from './request.models'; import { GetRequest } from './request.models';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions'; import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { RestRequestMethod } from './rest-request-method'; import { RestRequestMethod } from './rest-request-method';
import { coreSelector } from '../core.selectors'; import { coreSelector } from '../core.selectors';
@@ -351,7 +351,29 @@ export class RequestService {
map((request: RequestEntry) => isStale(request.state)), map((request: RequestEntry) => isStale(request.state)),
filter((stale: boolean) => stale), filter((stale: boolean) => stale),
take(1), take(1),
); );
}
/**
* Mark a request as stale
* @param href the href of the request
* @return an Observable that will emit true once the Request becomes stale
*/
setStaleByHref(href: string): Observable<boolean> {
const requestEntry$ = this.getByHref(href);
requestEntry$.pipe(
map((re: RequestEntry) => re.request.uuid),
take(1),
).subscribe((uuid: string) => {
this.store.dispatch(new RequestStaleAction(uuid));
});
return requestEntry$.pipe(
map((request: RequestEntry) => isStale(request.state)),
filter((stale: boolean) => stale),
take(1)
);
} }
/** /**
@@ -364,10 +386,10 @@ export class RequestService {
// if it's not a GET request // if it's not a GET request
if (request.method !== RestRequestMethod.GET) { if (request.method !== RestRequestMethod.GET) {
return true; return true;
// if it is a GET request, check it isn't pending // if it is a GET request, check it isn't pending
} else if (this.isPending(request)) { } else if (this.isPending(request)) {
return false; return false;
// if it is pending, check if we're allowed to use a cached version // if it is pending, check if we're allowed to use a cached version
} else if (!useCachedVersionIfAvailable) { } else if (!useCachedVersionIfAvailable) {
return true; return true;
} else { } else {

View File

@@ -1,16 +1,18 @@
import { RootDataService } from './root-data.service'; import { RootDataService } from './root-data.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import {
import { Observable, of } from 'rxjs'; createSuccessfulRemoteDataObject$,
createFailedRemoteDataObject$
} from '../../shared/remote-data.utils';
import { Observable } from 'rxjs';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { Root } from './root.model'; import { Root } from './root.model';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { cold } from 'jasmine-marbles'; import { cold } from 'jasmine-marbles';
describe('RootDataService', () => { describe('RootDataService', () => {
let service: RootDataService; let service: RootDataService;
let halService: HALEndpointService; let halService: HALEndpointService;
let restService; let requestService;
let rootEndpoint; let rootEndpoint;
let findByHrefSpy; let findByHrefSpy;
@@ -19,10 +21,10 @@ describe('RootDataService', () => {
halService = jasmine.createSpyObj('halService', { halService = jasmine.createSpyObj('halService', {
getRootHref: rootEndpoint, getRootHref: rootEndpoint,
}); });
restService = jasmine.createSpyObj('halService', { requestService = jasmine.createSpyObj('requestService', [
get: jasmine.createSpy('get'), 'setStaleByHref',
}); ]);
service = new RootDataService(null, null, null, halService, restService); service = new RootDataService(requestService, null, null, halService);
findByHrefSpy = spyOn(service as any, 'findByHref'); findByHrefSpy = spyOn(service as any, 'findByHref');
findByHrefSpy.and.returnValue(createSuccessfulRemoteDataObject$({})); findByHrefSpy.and.returnValue(createSuccessfulRemoteDataObject$({}));
@@ -47,12 +49,8 @@ describe('RootDataService', () => {
let result$: Observable<boolean>; let result$: Observable<boolean>;
it('should return observable of true when root endpoint is available', () => { it('should return observable of true when root endpoint is available', () => {
const mockResponse = { spyOn(service, 'findRoot').and.returnValue(createSuccessfulRemoteDataObject$<Root>({} as any));
statusCode: 200,
statusText: 'OK'
} as RawRestResponse;
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability(); result$ = service.checkServerAvailability();
expect(result$).toBeObservable(cold('(a|)', { expect(result$).toBeObservable(cold('(a|)', {
@@ -61,12 +59,8 @@ describe('RootDataService', () => {
}); });
it('should return observable of false when root endpoint is not available', () => { it('should return observable of false when root endpoint is not available', () => {
const mockResponse = { spyOn(service, 'findRoot').and.returnValue(createFailedRemoteDataObject$<Root>('500'));
statusCode: 500,
statusText: 'Internal Server Error'
} as RawRestResponse;
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability(); result$ = service.checkServerAvailability();
expect(result$).toBeObservable(cold('(a|)', { expect(result$).toBeObservable(cold('(a|)', {
@@ -75,4 +69,12 @@ describe('RootDataService', () => {
}); });
}); });
describe(`invalidateRootCache`, () => {
it(`should set the cached root request to stale`, () => {
service.invalidateRootCache();
expect(halService.getRootHref).toHaveBeenCalled();
expect(requestService.setStaleByHref).toHaveBeenCalledWith(rootEndpoint);
});
});
}); });

View File

@@ -6,11 +6,10 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { DspaceRestService } from '../dspace-rest/dspace-rest.service';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
import { BaseDataService } from './base/base-data.service'; import { BaseDataService } from './base/base-data.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { getFirstCompletedRemoteData } from '../shared/operators';
/** /**
* A service to retrieve the {@link Root} object from the REST API. * A service to retrieve the {@link Root} object from the REST API.
@@ -22,7 +21,6 @@ export class RootDataService extends BaseDataService<Root> {
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected restService: DspaceRestService,
) { ) {
super('', requestService, rdbService, objectCache, halService, 6 * 60 * 60 * 1000); super('', requestService, rdbService, objectCache, halService, 6 * 60 * 60 * 1000);
} }
@@ -31,12 +29,13 @@ export class RootDataService extends BaseDataService<Root> {
* Check if root endpoint is available * Check if root endpoint is available
*/ */
checkServerAvailability(): Observable<boolean> { checkServerAvailability(): Observable<boolean> {
return this.restService.get(this.halService.getRootHref()).pipe( return this.findRoot().pipe(
catchError((err ) => { catchError((err ) => {
console.error(err); console.error(err);
return observableOf(false); return observableOf(false);
}), }),
map((res: RawRestResponse) => res.statusCode === 200) getFirstCompletedRemoteData(),
map((rootRd: RemoteData<Root>) => rootRd.statusCode === 200)
); );
} }
@@ -57,6 +56,6 @@ export class RootDataService extends BaseDataService<Root> {
* Set to sale the root endpoint cache hit * Set to sale the root endpoint cache hit
*/ */
invalidateRootCache() { invalidateRootCache() {
this.requestService.setStaleByHrefSubstring(this.halService.getRootHref()); this.requestService.setStaleByHref(this.halService.getRootHref());
} }
} }

View File

@@ -11,6 +11,7 @@ import {
EPeopleRegistryCancelEPersonAction, EPeopleRegistryCancelEPersonAction,
EPeopleRegistryEditEPersonAction EPeopleRegistryEditEPersonAction
} from '../../access-control/epeople-registry/epeople-registry.actions'; } from '../../access-control/epeople-registry/epeople-registry.actions';
import { GroupMock } from '../../shared/testing/group-mock';
import { RequestParam } from '../cache/models/request-param.model'; import { RequestParam } from '../cache/models/request-param.model';
import { ChangeAnalyzer } from '../data/change-analyzer'; import { ChangeAnalyzer } from '../data/change-analyzer';
import { PatchRequest, PostRequest } from '../data/request.models'; import { PatchRequest, PostRequest } from '../data/request.models';
@@ -139,6 +140,30 @@ describe('EPersonDataService', () => {
}); });
}); });
describe('searchNonMembers', () => {
beforeEach(() => {
spyOn(service, 'searchBy');
});
it('search with empty query and a group ID', () => {
service.searchNonMembers('', GroupMock.id);
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new RequestParam('query', '')),
Object.assign(new RequestParam('group', GroupMock.id))]
});
expect(service.searchBy).toHaveBeenCalledWith('isNotMemberOf', options, true, true);
});
it('search with query and a group ID', () => {
service.searchNonMembers('test', GroupMock.id);
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new RequestParam('query', 'test')),
Object.assign(new RequestParam('group', GroupMock.id))]
});
expect(service.searchBy).toHaveBeenCalledWith('isNotMemberOf', options, true, true);
});
});
describe('updateEPerson', () => { describe('updateEPerson', () => {
beforeEach(() => { beforeEach(() => {
spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock)); spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock));

View File

@@ -32,6 +32,7 @@ import { SearchData, SearchDataImpl } from '../data/base/search-data';
import { PatchData, PatchDataImpl } from '../data/base/patch-data'; import { PatchData, PatchDataImpl } from '../data/base/patch-data';
import { DeleteData, DeleteDataImpl } from '../data/base/delete-data'; import { DeleteData, DeleteDataImpl } from '../data/base/delete-data';
import { RestRequestMethod } from '../data/rest-request-method'; import { RestRequestMethod } from '../data/rest-request-method';
import { getEPersonEditRoute, getEPersonsRoute } from '../../access-control/access-control-routing-paths';
const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry; const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry;
const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson); const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson);
@@ -173,6 +174,34 @@ export class EPersonDataService extends IdentifiableDataService<EPerson> impleme
return this.searchBy(searchMethod, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.searchBy(searchMethod, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/**
* Searches for all EPerons which are *not* a member of a given group, via a passed in query
* (searches all EPerson metadata and by exact UUID).
* Endpoint used: /eperson/epesons/search/isNotMemberOf?query=<:string>&group=<:uuid>
* @param query search query param
* @param group UUID of group to exclude results from. Members of this group will never be returned.
* @param options
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
public searchNonMembers(query: string, group: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<EPerson>[]): Observable<RemoteData<PaginatedList<EPerson>>> {
const searchParams = [new RequestParam('query', query), new RequestParam('group', group)];
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('isNotMemberOf', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/** /**
* Add a new patch to the object cache * Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache * The patch is derived from the differences between the given object and its version in the object cache
@@ -278,15 +307,14 @@ export class EPersonDataService extends IdentifiableDataService<EPerson> impleme
this.editEPerson(ePerson); this.editEPerson(ePerson);
} }
}); });
return '/access-control/epeople'; return getEPersonEditRoute(ePerson.id);
} }
/** /**
* Get EPeople admin page * Get EPeople admin page
* @param ePerson New EPerson to edit
*/ */
public getEPeoplePageRouterLink(): string { public getEPeoplePageRouterLink(): string {
return '/access-control/epeople'; return getEPersonsRoute();
} }
/** /**

View File

@@ -43,11 +43,11 @@ describe('GroupDataService', () => {
let rdbService; let rdbService;
let objectCache; let objectCache;
function init() { function init() {
restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; restEndpointURL = 'https://rest.api/server/api/eperson';
groupsEndpoint = `${restEndpointURL}/groups`; groupsEndpoint = `${restEndpointURL}/groups`;
groups = [GroupMock, GroupMock2]; groups = [GroupMock, GroupMock2];
groups$ = createSuccessfulRemoteDataObject$(createPaginatedList(groups)); groups$ = createSuccessfulRemoteDataObject$(createPaginatedList(groups));
rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups': groups$ }); rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://rest.api/server/api/eperson/groups': groups$ });
halService = new HALEndpointServiceStub(restEndpointURL); halService = new HALEndpointServiceStub(restEndpointURL);
objectCache = getMockObjectCacheService(); objectCache = getMockObjectCacheService();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -110,6 +110,30 @@ describe('GroupDataService', () => {
}); });
}); });
describe('searchNonMemberGroups', () => {
beforeEach(() => {
spyOn(service, 'searchBy');
});
it('search with empty query and a group ID', () => {
service.searchNonMemberGroups('', GroupMock.id);
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new RequestParam('query', '')),
Object.assign(new RequestParam('group', GroupMock.id))]
});
expect(service.searchBy).toHaveBeenCalledWith('isNotMemberOf', options, true, true);
});
it('search with query and a group ID', () => {
service.searchNonMemberGroups('test', GroupMock.id);
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new RequestParam('query', 'test')),
Object.assign(new RequestParam('group', GroupMock.id))]
});
expect(service.searchBy).toHaveBeenCalledWith('isNotMemberOf', options, true, true);
});
});
describe('addSubGroupToGroup', () => { describe('addSubGroupToGroup', () => {
beforeEach(() => { beforeEach(() => {
objectCache.getByHref.and.returnValue(observableOf({ objectCache.getByHref.and.returnValue(observableOf({

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store'; import { createSelector, select, Store } from '@ngrx/store';
import { Observable, zip as observableZip } from 'rxjs'; import { Observable, zip as observableZip } from 'rxjs';
import { filter, map, take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { import {
GroupRegistryCancelGroupAction, GroupRegistryCancelGroupAction,
GroupRegistryEditGroupAction GroupRegistryEditGroupAction
@@ -38,6 +38,7 @@ import { PatchData, PatchDataImpl } from '../data/base/patch-data';
import { DeleteData, DeleteDataImpl } from '../data/base/delete-data'; import { DeleteData, DeleteDataImpl } from '../data/base/delete-data';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { RestRequestMethod } from '../data/rest-request-method'; import { RestRequestMethod } from '../data/rest-request-method';
import { getGroupEditRoute } from '../../access-control/access-control-routing-paths';
const groupRegistryStateSelector = (state: AppState) => state.groupRegistry; const groupRegistryStateSelector = (state: AppState) => state.groupRegistry;
const editGroupSelector = createSelector(groupRegistryStateSelector, (groupRegistryState: GroupRegistryState) => groupRegistryState.editGroup); const editGroupSelector = createSelector(groupRegistryStateSelector, (groupRegistryState: GroupRegistryState) => groupRegistryState.editGroup);
@@ -101,23 +102,31 @@ export class GroupDataService extends IdentifiableDataService<Group> implements
} }
/** /**
* Check if the current user is member of to the indicated group * Searches for all groups which are *not* a member of a given group, via a passed in query
* * (searches in group name and by exact UUID).
* @param groupName * Endpoint used: /eperson/groups/search/isNotMemberOf?query=<:string>&group=<:uuid>
* the group name * @param query search query param
* @return boolean * @param group UUID of group to exclude results from. Members of this group will never be returned.
* true if user is member of the indicated group, false otherwise * @param options
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/ */
isMemberOf(groupName: string): Observable<boolean> { public searchNonMemberGroups(query: string, group: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Group>[]): Observable<RemoteData<PaginatedList<Group>>> {
const searchHref = 'isMemberOf'; const searchParams = [new RequestParam('query', query), new RequestParam('group', group)];
const options = new FindListOptions(); let findListOptions = new FindListOptions();
options.searchParams = [new RequestParam('groupName', groupName)]; if (options) {
findListOptions = Object.assign(new FindListOptions(), options);
return this.searchBy(searchHref, options).pipe( }
filter((groups: RemoteData<PaginatedList<Group>>) => !groups.isResponsePending), if (findListOptions.searchParams) {
take(1), findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams];
map((groups: RemoteData<PaginatedList<Group>>) => groups.payload.totalElements > 0) } else {
); findListOptions.searchParams = searchParams;
}
return this.searchBy('isNotMemberOf', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/** /**
@@ -261,15 +270,15 @@ export class GroupDataService extends IdentifiableDataService<Group> implements
* @param group Group we want edit page for * @param group Group we want edit page for
*/ */
public getGroupEditPageRouterLink(group: Group): string { public getGroupEditPageRouterLink(group: Group): string {
return this.getGroupEditPageRouterLinkWithID(group.id); return getGroupEditRoute(group.id);
} }
/** /**
* Get Edit page of group * Get Edit page of group
* @param groupID Group ID we want edit page for * @param groupID Group ID we want edit page for
*/ */
public getGroupEditPageRouterLinkWithID(groupId: string): string { public getGroupEditPageRouterLinkWithID(groupID: string): string {
return '/access-control/groups/' + groupId; return getGroupEditRoute(groupID);
} }
/** /**

View File

@@ -13,9 +13,4 @@ export class EpersonDtoModel {
* Whether or not the linked EPerson is able to be deleted * Whether or not the linked EPerson is able to be deleted
*/ */
public ableToDelete: boolean; public ableToDelete: boolean;
/**
* Whether or not this EPerson is member of group on page it is being used on
*/
public memberOfGroup: boolean;
} }

View File

@@ -1,68 +1,86 @@
import { ServerCheckGuard } from './server-check.guard'; import { ServerCheckGuard } from './server-check.guard';
import { Router } from '@angular/router'; import { Router, NavigationStart, UrlTree, NavigationEnd, RouterEvent } from '@angular/router';
import { of } from 'rxjs'; import { of, ReplaySubject } from 'rxjs';
import { take } from 'rxjs/operators';
import { getPageInternalServerErrorRoute } from '../../app-routing-paths';
import { RootDataService } from '../data/root-data.service'; import { RootDataService } from '../data/root-data.service';
import { TestScheduler } from 'rxjs/testing';
import SpyObj = jasmine.SpyObj; import SpyObj = jasmine.SpyObj;
describe('ServerCheckGuard', () => { describe('ServerCheckGuard', () => {
let guard: ServerCheckGuard; let guard: ServerCheckGuard;
let router: SpyObj<Router>; let router: Router;
const eventSubject = new ReplaySubject<RouterEvent>(1);
let rootDataServiceStub: SpyObj<RootDataService>; let rootDataServiceStub: SpyObj<RootDataService>;
let testScheduler: TestScheduler;
rootDataServiceStub = jasmine.createSpyObj('RootDataService', { let redirectUrlTree: UrlTree;
checkServerAvailability: jasmine.createSpy('checkServerAvailability'),
invalidateRootCache: jasmine.createSpy('invalidateRootCache')
});
router = jasmine.createSpyObj('Router', {
navigateByUrl: jasmine.createSpy('navigateByUrl')
});
beforeEach(() => { beforeEach(() => {
testScheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
rootDataServiceStub = jasmine.createSpyObj('RootDataService', {
checkServerAvailability: jasmine.createSpy('checkServerAvailability'),
invalidateRootCache: jasmine.createSpy('invalidateRootCache'),
findRoot: jasmine.createSpy('findRoot')
});
redirectUrlTree = new UrlTree();
router = {
events: eventSubject.asObservable(),
navigateByUrl: jasmine.createSpy('navigateByUrl'),
parseUrl: jasmine.createSpy('parseUrl').and.returnValue(redirectUrlTree)
} as any;
guard = new ServerCheckGuard(router, rootDataServiceStub); guard = new ServerCheckGuard(router, rootDataServiceStub);
}); });
afterEach(() => {
router.navigateByUrl.calls.reset();
rootDataServiceStub.invalidateRootCache.calls.reset();
});
it('should be created', () => { it('should be created', () => {
expect(guard).toBeTruthy(); expect(guard).toBeTruthy();
}); });
describe('when root endpoint has succeeded', () => { describe('when root endpoint request has succeeded', () => {
beforeEach(() => { beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(true)); rootDataServiceStub.checkServerAvailability.and.returnValue(of(true));
}); });
it('should not redirect to error page', () => { it('should return true', () => {
guard.canActivateChild({} as any, {} as any).pipe( testScheduler.run(({ expectObservable }) => {
take(1) const result$ = guard.canActivateChild({} as any, {} as any);
).subscribe((canActivate: boolean) => { expectObservable(result$).toBe('(a|)', { a: true });
expect(canActivate).toEqual(true);
expect(rootDataServiceStub.invalidateRootCache).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
}); });
}); });
}); });
describe('when root endpoint has not succeeded', () => { describe('when root endpoint request has not succeeded', () => {
beforeEach(() => { beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(false)); rootDataServiceStub.checkServerAvailability.and.returnValue(of(false));
}); });
it('should redirect to error page', () => { it('should return a UrlTree with the route to the 500 error page', () => {
guard.canActivateChild({} as any, {} as any).pipe( testScheduler.run(({ expectObservable }) => {
take(1) const result$ = guard.canActivateChild({} as any, {} as any);
).subscribe((canActivate: boolean) => { expectObservable(result$).toBe('(b|)', { b: redirectUrlTree });
expect(canActivate).toEqual(false);
expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith(getPageInternalServerErrorRoute());
}); });
expect(router.parseUrl).toHaveBeenCalledWith('/500');
});
});
describe(`listenForRouteChanges`, () => {
it(`should retrieve the root endpoint, without using the cache, when the method is first called`, () => {
testScheduler.run(() => {
guard.listenForRouteChanges();
expect(rootDataServiceStub.findRoot).toHaveBeenCalledWith(false);
});
});
it(`should invalidate the root cache on every NavigationStart event`, () => {
testScheduler.run(() => {
guard.listenForRouteChanges();
eventSubject.next(new NavigationStart(1,''));
eventSubject.next(new NavigationEnd(1,'', ''));
eventSubject.next(new NavigationStart(2,''));
eventSubject.next(new NavigationEnd(2,'', ''));
eventSubject.next(new NavigationStart(3,''));
});
expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalledTimes(3);
}); });
}); });
}); });

View File

@@ -1,8 +1,15 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router'; import {
ActivatedRouteSnapshot,
CanActivateChild,
Router,
RouterStateSnapshot,
UrlTree,
NavigationStart
} from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { take, tap } from 'rxjs/operators'; import { take, map, filter } from 'rxjs/operators';
import { RootDataService } from '../data/root-data.service'; import { RootDataService } from '../data/root-data.service';
import { getPageInternalServerErrorRoute } from '../../app-routing-paths'; import { getPageInternalServerErrorRoute } from '../../app-routing-paths';
@@ -23,17 +30,38 @@ export class ServerCheckGuard implements CanActivateChild {
*/ */
canActivateChild( canActivateChild(
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> { state: RouterStateSnapshot
): Observable<boolean | UrlTree> {
return this.rootDataService.checkServerAvailability().pipe( return this.rootDataService.checkServerAvailability().pipe(
take(1), take(1),
tap((isAvailable: boolean) => { map((isAvailable: boolean) => {
if (!isAvailable) { if (!isAvailable) {
this.rootDataService.invalidateRootCache(); return this.router.parseUrl(getPageInternalServerErrorRoute());
this.router.navigateByUrl(getPageInternalServerErrorRoute()); } else {
return true;
} }
}) })
); );
}
/**
* Listen to all router events. Every time a new navigation starts, invalidate the cache
* for the root endpoint. That way we retrieve it once per routing operation to ensure the
* backend is not down. But if the guard is called multiple times during the same routing
* operation, the cached version is used.
*/
listenForRouteChanges(): void {
// we'll always be too late for the first NavigationStart event with the router subscribe below,
// so this statement is for the very first route operation. A `find` without using the cache,
// rather than an invalidateRootCache, because invalidating as the app is bootstrapping can
// break other features
this.rootDataService.findRoot(false);
this.router.events.pipe(
filter(event => event instanceof NavigationStart),
).subscribe(() => {
this.rootDataService.invalidateRootCache();
});
} }
} }

View File

@@ -1,4 +1,4 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync, fakeAsync, flush } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CurationFormComponent } from './curation-form.component'; import { CurationFormComponent } from './curation-form.component';
@@ -16,6 +16,7 @@ import { ConfigurationDataService } from '../core/data/configuration-data.servic
import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import { ConfigurationProperty } from '../core/shared/configuration-property.model';
import { getProcessDetailRoute } from '../process-page/process-page-routing.paths'; import { getProcessDetailRoute } from '../process-page/process-page-routing.paths';
import { HandleService } from '../shared/handle.service'; import { HandleService } from '../shared/handle.service';
import { of as observableOf } from 'rxjs';
describe('CurationFormComponent', () => { describe('CurationFormComponent', () => {
let comp: CurationFormComponent; let comp: CurationFormComponent;
@@ -54,7 +55,7 @@ describe('CurationFormComponent', () => {
}); });
handleService = { handleService = {
normalizeHandle: (a) => a normalizeHandle: (a: string) => observableOf(a),
} as any; } as any;
notificationsService = new NotificationsServiceStub(); notificationsService = new NotificationsServiceStub();
@@ -150,12 +151,13 @@ describe('CurationFormComponent', () => {
], []); ], []);
}); });
it(`should show an error notification and return when an invalid dsoHandle is provided`, () => { it(`should show an error notification and return when an invalid dsoHandle is provided`, fakeAsync(() => {
comp.dsoHandle = 'test-handle'; comp.dsoHandle = 'test-handle';
spyOn(handleService, 'normalizeHandle').and.returnValue(null); spyOn(handleService, 'normalizeHandle').and.returnValue(observableOf(null));
comp.submit(); comp.submit();
flush();
expect(notificationsService.error).toHaveBeenCalled(); expect(notificationsService.error).toHaveBeenCalled();
expect(scriptDataService.invoke).not.toHaveBeenCalled(); expect(scriptDataService.invoke).not.toHaveBeenCalled();
}); }));
}); });

View File

@@ -1,23 +1,23 @@
import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ScriptDataService } from '../core/data/processes/script-data.service'; import { ScriptDataService } from '../core/data/processes/script-data.service';
import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../core/shared/operators';
import { find, map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { NotificationsService } from '../shared/notifications/notifications.service'; import { NotificationsService } from '../shared/notifications/notifications.service';
import { TranslateService, TranslateModule } from '@ngx-translate/core'; import { TranslateService, TranslateModule } from '@ngx-translate/core';
import { hasValue, isEmpty, isNotEmpty } from '../shared/empty.util'; import { hasValue, isEmpty, isNotEmpty } from '../shared/empty.util';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ProcessDataService } from '../core/data/processes/process-data.service';
import { Process } from '../process-page/processes/process.model'; import { Process } from '../process-page/processes/process.model';
import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { ConfigurationDataService } from '../core/data/configuration-data.service';
import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import { ConfigurationProperty } from '../core/shared/configuration-property.model';
import { Observable } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { getProcessDetailRoute } from '../process-page/process-page-routing.paths'; import { getProcessDetailRoute } from '../process-page/process-page-routing.paths';
import { HandleService } from '../shared/handle.service'; import { HandleService } from '../shared/handle.service';
import { NgFor, NgIf } from '@angular/common'; import { NgFor, NgIf } from '@angular/common';
export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask'; export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask';
/** /**
* Component responsible for rendering the Curation Task form * Component responsible for rendering the Curation Task form
*/ */
@@ -27,7 +27,7 @@ export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask';
standalone: true, standalone: true,
imports: [FormsModule, ReactiveFormsModule, NgFor, NgIf, TranslateModule] imports: [FormsModule, ReactiveFormsModule, NgFor, NgIf, TranslateModule]
}) })
export class CurationFormComponent implements OnInit { export class CurationFormComponent implements OnDestroy, OnInit {
config: Observable<RemoteData<ConfigurationProperty>>; config: Observable<RemoteData<ConfigurationProperty>>;
tasks: string[]; tasks: string[];
@@ -36,10 +36,11 @@ export class CurationFormComponent implements OnInit {
@Input() @Input()
dsoHandle: string; dsoHandle: string;
subs: Subscription[] = [];
constructor( constructor(
private scriptDataService: ScriptDataService, private scriptDataService: ScriptDataService,
private configurationDataService: ConfigurationDataService, private configurationDataService: ConfigurationDataService,
private processDataService: ProcessDataService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private translateService: TranslateService, private translateService: TranslateService,
private handleService: HandleService, private handleService: HandleService,
@@ -48,6 +49,10 @@ export class CurationFormComponent implements OnInit {
) { ) {
} }
ngOnDestroy(): void {
this.subs.forEach((sub: Subscription) => sub.unsubscribe());
}
ngOnInit(): void { ngOnInit(): void {
this.form = new UntypedFormGroup({ this.form = new UntypedFormGroup({
task: new UntypedFormControl(''), task: new UntypedFormControl(''),
@@ -55,16 +60,15 @@ export class CurationFormComponent implements OnInit {
}); });
this.config = this.configurationDataService.findByPropertyName(CURATION_CFG); this.config = this.configurationDataService.findByPropertyName(CURATION_CFG);
this.config.pipe( this.subs.push(this.config.pipe(
find((rd: RemoteData<ConfigurationProperty>) => rd.hasSucceeded), getFirstSucceededRemoteDataPayload(),
map((rd: RemoteData<ConfigurationProperty>) => rd.payload) ).subscribe((configProperties: ConfigurationProperty) => {
).subscribe((configProperties) => {
this.tasks = configProperties.values this.tasks = configProperties.values
.filter((value) => isNotEmpty(value) && value.includes('=')) .filter((value) => isNotEmpty(value) && value.includes('='))
.map((value) => value.split('=')[1].trim()); .map((value) => value.split('=')[1].trim());
this.form.get('task').patchValue(this.tasks[0]); this.form.get('task').patchValue(this.tasks[0]);
this.cdr.detectChanges(); this.cdr.detectChanges();
}); }));
} }
/** /**
@@ -80,33 +84,41 @@ export class CurationFormComponent implements OnInit {
*/ */
submit() { submit() {
const taskName = this.form.get('task').value; const taskName = this.form.get('task').value;
let handle; let handle$: Observable<string | null>;
if (this.hasHandleValue()) { if (this.hasHandleValue()) {
handle = this.handleService.normalizeHandle(this.dsoHandle); handle$ = this.handleService.normalizeHandle(this.dsoHandle).pipe(
if (isEmpty(handle)) { map((handle: string | null) => {
this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), if (isEmpty(handle)) {
this.translateService.get('curation.form.submit.error.invalid-handle')); this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'),
return; this.translateService.get('curation.form.submit.error.invalid-handle'));
} }
return handle;
}),
);
} else { } else {
handle = this.handleService.normalizeHandle(this.form.get('handle').value); handle$ = this.handleService.normalizeHandle(this.form.get('handle').value).pipe(
if (isEmpty(handle)) { map((handle: string | null) => isEmpty(handle) ? 'all' : handle),
handle = 'all'; );
}
} }
this.scriptDataService.invoke('curate', [ this.subs.push(handle$.subscribe((handle: string) => {
{ name: '-t', value: taskName }, if (hasValue(handle)) {
{ name: '-i', value: handle }, this.subs.push(this.scriptDataService.invoke('curate', [
], []).pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<Process>) => { { name: '-t', value: taskName },
if (rd.hasSucceeded) { { name: '-i', value: handle },
this.notificationsService.success(this.translateService.get('curation.form.submit.success.head'), ], []).pipe(
this.translateService.get('curation.form.submit.success.content')); getFirstCompletedRemoteData(),
this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId)); ).subscribe((rd: RemoteData<Process>) => {
} else { if (rd.hasSucceeded) {
this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'), this.notificationsService.success(this.translateService.get('curation.form.submit.success.head'),
this.translateService.get('curation.form.submit.error.content')); this.translateService.get('curation.form.submit.success.content'));
void this.router.navigateByUrl(getProcessDetailRoute(rd.payload.processId));
} else {
this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'),
this.translateService.get('curation.form.submit.error.content'));
}
}));
} }
}); }));
} }
} }

View File

@@ -1,3 +1,3 @@
<ds-register-email-form <ds-themed-register-email-form
[MESSAGE_PREFIX]="'forgot-email.form'" [typeRequest]="typeRequest"> [MESSAGE_PREFIX]="'forgot-email.form'" [typeRequest]="typeRequest">
</ds-register-email-form> </ds-themed-register-email-form>

View File

@@ -3,13 +3,14 @@ import {
RegisterEmailFormComponent, RegisterEmailFormComponent,
TYPE_REQUEST_FORGOT TYPE_REQUEST_FORGOT
} from '../../register-email-form/register-email-form.component'; } from '../../register-email-form/register-email-form.component';
import { ThemedRegisterEmailFormComponent } from 'src/app/register-email-form/themed-registry-email-form.component';
@Component({ @Component({
selector: 'ds-forgot-email', selector: 'ds-forgot-email',
styleUrls: ['./forgot-email.component.scss'], styleUrls: ['./forgot-email.component.scss'],
templateUrl: './forgot-email.component.html', templateUrl: './forgot-email.component.html',
imports: [ imports: [
RegisterEmailFormComponent RegisterEmailFormComponent, ThemedRegisterEmailFormComponent
], ],
standalone: true standalone: true
}) })

View File

@@ -1,4 +1,4 @@
<div [ngClass]="{'open': !(isNavBarCollapsed | async)}"> <div [ngClass]="{'open': !(isNavBarCollapsed | async)}" id="header-navbar-wrapper">
<ds-themed-header></ds-themed-header> <ds-themed-header></ds-themed-header>
<ds-themed-navbar></ds-themed-navbar> <ds-themed-navbar></ds-themed-navbar>
</div> </div>

View File

@@ -1,4 +1,6 @@
:host { :host {
position: relative; position: relative;
z-index: var(--ds-nav-z-index); div#header-navbar-wrapper {
border-bottom: 1px var(--ds-header-navbar-border-bottom-color) solid;
}
} }

View File

@@ -1,6 +1,6 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, ElementRef } from '@angular/core';
import { ContextHelpService } from '../../shared/context-help.service'; import { ContextHelpService } from '../../shared/context-help.service';
import { Observable } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { NgIf, AsyncPipe } from '@angular/common'; import { NgIf, AsyncPipe } from '@angular/common';
@@ -19,12 +19,23 @@ import { NgIf, AsyncPipe } from '@angular/common';
export class ContextHelpToggleComponent implements OnInit { export class ContextHelpToggleComponent implements OnInit {
buttonVisible$: Observable<boolean>; buttonVisible$: Observable<boolean>;
subscriptions: Subscription[] = [];
constructor( constructor(
private contextHelpService: ContextHelpService, protected elRef: ElementRef,
) { } protected contextHelpService: ContextHelpService,
) {
}
ngOnInit(): void { ngOnInit(): void {
this.buttonVisible$ = this.contextHelpService.tooltipCount$().pipe(map(x => x > 0)); this.buttonVisible$ = this.contextHelpService.tooltipCount$().pipe(map(x => x > 0));
this.subscriptions.push(this.buttonVisible$.subscribe((showContextHelpToggle: boolean) => {
if (showContextHelpToggle) {
this.elRef.nativeElement.classList.remove('d-none');
} else {
this.elRef.nativeElement.classList.add('d-none');
}
}));
} }
onClick() { onClick() {

View File

@@ -7,12 +7,12 @@
<nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0"> <nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0">
<ds-themed-search-navbar></ds-themed-search-navbar> <ds-themed-search-navbar></ds-themed-search-navbar>
<ds-lang-switch></ds-lang-switch> <ds-themed-lang-switch></ds-themed-lang-switch>
<ds-context-help-toggle></ds-context-help-toggle> <ds-context-help-toggle></ds-context-help-toggle>
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu> <ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar> <ds-impersonate-navbar></ds-impersonate-navbar>
<div class="pl-2"> <div *ngIf="isXsOrSm$ | async" class="pl-2">
<button class="navbar-toggler" type="button" (click)="toggleNavbar()" <button class="navbar-toggler px-0" type="button" (click)="toggleNavbar()"
aria-controls="collapsingNav" aria-controls="collapsingNav"
aria-expanded="false" [attr.aria-label]="'nav.toggle' | translate"> aria-expanded="false" [attr.aria-label]="'nav.toggle' | translate">
<span class="navbar-toggler-icon fas fa-bars fa-fw" aria-hidden="true"></span> <span class="navbar-toggler-icon fas fa-bars fa-fw" aria-hidden="true"></span>

View File

@@ -1,3 +1,7 @@
header {
background-color: var(--ds-header-bg);
}
.navbar-brand img { .navbar-brand img {
max-height: var(--ds-header-logo-height); max-height: var(--ds-header-logo-height);
max-width: 100%; max-width: 100%;
@@ -20,3 +24,8 @@
} }
} }
.navbar {
display: flex;
gap: calc(var(--bs-spacer) / 3);
align-items: center;
}

View File

@@ -18,6 +18,8 @@ import { LangSwitchComponent } from '../shared/lang-switch/lang-switch.component
import { ContextHelpToggleComponent } from './context-help-toggle/context-help-toggle.component'; import { ContextHelpToggleComponent } from './context-help-toggle/context-help-toggle.component';
import { ThemedAuthNavMenuComponent } from '../shared/auth-nav-menu/themed-auth-nav-menu.component'; import { ThemedAuthNavMenuComponent } from '../shared/auth-nav-menu/themed-auth-nav-menu.component';
import { ImpersonateNavbarComponent } from '../shared/impersonate-navbar/impersonate-navbar.component'; import { ImpersonateNavbarComponent } from '../shared/impersonate-navbar/impersonate-navbar.component';
import { HostWindowService } from '../shared/host-window.service';
import { HostWindowServiceStub } from '../shared/testing/host-window-service.stub';
let comp: HeaderComponent; let comp: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>; let fixture: ComponentFixture<HeaderComponent>;
@@ -44,7 +46,8 @@ describe('HeaderComponent', () => {
providers: [ providers: [
{ provide: MenuService, useValue: menuService }, { provide: MenuService, useValue: menuService },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub()}, { provide: ActivatedRoute, useValue: new ActivatedRouteStub()},
{ provide: LocaleService, useValue: mockLocaleService } { provide: LocaleService, useValue: mockLocaleService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })
@@ -61,7 +64,7 @@ describe('HeaderComponent', () => {
fixture = TestBed.createComponent(HeaderComponent); fixture = TestBed.createComponent(HeaderComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
fixture.detectChanges();
}); });
describe('when the toggle button is clicked', () => { describe('when the toggle button is clicked', () => {

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { MenuService } from '../shared/menu/menu.service'; import { MenuService } from '../shared/menu/menu.service';
import { MenuID } from '../shared/menu/menu-id.model'; import { MenuID } from '../shared/menu/menu-id.model';
@@ -10,6 +10,8 @@ import { LangSwitchComponent } from '../shared/lang-switch/lang-switch.component
import { ThemedSearchNavbarComponent } from '../search-navbar/themed-search-navbar.component'; import { ThemedSearchNavbarComponent } from '../search-navbar/themed-search-navbar.component';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { HostWindowService } from '../shared/host-window.service';
import { ThemedLangSwitchComponent } from '../shared/lang-switch/themed-lang-switch.component';
/** /**
* Represents the header with the logo and simple navigation * Represents the header with the logo and simple navigation
@@ -19,22 +21,27 @@ import { RouterLink } from '@angular/router';
styleUrls: ['header.component.scss'], styleUrls: ['header.component.scss'],
templateUrl: 'header.component.html', templateUrl: 'header.component.html',
standalone: true, standalone: true,
imports: [RouterLink, NgbDropdownModule, ThemedSearchNavbarComponent, LangSwitchComponent, ContextHelpToggleComponent, ThemedAuthNavMenuComponent, ImpersonateNavbarComponent, TranslateModule] imports: [RouterLink, ThemedLangSwitchComponent, NgbDropdownModule, ThemedSearchNavbarComponent, LangSwitchComponent, ContextHelpToggleComponent, ThemedAuthNavMenuComponent, ImpersonateNavbarComponent, TranslateModule]
}) })
export class HeaderComponent { export class HeaderComponent implements OnInit {
/** /**
* Whether user is authenticated. * Whether user is authenticated.
* @type {Observable<string>} * @type {Observable<string>}
*/ */
public isAuthenticated: Observable<boolean>; public isAuthenticated: Observable<boolean>;
public showAuth = false; public isXsOrSm$: Observable<boolean>;
menuID = MenuID.PUBLIC; menuID = MenuID.PUBLIC;
constructor( constructor(
private menuService: MenuService protected menuService: MenuService,
protected windowService: HostWindowService,
) { ) {
} }
ngOnInit(): void {
this.isXsOrSm$ = this.windowService.isXsOrSm();
}
public toggleNavbar(): void { public toggleNavbar(): void {
this.menuService.toggleMenu(this.menuID); this.menuService.toggleMenu(this.menuID);
} }

View File

@@ -1,4 +1,4 @@
<div class="jumbotron jumbotron-fluid"> <div class="jumbotron jumbotron-fluid mt-ncs">
<div class="container"> <div class="container">
<div class="d-flex flex-wrap"> <div class="d-flex flex-wrap">
<div> <div>

View File

@@ -1,7 +1,5 @@
:host { :host {
display: block; display: block;
margin-top: calc(-1 * var(--ds-content-spacing));
margin-bottom: calc(-1 * var(--ds-content-spacing));
} }
.display-3 { .display-3 {

View File

@@ -1,12 +1,12 @@
<ng-container *ngVar="(itemRD$ | async) as itemRD"> <ng-container *ngVar="(itemRD$ | async) as itemRD">
<div class="mt-4" [ngClass]="placeholderFontClass" *ngIf="itemRD?.hasSucceeded && itemRD?.payload?.page.length > 0" @fadeIn> <div class="mt-4" [ngClass]="placeholderFontClass" *ngIf="itemRD?.hasSucceeded && itemRD?.payload?.page.length > 0" @fadeIn>
<div class="d-flex flex-row border-bottom mb-4 pb-4 ng-tns-c416-2"></div> <div class="d-flex flex-row border-bottom mb-4 pb-4"></div>
<h2> {{'home.recent-submissions.head' | translate}}</h2> <h2> {{'home.recent-submissions.head' | translate}}</h2>
<div class="my-4" *ngFor="let item of itemRD?.payload?.page"> <div class="my-4" *ngFor="let item of itemRD?.payload?.page">
<ds-listable-object-component-loader [object]="item" [viewMode]="viewMode" class="pb-4"> <ds-listable-object-component-loader [object]="item" [viewMode]="viewMode" class="pb-4">
</ds-listable-object-component-loader> </ds-listable-object-component-loader>
</div> </div>
<button (click)="onLoadMore()" class="btn btn-primary search-button mt-4 float-left ng-tns-c290-40"> {{'vocabulary-treeview.load-more' | translate }} ...</button> <button (click)="onLoadMore()" class="btn btn-primary search-button mt-4"> {{'vocabulary-treeview.load-more' | translate }} ...</button>
</div> </div>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.recent-submissions' | translate}}"></ds-error> <ds-error *ngIf="itemRD?.hasFailed" message="{{'error.recent-submissions' | translate}}"></ds-error>
<ds-loading *ngIf="!itemRD || itemRD.isLoading" message="{{'loading.recent-submissions' | translate}}"> <ds-loading *ngIf="!itemRD || itemRD.isLoading" message="{{'loading.recent-submissions' | translate}}">

View File

@@ -1,5 +1,5 @@
<div class="row row-offcanvas row-offcanvas-right"> <div class="row row-offcanvas row-offcanvas-right">
<div class="col-xs-12 col-sm-12 col-md-9 main-content"> <div class="col-xs-12 col-sm-12 col-md-9">
<form class="primary" [formGroup]="feedbackForm" (ngSubmit)="createFeedback()"> <form class="primary" [formGroup]="feedbackForm" (ngSubmit)="createFeedback()">
<h2>{{ 'info.feedback.head' | translate }}</h2> <h2>{{ 'info.feedback.head' | translate }}</h2>
<p>{{ 'info.feedback.info' | translate }}</p> <p>{{ 'info.feedback.info' | translate }}</p>

View File

@@ -38,6 +38,8 @@ import { ItemPageBitstreamsGuard } from './item-page-bitstreams.guard';
import { ItemPageRelationshipsGuard } from './item-page-relationships.guard'; import { ItemPageRelationshipsGuard } from './item-page-relationships.guard';
import { ItemPageVersionHistoryGuard } from './item-page-version-history.guard'; import { ItemPageVersionHistoryGuard } from './item-page-version-history.guard';
import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard'; import { ItemPageCollectionMapperGuard } from './item-page-collection-mapper.guard';
import { ItemPageCurateGuard } from './item-page-curate.guard';
import { ItemPageAccessControlGuard } from './item-page-access-control.guard';
import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component'; import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component';
import { ItemPageRegisterDoiGuard } from './item-page-register-doi.guard'; import { ItemPageRegisterDoiGuard } from './item-page-register-doi.guard';
import { ItemCurateComponent } from './item-curate/item-curate.component'; import { ItemCurateComponent } from './item-curate/item-curate.component';
@@ -87,7 +89,8 @@ import { ItemAccessControlComponent } from './item-access-control/item-access-co
{ {
path: 'curate', path: 'curate',
component: ItemCurateComponent, component: ItemCurateComponent,
data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true } data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true },
canActivate: [ItemPageCurateGuard]
}, },
{ {
path: 'relationships', path: 'relationships',
@@ -116,7 +119,8 @@ import { ItemAccessControlComponent } from './item-access-control/item-access-co
{ {
path: 'access-control', path: 'access-control',
component: ItemAccessControlComponent, component: ItemAccessControlComponent,
data: { title: 'item.edit.tabs.access-control.title', showBreadcrumbs: true } data: { title: 'item.edit.tabs.access-control.title', showBreadcrumbs: true },
canActivate: [ItemPageAccessControlGuard]
}, },
{ {
path: 'mapper', path: 'mapper',
@@ -202,11 +206,13 @@ import { ItemAccessControlComponent } from './item-access-control/item-access-co
ItemPageWithdrawGuard, ItemPageWithdrawGuard,
ItemPageAdministratorGuard, ItemPageAdministratorGuard,
ItemPageMetadataGuard, ItemPageMetadataGuard,
ItemPageCurateGuard,
ItemPageStatusGuard, ItemPageStatusGuard,
ItemPageBitstreamsGuard, ItemPageBitstreamsGuard,
ItemPageRelationshipsGuard, ItemPageRelationshipsGuard,
ItemPageVersionHistoryGuard, ItemPageVersionHistoryGuard,
ItemPageCollectionMapperGuard, ItemPageCollectionMapperGuard,
ItemPageAccessControlGuard,
ItemPageRegisterDoiGuard, ItemPageRegisterDoiGuard,
] ]
}) })

View File

@@ -25,7 +25,7 @@
<div class="{{columnSizes.columns[3].buildClasses()}} row-element d-flex align-items-center"> <div class="{{columnSizes.columns[3].buildClasses()}} row-element d-flex align-items-center">
<div class="text-center w-100"> <div class="text-center w-100">
<div class="btn-group relationship-action-buttons"> <div class="btn-group relationship-action-buttons">
<a *ngIf="bitstreamDownloadUrl != null" [href]="bitstreamDownloadUrl" <a *ngIf="bitstreamDownloadUrl != null" [routerLink]="bitstreamDownloadUrl"
class="btn btn-outline-primary btn-sm" class="btn btn-outline-primary btn-sm"
title="{{'item.edit.bitstreams.edit.buttons.download' | translate}}" title="{{'item.edit.bitstreams.edit.buttons.download' | translate}}"
[attr.data-test]="'download-button' | dsBrowserOnly"> [attr.data-test]="'download-button' | dsBrowserOnly">

View File

@@ -15,6 +15,7 @@ import { By } from '@angular/platform-browser';
import { BrowserOnlyMockPipe } from '../../../../shared/testing/browser-only-mock.pipe'; import { BrowserOnlyMockPipe } from '../../../../shared/testing/browser-only-mock.pipe';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub'; import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub';
import { RouterTestingModule } from '@angular/router/testing';
let comp: ItemEditBitstreamComponent; let comp: ItemEditBitstreamComponent;
let fixture: ComponentFixture<ItemEditBitstreamComponent>; let fixture: ComponentFixture<ItemEditBitstreamComponent>;
@@ -74,7 +75,10 @@ describe('ItemEditBitstreamComponent', () => {
); );
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), ItemEditBitstreamComponent, imports: [
TranslateModule.forRoot(),
RouterTestingModule.withRoutes([]),
ItemEditBitstreamComponent,
VarDirective], VarDirective],
declarations: [BrowserOnlyMockPipe], declarations: [BrowserOnlyMockPipe],
providers: [ providers: [

View File

@@ -21,7 +21,11 @@
<div class="col-12"> <div class="col-12">
<p> <p>
<label for="inheritPoliciesCheckbox"> <label for="inheritPoliciesCheckbox">
<input type="checkbox" name="tc" [(ngModel)]="inheritPolicies" id="inheritPoliciesCheckbox"> <ng-template #tooltipContent>
{{ 'item.edit.move.inheritpolicies.tooltip' | translate }}
</ng-template>
<input type="checkbox" name="tc" [(ngModel)]="inheritPolicies" id="inheritPoliciesCheckbox" [ngbTooltip]="tooltipContent"
>
{{'item.edit.move.inheritpolicies.checkbox' |translate}} {{'item.edit.move.inheritpolicies.checkbox' |translate}}
</label> </label>
</p> </p>

View File

@@ -22,12 +22,14 @@ import { AsyncPipe, NgIf } from '@angular/common';
import { import {
AuthorizedCollectionSelectorComponent AuthorizedCollectionSelectorComponent
} from '../../../shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component'; } from '../../../shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
@Component({ @Component({
selector: 'ds-item-move', selector: 'ds-item-move',
templateUrl: './item-move.component.html', templateUrl: './item-move.component.html',
imports: [ imports: [
TranslateModule, TranslateModule,
NgbModule,
FormsModule, FormsModule,
RouterLink, RouterLink,
AsyncPipe, AsyncPipe,

View File

@@ -28,4 +28,12 @@ export class ItemOperation {
this.disabled = disabled; this.disabled = disabled;
} }
/**
* Set whether this operation is authorized
* @param authorized
*/
setAuthorized(authorized: boolean): void {
this.authorized = authorized;
}
} }

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from '../item-page.resolver';
import { Item } from '../../core/shared/item.model';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights
*/
export class ItemPageAccessControlGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ItemPageResolver } from '../item-page.resolver';
import { Item } from '../../core/shared/item.model';
import { DsoPageSingleFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-single-feature.guard';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthService } from '../../core/auth/auth.service';
@Injectable({
providedIn: 'root'
})
/**
* Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights
*/
export class ItemPageCurateGuard extends DsoPageSingleFeatureGuard<Item> {
constructor(protected resolver: ItemPageResolver,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(resolver, authorizationService, router, authService);
}
/**
* Check administrator authorization rights
*/
getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.AdministratorOf);
}
}

View File

@@ -27,7 +27,7 @@
</div> </div>
<div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}"> <div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}">
<ds-item-operation *ngIf="operation" [operation]="operation"></ds-item-operation> <ds-item-operation [operation]="operation"></ds-item-operation>
</div> </div>
</div> </div>

View File

@@ -16,6 +16,7 @@ import { AuthorizationDataService } from '../../../core/data/feature-authorizati
import { IdentifierDataService } from '../../../core/data/identifier-data.service'; import { IdentifierDataService } from '../../../core/data/identifier-data.service';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service';
let mockIdentifierDataService: IdentifierDataService; let mockIdentifierDataService: IdentifierDataService;
let mockConfigurationDataService: ConfigurationDataService; let mockConfigurationDataService: ConfigurationDataService;
@@ -57,12 +58,18 @@ describe('ItemStatusComponent', () => {
}; };
let authorizationService: AuthorizationDataService; let authorizationService: AuthorizationDataService;
let orcidAuthService: any;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
authorizationService = jasmine.createSpyObj('authorizationService', { authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true) isAuthorized: observableOf(true)
}); });
orcidAuthService = jasmine.createSpyObj('OrcidAuthService', {
onlyAdminCanDisconnectProfileFromOrcid: observableOf ( true ),
isLinkedToOrcid: true
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule, ItemStatusComponent], imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule, ItemStatusComponent],
providers: [ providers: [
@@ -70,9 +77,10 @@ describe('ItemStatusComponent', () => {
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
{ provide: IdentifierDataService, useValue: mockIdentifierDataService }, { provide: IdentifierDataService, useValue: mockIdentifierDataService },
{ provide: ConfigurationDataService, useValue: mockConfigurationDataService } { provide: ConfigurationDataService, useValue: mockConfigurationDataService },
], schemas: [CUSTOM_ELEMENTS_SCHEMA] { provide: OrcidAuthService, useValue: orcidAuthService },
}).compileComponents(); ], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
})); }));
beforeEach(() => { beforeEach(() => {

View File

@@ -3,16 +3,14 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model'; import { ItemOperation } from '../item-operation/itemOperation.model';
import { distinctUntilChanged, map, mergeMap, switchMap, toArray } from 'rxjs/operators'; import { concatMap, distinctUntilChanged, first, map, mergeMap, switchMap, toArray } from 'rxjs/operators';
import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable, of, Subscription } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { hasValue } from '../../../shared/empty.util';
import { import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, } from '../../../core/shared/operators';
getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload,
} from '../../../core/shared/operators';
import { IdentifierDataService } from '../../../core/data/identifier-data.service'; import { IdentifierDataService } from '../../../core/data/identifier-data.service';
import { Identifier } from '../../../shared/object-list/identifier-data/identifier.model'; import { Identifier } from '../../../shared/object-list/identifier-data/identifier.model';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
@@ -21,6 +19,7 @@ import { IdentifierData } from '../../../shared/object-list/identifier-data/iden
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { AsyncPipe, NgClass, NgForOf, NgIf } from '@angular/common'; import { AsyncPipe, NgClass, NgForOf, NgIf } from '@angular/common';
import { ItemOperationComponent } from '../item-operation/item-operation.component'; import { ItemOperationComponent } from '../item-operation/item-operation.component';
import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service';
@Component({ @Component({
selector: 'ds-item-status', selector: 'ds-item-status',
@@ -86,6 +85,7 @@ export class ItemStatusComponent implements OnInit {
private authorizationService: AuthorizationDataService, private authorizationService: AuthorizationDataService,
private identifierDataService: IdentifierDataService, private identifierDataService: IdentifierDataService,
private configurationService: ConfigurationDataService, private configurationService: ConfigurationDataService,
private orcidAuthService: OrcidAuthService
) { ) {
} }
@@ -95,14 +95,16 @@ export class ItemStatusComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso)); this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso));
this.itemRD$.pipe( this.itemRD$.pipe(
first(),
map((data: RemoteData<Item>) => data.payload) map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => { ).pipe(
this.statusData = Object.assign({ switchMap((item: Item) => {
id: item.id, this.statusData = Object.assign({
handle: item.handle, id: item.id,
lastModified: item.lastModified handle: item.handle,
}); lastModified: item.lastModified
this.statusDataKeys = Object.keys(this.statusData); });
this.statusDataKeys = Object.keys(this.statusData);
// Observable for item identifiers (retrieved from embedded link) // Observable for item identifiers (retrieved from embedded link)
this.identifiers$ = this.identifierDataService.getIdentifierDataFor(item).pipe( this.identifiers$ = this.identifierDataService.getIdentifierDataFor(item).pipe(
@@ -118,99 +120,108 @@ export class ItemStatusComponent implements OnInit {
// Observable for configuration determining whether the Register DOI feature is enabled // Observable for configuration determining whether the Register DOI feature is enabled
let registerConfigEnabled$: Observable<boolean> = this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe( let registerConfigEnabled$: Observable<boolean> = this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
map((rd: RemoteData<ConfigurationProperty>) => { map((enabledRD: RemoteData<ConfigurationProperty>) => enabledRD.hasSucceeded && enabledRD.payload.values.length > 0)
// If the config property is exposed via rest and has a value set, return it
if (rd.hasSucceeded && hasValue(rd.payload) && isNotEmpty(rd.payload.values)) {
return rd.payload.values[0] === 'true';
}
// Otherwise, return false
return false;
})
); );
/* /**
Construct a base list of operations. * Construct a base list of operations.
The key is used to build messages * The key is used to build messages
i18n example: 'item.edit.tabs.status.buttons.<key>.label' * i18n example: 'item.edit.tabs.status.buttons.<key>.label'
The value is supposed to be a href for the button * The value is supposed to be a href for the button
*/
const operations: ItemOperation[] = [];
operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations', FeatureID.CanManagePolicies, true));
operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper', FeatureID.CanManageMappings, true));
if (item.isWithdrawn) {
operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate', FeatureID.ReinstateItem, true));
} else {
operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw', FeatureID.WithdrawItem, true));
}
if (item.isDiscoverable) {
operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private', FeatureID.CanMakePrivate, true));
} else {
operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public', FeatureID.CanMakePrivate, true));
}
operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete', FeatureID.CanDelete, true));
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move', FeatureID.CanMove, true));
this.operations$.next(operations);
/*
When the identifier data stream changes, determine whether the register DOI button should be shown or not.
This is based on whether the DOI is in the right state (minted or pending, not already queued for registration
or registered) and whether the configuration property identifiers.item-status.register-doi is true
*/ */
this.identifierDataService.getIdentifierDataFor(item).pipe( const currentUrl = this.getCurrentUrl(item);
getFirstSucceededRemoteData(), const inititalOperations: ItemOperation[] = [
getRemoteDataPayload(), new ItemOperation('authorizations', `${currentUrl}/authorizations`, FeatureID.CanManagePolicies, true),
mergeMap((data: IdentifierData) => { new ItemOperation('mappedCollections', `${currentUrl}/mapper`, FeatureID.CanManageMappings, true),
let identifiers = data.identifiers; item.isWithdrawn
let no_doi = true; ? new ItemOperation('reinstate', `${currentUrl}/reinstate`, FeatureID.ReinstateItem, true)
let pending = false; : new ItemOperation('withdraw', `${currentUrl}/withdraw`, FeatureID.WithdrawItem, true),
if (identifiers !== undefined && identifiers !== null) { item.isDiscoverable
identifiers.forEach((identifier: Identifier) => { ? new ItemOperation('private', `${currentUrl}/private`, FeatureID.CanMakePrivate, true)
if (hasValue(identifier) && identifier.identifierType === 'doi') { : new ItemOperation('public', `${currentUrl}/public`, FeatureID.CanMakePrivate, true),
// The item has some kind of DOI new ItemOperation('move', `${currentUrl}/move`, FeatureID.CanMove, true),
no_doi = false; new ItemOperation('delete', `${currentUrl}/delete`, FeatureID.CanDelete, true)
if (identifier.identifierStatus === 'PENDING' || identifier.identifierStatus === 'MINTED' ];
|| identifier.identifierStatus == null) {
// The item's DOI is pending, minted or null. this.operations$.next(inititalOperations);
// It isn't registered, reserved, queued for registration or reservation or update, deleted
// or queued for deletion. /**
pending = true; * When the identifier data stream changes, determine whether the register DOI button should be shown or not.
} * This is based on whether the DOI is in the right state (minted or pending, not already queued for registration
* or registered) and whether the configuration property identifiers.item-status.register-doi is true
*/
const ops$ = this.identifierDataService.getIdentifierDataFor(item).pipe(
getFirstCompletedRemoteData(),
mergeMap((dataRD: RemoteData<IdentifierData>) => {
if (dataRD.hasSucceeded) {
let identifiers = dataRD.payload.identifiers;
let no_doi = true;
let pending = false;
if (identifiers !== undefined && identifiers !== null) {
identifiers.forEach((identifier: Identifier) => {
if (hasValue(identifier) && identifier.identifierType === 'doi') {
// The item has some kind of DOI
no_doi = false;
if (['PENDING', 'MINTED', null].includes(identifier.identifierStatus)) {
// The item's DOI is pending, minted or null.
// It isn't registered, reserved, queued for registration or reservation or update, deleted
// or queued for deletion.
pending = true;
}
}
});
} }
}); // If there is no DOI, or a pending/minted/null DOI, and the config is enabled, return true
} return registerConfigEnabled$.pipe(
// If there is no DOI, or a pending/minted/null DOI, and the config is enabled, return true map((enabled: boolean) => {
return registerConfigEnabled$.pipe( return enabled && (pending || no_doi);
map((enabled: boolean) => { }
return enabled && (pending || no_doi); ));
} else {
return of(false);
}
}),
// Switch map pushes the register DOI operation onto a copy of the base array then returns to the pipe
switchMap((showDoi: boolean) => {
const ops = [...inititalOperations];
if (showDoi) {
const op = new ItemOperation('register-doi', `${currentUrl}/register-doi`, FeatureID.CanRegisterDOI, true);
ops.splice(ops.length - 1, 0, op); // Add item before last
}
return inititalOperations;
}),
concatMap((op: ItemOperation) => {
if (hasValue(op.featureID)) {
return this.authorizationService.isAuthorized(op.featureID, item.self).pipe(
distinctUntilChanged(),
map((authorized) => {
op.setDisabled(!authorized);
op.setAuthorized(authorized);
return op;
})
);
} }
));
}),
// Switch map pushes the register DOI operation onto a copy of the base array then returns to the pipe
switchMap((showDoi: boolean) => {
let ops = [...operations];
if (showDoi) {
ops.push(new ItemOperation('register-doi', this.getCurrentUrl(item) + '/register-doi', FeatureID.CanRegisterDOI, true));
}
return ops;
}),
// Merge map checks and transforms each operation in the array based on whether it is authorized or not (disabled)
mergeMap((op: ItemOperation) => {
if (hasValue(op.featureID)) {
return this.authorizationService.isAuthorized(op.featureID, item.self).pipe(
distinctUntilChanged(),
map((authorized) => new ItemOperation(op.operationKey, op.operationUrl, op.featureID, !authorized, authorized))
);
} else {
return [op]; return [op];
} }),
}), toArray()
// Wait for all operations to be emitted and return as an array );
toArray(),
).subscribe((data) => { let orcidOps$ = of([]);
// Update the operations$ subject that draws the administrative buttons on the status page if (this.orcidAuthService.isLinkedToOrcid(item)) {
this.operations$.next(data); orcidOps$ = this.orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid().pipe(
}); map((canDisconnect) => {
}); if (canDisconnect) {
return [new ItemOperation('unlinkOrcid', `${currentUrl}/unlink-orcid`)];
}
return [];
})
);
}
return combineLatest([ops$, orcidOps$]);
}),
map(([ops, orcidOps]: [ItemOperation[], ItemOperation[]]) => [...ops, ...orcidOps])
).subscribe((ops) => this.operations$.next(ops));
this.itemPageRoute$ = this.itemRD$.pipe( this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(), getAllSucceededRemoteDataPayload(),
@@ -219,6 +230,7 @@ export class ItemStatusComponent implements OnInit {
} }
/** /**
* Get the current url without query params * Get the current url without query params
* @returns {string} url * @returns {string} url

View File

@@ -77,7 +77,8 @@ export class MiradorViewerComponent implements OnInit {
const manifestApiEndpoint = encodeURIComponent(environment.rest.baseUrl + '/iiif/' const manifestApiEndpoint = encodeURIComponent(environment.rest.baseUrl + '/iiif/'
+ this.object.id + '/manifest'); + this.object.id + '/manifest');
// The Express path to Mirador viewer. // The Express path to Mirador viewer.
let viewerPath = '/iiif/mirador/index.html?manifest=' + manifestApiEndpoint; let viewerPath = `${environment.ui.nameSpace}${environment.ui.nameSpace.length > 1 ? '/' : ''}`
+ `iiif/mirador/index.html?manifest=${manifestApiEndpoint}`;
if (this.searchable) { if (this.searchable) {
// Tell the viewer add search to menu. // Tell the viewer add search to menu.
viewerPath += '&searchable=' + this.searchable; viewerPath += '&searchable=' + this.searchable;

View File

@@ -3,8 +3,8 @@
<div> <div>
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png" alt="{{'repository.image.logo' | translate}}"> <img class="mb-4 login-logo" src="assets/images/dspace-logo.png" alt="{{'repository.image.logo' | translate}}">
<h1 class="h3 mb-0 font-weight-normal">{{"login.form.header" | translate}}</h1> <h1 class="h3 mb-0 font-weight-normal">{{"login.form.header" | translate}}</h1>
<ds-log-in <ds-themed-log-in
[isStandalonePage]="true"></ds-log-in> [isStandalonePage]="true"></ds-themed-log-in>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -17,6 +17,7 @@ import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model';
import { isAuthenticated } from '../core/auth/selectors'; import { isAuthenticated } from '../core/auth/selectors';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { LogInComponent } from '../shared/log-in/log-in.component'; import { LogInComponent } from '../shared/log-in/log-in.component';
import { ThemedLogInComponent } from '../shared/log-in/themed-log-in.component';
/** /**
* This component represents the login page * This component represents the login page
@@ -26,7 +27,7 @@ import { LogInComponent } from '../shared/log-in/log-in.component';
styleUrls: ['./login-page.component.scss'], styleUrls: ['./login-page.component.scss'],
templateUrl: './login-page.component.html', templateUrl: './login-page.component.html',
standalone: true, standalone: true,
imports: [LogInComponent, TranslateModule] imports: [LogInComponent, ThemedLogInComponent, TranslateModule]
}) })
export class LoginPageComponent implements OnDestroy, OnInit { export class LoginPageComponent implements OnDestroy, OnInit {

View File

@@ -14,9 +14,9 @@
</a> </a>
<ul @slide *ngIf="(active | async)" (click)="deactivateSection($event)" <ul @slide *ngIf="(active | async)" (click)="deactivateSection($event)"
class="m-0 shadow-none border-top-0 dropdown-menu show"> class="m-0 shadow-none border-top-0 dropdown-menu show">
<ng-container *ngFor="let subSection of (subSections$ | async)"> <li *ngFor="let subSection of (subSections$ | async)">
<ng-container <ng-container
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container> *ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
</ng-container> </li>
</ul> </ul>
</div> </div>

View File

@@ -6,14 +6,20 @@
} }
.dropdown-menu { .dropdown-menu {
background-color: var(--ds-expandable-navbar-bg);
overflow: hidden; overflow: hidden;
min-width: 100%; min-width: 100%;
border-top-left-radius: 0; border-top-left-radius: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
::ng-deep a.nav-link { ::ng-deep a.nav-link {
color: var(--ds-expandable-navbar-link-color) !important;
padding-right: var(--bs-spacer); padding-right: var(--bs-spacer);
padding-left: var(--bs-spacer); padding-left: var(--bs-spacer);
white-space: nowrap; white-space: nowrap;
&:hover, &:focus {
color: var(--ds-expandable-navbar-link-color-hover) !important;
}
} }
} }

View File

@@ -4,7 +4,6 @@ import { MenuService } from '../../shared/menu/menu.service';
import { slide } from '../../shared/animations/slide'; import { slide } from '../../shared/animations/slide';
import { first } from 'rxjs/operators'; import { first } from 'rxjs/operators';
import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowService } from '../../shared/host-window.service';
import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator';
import { MenuID } from '../../shared/menu/menu-id.model'; import { MenuID } from '../../shared/menu/menu-id.model';
import { NgComponentOutlet, NgIf, NgFor, AsyncPipe } from '@angular/common'; import { NgComponentOutlet, NgIf, NgFor, AsyncPipe } from '@angular/common';
import { RouterLinkActive } from '@angular/router'; import { RouterLinkActive } from '@angular/router';
@@ -21,7 +20,6 @@ import { VarDirective } from '../../shared/utils/var.directive';
standalone: true, standalone: true,
imports: [VarDirective, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe] imports: [VarDirective, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe]
}) })
@rendersSectionForMenu(MenuID.PUBLIC, true)
export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit { export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit {
/** /**
* This section resides in the Public Navbar * This section resides in the Public Navbar

View File

@@ -8,11 +8,10 @@ import { MenuID } from '../../shared/menu/menu-id.model';
* Themed wrapper for ExpandableNavbarSectionComponent * Themed wrapper for ExpandableNavbarSectionComponent
*/ */
@Component({ @Component({
/* eslint-disable @angular-eslint/component-selector */ selector: 'ds-themed-expandable-navbar-section',
selector: 'li[ds-themed-expandable-navbar-section]', styleUrls: [],
styleUrls: [], templateUrl: '../../shared/theme-support/themed.component.html',
templateUrl: '../../shared/theme-support/themed.component.html', standalone: true
standalone: true
}) })
@rendersSectionForMenu(MenuID.PUBLIC, true) @rendersSectionForMenu(MenuID.PUBLIC, true)
export class ThemedExpandableNavbarSectionComponent extends ThemedComponent<ExpandableNavbarSectionComponent> { export class ThemedExpandableNavbarSectionComponent extends ThemedComponent<ExpandableNavbarSectionComponent> {

View File

@@ -9,12 +9,11 @@ import { NgComponentOutlet, AsyncPipe } from '@angular/common';
* Represents a non-expandable section in the navbar * Represents a non-expandable section in the navbar
*/ */
@Component({ @Component({
/* eslint-disable @angular-eslint/component-selector */ selector: 'ds-navbar-section',
selector: 'li[ds-navbar-section]', templateUrl: './navbar-section.component.html',
templateUrl: './navbar-section.component.html', styleUrls: ['./navbar-section.component.scss'],
styleUrls: ['./navbar-section.component.scss'], standalone: true,
standalone: true, imports: [NgComponentOutlet, AsyncPipe]
imports: [NgComponentOutlet, AsyncPipe]
}) })
@rendersSectionForMenu(MenuID.PUBLIC, false) @rendersSectionForMenu(MenuID.PUBLIC, false)
export class NavbarSectionComponent extends MenuSectionComponent implements OnInit { export class NavbarSectionComponent extends MenuSectionComponent implements OnInit {

View File

@@ -6,11 +6,11 @@
<div id="collapsingNav"> <div id="collapsingNav">
<ul class="navbar-nav navbar-navigation mr-auto shadow-none"> <ul class="navbar-nav navbar-navigation mr-auto shadow-none">
<li *ngIf="(isXsOrSm$ | async) && (isAuthenticated$ | async)"> <li *ngIf="(isXsOrSm$ | async) && (isAuthenticated$ | async)">
<ds-user-menu [inExpandableNavbar]="true"></ds-user-menu> <ds-themed-user-menu [inExpandableNavbar]="true"></ds-themed-user-menu>
</li> </li>
<ng-container *ngFor="let section of (sections | async)"> <li *ngFor="let section of (sections | async)">
<ng-container *ngComponentOutlet="(sectionMap$ | async).get(section.id)?.component; injector: (sectionMap$ | async).get(section.id)?.injector;"></ng-container> <ng-container *ngComponentOutlet="(sectionMap$ | async).get(section.id)?.component; injector: (sectionMap$ | async).get(section.id)?.injector;"></ng-container>
</ng-container> </li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
nav.navbar { nav.navbar {
border-bottom: 1px var(--ds-header-navbar-border-bottom-color) solid; background-color: var(--ds-navbar-bg);
align-items: baseline; align-items: baseline;
} }
@@ -11,9 +11,11 @@ nav.navbar {
position: absolute; position: absolute;
overflow: hidden; overflow: hidden;
height: 0; height: 0;
z-index: var(--ds-nav-z-index);
&.open { &.open {
height: auto; height: auto;
min-height: 100vh; //doesn't matter because wrapper is sticky min-height: 100vh; //doesn't matter because wrapper is sticky
border-bottom: 1px var(--ds-header-navbar-border-bottom-color) solid; // open navbar covers header-navbar-wrapper border
} }
} }
} }
@@ -38,8 +40,9 @@ nav.navbar {
.navbar-nav { .navbar-nav {
::ng-deep a.nav-link { ::ng-deep a.nav-link {
color: var(--ds-navbar-link-color); color: var(--ds-navbar-link-color);
}
::ng-deep a.nav-link:hover { &:hover, &:focus {
color: var(--ds-navbar-link-color-hover); color: var(--ds-navbar-link-color-hover);
}
} }
} }

View File

@@ -16,6 +16,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { UserMenuComponent } from '../shared/auth-nav-menu/user-menu/user-menu.component'; import { UserMenuComponent } from '../shared/auth-nav-menu/user-menu/user-menu.component';
import { NgClass, NgIf, NgFor, NgComponentOutlet, AsyncPipe } from '@angular/common'; import { NgClass, NgIf, NgFor, NgComponentOutlet, AsyncPipe } from '@angular/common';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { ThemedUserMenuComponent } from '../shared/auth-nav-menu/user-menu/themed-user-menu.component';
/** /**
* Component representing the public navbar * Component representing the public navbar
@@ -26,7 +27,7 @@ import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
templateUrl: './navbar.component.html', templateUrl: './navbar.component.html',
animations: [slideMobileNav], animations: [slideMobileNav],
standalone: true, standalone: true,
imports: [NgbDropdownModule, NgClass, NgIf, UserMenuComponent, NgFor, NgComponentOutlet, AsyncPipe, TranslateModule] imports: [NgbDropdownModule, NgClass, NgIf, UserMenuComponent, ThemedUserMenuComponent, NgFor, NgComponentOutlet, AsyncPipe, TranslateModule]
}) })
export class NavbarComponent extends MenuComponent { export class NavbarComponent extends MenuComponent {
/** /**

View File

@@ -2,16 +2,22 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { SharedModule } from '../shared/shared.module'; import { SharedModule } from '../shared/shared.module';
import { RegisterEmailFormComponent } from './register-email-form.component'; import { RegisterEmailFormComponent } from './register-email-form.component';
import { ThemedRegisterEmailFormComponent } from './themed-registry-email-form.component';
const DECLARATIONS = [
RegisterEmailFormComponent,
ThemedRegisterEmailFormComponent,
];
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
SharedModule, SharedModule,
RegisterEmailFormComponent ...DECLARATIONS,
], ],
providers: [], providers: [],
exports: [ exports: [
RegisterEmailFormComponent, ...DECLARATIONS,
] ]
}) })

View File

@@ -0,0 +1,37 @@
import { Component, Input } from '@angular/core';
import { ThemedComponent } from '../shared/theme-support/themed.component';
import { RegisterEmailFormComponent } from './register-email-form.component';
/**
* Themed wrapper for {@link RegisterEmailFormComponent}
*/
@Component({
selector: 'ds-themed-register-email-form',
styleUrls: [],
templateUrl: '../shared/theme-support/themed.component.html',
standalone: true
})
export class ThemedRegisterEmailFormComponent extends ThemedComponent<RegisterEmailFormComponent> {
@Input() MESSAGE_PREFIX: string;
@Input() typeRequest: string;
protected inAndOutputNames: (keyof RegisterEmailFormComponent & keyof this)[] = [
'MESSAGE_PREFIX',
'typeRequest',
];
protected getComponentName(): string {
return 'RegisterEmailFormComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../themes/${themeName}/app/register-email-form/register-email-form.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./register-email-form.component');
}
}

Some files were not shown because too many files have changed in this diff Show More