[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
label: Português do Brasil
active: true
- code: sr-lat
label: Srpski (lat)
active: true
- code: fi
label: Suomi
active: true
@@ -232,6 +235,9 @@ languages:
- code: el
label: Ελληνικά
active: true
- code: sr-cyr
label: Српски
active: true
- code: uk
label: раї́нська
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.
comcolSelectionSort:
sortField: 'dc.title'
sortDirection: 'ASC'
sortDirection: 'ASC'

View File

@@ -177,6 +177,8 @@ function generateViewEvent(uuid: string, dsoType: string): void {
[XSRF_REQUEST_HEADER] : csrfToken,
// use a known public IP address to avoid being seen as a "bot"
'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
body: { targetId: uuid, targetType: dsoType },

View File

@@ -15,14 +15,14 @@
"analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build --configuration development",
"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",
"test": "ng test --source-map=true --watch=false --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",
"lint": "ng lint",
"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:coverage": "rimraf coverage",
"clean:dist": "rimraf dist",
@@ -82,7 +82,7 @@
"@types/grecaptcha": "^3.0.4",
"angular-idle-preload": "3.0.0",
"angulartics2": "^12.2.0",
"axios": "^0.27.2",
"axios": "^1.6.0",
"bootstrap": "^4.6.1",
"cerialize": "0.1.18",
"cli-progress": "^3.12.0",

View File

@@ -1,12 +1,22 @@
import { URLCombiner } from '../core/url-combiner/url-combiner';
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() {
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString();
return new URLCombiner(getAccessControlModuleRoute(), GROUP_PATH).toString();
}
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 { GroupFormComponent } from './group-registry/group-form/group-form.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 { GroupPageGuard } from './group-registry/group-page.guard';
import {
@@ -13,12 +13,14 @@ import {
SiteAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
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({
imports: [
RouterModule.forChild([
{
path: 'epeople',
path: EPERSON_PATH,
component: EPeopleRegistryComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver
@@ -27,7 +29,26 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component';
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,
resolve: {
breadcrumb: I18nBreadcrumbResolver
@@ -36,7 +57,7 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component';
canActivate: [GroupAdministratorGuard]
},
{
path: `${GROUP_EDIT_PATH}/newGroup`,
path: `${GROUP_PATH}/create`,
component: GroupFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver
@@ -45,7 +66,7 @@ import { BulkAccessComponent } from './bulk-access/bulk-access.component';
canActivate: [GroupAdministratorGuard]
},
{
path: `${GROUP_EDIT_PATH}/:groupId`,
path: `${GROUP_PATH}/:groupId/edit`,
component: GroupFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver

View File

@@ -4,96 +4,91 @@
<div class="d-flex justify-content-between border-bottom mb-3">
<h2 id="header" class="pb-2">{{labelPrefix + 'head' | translate}}</h2>
<div *ngIf="!isEPersonFormShown">
<div>
<button class="mr-auto btn btn-success addEPerson-button"
(click)="isEPersonFormShown = true">
[routerLink]="'create'">
<i class="fas fa-plus"></i>
<span class="d-none d-sm-inline ml-1">{{labelPrefix + 'button.add' | translate}}</span>
</button>
</div>
</div>
<ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="reset()"
(cancelForm)="isEPersonFormShown = false"></ds-eperson-form>
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}}
<div *ngIf="!isEPersonFormShown">
<h3 id="search" class="border-bottom pb-2">{{labelPrefix + 'search.head' | translate}}
</h3>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
<option value="metadata">{{labelPrefix + 'search.scope.metadata' | translate}}</option>
<option value="email">{{labelPrefix + '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"
class="form-control" [attr.aria-label]="labelPrefix + 'search.placeholder' | translate"
[placeholder]="(labelPrefix + 'search.placeholder' | translate)">
<span class="input-group-append">
</h3>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">
<option value="metadata">{{labelPrefix + 'search.scope.metadata' | translate}}</option>
<option value="email">{{labelPrefix + '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"
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">
<i class="fas fa-search"></i> {{ labelPrefix + 'search.button' | translate }}
</button>
</span>
</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>
<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>

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('when you click on first delete eperson button', () => {
let ePeopleIdsFoundBeforeDelete;

View File

@@ -1,6 +1,6 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
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 { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
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 { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { PaginationComponent } from '../../shared/pagination/pagination.component';
import { getEPersonEditRoute, getEPersonsRoute } from '../access-control-routing-paths';
@Component({
selector: 'ds-epeople-registry',
templateUrl: './epeople-registry.component.html',
imports: [
TranslateModule,
RouterModule,
AsyncPipe,
NgIf,
EPersonFormComponent,
@@ -80,11 +82,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
currentPage: 1
});
/**
* Whether or not to show the EPerson form
*/
isEPersonFormShown: boolean;
// The search form
searchForm;
@@ -130,17 +127,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
*/
initialisePage() {
this.searching$.next(true);
this.isEPersonFormShown = false;
this.search({scope: this.currentSearchScope, query: this.currentSearchQuery});
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
if (eperson != null && eperson.id) {
this.isEPersonFormShown = true;
}
}));
this.subs.push(this.ePeople$.pipe(
switchMap((epeople: PaginatedList<EPerson>) => {
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(
map((authorized) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
@@ -149,7 +140,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
return epersonDtoModel;
})
);
})]).pipe(map((dtos: EpersonDtoModel[]) => {
})).pipe(map((dtos: EpersonDtoModel[]) => {
return buildPaginatedList(epeople.pageInfo, dtos);
}));
} else {
@@ -176,14 +167,14 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
const query: string = data.query;
const scope: string = data.scope;
if (query != null && this.currentSearchQuery !== query) {
this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], {
void this.router.navigate([getEPersonsRoute()], {
queryParamsHandling: 'merge'
});
this.currentSearchQuery = query;
this.paginationService.resetPage(this.config.id);
}
if (scope != null && this.currentSearchScope !== scope) {
this.router.navigate([this.epersonService.getEPeoplePageRouterLink()], {
void this.router.navigate([getEPersonsRoute()], {
queryParamsHandling: 'merge'
});
this.currentSearchScope = scope;
@@ -221,23 +212,6 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
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
*/
@@ -258,7 +232,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
if (restResponse.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', {name: this.dsoNameService.getName(ePerson)}));
} 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());
}
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
*/
@@ -300,20 +264,7 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.search({query: ''});
}
/**
* This method will set everything to stale, which will cause the lists on this page to update.
*/
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;
});
getEditEPeoplePage(id: string): string {
return getEPersonEditRoute(id);
}
}

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>
<h4>{{messagePrefix + '.create' | translate}}</h4>
</ng-template>
<div *ngIf="epersonService.getActiveEPerson() | async; then editHeader; else createHeader"></div>
<ng-template #editheader>
<h4>{{messagePrefix + '.edit' | translate}}</h4>
</ng-template>
<ng-template #createHeader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.create' | translate}}</h2>
</ng-template>
<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 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>
<ng-template #editHeader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.edit' | translate}}</h2>
</ng-template>
<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">
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
<ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
<ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading>
<div *ngIf="epersonService.getActiveEPerson() | async">
<h5>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h5>
<ds-pagination
*ngIf="(groups | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(groups | async)?.payload"
[collectionSize]="(groups | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
<ds-themed-loading [showMessage]="false" *ngIf="!(groups | async)"></ds-themed-loading>
<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>
<ds-pagination
*ngIf="(groups | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(groups | async)?.payload"
[collectionSize]="(groups | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
</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">
<div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
<div>
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]"
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button>
</ds-pagination>
<div *ngIf="(groups | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
<div>{{messagePrefix + '.memberOfNoGroups' | translate}}</div>
<div>
<button [routerLink]="[groupsDataService.getGroupRegistryRouterLink()]"
class="btn btn-primary">{{messagePrefix + '.goToGroups' | translate}}</button>
</div>
</div>
</div>
</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 { PaginationComponent } from '../../../shared/pagination/pagination.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', () => {
let component: EPersonFormComponent;
@@ -46,6 +50,8 @@ describe('EPersonFormComponent', () => {
let authorizationService: AuthorizationDataService;
let groupsDataService: GroupDataService;
let epersonRegistrationService: EpersonRegistrationService;
let route: ActivatedRouteStub;
let router: RouterStub;
let paginationService;
@@ -109,6 +115,9 @@ describe('EPersonFormComponent', () => {
},
getEPersonByEmail(email): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null);
},
findById(_id: string, _useCachedVersionIfAvailable = true, _reRequestOnStale = true, ..._linksToFollow: FollowLinkConfig<EPerson>[]): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(null);
}
};
builderService = Object.assign(getMockFormBuilderService(),{
@@ -185,6 +194,8 @@ describe('EPersonFormComponent', () => {
});
paginationService = new PaginationServiceStub();
route = new ActivatedRouteStub();
router = new RouterStub();
TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
TranslateModule.forRoot({
@@ -203,6 +214,8 @@ describe('EPersonFormComponent', () => {
{ provide: PaginationService, useValue: paginationService },
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring']) },
{ provide: EpersonRegistrationService, useValue: epersonRegistrationService },
{ provide: ActivatedRoute, useValue: route },
{ provide: Router, useValue: router },
EPeopleRegistryComponent
],
schemas: [NO_ERRORS_SCHEMA]
@@ -268,24 +281,18 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges();
});
describe('firstName, lastName and email should be required', () => {
it('form should be invalid because the firstName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.firstName.valid).toBeFalse();
expect(component.formGroup.controls.firstName.errors.required).toBeTrue();
});
}));
it('form should be invalid because the lastName is required', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.formGroup.controls.lastName.valid).toBeFalse();
expect(component.formGroup.controls.lastName.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();
});
}));
it('form should be invalid because the firstName is required', () => {
expect(component.formGroup.controls.firstName.valid).toBeFalse();
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();
expect(component.formGroup.controls.lastName.errors.required).toBeTrue();
});
it('form should be invalid because the email is required', () => {
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', () => {
@@ -295,24 +302,18 @@ describe('EPersonFormComponent', () => {
component.formGroup.controls.email.setValue('test@test.com');
fixture.detectChanges();
});
it('firstName should be valid because the firstName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
it('firstName should be valid because the firstName is set', () => {
expect(component.formGroup.controls.firstName.valid).toBeTrue();
expect(component.formGroup.controls.firstName.errors).toBeNull();
});
}));
it('lastName should be valid because the lastName is set', waitForAsync(() => {
fixture.whenStable().then(() => {
});
it('lastName should be valid because the lastName is set', () => {
expect(component.formGroup.controls.lastName.valid).toBeTrue();
expect(component.formGroup.controls.lastName.errors).toBeNull();
});
}));
it('email should be valid because the email is set', waitForAsync(() => {
fixture.whenStable().then(() => {
});
it('email should be valid because the email is set', () => {
expect(component.formGroup.controls.email.valid).toBeTrue();
expect(component.formGroup.controls.email.errors).toBeNull();
});
}));
});
});
@@ -321,12 +322,10 @@ describe('EPersonFormComponent', () => {
component.formGroup.controls.email.setValue('test@test');
fixture.detectChanges();
});
it('email should not be valid because the email pattern', waitForAsync(() => {
fixture.whenStable().then(() => {
it('email should not be valid because the email pattern', () => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.pattern).toBeTruthy();
});
}));
});
});
describe('after already utilized email', () => {
@@ -341,12 +340,10 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges();
});
it('email should not be valid because email is already taken', waitForAsync(() => {
fixture.whenStable().then(() => {
it('email should not be valid because email is already taken', () => {
expect(component.formGroup.controls.email.valid).toBeFalse();
expect(component.formGroup.controls.email.errors.emailTaken).toBeTruthy();
});
}));
});
});
@@ -398,11 +395,9 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges();
});
it('should emit a new eperson using the correct values', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
});
}));
it('should emit a new eperson using the correct values', () => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expected);
});
});
describe('with an active eperson', () => {
@@ -433,11 +428,9 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges();
});
it('should emit the existing eperson using the correct values', waitForAsync(() => {
fixture.whenStable().then(() => {
expect(component.submitForm.emit).toHaveBeenCalledWith(expectedWithId);
});
}));
it('should emit the existing eperson using the correct values', () => {
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'));
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);
fixture.detectChanges();
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', () => {

View File

@@ -43,6 +43,8 @@ import { AsyncPipe, NgClass, NgIf } from '@angular/common';
import { ThemedLoadingComponent } from '../../../shared/loading/themed-loading.component';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { RouterLink } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { getEPersonsRoute } from '../../access-control-routing-paths';
@Component({
selector: 'ds-eperson-form',
@@ -210,6 +212,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
public requestService: RequestService,
private epersonRegistrationService: EpersonRegistrationService,
public dsoNameService: DSONameService,
protected route: ActivatedRoute,
protected router: Router,
) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.epersonInitial = eperson;
@@ -229,7 +233,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
* This method will initialise the page
*/
initialisePage() {
this.subs.push(this.epersonService.findById(this.route.snapshot.params.id).subscribe((ePersonRD: RemoteData<EPerson>) => {
this.epersonService.editEPerson(ePersonRD.payload);
}));
observableCombineLatest([
this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`),
@@ -355,6 +361,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
onCancel() {
this.epersonService.cancelEditEPerson();
this.cancelForm.emit();
void this.router.navigate([getEPersonsRoute()]);
}
/**
@@ -406,6 +413,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.created.success', { name: this.dsoNameService.getName(ePersonToCreate) }));
this.submitForm.emit(ePersonToCreate);
this.epersonService.clearEPersonRequests();
void this.router.navigateByUrl(getEPersonsRoute());
} else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.created.failure', { name: this.dsoNameService.getName(ePersonToCreate) }));
this.cancelForm.emit();
@@ -445,6 +454,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.edited.success', { name: this.dsoNameService.getName(editedEperson) }));
this.submitForm.emit(editedEperson);
void this.router.navigateByUrl(getEPersonsRoute());
} else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.edited.failure', { name: this.dsoNameService.getName(editedEperson) }));
this.cancelForm.emit();
@@ -511,6 +521,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
).subscribe(({ restResponse, eperson }: { restResponse: RemoteData<NoContent> | null, eperson: EPerson }) => {
if (restResponse?.hasSucceeded) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(eperson) }));
void this.router.navigate([getEPersonsRoute()]);
} else {
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
* 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="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>
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.create' | translate}}</h2>
</ng-template>
<ng-template #editheader>
<ng-template #editHeader>
<h2 class="border-bottom pb-2">
<span
*dsContextHelp="{
@@ -39,7 +39,7 @@
<button (click)="onCancel()" type="button"
class="btn btn-outline-secondary"><i class="fas fa-arrow-left"></i> {{messagePrefix + '.return' | translate}}</button>
</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"
(click)="delete()" type="button">
<i class="fa fa-trash"></i> {{ messagePrefix + '.actions.delete' | translate}}

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ import { Group } from '../../../../core/eperson/models/group.model';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { 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 { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
@@ -42,28 +42,26 @@ describe('MembersListComponent', () => {
let ePersonDataServiceStub: any;
let groupsDataServiceStub: any;
let activeGroup;
let allEPersons: EPerson[];
let allGroups: Group[];
let epersonMembers: EPerson[];
let subgroupMembers: Group[];
let epersonNonMembers: EPerson[];
let paginationService;
beforeEach(waitForAsync(() => {
activeGroup = GroupMock;
epersonMembers = [EPersonMock2];
subgroupMembers = [GroupMock2];
allEPersons = [EPersonMock, EPersonMock2];
allGroups = [GroupMock, GroupMock2];
epersonNonMembers = [EPersonMock];
ePersonDataServiceStub = {
activeGroup: activeGroup,
epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers,
epersonNonMembers: epersonNonMembers,
// This method is used to get all the current members
findListByHref(_href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
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 === '') {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), allEPersons));
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), epersonNonMembers));
}
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
},
@@ -80,22 +78,22 @@ describe('MembersListComponent', () => {
groupsDataServiceStub = {
activeGroup: activeGroup,
epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers,
allGroups: allGroups,
epersonNonMembers: epersonNonMembers,
getActiveGroup(): Observable<Group> {
return observableOf(activeGroup);
},
getEPersonMembers() {
return this.epersonMembers;
},
searchGroups(query: string): Observable<RemoteData<PaginatedList<Group>>> {
if (query === '') {
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), this.allGroups));
}
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
},
addMemberToGroup(parentGroup, eperson: EPerson): Observable<RestResponse> {
this.epersonMembers = [...this.epersonMembers, eperson];
addMemberToGroup(parentGroup, epersonToAdd: EPerson): Observable<RestResponse> {
// Add eperson to list of members
this.epersonMembers = [...this.epersonMembers, epersonToAdd];
// Remove eperson from list of non-members
this.epersonNonMembers.forEach( (eperson: EPerson, index: number) => {
if (eperson.id === epersonToAdd.id) {
this.epersonNonMembers.splice(index, 1);
}
});
return observableOf(new RestResponse(true, 200, 'Success'));
},
clearGroupsRequests() {
@@ -108,14 +106,14 @@ describe('MembersListComponent', () => {
return '/access-control/groups/' + group.id;
},
deleteMemberFromGroup(parentGroup, epersonToDelete: EPerson): Observable<RestResponse> {
this.epersonMembers = this.epersonMembers.find((eperson: EPerson) => {
if (eperson.id !== epersonToDelete.id) {
return eperson;
// Remove eperson from list of members
this.epersonMembers.forEach( (eperson: EPerson, index: number) => {
if (eperson.id === epersonToDelete.id) {
this.epersonMembers.splice(index, 1);
}
});
if (this.epersonMembers === undefined) {
this.epersonMembers = [];
}
// Add eperson to list of non-members
this.epersonNonMembers = [...this.epersonNonMembers, epersonToDelete];
return observableOf(new RestResponse(true, 200, 'Success'));
}
};
@@ -168,13 +166,37 @@ describe('MembersListComponent', () => {
expect(comp).toBeDefined();
}));
it('should show list of eperson members of current active group', () => {
const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child'));
expect(epersonIdsFound.length).toEqual(1);
epersonMembers.map((eperson: EPerson) => {
expect(epersonIdsFound.find((foundEl) => {
return (foundEl.nativeElement.textContent.trim() === eperson.uuid);
})).toBeTruthy();
describe('current members list', () => {
it('should show list of eperson members of current active group', () => {
const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child'));
expect(epersonIdsFound.length).toEqual(1);
epersonMembers.map((eperson: EPerson) => {
expect(epersonIdsFound.find((foundEl) => {
return (foundEl.nativeElement.textContent.trim() === eperson.uuid);
})).toBeTruthy();
});
});
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', () => {
let epersonsFound: DebugElement[];
beforeEach(fakeAsync(() => {
spyOn(component, 'isMemberOfGroup').and.callFake((ePerson: EPerson) => {
return observableOf(activeGroup.epersons.includes(ePerson));
});
component.search({ scope: 'metadata', query: '' });
tick();
fixture.detectChanges();
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', () => {
expect(epersonsFound.length).toEqual(2);
it('should display only non-members of the group', () => {
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 have delete button, else it should have add button', () => {
const memberIds: string[] = activeGroup.epersons.map((ePerson: EPerson) => ePerson.id);
epersonsFound.map((foundEPersonRowElement: DebugElement) => {
const epersonId: DebugElement = foundEPersonRowElement.query(By.css('td:first-child'));
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'));
if (memberIds.includes(epersonId.nativeElement.textContent)) {
expect(addButton).toBeNull();
expect(deleteButton).not.toBeNull();
} else {
expect(deleteButton).toBeNull();
expect(addButton).not.toBeNull();
}
});
it('should display an add button next to non-members, not a delete button', () => {
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).not.toBeNull();
expect(deleteButton).toBeNull();
});
});
describe('if first add button is pressed', () => {
beforeEach(fakeAsync(() => {
beforeEach(() => {
const addButton: DebugElement = fixture.debugElement.query(By.css('#epersonsSearch tbody .fa-plus'));
addButton.nativeElement.click();
tick();
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();
});
});
});
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', () => {
it('then all (two) ePersons are member of the active group. No non-members left', () => {
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(deleteButton).toBeNull();
expect(addButton).not.toBeNull();
});
expect(epersonsFound.length).toEqual(0);
});
});
});

View File

@@ -4,28 +4,23 @@ import { Router, RouterLink } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
Observable,
of as observableOf,
Subscription,
BehaviorSubject,
combineLatest as observableCombineLatest,
ObservedValueOf,
BehaviorSubject
} from 'rxjs';
import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators';
import { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model';
import { map, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { EPerson } from '../../../../core/eperson/models/eperson.model';
import { Group } from '../../../../core/eperson/models/group.model';
import {
getFirstSucceededRemoteData,
getFirstCompletedRemoteData,
getAllCompletedRemoteData,
getRemoteDataPayload
} from '../../../../core/shared/operators';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
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 { DSONameService } from '../../../../core/breadcrumbs/dso-name.service';
import { ContextHelpDirective } from '../../../../shared/context-help.directive';
@@ -37,8 +32,8 @@ import { AsyncPipe, NgClass, NgForOf, NgIf } from '@angular/common';
*/
enum SubKey {
ActiveGroup,
MembersDTO,
SearchResultsDTO,
Members,
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
*/
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
*/
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
@@ -144,7 +139,6 @@ export class MembersListComponent implements OnInit, OnDestroy {
// Current search in edit group - epeople search form
currentSearchQuery: string;
currentSearchScope: string;
// Whether or not user has done a EPeople search yet
searchDone: boolean;
@@ -163,18 +157,17 @@ export class MembersListComponent implements OnInit, OnDestroy {
public dsoNameService: DSONameService,
) {
this.currentSearchQuery = '';
this.currentSearchScope = 'metadata';
}
ngOnInit(): void {
this.searchForm = this.formBuilder.group(({
scope: 'metadata',
query: '',
}));
this.subs.set(SubKey.ActiveGroup, this.groupDataService.getActiveGroup().subscribe((activeGroup: Group) => {
if (activeGroup != null) {
this.groupBeingEdited = activeGroup;
this.retrieveMembers(this.config.currentPage);
this.search({query: ''});
}
}));
}
@@ -186,8 +179,8 @@ export class MembersListComponent implements OnInit, OnDestroy {
* @private
*/
retrieveMembers(page: number): void {
this.unsubFrom(SubKey.MembersDTO);
this.subs.set(SubKey.MembersDTO,
this.unsubFrom(SubKey.Members);
this.subs.set(SubKey.Members,
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
switchMap((currentPagination) => {
return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, {
@@ -204,49 +197,12 @@ export class MembersListComponent implements OnInit, OnDestroy {
return rd;
}
}),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
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);
getRemoteDataPayload())
.subscribe((paginatedListOfEPersons: PaginatedList<EPerson>) => {
this.ePeopleMembersOfGroup.next(paginatedListOfEPersons);
}));
}
/**
* 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
* 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
* @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) {
ePerson.memberOfGroup = false;
deleteMemberFromGroup(eperson: EPerson) {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup != null) {
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, ePerson.eperson);
this.showNotifications('deleteMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup);
const response = this.groupDataService.deleteMemberFromGroup(activeGroup, eperson);
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 {
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
* @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) {
ePerson.memberOfGroup = true;
addMemberToGroup(eperson: EPerson) {
this.groupDataService.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => {
if (activeGroup != null) {
const response = this.groupDataService.addMemberToGroup(activeGroup, ePerson.eperson);
this.showNotifications('addMember', response, this.dsoNameService.getName(ePerson.eperson), activeGroup);
const response = this.groupDataService.addMemberToGroup(activeGroup, eperson);
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 {
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
* @param data Contains scope and query param
* Search all EPeople who are NOT a member of the current group by name, email or metadata
* @param data Contains query param
*/
search(data: any) {
this.unsubFrom(SubKey.SearchResultsDTO);
this.subs.set(SubKey.SearchResultsDTO,
this.unsubFrom(SubKey.SearchResults);
this.subs.set(SubKey.SearchResults,
this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe(
switchMap((paginationOptions) => {
const query: string = data.query;
const scope: string = data.scope;
if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) {
this.router.navigate([], {
queryParamsHandling: 'merge'
});
this.currentSearchQuery = query;
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;
return this.ePersonDataService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
return this.ePersonDataService.searchNonMembers(this.currentSearchQuery, this.groupBeingEdited.id, {
currentPage: paginationOptions.currentPage,
elementsPerPage: paginationOptions.pageSize
});
}, false, true);
}),
getAllCompletedRemoteData(),
map((rd: RemoteData<any>) => {
@@ -334,23 +286,9 @@ export class MembersListComponent implements OnInit, OnDestroy {
return rd;
}
}),
switchMap((epersonListRD: RemoteData<PaginatedList<EPerson>>) => {
const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => {
const dto$: Observable<EpersonDtoModel> = observableCombineLatest(
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);
getRemoteDataPayload())
.subscribe((paginatedListOfEPersons: PaginatedList<EPerson>) => {
this.ePeopleSearch.next(paginatedListOfEPersons);
}));
}

View File

@@ -1,6 +1,55 @@
<ng-container>
<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">
<span *dsContextHelp="{
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">
<div class="btn-group edit-field">
<button *ngIf="(isSubgroupOfGroup(group) | async) && !(isActiveGroup(group) | async)"
(click)="deleteSubgroupFromGroup(group)"
class="btn btn-outline-danger btn-sm deleteButton"
title="{{messagePrefix + '.table.edit.buttons.remove' | translate: { name: 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)"
<button (click)="addSubgroupToGroup(group)"
class="btn btn-outline-primary btn-sm addButton"
title="{{messagePrefix + '.table.edit.buttons.add' | translate: { name: dsoNameService.getName(group) } }}">
<i class="fas fa-plus fa-fw"></i>
@@ -90,53 +129,4 @@
{{messagePrefix + '.no-items' | translate}}
</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>

View File

@@ -1,12 +1,12 @@
import { CommonModule } from '@angular/common';
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 { BrowserModule, By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
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 { buildPaginatedList, PaginatedList } from '../../../../core/data/paginated-list.model';
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 { SubgroupsListComponent } from './subgroups-list.component';
import {
createSuccessfulRemoteDataObject$,
createSuccessfulRemoteDataObject
createSuccessfulRemoteDataObject$
} from '../../../../shared/remote-data.utils';
import { RouterMock } from '../../../../shared/mocks/router.mock';
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock';
import { TranslateLoaderMock } from '../../../../shared/testing/translate-loader.mock';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { map } from 'rxjs/operators';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
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 { PaginationComponent } from '../../../../shared/pagination/pagination.component';
import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub';
import { EPersonMock2 } from 'src/app/shared/testing/eperson.mock';
describe('SubgroupsListComponent', () => {
let component: SubgroupsListComponent;
@@ -42,57 +41,72 @@ describe('SubgroupsListComponent', () => {
let builderService: FormBuilderService;
let ePersonDataServiceStub: any;
let groupsDataServiceStub: any;
let activeGroup;
let activeGroup: Group;
let subgroups: Group[];
let allGroups: Group[];
let groupNonMembers: Group[];
let routerStub;
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(() => {
activeGroup = GroupMock;
activeGroup = mockActiveGroup;
subgroups = [GroupMock2];
allGroups = [GroupMock, GroupMock2];
groupNonMembers = [GroupMock];
ePersonDataServiceStub = {};
groupsDataServiceStub = {
activeGroup: activeGroup,
subgroups$: new BehaviorSubject(subgroups),
subgroups: subgroups,
groupNonMembers: groupNonMembers,
getActiveGroup(): Observable<Group> {
return observableOf(this.activeGroup);
},
getSubgroups(): Group {
return this.activeGroup;
return this.subgroups;
},
findListByHref(
_href: string
): Observable<RemoteData<PaginatedList<Group>>> {
return this.subgroups$.pipe(
map((currentGroups: Group[]) => {
return createSuccessfulRemoteDataObject(
buildPaginatedList<Group>(new PageInfo(), currentGroups)
);
})
);
// This method is used to get all the current subgroups
findListByHref(_href: string): Observable<RemoteData<PaginatedList<Group>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList<Group>(new PageInfo(), groupsDataServiceStub.getSubgroups()));
},
getGroupEditPageRouterLink(group: Group): string {
return '/access-control/groups/' + group.id;
},
searchGroups(
query: string
): Observable<RemoteData<PaginatedList<Group>>> {
// This method is used to get all groups which are NOT currently a subgroup member
searchNonMemberGroups(query: string, group: string): Observable<RemoteData<PaginatedList<Group>>> {
if (query === '') {
return createSuccessfulRemoteDataObject$(
buildPaginatedList(new PageInfo(), allGroups)
);
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), groupNonMembers));
}
return createSuccessfulRemoteDataObject$(
buildPaginatedList(new PageInfo(), [])
);
},
addSubGroupToGroup(
parentGroup,
subgroup: Group
): Observable<RestResponse> {
this.subgroups$.next([...this.subgroups$.getValue(), subgroup]);
addSubGroupToGroup(parentGroup, subgroupToAdd: Group): Observable<RestResponse> {
// Add group to list of subgroups
this.subgroups = [...this.subgroups, subgroupToAdd];
// Remove group from list of non-members
this.groupNonMembers.forEach( (group: Group, index: number) => {
if (group.id === subgroupToAdd.id) {
this.groupNonMembers.splice(index, 1);
}
});
return observableOf(new RestResponse(true, 200, 'Success'));
},
clearGroupsRequests() {
@@ -101,17 +115,15 @@ describe('SubgroupsListComponent', () => {
clearGroupLinkRequests() {
// empty
},
deleteSubGroupFromGroup(
parentGroup,
subgroup: Group
): Observable<RestResponse> {
this.subgroups$.next(
this.subgroups$.getValue().filter((group: Group) => {
if (group.id !== subgroup.id) {
return group;
}
})
);
deleteSubGroupFromGroup(parentGroup, subgroupToDelete: Group): Observable<RestResponse> {
// Remove group from list of subgroups
this.subgroups.forEach( (group: Group, index: number) => {
if (group.id === subgroupToDelete.id) {
this.subgroups.splice(index, 1);
}
});
// Add group to list of non-members
this.groupNonMembers = [...this.groupNonMembers, subgroupToDelete];
return observableOf(new RestResponse(true, 200, 'Success'));
},
};
@@ -174,30 +186,38 @@ describe('SubgroupsListComponent', () => {
expect(comp).toBeDefined();
}));
it('should show list of subgroups of current active group', () => {
const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child'));
expect(groupIdsFound.length).toEqual(1);
activeGroup.subgroups.map((group: Group) => {
expect(groupIdsFound.find((foundEl) => {
return (foundEl.nativeElement.textContent.trim() === group.uuid);
})).toBeTruthy();
});
});
describe('if first group delete button is pressed', () => {
let groupsFound: DebugElement[];
beforeEach(fakeAsync(() => {
const addButton = fixture.debugElement.query(By.css('#subgroupsOfGroup tbody .deleteButton'));
addButton.triggerEventHandler('click', {
preventDefault: () => {/**/
}
describe('current subgroup list', () => {
it('should show list of subgroups of current active group', () => {
const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child'));
expect(groupIdsFound.length).toEqual(1);
subgroups.map((group: Group) => {
expect(groupIdsFound.find((foundEl) => {
return (foundEl.nativeElement.textContent.trim() === group.uuid);
})).toBeTruthy();
});
});
it('should show a delete button next to each subgroup', () => {
const subgroupsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tbody tr'));
subgroupsFound.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).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[];
beforeEach(fakeAsync(() => {
component.search({ query: '' });
fixture.detectChanges();
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
}));
it('should display all groups', () => {
fixture.detectChanges();
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
expect(groupsFound.length).toEqual(2);
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
it('should display only non-member groups (i.e. groups that are not a subgroup)', () => {
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) => {
return (foundEl.nativeElement.textContent.trim() === group.uuid);
})).toBeTruthy();
});
});
describe('if group is already a subgroup', () => {
it('should have delete button, else it should have add button', () => {
it('should display an add button next to non-member groups, not a delete 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();
});
it('then all (two) Groups are subgroups of the active group. No non-members left', () => {
groupsFound = fixture.debugElement.queryAll(By.css('#groupsSearch tbody tr'));
const getSubgroups = groupsDataServiceStub.getSubgroups().subgroups;
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();
}
});
}
expect(groupsFound.length).toEqual(0);
});
});
});

View File

@@ -2,16 +2,15 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
import { map, mergeMap, switchMap, take } from 'rxjs/operators';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { Group } from '../../../../core/eperson/models/group.model';
import {
getFirstCompletedRemoteData,
getFirstSucceededRemoteData,
getRemoteDataPayload
getAllCompletedRemoteData,
getFirstCompletedRemoteData
} from '../../../../core/shared/operators';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
@@ -117,6 +116,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup != null) {
this.groupBeingEdited = activeGroup;
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
* @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) {
const response = this.groupDataService.deleteSubGroupFromGroup(activeGroup, subgroup);
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 {
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) {
const response = this.groupDataService.addSubGroupToGroup(activeGroup, subgroup);
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 {
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
*/
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.subs.set(SubKey.SearchResults, this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe(
switchMap((config) => this.groupDataService.searchGroups(this.currentSearchQuery, {
currentPage: config.currentPage,
elementsPerPage: config.pageSize
}, true, true, followLink('object')
))
).subscribe((rd: RemoteData<PaginatedList<Group>>) => {
this.searchResults$.next(rd);
}));
this.subs.set(SubKey.SearchResults,
this.paginationService.getCurrentPagination(this.configSearch.id, this.configSearch).pipe(
switchMap((paginationOptions) => {
const query: string = data.query;
if (query != null && this.currentSearchQuery !== query && this.groupBeingEdited) {
this.currentSearchQuery = query;
this.paginationService.resetPage(this.configSearch.id);
}
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>
<div>
<button class="mr-auto btn btn-success"
[routerLink]="['newGroup']">
[routerLink]="'create'">
<i class="fas fa-plus"></i>
<span class="d-none d-sm-inline ml-1">{{messagePrefix + 'button.add' | translate}}</span>
</button>

View File

@@ -234,18 +234,28 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
/**
* 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
*/
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)
* NOTE: At this time we only grab the *first* subgroup in order to receive the `totalElements` value
* needed for our HTML template.
* @param 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,
DynamicFormGroupModel,
DynamicFormLayout,
DynamicInputModel
DynamicInputModel,
DynamicTextAreaModel
} from '@ng-dynamic-forms/core';
import { UntypedFormGroup } from '@angular/forms';
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
*/
scopeNote: DynamicInputModel;
scopeNote: DynamicTextAreaModel;
/**
* A list of all dynamic input models
@@ -141,11 +142,12 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy {
maxLength: 'error.validation.metadata.qualifier.max-length',
},
});
this.scopeNote = new DynamicInputModel({
this.scopeNote = new DynamicTextAreaModel({
id: 'scopeNote',
label: scopenote,
name: 'scopeNote',
required: false,
rows: 5,
});
this.formModel = [
new DynamicFormGroupModel(

View File

@@ -41,7 +41,7 @@
</label>
</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>
</tr>
</tbody>

View File

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

View File

@@ -26,10 +26,10 @@
</div>
</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>
</li>
</ul>
</div>
<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
*/
@Component({
/* eslint-disable @angular-eslint/component-selector */
selector: 'li[ds-expandable-admin-sidebar-section]',
templateUrl: './expandable-admin-sidebar-section.component.html',
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
animations: [rotate, slide, bgColor],
standalone: true,
imports: [NgClass, NgComponentOutlet, NgIf, NgFor, AsyncPipe, TranslateModule]
selector: 'ds-expandable-admin-sidebar-section',
templateUrl: './expandable-admin-sidebar-section.component.html',
styleUrls: ['./expandable-admin-sidebar-section.component.scss'],
animations: [rotate, slide, bgColor],
standalone: true,
imports: [NgClass, NgComponentOutlet, NgIf, NgFor, AsyncPipe, TranslateModule]
})
@rendersSectionForMenu(MenuID.ADMIN, true)

View File

@@ -1,6 +1,6 @@
<ng-container *ngVar="(breadcrumbs$ | async) as breadcrumbs">
<nav *ngIf="(showBreadcrumbs$ | async)" aria-label="breadcrumb" class="nav-breadcrumb">
<ol class="container breadcrumb">
<ol class="container breadcrumb my-0">
<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;">

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ import {
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
import { TranslateModule } from '@ngx-translate/core';
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';
@@ -53,6 +54,7 @@ export const BBM_PAGINATION_ID = 'bbm';
ThemedComcolPageHandleComponent,
ComcolPageContentComponent,
DsoEditMenuComponent,
ThemedBrowseByComponent,
ThemedComcolPageBrowseByComponent,
BrowseByComponent,
TranslateModule,
@@ -191,7 +193,11 @@ export class BrowseByMetadataPageComponent implements OnInit, OnDestroy {
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();
}

View File

@@ -29,6 +29,7 @@ import {
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
import { TranslateModule } from '@ngx-translate/core';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { ThemedBrowseByComponent } from 'src/app/shared/browse-by/themed-browse-by.component';
@Component({
selector: 'ds-browse-by-title-page',
@@ -47,7 +48,8 @@ import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.comp
ThemedComcolPageBrowseByComponent,
BrowseByComponent,
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 { DsoPageModule } from '../shared/dso-page/dso-page.module';
import { FormModule } from '../shared/form/form.module';
import { SharedModule } from '../shared/shared.module';
const ENTRY_COMPONENTS = [
// put only entry components that use custom decorator
@@ -37,6 +38,7 @@ const ENTRY_COMPONENTS = [
FormModule,
BrowseBySwitcherComponent,
ThemedBrowseBySwitcherComponent,
SharedModule,
...ENTRY_COMPONENTS
],
exports: [

View File

@@ -34,9 +34,6 @@
</ds-comcol-page-content>
</header>
<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>
<section class="comcol-page-browse-section">
<!-- Browse-By Links -->

View File

@@ -44,9 +44,6 @@ import {
ThemedComcolPageHandleComponent
} 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 {
DsoPageSubscriptionButtonComponent
} from '../shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component';
import {
ThemedComcolPageBrowseByComponent
} 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,
ThemedComcolPageHandleComponent,
DsoEditMenuComponent,
DsoPageSubscriptionButtonComponent,
ThemedComcolPageBrowseByComponent,
ObjectCollectionComponent
],

View File

@@ -24,6 +24,7 @@ import { FlatNode } from './flat-node.model';
import { ShowMoreFlatNode } from './show-more-flat-node.model';
import { FindListOptions } from '../core/data/find-list-options.model';
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
export const combineAndFlatten = (obsList: Observable<FlatNode[]>[]): Observable<FlatNode[]> =>
@@ -186,7 +187,7 @@ export class CommunityListService {
return this.transformCommunity(community, level, parent, expandedNodes);
});
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);
@@ -257,7 +258,7 @@ export class CommunityListService {
let nodes = rd.payload.page
.map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode));
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;
} else {

View File

@@ -8,7 +8,7 @@
<span class="fa fa-chevron-right invisible" aria-hidden="true"></span>
</button>
<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">
<i class="fas fa-angle-down"></i> {{ 'communityList.showMore' | translate }}
</button>

View File

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

View File

@@ -92,7 +92,7 @@ export class CommunityListComponent implements OnInit, OnDestroy {
toggleExpanded(node: FlatNode) {
this.loadingNode = node;
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;
} else {
this.expandedNodes.push(node);
@@ -119,19 +119,18 @@ export class CommunityListComponent implements OnInit, OnDestroy {
getNextPage(node: FlatNode): void {
this.loadingNode = node;
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);
parentNodeInExpandedNodes.currentCollectionPage++;
}
if (node.id === 'community') {
if (node.id.startsWith('community')) {
const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id);
parentNodeInExpandedNodes.currentCommunityPage++;
}
this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes);
} else {
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>
</header>
<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>
<section class="comcol-page-browse-section">

View File

@@ -35,9 +35,6 @@ import {
ThemedComcolPageBrowseByComponent
} 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 {
DsoPageSubscriptionButtonComponent
} from '../shared/dso-page/dso-page-subscription-button/dso-page-subscription-button.component';
import {
ThemedComcolPageHandleComponent
} from '../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component';
@@ -62,7 +59,6 @@ import { VarDirective } from '../shared/utils/var.directive';
ThemedCollectionPageSubCollectionListComponent,
ThemedComcolPageBrowseByComponent,
DsoEditMenuComponent,
DsoPageSubscriptionButtonComponent,
ThemedComcolPageHandleComponent,
ComcolPageLogoComponent,
ComcolPageHeaderComponent,

View File

@@ -1,7 +1,7 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
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 { 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);
subscriptions: Subscription[] = [];
constructor(
protected cds: CollectionDataService,
protected paginationService: PaginationService,
@@ -93,7 +95,7 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro
const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config);
const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig);
observableCombineLatest([pagination$, sort$]).pipe(
this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe(
switchMap(([currentPagination, currentSort]) => {
return this.cds.findByParent(this.community.id, {
currentPage: currentPagination.currentPage,
@@ -103,11 +105,12 @@ export class CommunityPageSubCollectionListComponent implements OnInit, OnDestro
})
).subscribe((results) => {
this.subCollectionsRDObs.next(results);
});
}));
}
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 { 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 { 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);
subscriptions: Subscription[] = [];
constructor(
protected cds: CommunityDataService,
protected paginationService: PaginationService,
@@ -95,7 +97,7 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy
const pagination$ = this.paginationService.getCurrentPagination(this.config.id, this.config);
const sort$ = this.paginationService.getCurrentSort(this.config.id, this.sortConfig);
observableCombineLatest([pagination$, sort$]).pipe(
this.subscriptions.push(observableCombineLatest([pagination$, sort$]).pipe(
switchMap(([currentPagination, currentSort]) => {
return this.cds.findByParent(this.community.id, {
currentPage: currentPagination.currentPage,
@@ -105,11 +107,12 @@ export class CommunityPageSubCommunityListComponent implements OnInit, OnDestroy
})
).subscribe((results) => {
this.subCommunitiesRDObs.next(results);
});
}));
}
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 { RouteEffects } from './services/route.effects';
import { RouterEffects } from './router/router.effects';
import { MenuEffects } from '../shared/menu/menu.effects';
export const coreEffects = [
RequestEffects,
@@ -18,4 +19,5 @@ export const coreEffects = [
ObjectUpdatesEffects,
RouteEffects,
RouterEffects,
MenuEffects,
];

View File

@@ -1,4 +1,3 @@
/* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
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 { EMBED_SEPARATOR } from './base/base-data.service';
import { HardRedirectService } from '../services/hard-redirect.service';
import { environment } from '../../../environments/environment.test';
import { AppConfig } from '../../../config/app-config.interface';
describe('DsoRedirectService', () => {
let scheduler: TestScheduler;
@@ -56,6 +58,7 @@ describe('DsoRedirectService', () => {
});
service = new DsoRedirectService(
environment as AppConfig,
requestService,
rdbService,
objectCache,
@@ -107,7 +110,7 @@ describe('DsoRedirectService', () => {
redir.subscribe();
scheduler.schedule(() => redir);
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', () => {
remoteData.payload.type = 'item';
@@ -124,7 +127,7 @@ describe('DsoRedirectService', () => {
redir.subscribe();
scheduler.schedule(() => redir);
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', () => {
@@ -133,7 +136,7 @@ describe('DsoRedirectService', () => {
redir.subscribe();
scheduler.schedule(() => redir);
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', () => {
@@ -142,7 +145,7 @@ describe('DsoRedirectService', () => {
redir.subscribe();
scheduler.schedule(() => redir);
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/
*/
/* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core';
import { Injectable, Inject } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
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 { getDSORoute } from '../../app-routing-paths';
import { HardRedirectService } from '../services/hard-redirect.service';
import { APP_CONFIG, AppConfig } from '../../../config/app-config.interface';
const ID_ENDPOINT = 'pid';
const UUID_ENDPOINT = 'dso';
@@ -70,6 +71,7 @@ export class DsoRedirectService {
private dataService: DsoByIdOrUUIDDataService;
constructor(
@Inject(APP_CONFIG) protected appConfig: AppConfig,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
@@ -98,7 +100,7 @@ export class DsoRedirectService {
let newRoute = getDSORoute(dso);
if (hasValue(newRoute)) {
// 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 [];
}
}),
catchError(() => observableOf(false)),
catchError(() => observableOf([])),
oneAuthorizationMatchesFeature(featureId)
);
}

View File

@@ -68,13 +68,13 @@ export const oneAuthorizationMatchesFeature = (featureID: FeatureID) =>
source.pipe(
switchMap((authorizations: Authorization[]) => {
if (isNotEmpty(authorizations)) {
return observableCombineLatest(
return observableCombineLatest([
...authorizations
.filter((authorization: Authorization) => hasValue(authorization.feature))
.map((authorization: Authorization) => authorization.feature.pipe(
getFirstSucceededRemoteDataPayload()
))
);
]);
} else {
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', () => {
let dispatchSpy: jasmine.Spy;
let getByUUIDSpy: jasmine.Spy;

View File

@@ -16,7 +16,7 @@ import {
RequestExecuteAction,
RequestStaleAction
} from './request.actions';
import { GetRequest} from './request.models';
import { GetRequest } from './request.models';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { RestRequestMethod } from './rest-request-method';
import { coreSelector } from '../core.selectors';
@@ -351,7 +351,29 @@ export class RequestService {
map((request: RequestEntry) => isStale(request.state)),
filter((stale: boolean) => stale),
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 (request.method !== RestRequestMethod.GET) {
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)) {
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) {
return true;
} else {

View File

@@ -1,16 +1,18 @@
import { RootDataService } from './root-data.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { Observable, of } from 'rxjs';
import {
createSuccessfulRemoteDataObject$,
createFailedRemoteDataObject$
} from '../../shared/remote-data.utils';
import { Observable } from 'rxjs';
import { RemoteData } from './remote-data';
import { Root } from './root.model';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { cold } from 'jasmine-marbles';
describe('RootDataService', () => {
let service: RootDataService;
let halService: HALEndpointService;
let restService;
let requestService;
let rootEndpoint;
let findByHrefSpy;
@@ -19,10 +21,10 @@ describe('RootDataService', () => {
halService = jasmine.createSpyObj('halService', {
getRootHref: rootEndpoint,
});
restService = jasmine.createSpyObj('halService', {
get: jasmine.createSpy('get'),
});
service = new RootDataService(null, null, null, halService, restService);
requestService = jasmine.createSpyObj('requestService', [
'setStaleByHref',
]);
service = new RootDataService(requestService, null, null, halService);
findByHrefSpy = spyOn(service as any, 'findByHref');
findByHrefSpy.and.returnValue(createSuccessfulRemoteDataObject$({}));
@@ -47,12 +49,8 @@ describe('RootDataService', () => {
let result$: Observable<boolean>;
it('should return observable of true when root endpoint is available', () => {
const mockResponse = {
statusCode: 200,
statusText: 'OK'
} as RawRestResponse;
spyOn(service, 'findRoot').and.returnValue(createSuccessfulRemoteDataObject$<Root>({} as any));
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability();
expect(result$).toBeObservable(cold('(a|)', {
@@ -61,12 +59,8 @@ describe('RootDataService', () => {
});
it('should return observable of false when root endpoint is not available', () => {
const mockResponse = {
statusCode: 500,
statusText: 'Internal Server Error'
} as RawRestResponse;
spyOn(service, 'findRoot').and.returnValue(createFailedRemoteDataObject$<Root>('500'));
restService.get.and.returnValue(of(mockResponse));
result$ = service.checkServerAvailability();
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 { RemoteData } from './remote-data';
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 { BaseDataService } from './base/base-data.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.
@@ -22,7 +21,6 @@ export class RootDataService extends BaseDataService<Root> {
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected restService: DspaceRestService,
) {
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
*/
checkServerAvailability(): Observable<boolean> {
return this.restService.get(this.halService.getRootHref()).pipe(
return this.findRoot().pipe(
catchError((err ) => {
console.error(err);
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
*/
invalidateRootCache() {
this.requestService.setStaleByHrefSubstring(this.halService.getRootHref());
this.requestService.setStaleByHref(this.halService.getRootHref());
}
}

View File

@@ -11,6 +11,7 @@ import {
EPeopleRegistryCancelEPersonAction,
EPeopleRegistryEditEPersonAction
} from '../../access-control/epeople-registry/epeople-registry.actions';
import { GroupMock } from '../../shared/testing/group-mock';
import { RequestParam } from '../cache/models/request-param.model';
import { ChangeAnalyzer } from '../data/change-analyzer';
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', () => {
beforeEach(() => {
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 { DeleteData, DeleteDataImpl } from '../data/base/delete-data';
import { RestRequestMethod } from '../data/rest-request-method';
import { getEPersonEditRoute, getEPersonsRoute } from '../../access-control/access-control-routing-paths';
const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry;
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);
}
/**
* 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
* 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);
}
});
return '/access-control/epeople';
return getEPersonEditRoute(ePerson.id);
}
/**
* Get EPeople admin page
* @param ePerson New EPerson to edit
*/
public getEPeoplePageRouterLink(): string {
return '/access-control/epeople';
return getEPersonsRoute();
}
/**

View File

@@ -43,11 +43,11 @@ describe('GroupDataService', () => {
let rdbService;
let objectCache;
function init() {
restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson';
restEndpointURL = 'https://rest.api/server/api/eperson';
groupsEndpoint = `${restEndpointURL}/groups`;
groups = [GroupMock, GroupMock2];
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);
objectCache = getMockObjectCacheService();
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', () => {
beforeEach(() => {
objectCache.getByHref.and.returnValue(observableOf({

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store';
import { Observable, zip as observableZip } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';
import { take } from 'rxjs/operators';
import {
GroupRegistryCancelGroupAction,
GroupRegistryEditGroupAction
@@ -38,6 +38,7 @@ import { PatchData, PatchDataImpl } from '../data/base/patch-data';
import { DeleteData, DeleteDataImpl } from '../data/base/delete-data';
import { Operation } from 'fast-json-patch';
import { RestRequestMethod } from '../data/rest-request-method';
import { getGroupEditRoute } from '../../access-control/access-control-routing-paths';
const groupRegistryStateSelector = (state: AppState) => state.groupRegistry;
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
*
* @param groupName
* the group name
* @return boolean
* true if user is member of the indicated group, false otherwise
* 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).
* Endpoint used: /eperson/groups/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
*/
isMemberOf(groupName: string): Observable<boolean> {
const searchHref = 'isMemberOf';
const options = new FindListOptions();
options.searchParams = [new RequestParam('groupName', groupName)];
return this.searchBy(searchHref, options).pipe(
filter((groups: RemoteData<PaginatedList<Group>>) => !groups.isResponsePending),
take(1),
map((groups: RemoteData<PaginatedList<Group>>) => groups.payload.totalElements > 0)
);
public searchNonMemberGroups(query: string, group: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Group>[]): Observable<RemoteData<PaginatedList<Group>>> {
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);
}
/**
@@ -261,15 +270,15 @@ export class GroupDataService extends IdentifiableDataService<Group> implements
* @param group Group we want edit page for
*/
public getGroupEditPageRouterLink(group: Group): string {
return this.getGroupEditPageRouterLinkWithID(group.id);
return getGroupEditRoute(group.id);
}
/**
* Get Edit page of group
* @param groupID Group ID we want edit page for
*/
public getGroupEditPageRouterLinkWithID(groupId: string): string {
return '/access-control/groups/' + groupId;
public getGroupEditPageRouterLinkWithID(groupID: string): string {
return getGroupEditRoute(groupID);
}
/**

View File

@@ -13,9 +13,4 @@ export class EpersonDtoModel {
* Whether or not the linked EPerson is able to be deleted
*/
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 { Router } from '@angular/router';
import { Router, NavigationStart, UrlTree, NavigationEnd, RouterEvent } from '@angular/router';
import { of } from 'rxjs';
import { take } from 'rxjs/operators';
import { getPageInternalServerErrorRoute } from '../../app-routing-paths';
import { of, ReplaySubject } from 'rxjs';
import { RootDataService } from '../data/root-data.service';
import { TestScheduler } from 'rxjs/testing';
import SpyObj = jasmine.SpyObj;
describe('ServerCheckGuard', () => {
let guard: ServerCheckGuard;
let router: SpyObj<Router>;
let router: Router;
const eventSubject = new ReplaySubject<RouterEvent>(1);
let rootDataServiceStub: SpyObj<RootDataService>;
rootDataServiceStub = jasmine.createSpyObj('RootDataService', {
checkServerAvailability: jasmine.createSpy('checkServerAvailability'),
invalidateRootCache: jasmine.createSpy('invalidateRootCache')
});
router = jasmine.createSpyObj('Router', {
navigateByUrl: jasmine.createSpy('navigateByUrl')
});
let testScheduler: TestScheduler;
let redirectUrlTree: UrlTree;
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);
});
afterEach(() => {
router.navigateByUrl.calls.reset();
rootDataServiceStub.invalidateRootCache.calls.reset();
});
it('should be created', () => {
expect(guard).toBeTruthy();
});
describe('when root endpoint has succeeded', () => {
describe('when root endpoint request has succeeded', () => {
beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(true));
});
it('should not redirect to error page', () => {
guard.canActivateChild({} as any, {} as any).pipe(
take(1)
).subscribe((canActivate: boolean) => {
expect(canActivate).toEqual(true);
expect(rootDataServiceStub.invalidateRootCache).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
it('should return true', () => {
testScheduler.run(({ expectObservable }) => {
const result$ = guard.canActivateChild({} as any, {} as any);
expectObservable(result$).toBe('(a|)', { a: true });
});
});
});
describe('when root endpoint has not succeeded', () => {
describe('when root endpoint request has not succeeded', () => {
beforeEach(() => {
rootDataServiceStub.checkServerAvailability.and.returnValue(of(false));
});
it('should redirect to error page', () => {
guard.canActivateChild({} as any, {} as any).pipe(
take(1)
).subscribe((canActivate: boolean) => {
expect(canActivate).toEqual(false);
expect(rootDataServiceStub.invalidateRootCache).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith(getPageInternalServerErrorRoute());
it('should return a UrlTree with the route to the 500 error page', () => {
testScheduler.run(({ expectObservable }) => {
const result$ = guard.canActivateChild({} as any, {} as any);
expectObservable(result$).toBe('(b|)', { b: redirectUrlTree });
});
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 { ActivatedRouteSnapshot, CanActivateChild, Router, RouterStateSnapshot } from '@angular/router';
import {
ActivatedRouteSnapshot,
CanActivateChild,
Router,
RouterStateSnapshot,
UrlTree,
NavigationStart
} from '@angular/router';
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 { getPageInternalServerErrorRoute } from '../../app-routing-paths';
@@ -23,17 +30,38 @@ export class ServerCheckGuard implements CanActivateChild {
*/
canActivateChild(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): Observable<boolean> {
state: RouterStateSnapshot
): Observable<boolean | UrlTree> {
return this.rootDataService.checkServerAvailability().pipe(
take(1),
tap((isAvailable: boolean) => {
map((isAvailable: boolean) => {
if (!isAvailable) {
this.rootDataService.invalidateRootCache();
this.router.navigateByUrl(getPageInternalServerErrorRoute());
return this.router.parseUrl(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 { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
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 { getProcessDetailRoute } from '../process-page/process-page-routing.paths';
import { HandleService } from '../shared/handle.service';
import { of as observableOf } from 'rxjs';
describe('CurationFormComponent', () => {
let comp: CurationFormComponent;
@@ -54,7 +55,7 @@ describe('CurationFormComponent', () => {
});
handleService = {
normalizeHandle: (a) => a
normalizeHandle: (a: string) => observableOf(a),
} as any;
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';
spyOn(handleService, 'normalizeHandle').and.returnValue(null);
spyOn(handleService, 'normalizeHandle').and.returnValue(observableOf(null));
comp.submit();
flush();
expect(notificationsService.error).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 { UntypedFormControl, UntypedFormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { find, map } from 'rxjs/operators';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../core/shared/operators';
import { map } from 'rxjs/operators';
import { NotificationsService } from '../shared/notifications/notifications.service';
import { TranslateService, TranslateModule } from '@ngx-translate/core';
import { hasValue, isEmpty, isNotEmpty } from '../shared/empty.util';
import { RemoteData } from '../core/data/remote-data';
import { Router } from '@angular/router';
import { ProcessDataService } from '../core/data/processes/process-data.service';
import { Process } from '../process-page/processes/process.model';
import { ConfigurationDataService } from '../core/data/configuration-data.service';
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 { HandleService } from '../shared/handle.service';
import { NgFor, NgIf } from '@angular/common';
export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask';
/**
* Component responsible for rendering the Curation Task form
*/
@@ -27,7 +27,7 @@ export const CURATION_CFG = 'plugin.named.org.dspace.curate.CurationTask';
standalone: true,
imports: [FormsModule, ReactiveFormsModule, NgFor, NgIf, TranslateModule]
})
export class CurationFormComponent implements OnInit {
export class CurationFormComponent implements OnDestroy, OnInit {
config: Observable<RemoteData<ConfigurationProperty>>;
tasks: string[];
@@ -36,10 +36,11 @@ export class CurationFormComponent implements OnInit {
@Input()
dsoHandle: string;
subs: Subscription[] = [];
constructor(
private scriptDataService: ScriptDataService,
private configurationDataService: ConfigurationDataService,
private processDataService: ProcessDataService,
private notificationsService: NotificationsService,
private translateService: TranslateService,
private handleService: HandleService,
@@ -48,6 +49,10 @@ export class CurationFormComponent implements OnInit {
) {
}
ngOnDestroy(): void {
this.subs.forEach((sub: Subscription) => sub.unsubscribe());
}
ngOnInit(): void {
this.form = new UntypedFormGroup({
task: new UntypedFormControl(''),
@@ -55,16 +60,15 @@ export class CurationFormComponent implements OnInit {
});
this.config = this.configurationDataService.findByPropertyName(CURATION_CFG);
this.config.pipe(
find((rd: RemoteData<ConfigurationProperty>) => rd.hasSucceeded),
map((rd: RemoteData<ConfigurationProperty>) => rd.payload)
).subscribe((configProperties) => {
this.subs.push(this.config.pipe(
getFirstSucceededRemoteDataPayload(),
).subscribe((configProperties: ConfigurationProperty) => {
this.tasks = configProperties.values
.filter((value) => isNotEmpty(value) && value.includes('='))
.map((value) => value.split('=')[1].trim());
this.form.get('task').patchValue(this.tasks[0]);
this.cdr.detectChanges();
});
}));
}
/**
@@ -80,33 +84,41 @@ export class CurationFormComponent implements OnInit {
*/
submit() {
const taskName = this.form.get('task').value;
let handle;
let handle$: Observable<string | null>;
if (this.hasHandleValue()) {
handle = this.handleService.normalizeHandle(this.dsoHandle);
if (isEmpty(handle)) {
this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'),
this.translateService.get('curation.form.submit.error.invalid-handle'));
return;
}
handle$ = this.handleService.normalizeHandle(this.dsoHandle).pipe(
map((handle: string | null) => {
if (isEmpty(handle)) {
this.notificationsService.error(this.translateService.get('curation.form.submit.error.head'),
this.translateService.get('curation.form.submit.error.invalid-handle'));
}
return handle;
}),
);
} else {
handle = this.handleService.normalizeHandle(this.form.get('handle').value);
if (isEmpty(handle)) {
handle = 'all';
}
handle$ = this.handleService.normalizeHandle(this.form.get('handle').value).pipe(
map((handle: string | null) => isEmpty(handle) ? 'all' : handle),
);
}
this.scriptDataService.invoke('curate', [
{ name: '-t', value: taskName },
{ name: '-i', value: handle },
], []).pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData<Process>) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get('curation.form.submit.success.head'),
this.translateService.get('curation.form.submit.success.content'));
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'));
this.subs.push(handle$.subscribe((handle: string) => {
if (hasValue(handle)) {
this.subs.push(this.scriptDataService.invoke('curate', [
{ name: '-t', value: taskName },
{ name: '-i', value: handle },
], []).pipe(
getFirstCompletedRemoteData(),
).subscribe((rd: RemoteData<Process>) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get('curation.form.submit.success.head'),
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">
</ds-register-email-form>
</ds-themed-register-email-form>

View File

@@ -3,13 +3,14 @@ import {
RegisterEmailFormComponent,
TYPE_REQUEST_FORGOT
} from '../../register-email-form/register-email-form.component';
import { ThemedRegisterEmailFormComponent } from 'src/app/register-email-form/themed-registry-email-form.component';
@Component({
selector: 'ds-forgot-email',
styleUrls: ['./forgot-email.component.scss'],
templateUrl: './forgot-email.component.html',
imports: [
RegisterEmailFormComponent
RegisterEmailFormComponent, ThemedRegisterEmailFormComponent
],
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-navbar></ds-themed-navbar>
</div>

View File

@@ -1,4 +1,6 @@
:host {
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 { Observable } from 'rxjs';
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { TranslateModule } from '@ngx-translate/core';
import { NgIf, AsyncPipe } from '@angular/common';
@@ -19,12 +19,23 @@ import { NgIf, AsyncPipe } from '@angular/common';
export class ContextHelpToggleComponent implements OnInit {
buttonVisible$: Observable<boolean>;
subscriptions: Subscription[] = [];
constructor(
private contextHelpService: ContextHelpService,
) { }
protected elRef: ElementRef,
protected contextHelpService: ContextHelpService,
) {
}
ngOnInit(): void {
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() {

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">
<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-themed-auth-nav-menu></ds-themed-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar>
<div class="pl-2">
<button class="navbar-toggler" type="button" (click)="toggleNavbar()"
<div *ngIf="isXsOrSm$ | async" class="pl-2">
<button class="navbar-toggler px-0" type="button" (click)="toggleNavbar()"
aria-controls="collapsingNav"
aria-expanded="false" [attr.aria-label]="'nav.toggle' | translate">
<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 {
max-height: var(--ds-header-logo-height);
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 { ThemedAuthNavMenuComponent } from '../shared/auth-nav-menu/themed-auth-nav-menu.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 fixture: ComponentFixture<HeaderComponent>;
@@ -44,7 +46,8 @@ describe('HeaderComponent', () => {
providers: [
{ provide: MenuService, useValue: menuService },
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub()},
{ provide: LocaleService, useValue: mockLocaleService }
{ provide: LocaleService, useValue: mockLocaleService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
],
schemas: [NO_ERRORS_SCHEMA]
})
@@ -61,7 +64,7 @@ describe('HeaderComponent', () => {
fixture = TestBed.createComponent(HeaderComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
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 { MenuService } from '../shared/menu/menu.service';
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 { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
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
@@ -19,22 +21,27 @@ import { RouterLink } from '@angular/router';
styleUrls: ['header.component.scss'],
templateUrl: 'header.component.html',
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.
* @type {Observable<string>}
*/
public isAuthenticated: Observable<boolean>;
public showAuth = false;
public isXsOrSm$: Observable<boolean>;
menuID = MenuID.PUBLIC;
constructor(
private menuService: MenuService
protected menuService: MenuService,
protected windowService: HostWindowService,
) {
}
ngOnInit(): void {
this.isXsOrSm$ = this.windowService.isXsOrSm();
}
public toggleNavbar(): void {
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="d-flex flex-wrap">
<div>

View File

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

View File

@@ -1,12 +1,12 @@
<ng-container *ngVar="(itemRD$ | async) as itemRD">
<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>
<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>
</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>
<ds-error *ngIf="itemRD?.hasFailed" message="{{'error.recent-submissions' | translate}}"></ds-error>
<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="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()">
<h2>{{ 'info.feedback.head' | translate }}</h2>
<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 { ItemPageVersionHistoryGuard } from './item-page-version-history.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 { ItemPageRegisterDoiGuard } from './item-page-register-doi.guard';
import { ItemCurateComponent } from './item-curate/item-curate.component';
@@ -87,7 +89,8 @@ import { ItemAccessControlComponent } from './item-access-control/item-access-co
{
path: 'curate',
component: ItemCurateComponent,
data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true }
data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true },
canActivate: [ItemPageCurateGuard]
},
{
path: 'relationships',
@@ -116,7 +119,8 @@ import { ItemAccessControlComponent } from './item-access-control/item-access-co
{
path: 'access-control',
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',
@@ -202,11 +206,13 @@ import { ItemAccessControlComponent } from './item-access-control/item-access-co
ItemPageWithdrawGuard,
ItemPageAdministratorGuard,
ItemPageMetadataGuard,
ItemPageCurateGuard,
ItemPageStatusGuard,
ItemPageBitstreamsGuard,
ItemPageRelationshipsGuard,
ItemPageVersionHistoryGuard,
ItemPageCollectionMapperGuard,
ItemPageAccessControlGuard,
ItemPageRegisterDoiGuard,
]
})

View File

@@ -25,7 +25,7 @@
<div class="{{columnSizes.columns[3].buildClasses()}} row-element d-flex align-items-center">
<div class="text-center w-100">
<div class="btn-group relationship-action-buttons">
<a *ngIf="bitstreamDownloadUrl != null" [href]="bitstreamDownloadUrl"
<a *ngIf="bitstreamDownloadUrl != null" [routerLink]="bitstreamDownloadUrl"
class="btn btn-outline-primary btn-sm"
title="{{'item.edit.bitstreams.edit.buttons.download' | translate}}"
[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 { ActivatedRoute } from '@angular/router';
import { ActivatedRouteStub } from '../../../../shared/testing/active-router.stub';
import { RouterTestingModule } from '@angular/router/testing';
let comp: ItemEditBitstreamComponent;
let fixture: ComponentFixture<ItemEditBitstreamComponent>;
@@ -74,7 +75,10 @@ describe('ItemEditBitstreamComponent', () => {
);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), ItemEditBitstreamComponent,
imports: [
TranslateModule.forRoot(),
RouterTestingModule.withRoutes([]),
ItemEditBitstreamComponent,
VarDirective],
declarations: [BrowserOnlyMockPipe],
providers: [

View File

@@ -21,7 +21,11 @@
<div class="col-12">
<p>
<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}}
</label>
</p>

View File

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

View File

@@ -28,4 +28,12 @@ export class ItemOperation {
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 *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>

View File

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

View File

@@ -3,16 +3,14 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model';
import { distinctUntilChanged, map, mergeMap, switchMap, toArray } from 'rxjs/operators';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { concatMap, distinctUntilChanged, first, map, mergeMap, switchMap, toArray } from 'rxjs/operators';
import { BehaviorSubject, combineLatest, Observable, of, Subscription } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import {
getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload,
} from '../../../core/shared/operators';
import { hasValue } from '../../../shared/empty.util';
import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, } from '../../../core/shared/operators';
import { IdentifierDataService } from '../../../core/data/identifier-data.service';
import { Identifier } from '../../../shared/object-list/identifier-data/identifier.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 { AsyncPipe, NgClass, NgForOf, NgIf } from '@angular/common';
import { ItemOperationComponent } from '../item-operation/item-operation.component';
import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service';
@Component({
selector: 'ds-item-status',
@@ -86,6 +85,7 @@ export class ItemStatusComponent implements OnInit {
private authorizationService: AuthorizationDataService,
private identifierDataService: IdentifierDataService,
private configurationService: ConfigurationDataService,
private orcidAuthService: OrcidAuthService
) {
}
@@ -95,14 +95,16 @@ export class ItemStatusComponent implements OnInit {
ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso));
this.itemRD$.pipe(
first(),
map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => {
this.statusData = Object.assign({
id: item.id,
handle: item.handle,
lastModified: item.lastModified
});
this.statusDataKeys = Object.keys(this.statusData);
).pipe(
switchMap((item: Item) => {
this.statusData = Object.assign({
id: item.id,
handle: item.handle,
lastModified: item.lastModified
});
this.statusDataKeys = Object.keys(this.statusData);
// Observable for item identifiers (retrieved from embedded link)
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
let registerConfigEnabled$: Observable<boolean> = this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<ConfigurationProperty>) => {
// 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;
})
map((enabledRD: RemoteData<ConfigurationProperty>) => enabledRD.hasSucceeded && enabledRD.payload.values.length > 0)
);
/*
Construct a base list of operations.
The key is used to build messages
i18n example: 'item.edit.tabs.status.buttons.<key>.label'
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
/**
* Construct a base list of operations.
* The key is used to build messages
* i18n example: 'item.edit.tabs.status.buttons.<key>.label'
* The value is supposed to be a href for the button
*/
this.identifierDataService.getIdentifierDataFor(item).pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
mergeMap((data: IdentifierData) => {
let identifiers = data.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 (identifier.identifierStatus === 'PENDING' || identifier.identifierStatus === 'MINTED'
|| identifier.identifierStatus == null) {
// 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;
}
const currentUrl = this.getCurrentUrl(item);
const inititalOperations: ItemOperation[] = [
new ItemOperation('authorizations', `${currentUrl}/authorizations`, FeatureID.CanManagePolicies, true),
new ItemOperation('mappedCollections', `${currentUrl}/mapper`, FeatureID.CanManageMappings, true),
item.isWithdrawn
? new ItemOperation('reinstate', `${currentUrl}/reinstate`, FeatureID.ReinstateItem, true)
: new ItemOperation('withdraw', `${currentUrl}/withdraw`, FeatureID.WithdrawItem, true),
item.isDiscoverable
? new ItemOperation('private', `${currentUrl}/private`, FeatureID.CanMakePrivate, true)
: new ItemOperation('public', `${currentUrl}/public`, FeatureID.CanMakePrivate, true),
new ItemOperation('move', `${currentUrl}/move`, FeatureID.CanMove, true),
new ItemOperation('delete', `${currentUrl}/delete`, FeatureID.CanDelete, true)
];
this.operations$.next(inititalOperations);
/**
* 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(
map((enabled: boolean) => {
return enabled && (pending || no_doi);
// If there is no DOI, or a pending/minted/null DOI, and the config is enabled, return true
return registerConfigEnabled$.pipe(
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];
}
}),
// Wait for all operations to be emitted and return as an array
toArray(),
).subscribe((data) => {
// Update the operations$ subject that draws the administrative buttons on the status page
this.operations$.next(data);
});
});
}),
toArray()
);
let orcidOps$ = of([]);
if (this.orcidAuthService.isLinkedToOrcid(item)) {
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(
getAllSucceededRemoteDataPayload(),
@@ -219,6 +230,7 @@ export class ItemStatusComponent implements OnInit {
}
/**
* Get the current url without query params
* @returns {string} url

View File

@@ -77,7 +77,8 @@ export class MiradorViewerComponent implements OnInit {
const manifestApiEndpoint = encodeURIComponent(environment.rest.baseUrl + '/iiif/'
+ this.object.id + '/manifest');
// 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) {
// Tell the viewer add search to menu.
viewerPath += '&searchable=' + this.searchable;

View File

@@ -3,8 +3,8 @@
<div>
<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>
<ds-log-in
[isStandalonePage]="true"></ds-log-in>
<ds-themed-log-in
[isStandalonePage]="true"></ds-themed-log-in>
</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 { TranslateModule } from '@ngx-translate/core';
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
@@ -26,7 +27,7 @@ import { LogInComponent } from '../shared/log-in/log-in.component';
styleUrls: ['./login-page.component.scss'],
templateUrl: './login-page.component.html',
standalone: true,
imports: [LogInComponent, TranslateModule]
imports: [LogInComponent, ThemedLogInComponent, TranslateModule]
})
export class LoginPageComponent implements OnDestroy, OnInit {

View File

@@ -14,9 +14,9 @@
</a>
<ul @slide *ngIf="(active | async)" (click)="deactivateSection($event)"
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
*ngComponentOutlet="(sectionMap$ | async).get(subSection.id).component; injector: (sectionMap$ | async).get(subSection.id).injector;"></ng-container>
</ng-container>
</li>
</ul>
</div>

View File

@@ -6,14 +6,20 @@
}
.dropdown-menu {
background-color: var(--ds-expandable-navbar-bg);
overflow: hidden;
min-width: 100%;
border-top-left-radius: 0;
border-top-right-radius: 0;
::ng-deep a.nav-link {
color: var(--ds-expandable-navbar-link-color) !important;
padding-right: var(--bs-spacer);
padding-left: var(--bs-spacer);
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 { first } from 'rxjs/operators';
import { HostWindowService } from '../../shared/host-window.service';
import { rendersSectionForMenu } from '../../shared/menu/menu-section.decorator';
import { MenuID } from '../../shared/menu/menu-id.model';
import { NgComponentOutlet, NgIf, NgFor, AsyncPipe } from '@angular/common';
import { RouterLinkActive } from '@angular/router';
@@ -21,7 +20,6 @@ import { VarDirective } from '../../shared/utils/var.directive';
standalone: true,
imports: [VarDirective, RouterLinkActive, NgComponentOutlet, NgIf, NgFor, AsyncPipe]
})
@rendersSectionForMenu(MenuID.PUBLIC, true)
export class ExpandableNavbarSectionComponent extends NavbarSectionComponent implements OnInit {
/**
* 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
*/
@Component({
/* eslint-disable @angular-eslint/component-selector */
selector: 'li[ds-themed-expandable-navbar-section]',
styleUrls: [],
templateUrl: '../../shared/theme-support/themed.component.html',
standalone: true
selector: 'ds-themed-expandable-navbar-section',
styleUrls: [],
templateUrl: '../../shared/theme-support/themed.component.html',
standalone: true
})
@rendersSectionForMenu(MenuID.PUBLIC, true)
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
*/
@Component({
/* eslint-disable @angular-eslint/component-selector */
selector: 'li[ds-navbar-section]',
templateUrl: './navbar-section.component.html',
styleUrls: ['./navbar-section.component.scss'],
standalone: true,
imports: [NgComponentOutlet, AsyncPipe]
selector: 'ds-navbar-section',
templateUrl: './navbar-section.component.html',
styleUrls: ['./navbar-section.component.scss'],
standalone: true,
imports: [NgComponentOutlet, AsyncPipe]
})
@rendersSectionForMenu(MenuID.PUBLIC, false)
export class NavbarSectionComponent extends MenuSectionComponent implements OnInit {

View File

@@ -6,11 +6,11 @@
<div id="collapsingNav">
<ul class="navbar-nav navbar-navigation mr-auto shadow-none">
<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>
<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>
</li>
</ul>
</div>
</div>

View File

@@ -1,5 +1,5 @@
nav.navbar {
border-bottom: 1px var(--ds-header-navbar-border-bottom-color) solid;
background-color: var(--ds-navbar-bg);
align-items: baseline;
}
@@ -11,9 +11,11 @@ nav.navbar {
position: absolute;
overflow: hidden;
height: 0;
z-index: var(--ds-nav-z-index);
&.open {
height: auto;
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 {
::ng-deep a.nav-link {
color: var(--ds-navbar-link-color);
}
::ng-deep a.nav-link:hover {
color: var(--ds-navbar-link-color-hover);
&:hover, &:focus {
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 { NgClass, NgIf, NgFor, NgComponentOutlet, AsyncPipe } from '@angular/common';
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
@@ -26,7 +27,7 @@ import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
templateUrl: './navbar.component.html',
animations: [slideMobileNav],
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 {
/**

View File

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