Merge branch 'main' into w2p-79768_fix-issues-with-meta-tags

This commit is contained in:
Yura Bondarenko
2021-07-01 22:55:29 +02:00
209 changed files with 3144 additions and 1455 deletions

15
SECURITY.md Normal file
View File

@@ -0,0 +1,15 @@
# Security Policy
## Supported Versions
For information regarding which versions of DSpace are currently under support, please see our DSpace Software Support Policy:
https://wiki.lyrasis.org/display/DSPACE/DSpace+Software+Support+Policy
## Reporting a Vulnerability
If you believe you have found a security vulnerability in a supported version of DSpace, we encourage you to let us know right away.
We will investigate all legitimate reports and do our best to quickly fix the problem. Please see our DSpace Software Support Policy
for information on privately reporting vulnerabilities:
https://wiki.lyrasis.org/display/DSPACE/DSpace+Software+Support+Policy

View File

@@ -1,5 +1,9 @@
# Docker Compose files # Docker Compose files
***
:warning: **NOT PRODUCTION READY** The below Docker Compose resources are not guaranteed "production ready" at this time. They have been built for development/testing only. Therefore, DSpace Docker images may not be fully secured or up-to-date. While you are welcome to base your own images on these DSpace images/resources, these should not be used "as is" in any production scenario.
***
## docker directory ## docker directory
- docker-compose.yml - docker-compose.yml
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.

View File

@@ -69,7 +69,6 @@ exports.config = {
plugins: [{ plugins: [{
path: '../node_modules/protractor-istanbul-plugin' path: '../node_modules/protractor-istanbul-plugin'
}], }],
framework: 'jasmine', framework: 'jasmine',
jasmineNodeOpts: { jasmineNodeOpts: {
showColors: true, showColors: true,
@@ -85,7 +84,7 @@ exports.config = {
onPrepare: function () { onPrepare: function () {
jasmine.getEnv().addReporter(new SpecReporter({ jasmine.getEnv().addReporter(new SpecReporter({
spec: { spec: {
displayStacktrace: true displayStacktrace: 'pretty'
} }
})); }));
} }

View File

@@ -1,11 +1,10 @@
<li class="sidebar-section"> <li class="sidebar-section">
<a class="nav-item nav-link shortcut-icon" attr.aria-labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" [routerLink]="itemModel.link"> <a href="javascript:void(0);" class="nav-item nav-link shortcut-icon" attr.aria-labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" [routerLink]="itemModel.link">
<i class="fas fa-{{section.icon}} fa-fw"></i> <i class="fas fa-{{section.icon}} fa-fw"></i>
</a> </a>
<div class="sidebar-collapsible"> <div class="sidebar-collapsible">
<span id="sidebarName-{{section.id}}" class="section-header-text"> <span id="sidebarName-{{section.id}}" class="section-header-text">
<ng-container <a class="nav-item nav-link" tabindex="-1" [routerLink]="itemModel.link">{{itemModel.text | translate}}</a>
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
</span> </span>
</div> </div>
</li> </li>

View File

@@ -10,14 +10,14 @@
<div class="sidebar-top-level-items"> <div class="sidebar-top-level-items">
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="admin-menu-header sidebar-section"> <li class="admin-menu-header sidebar-section">
<a class="shortcut-icon navbar-brand mr-0" href="#"> <a class="shortcut-icon navbar-brand mr-0" href="javascript:void(0);">
<span class="logo-wrapper"> <span class="logo-wrapper">
<img class="admin-logo" src="assets/images/dspace-logo-mini.svg" <img class="admin-logo" src="assets/images/dspace-logo-mini.svg"
[alt]="('menu.header.image.logo') | translate"> [alt]="('menu.header.image.logo') | translate">
</span> </span>
</a> </a>
<div class="sidebar-collapsible"> <div class="sidebar-collapsible">
<a class="navbar-brand mr-0" href="#"> <a class="navbar-brand mr-0" href="javascript:void(0);">
<h4 class="section-header-text mb-0">{{'menu.header.admin' | <h4 class="section-header-text mb-0">{{'menu.header.admin' |
translate}}</h4> translate}}</h4>
</a> </a>
@@ -33,7 +33,7 @@
<div class="navbar-nav"> <div class="navbar-nav">
<div class="sidebar-section" id="sidebar-collapse-toggle"> <div class="sidebar-section" id="sidebar-collapse-toggle">
<a class="nav-item nav-link shortcut-icon" <a class="nav-item nav-link shortcut-icon"
href="#" href="javascript:void(0);"
(click)="toggle($event)"> (click)="toggle($event)">
<i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right" <i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right"
[title]="'menu.section.icon.pin' | translate"></i> [title]="'menu.section.icon.pin' | translate"></i>
@@ -42,7 +42,7 @@
</a> </a>
<div class="sidebar-collapsible"> <div class="sidebar-collapsible">
<a class="nav-item nav-link sidebar-section" <a class="nav-item nav-link sidebar-section"
href="#" href="javascript:void(0);"
(click)="toggle($event)"> (click)="toggle($event)">
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span> <span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span> <span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>

View File

@@ -531,14 +531,17 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
* Create menu sections dependent on whether or not the current user can manage access control groups * Create menu sections dependent on whether or not the current user can manage access control groups
*/ */
createAccessControlMenuSections() { createAccessControlMenuSections() {
this.authorizationService.isAuthorized(FeatureID.CanManageGroups).subscribe((authorized) => { observableCombineLatest(
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
this.authorizationService.isAuthorized(FeatureID.CanManageGroups)
).subscribe(([isSiteAdmin, canManageGroups]) => {
const menuList = [ const menuList = [
/* Access Control */ /* Access Control */
{ {
id: 'access_control_people', id: 'access_control_people',
parentID: 'access_control', parentID: 'access_control',
active: false, active: false,
visible: authorized, visible: isSiteAdmin,
model: { model: {
type: MenuItemType.LINK, type: MenuItemType.LINK,
text: 'menu.section.access_control_people', text: 'menu.section.access_control_people',
@@ -549,7 +552,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
id: 'access_control_groups', id: 'access_control_groups',
parentID: 'access_control', parentID: 'access_control',
active: false, active: false,
visible: authorized, visible: canManageGroups,
model: { model: {
type: MenuItemType.LINK, type: MenuItemType.LINK,
text: 'menu.section.access_control_groups', text: 'menu.section.access_control_groups',
@@ -571,7 +574,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
{ {
id: 'access_control', id: 'access_control',
active: false, active: false,
visible: authorized, visible: canManageGroups || isSiteAdmin,
model: { model: {
type: MenuItemType.TEXT, type: MenuItemType.TEXT,
text: 'menu.section.access_control' text: 'menu.section.access_control'

View File

@@ -3,12 +3,12 @@
value: ((expanded | async) ? 'endBackground' : 'startBackground'), value: ((expanded | async) ? 'endBackground' : 'startBackground'),
params: {endColor: (sidebarActiveBg | async)}}"> params: {endColor: (sidebarActiveBg | async)}}">
<div class="icon-wrapper"> <div class="icon-wrapper">
<a class="nav-item nav-link shortcut-icon" attr.aria.labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" (click)="toggleSection($event)" href="#"> <a class="nav-item nav-link shortcut-icon" attr.aria.labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" (click)="toggleSection($event)" href="javascript:void(0);">
<i class="fas fa-{{section.icon}} fa-fw"></i> <i class="fas fa-{{section.icon}} fa-fw"></i>
</a> </a>
</div> </div>
<div class="sidebar-collapsible"> <div class="sidebar-collapsible">
<a class="nav-item nav-link" href="#" <a class="nav-item nav-link" href="javascript:void(0);" tabindex="-1"
(click)="toggleSection($event)"> (click)="toggleSection($event)">
<span id="sidebarName-{{section.id}}" class="section-header-text"> <span id="sidebarName-{{section.id}}" class="section-header-text">
<ng-container <ng-container

View File

@@ -0,0 +1,10 @@
<div class="container">
<ds-resource-policies [resourceType]="'bitstream'" [resourceUUID]="(dsoRD$ | async)?.payload?.id"></ds-resource-policies>
<div class="button-row bottom">
<div class="text-right">
<a [routerLink]="['/bitstreams', (dsoRD$ | async)?.payload?.id, 'edit']" role="button" class="btn btn-outline-secondary mr-1">
<i class="fas fa-arrow-left"></i> {{'bitstream.edit.return' | translate}}
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,84 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { cold } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations.component';
import { Bitstream } from '../../core/shared/bitstream.model';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
describe('BitstreamAuthorizationsComponent', () => {
let comp: BitstreamAuthorizationsComponent<DSpaceObject>;
let fixture: ComponentFixture<BitstreamAuthorizationsComponent<any>>;
const bitstream = Object.assign(new Bitstream(), {
sizeBytes: 10000,
metadata: {
'dc.title': [
{
value: 'file name',
language: null
}
]
},
_links: {
content: { href: 'file-selflink' }
}
});
const bitstreamRD = createSuccessfulRemoteDataObject(bitstream);
const routeStub = {
data: observableOf({
bitstream: bitstreamRD
})
};
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
CommonModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})
],
declarations: [BitstreamAuthorizationsComponent],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
ChangeDetectorRef,
BitstreamAuthorizationsComponent,
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BitstreamAuthorizationsComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
comp = null;
fixture.destroy();
});
it('should create', () => {
expect(comp).toBeTruthy();
});
it('should init dso remote data properly', (done) => {
const expected = cold('(a|)', { a: bitstreamRD });
expect(comp.dsoRD$).toBeObservable(expected);
done();
});
});

View File

@@ -0,0 +1,40 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { first, map } from 'rxjs/operators';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
@Component({
selector: 'ds-collection-authorizations',
templateUrl: './bitstream-authorizations.component.html',
})
/**
* Component that handles the Collection Authorizations
*/
export class BitstreamAuthorizationsComponent<TDomain extends DSpaceObject> implements OnInit {
/**
* The initial DSO object
*/
public dsoRD$: Observable<RemoteData<TDomain>>;
/**
* Initialize instance variables
*
* @param {ActivatedRoute} route
*/
constructor(
private route: ActivatedRoute
) {
}
/**
* Initialize the component, setting up the collection
*/
ngOnInit(): void {
this.dsoRD$ = this.route.data.pipe(first(), map((data) => data.bitstream));
}
}

View File

@@ -4,8 +4,14 @@ import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream
import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { BitstreamPageResolver } from './bitstream-page.resolver'; import { BitstreamPageResolver } from './bitstream-page.resolver';
import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component'; import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component';
import { ResourcePolicyTargetResolver } from '../shared/resource-policies/resolvers/resource-policy-target.resolver';
import { ResourcePolicyCreateComponent } from '../shared/resource-policies/create/resource-policy-create.component';
import { ResourcePolicyResolver } from '../shared/resource-policies/resolvers/resource-policy.resolver';
import { ResourcePolicyEditComponent } from '../shared/resource-policies/edit/resource-policy-edit.component';
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
const EDIT_BITSTREAM_PATH = ':id/edit'; const EDIT_BITSTREAM_PATH = ':id/edit';
const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations';
/** /**
* Routing module to help navigate Bitstream pages * Routing module to help navigate Bitstream pages
@@ -27,6 +33,36 @@ const EDIT_BITSTREAM_PATH = ':id/edit';
bitstream: BitstreamPageResolver bitstream: BitstreamPageResolver
}, },
canActivate: [AuthenticatedGuard] canActivate: [AuthenticatedGuard]
},
{
path: EDIT_BITSTREAM_AUTHORIZATIONS_PATH,
children: [
{
path: 'create',
resolve: {
resourcePolicyTarget: ResourcePolicyTargetResolver
},
component: ResourcePolicyCreateComponent,
data: { title: 'resource-policies.create.page.title', showBreadcrumbs: true }
},
{
path: 'edit',
resolve: {
resourcePolicy: ResourcePolicyResolver
},
component: ResourcePolicyEditComponent,
data: { title: 'resource-policies.edit.page.title', showBreadcrumbs: true }
},
{
path: '',
resolve: {
bitstream: BitstreamPageResolver
},
component: BitstreamAuthorizationsComponent,
data: { title: 'bitstream.edit.authorizations.title', showBreadcrumbs: true }
}
]
} }
]) ])
], ],

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { SharedModule } from '../shared/shared.module'; import { SharedModule } from '../shared/shared.module';
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component'; import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
import { BitstreamPageRoutingModule } from './bitstream-page-routing.module'; import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
/** /**
* This module handles all components that are necessary for Bitstream related pages * This module handles all components that are necessary for Bitstream related pages
@@ -14,6 +15,7 @@ import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
BitstreamPageRoutingModule BitstreamPageRoutingModule
], ],
declarations: [ declarations: [
BitstreamAuthorizationsComponent,
EditBitstreamPageComponent EditBitstreamPageComponent
] ]
}) })

View File

@@ -35,7 +35,7 @@ export class BitstreamPageResolver implements Resolve<RemoteData<Bitstream>> {
*/ */
get followLinks(): FollowLinkConfig<Bitstream>[] { get followLinks(): FollowLinkConfig<Bitstream>[] {
return [ return [
followLink('bundle', undefined, true, true, true, followLink('item')), followLink('bundle', {}, followLink('item')),
followLink('format') followLink('format')
]; ];
} }

View File

@@ -19,7 +19,11 @@
[submitLabel]="'form.save'" [submitLabel]="'form.save'"
(submitForm)="onSubmit()" (submitForm)="onSubmit()"
(cancel)="onCancel()" (cancel)="onCancel()"
(dfChange)="onChange($event)"></ds-form> (dfChange)="onChange($event)">
<div additional class="container py-3">
<a [routerLink]="['/bitstreams', bitstreamRD?.payload?.id, 'authorizations']">{{'bitstream.edit.authorizations.link' | translate}}</a>
</div>
</ds-form>
</div> </div>
</div> </div>
<ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error> <ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error>

View File

@@ -18,12 +18,8 @@ import { hasValue } from '../../shared/empty.util';
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms';
import { FileSizePipe } from '../../shared/utils/file-size-pipe'; import { FileSizePipe } from '../../shared/utils/file-size-pipe';
import { VarDirective } from '../../shared/utils/var.directive'; import { VarDirective } from '../../shared/utils/var.directive';
import { import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
createSuccessfulRemoteDataObject, import { getEntityEditRoute } from '../../+item-page/item-page-routing-paths';
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { RouterStub } from '../../shared/testing/router.stub';
import { getEntityEditRoute, getItemEditRoute } from '../../+item-page/item-page-routing-paths';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { Item } from '../../core/shared/item.model'; import { Item } from '../../core/shared/item.model';
@@ -39,7 +35,6 @@ let bitstream: Bitstream;
let selectedFormat: BitstreamFormat; let selectedFormat: BitstreamFormat;
let allFormats: BitstreamFormat[]; let allFormats: BitstreamFormat[];
let router: Router; let router: Router;
let routerStub;
describe('EditBitstreamPageComponent', () => { describe('EditBitstreamPageComponent', () => {
let comp: EditBitstreamPageComponent; let comp: EditBitstreamPageComponent;
@@ -129,10 +124,6 @@ describe('EditBitstreamPageComponent', () => {
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats)) findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats))
}); });
const itemPageUrl = `fake-url/some-uuid`;
routerStub = Object.assign(new RouterStub(), {
url: `${itemPageUrl}`
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule], imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective], declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
@@ -142,7 +133,6 @@ describe('EditBitstreamPageComponent', () => {
{ provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: createSuccessfulRemoteDataObject(bitstream) }), snapshot: { queryParams: {} } } }, { provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: createSuccessfulRemoteDataObject(bitstream) }), snapshot: { queryParams: {} } } },
{ provide: BitstreamDataService, useValue: bitstreamService }, { provide: BitstreamDataService, useValue: bitstreamService },
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService }, { provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
{ provide: Router, useValue: routerStub },
ChangeDetectorRef ChangeDetectorRef
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
@@ -154,7 +144,8 @@ describe('EditBitstreamPageComponent', () => {
fixture = TestBed.createComponent(EditBitstreamPageComponent); fixture = TestBed.createComponent(EditBitstreamPageComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
router = (comp as any).router; router = TestBed.inject(Router);
spyOn(router, 'navigate');
}); });
describe('on startup', () => { describe('on startup', () => {
@@ -241,14 +232,14 @@ describe('EditBitstreamPageComponent', () => {
it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => { it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => {
comp.itemId = 'some-uuid1'; comp.itemId = 'some-uuid1';
comp.navigateToItemEditBitstreams(); comp.navigateToItemEditBitstreams();
expect(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']); expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']);
}); });
}); });
describe('when navigateToItemEditBitstreams is called, and the component does not have an itemId', () => { describe('when navigateToItemEditBitstreams is called, and the component does not have an itemId', () => {
it('should redirect to the item edit page on the bitstreams tab with the itemId from the bundle links ', () => { it('should redirect to the item edit page on the bitstreams tab with the itemId from the bundle links ', () => {
comp.itemId = undefined; comp.itemId = undefined;
comp.navigateToItemEditBitstreams(); comp.navigateToItemEditBitstreams();
expect(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']); expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']);
}); });
}); });
}); });

View File

@@ -19,10 +19,10 @@ import { cloneDeep } from 'lodash';
import { BitstreamDataService } from '../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { import {
getAllSucceededRemoteDataPayload, getAllSucceededRemoteDataPayload,
getFirstSucceededRemoteDataPayload, getFirstCompletedRemoteData,
getRemoteDataPayload,
getFirstSucceededRemoteData, getFirstSucceededRemoteData,
getFirstCompletedRemoteData getFirstSucceededRemoteDataPayload,
getRemoteDataPayload
} from '../../core/shared/operators'; } from '../../core/shared/operators';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service'; import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
@@ -131,15 +131,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
rows: 10 rows: 10
}); });
/**
* The Dynamic Input Model for the file's embargo (disabled on this page)
*/
embargoModel = new DynamicInputModel({
id: 'embargo',
name: 'embargo',
disabled: true
});
/** /**
* The Dynamic Input Model for the selected format * The Dynamic Input Model for the selected format
*/ */
@@ -159,7 +150,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
/** /**
* All input models in a simple array for easier iterations * All input models in a simple array for easier iterations
*/ */
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.embargoModel, this.selectedFormatModel, this.newFormatModel]; inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.selectedFormatModel, this.newFormatModel];
/** /**
* The dynamic form fields used for editing the information of a bitstream * The dynamic form fields used for editing the information of a bitstream
@@ -179,12 +170,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
this.descriptionModel this.descriptionModel
] ]
}), }),
new DynamicFormGroupModel({
id: 'embargoContainer',
group: [
this.embargoModel
]
}),
new DynamicFormGroupModel({ new DynamicFormGroupModel({
id: 'formatContainer', id: 'formatContainer',
group: [ group: [
@@ -243,11 +228,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
host: 'row' host: 'row'
} }
}, },
embargoContainer: {
grid: {
host: 'row'
}
},
formatContainer: { formatContainer: {
grid: { grid: {
host: 'row' host: 'row'

View File

@@ -14,7 +14,7 @@ import { ResolvedAction } from '../core/resolving/resolver.actions';
* Requesting them as embeds will limit the number of requests * Requesting them as embeds will limit the number of requests
*/ */
export const COLLECTION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Collection>[] = [ export const COLLECTION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Collection>[] = [
followLink('parentCommunity', undefined, true, true, true, followLink('parentCommunity', {},
followLink('parentCommunity') followLink('parentCommunity')
), ),
followLink('logo') followLink('logo')

View File

@@ -2,15 +2,16 @@
<div class="row"> <div class="row">
<ng-container *ngVar="(dsoRD$ | async)?.payload as dso"> <ng-container *ngVar="(dsoRD$ | async)?.payload as dso">
<div class="col-12 pb-4"> <div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate}}</h2> <h2 id="header" class="border-bottom pb-2">{{ 'collection.delete.head' | translate}}</h2>
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p> <p class="pb-2">{{ 'collection.delete.text' | translate:{ dso: dso.name } }}</p>
<div class="form-group row"> <div class="form-group row">
<div class="col text-right"> <div class="col text-right">
<button class="btn btn-outline-secondary" (click)="onCancel(dso)"> <button class="btn btn-outline-secondary" (click)="onCancel(dso)" [disabled]="(processing$ | async)">
<i class="fas fa-times"></i> {{'community.delete.cancel' | translate}} <i class="fas fa-times"></i> {{'collection.delete.cancel' | translate}}
</button> </button>
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)"> <button class="btn btn-danger mr-2" (click)="onConfirm(dso)" [disabled]="(processing$ | async)">
<i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}} <span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'collection.delete.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)"><i class="fas fa-trash"></i> {{'collection.delete.confirm' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -6,11 +6,12 @@
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p> <p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p>
<div class="form-group row"> <div class="form-group row">
<div class="col text-right"> <div class="col text-right">
<button class="btn btn-outline-secondary" (click)="onCancel(dso)"> <button class="btn btn-outline-secondary" (click)="onCancel(dso)" [disabled]="(processing$ | async)">
<i class="fas fa-times"></i> {{'community.delete.cancel' | translate}} <i class="fas fa-times"></i> {{'community.delete.cancel' | translate}}
</button> </button>
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)"> <button class="btn btn-danger mr-2" (click)="onConfirm(dso)" [disabled]="(processing$ | async)">
<i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}} <span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'community.delete.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)"><i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -20,6 +20,7 @@ import { ThemedHomePageComponent } from './themed-home-page.component';
id: 'statistics_site', id: 'statistics_site',
active: true, active: true,
visible: true, visible: true,
index: 2,
model: { model: {
type: MenuItemType.LINK, type: MenuItemType.LINK,
text: 'menu.section.statistics', text: 'menu.section.statistics',

View File

@@ -4,10 +4,9 @@ import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
import { catchError, filter, first, map, mergeMap, take } from 'rxjs/operators'; import { catchError, filter, first, map, mergeMap, take } from 'rxjs/operators';
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model'; import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
import { import {
getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataWithNotEmptyPayload,
getFirstSucceededRemoteDataWithNotEmptyPayload
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { followLink } from '../../../shared/utils/follow-link-config.model'; import { followLink } from '../../../shared/utils/follow-link-config.model';
@@ -15,7 +14,6 @@ import { LinkService } from '../../../core/cache/builders/link.service';
import { Bundle } from '../../../core/shared/bundle.model'; import { Bundle } from '../../../core/shared/bundle.model';
import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { Bitstream } from '../../../core/shared/bitstream.model'; import { Bitstream } from '../../../core/shared/bitstream.model';
import { FindListOptions } from '../../../core/data/request.models';
/** /**
* Interface for a bundle's bitstream map entry * Interface for a bundle's bitstream map entry
@@ -79,7 +77,7 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy {
getFirstSucceededRemoteDataWithNotEmptyPayload(), getFirstSucceededRemoteDataWithNotEmptyPayload(),
map((item: Item) => this.linkService.resolveLink( map((item: Item) => this.linkService.resolveLink(
item, item,
followLink('bundles', new FindListOptions(), true, true, true, followLink('bitstreams')) followLink('bundles', {}, followLink('bitstreams'))
)) ))
) as Observable<Item>; ) as Observable<Item>;

View File

@@ -5,19 +5,16 @@
<p>{{'item.edit.move.description' | translate}}</p> <p>{{'item.edit.move.description' | translate}}</p>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<ds-dso-input-suggestions #f id="search-form" <div class="card mb-3">
[suggestions]="(collectionSearchResults | async)" <div class="card-header">{{'dso-selector.placeholder' | translate: { type: 'collection' } }}</div>
[placeholder]="'item.edit.move.search.placeholder'| translate" <div class="card-body">
[action]="getCurrentUrl()" <ds-authorized-collection-selector [types]="COLLECTIONS"
[name]="'item-move'" [currentDSOId]="selectedCollection ? selectedCollection.id : originalCollection.id"
[(ngModel)]="selectedCollectionName" (onSelect)="selectDso($event)">
(clickSuggestion)="onClick($event)" </ds-authorized-collection-selector>
(typeSuggestion)="resetCollection($event)" </div>
(findSuggestions)="findSuggestions($event)" <div></div>
(click)="f.open()" </div>
ngDefaultControl>
</ds-dso-input-suggestions>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@@ -33,16 +30,24 @@
</div> </div>
</div> </div>
<button (click)="moveCollection()" class="btn btn-primary" [disabled]=!canSubmit> <div class="button-row bottom">
<span *ngIf="!processing"> {{'item.edit.move.move' | translate}}</span> <div class="float-right">
<span *ngIf="processing"><i class='fas fa-circle-notch fa-spin'></i> <button [routerLink]="[(itemPageRoute$ | async), 'edit']" class="btn btn-outline-secondary">
{{'item.edit.move.processing' | translate}} <i class="fas fa-arrow-left"></i> {{'item.edit.move.cancel' | translate}}
</button>
<button class="btn btn-primary mr-0" [disabled]="!canMove" (click)="moveToCollection()">
<span *ngIf="!processing">
<i class="fas fa-save"></i> {{'item.edit.move.save-button' | translate}}
</span>
<span *ngIf="processing">
<i class="fas fa-circle-notch fa-spin"></i> {{'item.edit.move.processing' | translate}}
</span> </span>
</button> </button>
<button [routerLink]="[(itemPageRoute$ | async), 'edit']" <button class="btn btn-danger" [disabled]="!canSubmit" (click)="discard()">
class="btn btn-outline-secondary"> <i class="fas fa-times"></i> {{"item.edit.move.discard-button" | translate}}
{{'item.edit.move.cancel' | translate}}
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>

View File

@@ -21,6 +21,8 @@ import {
createSuccessfulRemoteDataObject$ createSuccessfulRemoteDataObject$
} from '../../../shared/remote-data.utils'; } from '../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../shared/testing/utils.test';
import { RequestService } from '../../../core/data/request.service';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
describe('ItemMoveComponent', () => { describe('ItemMoveComponent', () => {
let comp: ItemMoveComponent; let comp: ItemMoveComponent;
@@ -47,18 +49,25 @@ describe('ItemMoveComponent', () => {
name: 'Test collection 2' name: 'Test collection 2'
}); });
const mockItemDataService = jasmine.createSpyObj({ let itemDataService;
moveToCollection: createSuccessfulRemoteDataObject$(collection1)
const mockItemDataServiceSuccess = jasmine.createSpyObj({
moveToCollection: createSuccessfulRemoteDataObject$(collection1),
findById: createSuccessfulRemoteDataObject$(mockItem),
}); });
const mockItemDataServiceFail = jasmine.createSpyObj({ const mockItemDataServiceFail = jasmine.createSpyObj({
moveToCollection: createFailedRemoteDataObject$('Internal server error', 500) moveToCollection: createFailedRemoteDataObject$('Internal server error', 500),
findById: createSuccessfulRemoteDataObject$(mockItem),
}); });
const routeStub = { const routeStub = {
data: observableOf({ data: observableOf({
dso: createSuccessfulRemoteDataObject(Object.assign(new Item(), { dso: createSuccessfulRemoteDataObject(Object.assign(new Item(), {
id: 'item1' id: 'item1',
owningCollection: createSuccessfulRemoteDataObject$(Object.assign(new Collection(), {
id: 'originalOwningCollection',
}))
})) }))
}) })
}; };
@@ -79,8 +88,9 @@ describe('ItemMoveComponent', () => {
const notificationsServiceStub = new NotificationsServiceStub(); const notificationsServiceStub = new NotificationsServiceStub();
describe('ItemMoveComponent success', () => { const init = (mockItemDataService) => {
beforeEach(() => { itemDataService = mockItemDataService;
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemMoveComponent], declarations: [ItemMoveComponent],
@@ -90,6 +100,7 @@ describe('ItemMoveComponent', () => {
{ provide: ItemDataService, useValue: mockItemDataService }, { provide: ItemDataService, useValue: mockItemDataService },
{ provide: NotificationsService, useValue: notificationsServiceStub }, { provide: NotificationsService, useValue: notificationsServiceStub },
{ provide: SearchService, useValue: mockSearchService }, { provide: SearchService, useValue: mockSearchService },
{ provide: RequestService, useValue: getMockRequestService() },
], schemas: [ ], schemas: [
CUSTOM_ELEMENTS_SCHEMA CUSTOM_ELEMENTS_SCHEMA
] ]
@@ -97,25 +108,20 @@ describe('ItemMoveComponent', () => {
fixture = TestBed.createComponent(ItemMoveComponent); fixture = TestBed.createComponent(ItemMoveComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); };
it('should load suggestions', () => {
const expected = [
collection1,
collection2
];
comp.collectionSearchResults.subscribe((value) => { describe('ItemMoveComponent success', () => {
expect(value).toEqual(expected); beforeEach(() => {
} init(mockItemDataServiceSuccess);
);
}); });
it('should get current url ', () => { it('should get current url ', () => {
expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit'); expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit');
}); });
it('should on click select the correct collection name and id', () => { it('should select the correct collection name and id on click', () => {
const data = collection1; const data = collection1;
comp.onClick(data); comp.selectDso(data);
expect(comp.selectedCollectionName).toEqual('Test collection 1'); expect(comp.selectedCollectionName).toEqual('Test collection 1');
expect(comp.selectedCollection).toEqual(collection1); expect(comp.selectedCollection).toEqual(collection1);
@@ -128,12 +134,12 @@ describe('ItemMoveComponent', () => {
}); });
comp.selectedCollectionName = 'selected-collection-id'; comp.selectedCollectionName = 'selected-collection-id';
comp.selectedCollection = collection1; comp.selectedCollection = collection1;
comp.moveCollection(); comp.moveToCollection();
expect(mockItemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1); expect(itemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1);
}); });
it('should call notificationsService success message on success', () => { it('should call notificationsService success message on success', () => {
comp.moveCollection(); comp.moveToCollection();
expect(notificationsServiceStub.success).toHaveBeenCalled(); expect(notificationsServiceStub.success).toHaveBeenCalled();
}); });
@@ -142,26 +148,11 @@ describe('ItemMoveComponent', () => {
describe('ItemMoveComponent fail', () => { describe('ItemMoveComponent fail', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ init(mockItemDataServiceFail);
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemMoveComponent],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: Router, useValue: routerStub },
{ provide: ItemDataService, useValue: mockItemDataServiceFail },
{ provide: NotificationsService, useValue: notificationsServiceStub },
{ provide: SearchService, useValue: mockSearchService },
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
}).compileComponents();
fixture = TestBed.createComponent(ItemMoveComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
}); });
it('should call notificationsService error message on fail', () => { it('should call notificationsService error message on fail', () => {
comp.moveCollection(); comp.moveToCollection();
expect(notificationsServiceStub.error).toHaveBeenCalled(); expect(notificationsServiceStub.error).toHaveBeenCalled();
}); });

View File

@@ -1,25 +1,21 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { first, map } from 'rxjs/operators'; import { map, switchMap } from 'rxjs/operators';
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { import {
getFirstSucceededRemoteData, getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload,
getFirstCompletedRemoteData, getAllSucceededRemoteDataPayload
} from '../../../core/shared/operators'; } from '../../../core/shared/operators';
import { ItemDataService } from '../../../core/data/item-data.service'; import { ItemDataService } from '../../../core/data/item-data.service';
import { Observable, of as observableOf } from 'rxjs'; import { Observable } from 'rxjs';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SearchService } from '../../../core/shared/search/search.service'; import { SearchService } from '../../../core/shared/search/search.service';
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
import { SearchResult } from '../../../shared/search/search-result.model';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { RequestService } from '../../../core/data/request.service';
@Component({ @Component({
selector: 'ds-item-move', selector: 'ds-item-move',
@@ -38,7 +34,8 @@ export class ItemMoveComponent implements OnInit {
inheritPolicies = false; inheritPolicies = false;
itemRD$: Observable<RemoteData<Item>>; itemRD$: Observable<RemoteData<Item>>;
collectionSearchResults: Observable<any[]> = observableOf([]); originalCollection: Collection;
selectedCollectionName: string; selectedCollectionName: string;
selectedCollection: Collection; selectedCollection: Collection;
canSubmit = false; canSubmit = false;
@@ -46,23 +43,26 @@ export class ItemMoveComponent implements OnInit {
item: Item; item: Item;
processing = false; processing = false;
pagination = new PaginationComponentOptions();
/** /**
* Route to the item's page * Route to the item's page
*/ */
itemPageRoute$: Observable<string>; itemPageRoute$: Observable<string>;
COLLECTIONS = [DSpaceObjectType.COLLECTION];
constructor(private route: ActivatedRoute, constructor(private route: ActivatedRoute,
private router: Router, private router: Router,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private itemDataService: ItemDataService, private itemDataService: ItemDataService,
private searchService: SearchService, private searchService: SearchService,
private translateService: TranslateService) { private translateService: TranslateService,
} private requestService: RequestService,
) {}
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(map((data) => data.dso), getFirstSucceededRemoteData()) as Observable<RemoteData<Item>>; this.itemRD$ = this.route.data.pipe(
map((data) => data.dso), getFirstSucceededRemoteData()
) as Observable<RemoteData<Item>>;
this.itemPageRoute$ = this.itemRD$.pipe( this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(), getAllSucceededRemoteDataPayload(),
map((item) => getItemPageRoute(item)) map((item) => getItemPageRoute(item))
@@ -71,43 +71,22 @@ export class ItemMoveComponent implements OnInit {
this.item = rd.payload; this.item = rd.payload;
} }
); );
this.pagination.pageSize = 5; this.itemRD$.pipe(
this.loadSuggestions(''); getFirstSucceededRemoteData(),
} getRemoteDataPayload(),
switchMap((item) => item.owningCollection),
/** getFirstSucceededRemoteData(),
* Find suggestions based on entered query getRemoteDataPayload(),
* @param query - Search query ).subscribe((collection) => {
*/ this.originalCollection = collection;
findSuggestions(query): void {
this.loadSuggestions(query);
}
/**
* Load all available collections to move the item to.
* TODO: When the API support it, only fetch collections where user has ADD rights to.
*/
loadSuggestions(query): void {
this.collectionSearchResults = this.searchService.search(new PaginatedSearchOptions({
pagination: this.pagination,
dsoTypes: [DSpaceObjectType.COLLECTION],
query: query
})).pipe(
first(),
map((rd: RemoteData<PaginatedList<SearchResult<DSpaceObject>>>) => {
return rd.payload.page.map((searchResult) => {
return searchResult.indexableObject;
}); });
}) ,
);
} }
/** /**
* Set the collection name and id based on the selected value * Set the collection name and id based on the selected value
* @param data - obtained from the ds-input-suggestions component * @param data - obtained from the ds-input-suggestions component
*/ */
onClick(data: any): void { selectDso(data: any): void {
this.selectedCollection = data; this.selectedCollection = data;
this.selectedCollectionName = data.name; this.selectedCollectionName = data.name;
this.canSubmit = true; this.canSubmit = true;
@@ -123,26 +102,41 @@ export class ItemMoveComponent implements OnInit {
/** /**
* Moves the item to a new collection based on the selected collection * Moves the item to a new collection based on the selected collection
*/ */
moveCollection() { moveToCollection() {
this.processing = true; this.processing = true;
this.itemDataService.moveToCollection(this.item.id, this.selectedCollection).pipe(getFirstCompletedRemoteData()).subscribe( const move$ = this.itemDataService.moveToCollection(this.item.id, this.selectedCollection)
(response: RemoteData<Collection>) => { .pipe(getFirstCompletedRemoteData());
this.router.navigate([getItemEditRoute(this.item)]);
move$.subscribe((response: RemoteData<any>) => {
if (response.hasSucceeded) { if (response.hasSucceeded) {
this.notificationsService.success(this.translateService.get('item.edit.move.success')); this.notificationsService.success(this.translateService.get('item.edit.move.success'));
} else { } else {
this.notificationsService.error(this.translateService.get('item.edit.move.error')); this.notificationsService.error(this.translateService.get('item.edit.move.error'));
} }
});
move$.pipe(
switchMap(() => this.requestService.setStaleByHrefSubstring(this.item.id)),
switchMap(() =>
this.itemDataService.findById(
this.item.id,
false,
true,
followLink('owningCollection')
)),
getFirstCompletedRemoteData()
).subscribe(() => {
this.processing = false; this.processing = false;
} this.router.navigate([getItemEditRoute(this.item)]);
); });
} }
/** discard(): void {
* Resets the can submit when the user changes the content of the input field this.selectedCollection = null;
* @param data
*/
resetCollection(data: any) {
this.canSubmit = false; this.canSubmit = false;
} }
get canMove(): boolean {
return this.canSubmit && this.selectedCollection?.id !== this.originalCollection.id;
}
} }

View File

@@ -6,8 +6,15 @@
</button> </button>
</h5> </h5>
<ng-container *ngVar="updates$ | async as updates"> <ng-container *ngVar="updates$ | async as updates">
<ng-container *ngIf="updates"> <ng-container *ngIf="updates && !(loading$ | async)">
<ng-container *ngVar="updates | dsObjectValues as updateValues"> <ng-container *ngVar="updates | dsObjectValues as updateValues">
<ds-pagination
[paginationOptions]="paginationConfig"
[pageInfoState]="(relationshipsRd$ | async)?.payload?.pageInfo"
[collectionSize]="(relationshipsRd$ | async)?.payload?.totalElements + (this.nbAddedFields$ | async)"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
<div class="my-2">
<ds-edit-relationship *ngFor="let updateValue of updateValues; trackBy: trackUpdate" <ds-edit-relationship *ngFor="let updateValue of updateValues; trackBy: trackUpdate"
class="relationship-row d-block alert" class="relationship-row d-block alert"
[fieldUpdate]="updateValue || {}" [fieldUpdate]="updateValue || {}"
@@ -19,8 +26,10 @@
'alert-danger': updateValue.changeType === 2 'alert-danger': updateValue.changeType === 2
}"> }">
</ds-edit-relationship> </ds-edit-relationship>
</div>
</ds-pagination>
<div *ngIf="updateValues.length === 0">{{"item.edit.relationships.no-relationships" | translate}}</div> <div *ngIf="updateValues.length === 0">{{"item.edit.relationships.no-relationships" | translate}}</div>
</ng-container> </ng-container>
</ng-container> </ng-container>
<ds-loading *ngIf="!updates"></ds-loading> <ds-loading *ngIf="loading$ | async"></ds-loading>
</ng-container> </ng-container>

View File

@@ -16,6 +16,12 @@ import { SharedModule } from '../../../../shared/shared.module';
import { EditRelationshipListComponent } from './edit-relationship-list.component'; import { EditRelationshipListComponent } from './edit-relationship-list.component';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../../shared/testing/utils.test';
import { PaginationService } from '../../../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
import { HostWindowService } from '../../../../shared/host-window.service';
import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub';
import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
let comp: EditRelationshipListComponent; let comp: EditRelationshipListComponent;
let fixture: ComponentFixture<EditRelationshipListComponent>; let fixture: ComponentFixture<EditRelationshipListComponent>;
@@ -25,6 +31,8 @@ let linkService;
let objectUpdatesService; let objectUpdatesService;
let relationshipService; let relationshipService;
let selectableListService; let selectableListService;
let paginationService;
let hostWindowService;
const url = 'http://test-url.com/test-url'; const url = 'http://test-url.com/test-url';
@@ -37,9 +45,21 @@ let fieldUpdate1;
let fieldUpdate2; let fieldUpdate2;
let relationships; let relationships;
let relationshipType; let relationshipType;
let paginationOptions;
describe('EditRelationshipListComponent', () => { describe('EditRelationshipListComponent', () => {
const resetComponent = () => {
fixture = TestBed.createComponent(EditRelationshipListComponent);
comp = fixture.componentInstance;
de = fixture.debugElement;
comp.item = item;
comp.itemType = entityType;
comp.url = url;
comp.relationshipType = relationshipType;
fixture.detectChanges();
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
entityType = Object.assign(new ItemType(), { entityType = Object.assign(new ItemType(), {
@@ -63,6 +83,12 @@ describe('EditRelationshipListComponent', () => {
rightwardType: 'isPublicationOfAuthor', rightwardType: 'isPublicationOfAuthor',
}); });
paginationOptions = Object.assign(new PaginationComponentOptions(), {
id: `er${relationshipType.id}`,
pageSize: 5,
currentPage: 1,
});
author1 = Object.assign(new Item(), { author1 = Object.assign(new Item(), {
id: 'author1', id: 'author1',
uuid: 'author1' uuid: 'author1'
@@ -141,6 +167,10 @@ describe('EditRelationshipListComponent', () => {
resolveLinks: () => null, resolveLinks: () => null,
}; };
paginationService = new PaginationServiceStub(paginationOptions);
hostWindowService = new HostWindowServiceStub(1200);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()], imports: [SharedModule, TranslateModule.forRoot()],
declarations: [EditRelationshipListComponent], declarations: [EditRelationshipListComponent],
@@ -149,22 +179,15 @@ describe('EditRelationshipListComponent', () => {
{ provide: RelationshipService, useValue: relationshipService }, { provide: RelationshipService, useValue: relationshipService },
{ provide: SelectableListService, useValue: selectableListService }, { provide: SelectableListService, useValue: selectableListService },
{ provide: LinkService, useValue: linkService }, { provide: LinkService, useValue: linkService },
{ provide: PaginationService, useValue: paginationService },
{ provide: HostWindowService, useValue: hostWindowService },
], schemas: [ ], schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA
] ]
}).compileComponents(); }).compileComponents();
}));
beforeEach(() => { resetComponent();
fixture = TestBed.createComponent(EditRelationshipListComponent); }));
comp = fixture.componentInstance;
de = fixture.debugElement;
comp.item = item;
comp.itemType = entityType;
comp.url = url;
comp.relationshipType = relationshipType;
fixture.detectChanges();
});
describe('changeType is REMOVE', () => { describe('changeType is REMOVE', () => {
beforeEach(() => { beforeEach(() => {
@@ -176,4 +199,82 @@ describe('EditRelationshipListComponent', () => {
expect(element.classList).toContain('alert-danger'); expect(element.classList).toContain('alert-danger');
}); });
}); });
describe('pagination component', () => {
let paginationComp: PaginationComponent;
beforeEach(() => {
paginationComp = de.query(By.css('ds-pagination')).componentInstance;
});
it('should receive the correct pagination config', () => {
expect(paginationComp.paginationOptions).toEqual(paginationOptions);
});
it('should receive correct collection size', () => {
expect(paginationComp.collectionSize).toEqual(relationships.length);
});
});
describe('relationshipService.getItemRelationshipsByLabel', () => {
it('should receive the correct pagination info', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const findListOptions = callArgs[2];
expect(findListOptions.elementsPerPage).toEqual(paginationOptions.pageSize);
expect(findListOptions.currentPage).toEqual(paginationOptions.currentPage);
});
describe('when the publication is on the left side of the relationship', () => {
beforeEach(() => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(entityType), // publication
rightType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
leftwardType: 'isAuthorOfPublication',
rightwardType: 'isPublicationOfAuthor',
});
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
it('should fetch isAuthorOfPublication', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const label = callArgs[1];
expect(label).toEqual('isAuthorOfPublication');
});
});
describe('when the publication is on the right side of the relationship', () => {
beforeEach(() => {
relationshipType = Object.assign(new RelationshipType(), {
id: '1',
uuid: '1',
leftType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
rightType: createSuccessfulRemoteDataObject$(entityType), // publication
leftwardType: 'isPublicationOfAuthor',
rightwardType: 'isAuthorOfPublication',
});
relationshipService.getItemRelationshipsByLabel.calls.reset();
resetComponent();
});
it('should fetch isAuthorOfPublication', () => {
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
const label = callArgs[1];
expect(label).toEqual('isAuthorOfPublication');
});
});
});
}); });

View File

@@ -1,9 +1,14 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { LinkService } from '../../../../core/cache/builders/link.service'; import { LinkService } from '../../../../core/cache/builders/link.service';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { combineLatest as observableCombineLatest, Observable, of } from 'rxjs'; import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
from as observableFrom
} from 'rxjs';
import { import {
FieldUpdate, FieldUpdate,
FieldUpdates, FieldUpdates,
@@ -11,14 +16,24 @@ import {
} from '../../../../core/data/object-updates/object-updates.reducer'; } from '../../../../core/data/object-updates/object-updates.reducer';
import { RelationshipService } from '../../../../core/data/relationship.service'; import { RelationshipService } from '../../../../core/data/relationship.service';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { defaultIfEmpty, map, mergeMap, switchMap, take, startWith } from 'rxjs/operators'; import {
import { hasValue, hasValueOperator } from '../../../../shared/empty.util'; defaultIfEmpty,
map,
mergeMap,
switchMap,
take,
startWith,
toArray,
tap
} from 'rxjs/operators';
import { hasValue, hasValueOperator, hasNoValue } from '../../../../shared/empty.util';
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
import { import {
getAllSucceededRemoteData,
getRemoteDataPayload, getRemoteDataPayload,
getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload,
getAllSucceededRemoteData,
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model'; import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component'; import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component';
@@ -30,6 +45,10 @@ import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { PaginatedList } from '../../../../core/data/paginated-list.model'; import { PaginatedList } from '../../../../core/data/paginated-list.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { Collection } from '../../../../core/shared/collection.model'; import { Collection } from '../../../../core/shared/collection.model';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { Subscription } from 'rxjs/internal/Subscription';
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
import { PaginationService } from '../../../../core/pagination/pagination.service';
@Component({ @Component({
selector: 'ds-edit-relationship-list', selector: 'ds-edit-relationship-list',
@@ -40,7 +59,7 @@ import { Collection } from '../../../../core/shared/collection.model';
* A component creating a list of editable relationships of a certain type * A component creating a list of editable relationships of a certain type
* The relationships are rendered as a list of related items * The relationships are rendered as a list of related items
*/ */
export class EditRelationshipListComponent implements OnInit { export class EditRelationshipListComponent implements OnInit, OnDestroy {
/** /**
* The item to display related items for * The item to display related items for
@@ -60,6 +79,17 @@ export class EditRelationshipListComponent implements OnInit {
*/ */
@Input() relationshipType: RelationshipType; @Input() relationshipType: RelationshipType;
/**
* Observable that emits the left and right item type of {@link relationshipType} simultaneously.
*/
private relationshipLeftAndRightType$: Observable<[ItemType, ItemType]>;
/**
* Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType},
* false if it is on the right-hand side and undefined in the rare case that it is on neither side.
*/
private currentItemIsLeftItem$: Observable<boolean>;
private relatedEntityType$: Observable<ItemType>; private relatedEntityType$: Observable<ItemType>;
/** /**
@@ -70,7 +100,38 @@ export class EditRelationshipListComponent implements OnInit {
/** /**
* The FieldUpdates for the relationships in question * The FieldUpdates for the relationships in question
*/ */
updates$: Observable<FieldUpdates>; updates$: BehaviorSubject<FieldUpdates> = new BehaviorSubject(undefined);
/**
* The RemoteData for the relationships
*/
relationshipsRd$: BehaviorSubject<RemoteData<PaginatedList<Relationship>>> = new BehaviorSubject(undefined);
/**
* Whether the current page is the last page
*/
isLastPage$: BehaviorSubject<boolean> = new BehaviorSubject(true);
/**
* Whether we're loading
*/
loading$: BehaviorSubject<boolean> = new BehaviorSubject(true);
/**
* The number of added fields that haven't been saved yet
*/
nbAddedFields$: BehaviorSubject<number> = new BehaviorSubject(0);
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
private subs: Subscription[] = [];
/**
* The pagination config
*/
paginationConfig: PaginationComponentOptions;
/** /**
* A reference to the lookup window * A reference to the lookup window
@@ -82,6 +143,7 @@ export class EditRelationshipListComponent implements OnInit {
protected linkService: LinkService, protected linkService: LinkService,
protected relationshipService: RelationshipService, protected relationshipService: RelationshipService,
protected modalService: NgbModal, protected modalService: NgbModal,
protected paginationService: PaginationService,
protected selectableListService: SelectableListService, protected selectableListService: SelectableListService,
) { ) {
} }
@@ -172,6 +234,10 @@ export class EditRelationshipListComponent implements OnInit {
this.objectUpdatesService.saveAddFieldUpdate(this.url, update); this.objectUpdatesService.saveAddFieldUpdate(this.url, update);
}); });
} }
this.loading$.next(true);
// emit the last page again to trigger a fieldupdates refresh
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
}); });
}); });
}; };
@@ -186,6 +252,10 @@ export class EditRelationshipListComponent implements OnInit {
) )
); );
}); });
this.loading$.next(true);
// emit the last page again to trigger a fieldupdates refresh
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
}; };
this.relatedEntityType$ this.relatedEntityType$
.pipe(take(1)) .pipe(take(1))
@@ -212,10 +282,10 @@ export class EditRelationshipListComponent implements OnInit {
if (field.relationship) { if (field.relationship) {
return this.getRelatedItem(field.relationship); return this.getRelatedItem(field.relationship);
} else { } else {
return of(field.relatedItem); return observableOf(field.relatedItem);
} }
}) })
) : of([]) ) : observableOf([])
), ),
take(1), take(1),
map((items) => items.map((item) => { map((items) => items.map((item) => {
@@ -267,15 +337,16 @@ export class EditRelationshipListComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
// store the left and right type of the relationship in a single observable
this.relatedEntityType$ = this.relationshipLeftAndRightType$ = observableCombineLatest([
observableCombineLatest([
this.relationshipType.leftType, this.relationshipType.leftType,
this.relationshipType.rightType, this.relationshipType.rightType,
].map((type) => type.pipe( ].map((type) => type.pipe(
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
getRemoteDataPayload(), getRemoteDataPayload(),
))).pipe( ))) as Observable<[ItemType, ItemType]>;
this.relatedEntityType$ = this.relationshipLeftAndRightType$.pipe(
map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)), map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)),
hasValueOperator() hasValueOperator()
); );
@@ -286,65 +357,142 @@ export class EditRelationshipListComponent implements OnInit {
(relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}` (relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}`
); );
this.updates$ = this.getItemRelationships().pipe( this.currentItemIsLeftItem$ = this.relationshipLeftAndRightType$.pipe(
switchMap((relationships) => map(([leftType, rightType]: [ItemType, ItemType]) => {
observableCombineLatest( if (leftType.id === this.itemType.id) {
relationships.map((relationship) => this.relationshipService.isLeftItem(relationship, this.item)) return true;
}
if (rightType.id === this.itemType.id) {
return false;
}
// should never happen...
console.warn(`The item ${this.item.uuid} is not on the right or the left side of relationship type ${this.relationshipType.uuid}`);
return undefined;
})
);
// initialize the pagination options
this.paginationConfig = new PaginationComponentOptions();
this.paginationConfig.id = `er${this.relationshipType.id}`;
this.paginationConfig.pageSize = 5;
this.paginationConfig.currentPage = 1;
// get the pagination params from the route
const currentPagination$ = this.paginationService.getCurrentPagination(
this.paginationConfig.id,
this.paginationConfig
).pipe( ).pipe(
defaultIfEmpty([]), tap(() => this.loading$.next(true))
map((isLeftItemArray) => isLeftItemArray.map((isLeftItem, index) => { );
const relationship = relationships[index];
const nameVariant = isLeftItem ? relationship.rightwardValue : relationship.leftwardValue; this.subs.push(
observableCombineLatest([
currentPagination$,
this.currentItemIsLeftItem$,
]).pipe(
switchMap(([currentPagination, currentItemIsLeftItem]: [PaginationComponentOptions, boolean]) =>
// get the relationships for the current item, relationshiptype and page
this.relationshipService.getItemRelationshipsByLabel(
this.item,
currentItemIsLeftItem ? this.relationshipType.leftwardType : this.relationshipType.rightwardType,
{
elementsPerPage: currentPagination.pageSize,
currentPage: currentPagination.currentPage,
},
false,
true,
followLink('leftItem'),
followLink('rightItem'),
)),
).subscribe((rd: RemoteData<PaginatedList<Relationship>>) => {
this.relationshipsRd$.next(rd);
})
);
// keep isLastPage$ up to date based on relationshipsRd$
this.subs.push(this.relationshipsRd$.pipe(
hasValueOperator(),
getAllSucceededRemoteData()
).subscribe((rd: RemoteData<PaginatedList<Relationship>>) => {
this.isLastPage$.next(hasNoValue(rd.payload._links.next));
}));
this.subs.push(this.relationshipsRd$.pipe(
hasValueOperator(),
getAllSucceededRemoteData(),
switchMap((rd: RemoteData<PaginatedList<Relationship>>) =>
// emit each relationship in the page separately
observableFrom(rd.payload.page).pipe(
mergeMap((relationship: Relationship) =>
// check for each relationship whether it's the left item
this.relationshipService.isLeftItem(relationship, this.item).pipe(
// emit an array containing both the relationship and whether it's the left item,
// as we'll need both
map((isLeftItem: boolean) => [relationship, isLeftItem])
)
),
map(([relationship, isLeftItem]: [Relationship, boolean]) => {
// turn it into a RelationshipIdentifiable, an
const nameVariant =
isLeftItem ? relationship.rightwardValue : relationship.leftwardValue;
return { return {
uuid: relationship.id, uuid: relationship.id,
type: this.relationshipType, type: this.relationshipType,
relationship, relationship,
nameVariant, nameVariant,
} as RelationshipIdentifiable; } as RelationshipIdentifiable;
})), }),
// wait until all relationships have been processed, and emit them all as a single array
toArray(),
// if the pipe above completes without emitting anything, emit an empty array instead
defaultIfEmpty([])
)), )),
switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields).pipe( switchMap((nextFields: RelationshipIdentifiable[]) => {
map((fieldUpdates) => { // Get a list that contains the unsaved changes for the page, as well as the page of
// RelationshipIdentifiables, as a single list of FieldUpdates
return this.objectUpdatesService.getFieldUpdates(this.url, nextFields).pipe(
map((fieldUpdates: FieldUpdates) => {
const fieldUpdatesFiltered: FieldUpdates = {}; const fieldUpdatesFiltered: FieldUpdates = {};
this.nbAddedFields$.next(0);
// iterate over the fieldupdates and filter out the ones that pertain to this
// relationshiptype
Object.keys(fieldUpdates).forEach((uuid) => { Object.keys(fieldUpdates).forEach((uuid) => {
if (hasValue(fieldUpdates[uuid])) { if (hasValue(fieldUpdates[uuid])) {
const field = fieldUpdates[uuid].field; const field = fieldUpdates[uuid].field as RelationshipIdentifiable;
if ((field as RelationshipIdentifiable).type.id === this.relationshipType.id) { // only include fieldupdates regarding this RelationshipType
if (field.type.id === this.relationshipType.id) {
// if it's a newly added relationship
if (fieldUpdates[uuid].changeType === FieldChangeType.ADD) {
// increase the counter that tracks new relationships
this.nbAddedFields$.next(this.nbAddedFields$.getValue() + 1);
if (this.isLastPage$.getValue() === true) {
// only include newly added relationships to the output if we're on the last
// page
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid]; fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
} }
} else {
// include all others
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
}
}
} }
}); });
return fieldUpdatesFiltered; return fieldUpdatesFiltered;
}), }),
)),
startWith({}),
); );
}),
startWith({}),
).subscribe((updates: FieldUpdates) => {
this.loading$.next(false);
this.updates$.next(updates);
}));
} }
private getItemRelationships() { ngOnDestroy(): void {
this.linkService.resolveLink(this.item, this.subs
followLink('relationships', undefined, true, true, true, .filter((subscription) => hasValue(subscription))
followLink('relationshipType'), .forEach((subscription) => subscription.unsubscribe());
followLink('leftItem'),
followLink('rightItem'),
));
return this.item.relationships.pipe(
getAllSucceededRemoteData(),
map((relationships: RemoteData<PaginatedList<Relationship>>) => relationships.payload.page.filter((relationship: Relationship) => hasValue(relationship))),
switchMap((itemRelationships: Relationship[]) =>
observableCombineLatest(
itemRelationships
.map((relationship) => relationship.relationshipType.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
))
).pipe(
defaultIfEmpty([]),
map((relationshipTypes) => itemRelationships.filter(
(relationship, index) => relationshipTypes[index].id === this.relationshipType.id)
),
)
),
);
} }
} }

View File

@@ -227,7 +227,6 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
* Sends all initial values of this item to the object updates service * Sends all initial values of this item to the object updates service
*/ */
public initializeOriginalFields() { public initializeOriginalFields() {
console.log('init');
return this.relationshipService.getRelatedItems(this.item).pipe( return this.relationshipService.getRelatedItems(this.item).pipe(
take(1), take(1),
).subscribe((items: Item[]) => { ).subscribe((items: Item[]) => {

View File

@@ -1,4 +1,4 @@
<div class="simple-view-element" [class.d-none]="content.textContent.trim().length === 0 && hasNoValue(content.querySelector('img'))"> <div class="simple-view-element" [class.d-none]="hideIfNoTextContent && content.textContent.trim().length === 0">
<h5 class="simple-view-element-header" *ngIf="label">{{ label }}</h5> <h5 class="simple-view-element-header" *ngIf="label">{{ label }}</h5>
<div #content class="simple-view-element-body"> <div #content class="simple-view-element-body">
<ng-content></ng-content> <ng-content></ng-content>

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, Input } from '@angular/core';
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component'; import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component';
@@ -6,35 +6,34 @@ import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.componen
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
@Component({ @Component({
selector: 'ds-component-without-content', selector: 'ds-component-without-content',
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' + template: '<ds-metadata-field-wrapper [hideIfNoTextContent]="hideIfNoTextContent" [label]="\'test label\'">\n' +
'</ds-metadata-field-wrapper>' '</ds-metadata-field-wrapper>'
}) })
class NoContentComponent {} class NoContentComponent {
public hideIfNoTextContent = true;
}
@Component({ @Component({
selector: 'ds-component-with-empty-spans', selector: 'ds-component-with-empty-spans',
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' + template: '<ds-metadata-field-wrapper [hideIfNoTextContent]="hideIfNoTextContent" [label]="\'test label\'">\n' +
' <span></span>\n' + ' <span></span>\n' +
' <span></span>\n' + ' <span></span>\n' +
'</ds-metadata-field-wrapper>' '</ds-metadata-field-wrapper>'
}) })
class SpanContentComponent {} class SpanContentComponent {
@Input() hideIfNoTextContent = true;
}
@Component({ @Component({
selector: 'ds-component-with-text', selector: 'ds-component-with-text',
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' + template: '<ds-metadata-field-wrapper [hideIfNoTextContent]="hideIfNoTextContent" [label]="\'test label\'">\n' +
' <span>The quick brown fox jumps over the lazy dog</span>\n' + ' <span>The quick brown fox jumps over the lazy dog</span>\n' +
'</ds-metadata-field-wrapper>' '</ds-metadata-field-wrapper>'
}) })
class TextContentComponent {} class TextContentComponent {
@Input() hideIfNoTextContent = true;
}
@Component({
selector: 'ds-component-with-image',
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
' <img src="https://some/image.png" alt="an alt text">\n' +
'</ds-metadata-field-wrapper>'
})
class ImgContentComponent {}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */
describe('MetadataFieldWrapperComponent', () => { describe('MetadataFieldWrapperComponent', () => {
@@ -43,7 +42,7 @@ describe('MetadataFieldWrapperComponent', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent, ImgContentComponent] declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent]
}).compileComponents(); }).compileComponents();
})); }));
@@ -58,6 +57,7 @@ describe('MetadataFieldWrapperComponent', () => {
expect(component).toBeDefined(); expect(component).toBeDefined();
}); });
describe('with hideIfNoTextContent=true', () => {
it('should not show the component when there is no content', () => { it('should not show the component when there is no content', () => {
const parentFixture = TestBed.createComponent(NoContentComponent); const parentFixture = TestBed.createComponent(NoContentComponent);
parentFixture.detectChanges(); parentFixture.detectChanges();
@@ -66,7 +66,7 @@ describe('MetadataFieldWrapperComponent', () => {
expect(nativeWrapper.classList.contains('d-none')).toBe(true); expect(nativeWrapper.classList.contains('d-none')).toBe(true);
}); });
it('should not show the component when there is DOM content but not text or an image', () => { it('should not show the component when there is no text content', () => {
const parentFixture = TestBed.createComponent(SpanContentComponent); const parentFixture = TestBed.createComponent(SpanContentComponent);
parentFixture.detectChanges(); parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement; const parentNative = parentFixture.nativeElement;
@@ -82,14 +82,35 @@ describe('MetadataFieldWrapperComponent', () => {
parentFixture.detectChanges(); parentFixture.detectChanges();
expect(nativeWrapper.classList.contains('d-none')).toBe(false); expect(nativeWrapper.classList.contains('d-none')).toBe(false);
}); });
});
it('should show the component when there is img content', () => { describe('with hideIfNoTextContent=false', () => {
const parentFixture = TestBed.createComponent(ImgContentComponent); it('should show the component when there is no content', () => {
const parentFixture = TestBed.createComponent(NoContentComponent);
parentFixture.componentInstance.hideIfNoTextContent = false;
parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement;
const nativeWrapper = parentNative.querySelector(wrapperSelector);
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
});
it('should show the component when there is no text content', () => {
const parentFixture = TestBed.createComponent(SpanContentComponent);
parentFixture.componentInstance.hideIfNoTextContent = false;
parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement;
const nativeWrapper = parentNative.querySelector(wrapperSelector);
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
});
it('should show the component when there is text content', () => {
const parentFixture = TestBed.createComponent(TextContentComponent);
parentFixture.componentInstance.hideIfNoTextContent = false;
parentFixture.detectChanges(); parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement; const parentNative = parentFixture.nativeElement;
const nativeWrapper = parentNative.querySelector(wrapperSelector); const nativeWrapper = parentNative.querySelector(wrapperSelector);
parentFixture.detectChanges(); parentFixture.detectChanges();
expect(nativeWrapper.classList.contains('d-none')).toBe(false); expect(nativeWrapper.classList.contains('d-none')).toBe(false);
}); });
});
}); });

View File

@@ -1,5 +1,4 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { hasNoValue } from '../../../shared/empty.util';
/** /**
* This component renders any content inside this wrapper. * This component renders any content inside this wrapper.
@@ -17,10 +16,5 @@ export class MetadataFieldWrapperComponent {
*/ */
@Input() label: string; @Input() label: string;
/** @Input() hideIfNoTextContent = true;
* Make hasNoValue() available in the template
*/
hasNoValue(o: any): boolean {
return hasNoValue(o);
}
} }

View File

@@ -11,7 +11,7 @@
[retainScrollPosition]="true"> [retainScrollPosition]="true">
<div class="file-section row" *ngFor="let file of originals?.page;"> <div class="file-section row mb-3" *ngFor="let file of originals?.page;">
<div class="col-3"> <div class="col-3">
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail> <ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
</div> </div>

View File

@@ -68,7 +68,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
{elementsPerPage: options.pageSize, currentPage: options.currentPage}, {elementsPerPage: options.pageSize, currentPage: options.currentPage},
true, true,
true, true,
followLink('format') followLink('format'),
followLink('thumbnail'),
)), )),
tap((rd: RemoteData<PaginatedList<Bitstream>>) => { tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(rd.errorMessage)) { if (hasValue(rd.errorMessage)) {
@@ -85,7 +86,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
{elementsPerPage: options.pageSize, currentPage: options.currentPage}, {elementsPerPage: options.pageSize, currentPage: options.currentPage},
true, true,
true, true,
followLink('format') followLink('format'),
followLink('thumbnail'),
)), )),
tap((rd: RemoteData<PaginatedList<Bitstream>>) => { tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(rd.errorMessage)) { if (hasValue(rd.errorMessage)) {

View File

@@ -5,7 +5,6 @@ import { RemoteData } from '../core/data/remote-data';
import { ItemDataService } from '../core/data/item-data.service'; import { ItemDataService } from '../core/data/item-data.service';
import { Item } from '../core/shared/item.model'; import { Item } from '../core/shared/item.model';
import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model'; import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model';
import { FindListOptions } from '../core/data/request.models';
import { getFirstCompletedRemoteData } from '../core/shared/operators'; import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { ResolvedAction } from '../core/resolving/resolver.actions'; import { ResolvedAction } from '../core/resolving/resolver.actions';
@@ -15,13 +14,13 @@ import { ResolvedAction } from '../core/resolving/resolver.actions';
* Requesting them as embeds will limit the number of requests * Requesting them as embeds will limit the number of requests
*/ */
export const ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Item>[] = [ export const ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Item>[] = [
followLink('owningCollection', undefined, true, true, true, followLink('owningCollection', {},
followLink('parentCommunity', undefined, true, true, true, followLink('parentCommunity', {},
followLink('parentCommunity')) followLink('parentCommunity'))
), ),
followLink('bundles', new FindListOptions(), true, true, true, followLink('bitstreams')),
followLink('relationships'), followLink('relationships'),
followLink('version', undefined, true, true, true, followLink('versionhistory')), followLink('version', {}, followLink('versionhistory')),
followLink('thumbnail')
]; ];
/** /**

View File

@@ -9,8 +9,8 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ng-container *ngIf="!mediaViewer.image"> <ng-container *ngIf="!mediaViewer.image">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
</ng-container> </ng-container>
<ng-container *ngIf="mediaViewer.image"> <ng-container *ngIf="mediaViewer.image">

View File

@@ -1,10 +1,6 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../../../environments/environment'; import { environment } from '../../../../../environments/environment';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators';
import { getItemPageRoute } from '../../../item-page-routing-paths'; import { getItemPageRoute } from '../../../item-page-routing-paths';
@Component({ @Component({
@@ -21,19 +17,10 @@ export class ItemComponent implements OnInit {
* Route to the item page * Route to the item page
*/ */
itemPageRoute: string; itemPageRoute: string;
mediaViewer = environment.mediaViewer;
constructor(protected bitstreamDataService: BitstreamDataService) { mediaViewer = environment.mediaViewer;
}
ngOnInit(): void { ngOnInit(): void {
this.itemPageRoute = getItemPageRoute(this.object); this.itemPageRoute = getItemPageRoute(this.object);
} }
// TODO refactor to return RemoteData, and thumbnail template to deal with loading
getThumbnail(): Observable<Bitstream> {
return this.bitstreamDataService.getThumbnailFor(this.object).pipe(
getFirstSucceededRemoteDataPayload()
);
}
} }

View File

@@ -9,8 +9,8 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ng-container *ngIf="!mediaViewer.image"> <ng-container *ngIf="!mediaViewer.image">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
</ng-container> </ng-container>
<ng-container *ngIf="mediaViewer.image"> <ng-container *ngIf="mediaViewer.image">

View File

@@ -16,9 +16,11 @@ import { SearchResult } from '../shared/search/search-result.model';
import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; import { SearchConfigurationService } from '../core/shared/search/search-configuration.service';
import { SearchService } from '../core/shared/search/search.service'; import { SearchService } from '../core/shared/search/search.service';
import { currentPath } from '../shared/utils/route.utils'; import { currentPath } from '../shared/utils/route.utils';
import { Router} from '@angular/router'; import { Router } from '@angular/router';
import { Context } from '../core/shared/context.model'; import { Context } from '../core/shared/context.model';
import { SortOptions } from '../core/cache/models/sort-options.model'; import { SortOptions } from '../core/cache/models/sort-options.model';
import { followLink } from '../shared/utils/follow-link-config.model';
import { Item } from '../core/shared/item.model';
@Component({ @Component({
selector: 'ds-search', selector: 'ds-search',
@@ -128,8 +130,11 @@ export class SearchComponent implements OnInit {
this.searchLink = this.getSearchLink(); this.searchLink = this.getSearchLink();
this.searchOptions$ = this.getSearchOptions(); this.searchOptions$ = this.getSearchOptions();
this.sub = this.searchOptions$.pipe( this.sub = this.searchOptions$.pipe(
switchMap((options) => this.service.search(options).pipe(getFirstSucceededRemoteData(), startWith(undefined)))) switchMap((options) => this.service.search(
.subscribe((results) => { options, undefined, true, true, followLink<Item>('thumbnail', { isOptional: true })
).pipe(getFirstSucceededRemoteData(), startWith(undefined))
)
).subscribe((results) => {
this.resultsRD$.next(results); this.resultsRD$.next(results);
}); });
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe( this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(

View File

@@ -3,6 +3,10 @@ import { getAccessControlModuleRoute } from '../app-routing-paths';
export const GROUP_EDIT_PATH = 'groups'; export const GROUP_EDIT_PATH = 'groups';
export function getGroupsRoute() {
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString();
}
export function getGroupEditRoute(id: string) { export function getGroupEditRoute(id: string) {
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString(); return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString();
} }

View File

@@ -5,6 +5,9 @@ import { GroupFormComponent } from './group-registry/group-form/group-form.compo
import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { GROUP_EDIT_PATH } from './access-control-routing-paths'; import { GROUP_EDIT_PATH } from './access-control-routing-paths';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { GroupPageGuard } from './group-registry/group-page.guard';
import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -15,7 +18,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
}, },
data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' } data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' },
canActivate: [SiteAdministratorGuard]
}, },
{ {
path: GROUP_EDIT_PATH, path: GROUP_EDIT_PATH,
@@ -23,7 +27,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
}, },
data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' } data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' },
canActivate: [GroupAdministratorGuard]
}, },
{ {
path: `${GROUP_EDIT_PATH}/newGroup`, path: `${GROUP_EDIT_PATH}/newGroup`,
@@ -31,7 +36,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
}, },
data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' } data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' },
canActivate: [GroupAdministratorGuard]
}, },
{ {
path: `${GROUP_EDIT_PATH}/:groupId`, path: `${GROUP_EDIT_PATH}/:groupId`,
@@ -39,7 +45,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
resolve: { resolve: {
breadcrumb: I18nBreadcrumbResolver breadcrumb: I18nBreadcrumbResolver
}, },
data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' } data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' },
canActivate: [GroupPageGuard]
} }
]) ])
] ]

View File

@@ -0,0 +1,83 @@
import { GroupPageGuard } from './group-page.guard';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { AuthService } from '../../core/auth/auth.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
describe('GroupPageGuard', () => {
const groupsEndpointUrl = 'https://test.org/api/eperson/groups';
const groupUuid = '0d6f89df-f95a-4829-943c-f21f434fb892';
const groupEndpointUrl = `${groupsEndpointUrl}/${groupUuid}`;
const routeSnapshotWithGroupId = {
params: {
groupId: groupUuid,
}
} as unknown as ActivatedRouteSnapshot;
let guard: GroupPageGuard;
let halEndpointService: HALEndpointService;
let authorizationService: AuthorizationDataService;
let router: Router;
let authService: AuthService;
beforeEach(() => {
halEndpointService = jasmine.createSpyObj(['getEndpoint']);
(halEndpointService as any).getEndpoint.and.returnValue(observableOf(groupsEndpointUrl));
authorizationService = jasmine.createSpyObj(['isAuthorized']);
// NOTE: value is set in beforeEach
router = jasmine.createSpyObj(['parseUrl']);
(router as any).parseUrl.and.returnValue = {};
authService = jasmine.createSpyObj(['isAuthenticated']);
(authService as any).isAuthenticated.and.returnValue(observableOf(true));
guard = new GroupPageGuard(halEndpointService, authorizationService, router, authService);
});
it('should be created', () => {
expect(guard).toBeTruthy();
});
describe('canActivate', () => {
describe('when the current user can manage the group', () => {
beforeEach(() => {
(authorizationService as any).isAuthorized.and.returnValue(observableOf(true));
});
it('should return true', (done) => {
guard.canActivate(
routeSnapshotWithGroupId, { url: 'current-url'} as any
).subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanManageGroup, groupEndpointUrl, undefined
);
expect(result).toBeTrue();
done();
});
});
});
describe('when the current user can not manage the group', () => {
beforeEach(() => {
(authorizationService as any).isAuthorized.and.returnValue(observableOf(false));
});
it('should not return true', (done) => {
guard.canActivate(
routeSnapshotWithGroupId, { url: 'current-url'} as any
).subscribe((result) => {
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
FeatureID.CanManageGroup, groupEndpointUrl, undefined
);
expect(result).not.toBeTrue();
done();
});
});
});
});
});

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable, of as observableOf } from 'rxjs';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { AuthService } from '../../core/auth/auth.service';
import { SomeFeatureAuthorizationGuard } from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class GroupPageGuard extends SomeFeatureAuthorizationGuard {
protected groupsEndpoint = 'groups';
constructor(protected halEndpointService: HALEndpointService,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected authService: AuthService) {
super(authorizationService, router, authService);
}
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
return observableOf([FeatureID.CanManageGroup]);
}
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return this.halEndpointService.getEndpoint(this.groupsEndpoint).pipe(
map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`)
);
}
}

View File

@@ -33,9 +33,9 @@
</div> </div>
</form> </form>
<ds-loading *ngIf="searching$ | async"></ds-loading> <ds-loading *ngIf="loading$ | async"></ds-loading>
<ds-pagination <ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)" *ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(loading$ | async)"
[paginationOptions]="config" [paginationOptions]="config"
[pageInfoState]="pageInfoState$" [pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements" [collectionSize]="(pageInfoState$ | async)?.totalElements"
@@ -59,11 +59,23 @@
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td> <td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
<td> <td>
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button [routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)" <ng-container [ngSwitch]="groupDto.ableToEdit">
class="btn btn-outline-primary btn-sm" <button *ngSwitchCase="true"
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: groupDto.group.name} }}"> [routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)"
class="btn btn-outline-primary btn-sm btn-edit"
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: groupDto.group.name} }}"
>
<i class="fas fa-edit fa-fw"></i> <i class="fas fa-edit fa-fw"></i>
</button> </button>
<button *ngSwitchCase="false"
[disabled]="true"
class="btn btn-outline-primary btn-sm btn-edit"
placement="left"
[ngbTooltip]="'admin.access-control.epeople.table.edit.buttons.edit-disabled' | translate"
>
<i class="fas fa-edit fa-fw"></i>
</button>
</ng-container>
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete" <button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm" (click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm"
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}"> title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}">

View File

@@ -30,6 +30,7 @@ import { routeServiceStub } from '../../shared/testing/route-service.stub';
import { RouterMock } from '../../shared/mocks/router.mock'; import { RouterMock } from '../../shared/mocks/router.mock';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub'; import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
describe('GroupRegistryComponent', () => { describe('GroupRegistryComponent', () => {
let component: GroupsRegistryComponent; let component: GroupsRegistryComponent;
@@ -43,6 +44,26 @@ describe('GroupRegistryComponent', () => {
let mockEPeople; let mockEPeople;
let paginationService; let paginationService;
/**
* Set authorizationService.isAuthorized to return the following values.
* @param isAdmin whether or not the current user is an admin.
* @param canManageGroup whether or not the current user can manage all groups.
*/
const setIsAuthorized = (isAdmin: boolean, canManageGroup: boolean) => {
(authorizationService as any).isAuthorized.and.callFake((featureId?: FeatureID) => {
switch (featureId) {
case FeatureID.AdministratorOf:
return observableOf(isAdmin);
case FeatureID.CanManageGroup:
return observableOf(canManageGroup);
case FeatureID.CanDelete:
return observableOf(true);
default:
throw new Error(`setIsAuthorized: this fake implementation does not support ${featureId}.`);
}
});
};
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
mockGroups = [GroupMock, GroupMock2]; mockGroups = [GroupMock, GroupMock2];
mockEPeople = [EPersonMock, EPersonMock2]; mockEPeople = [EPersonMock, EPersonMock2];
@@ -131,9 +152,8 @@ describe('GroupRegistryComponent', () => {
return createSuccessfulRemoteDataObject$(undefined); return createSuccessfulRemoteDataObject$(undefined);
} }
}; };
authorizationService = jasmine.createSpyObj('authorizationService', { authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
isAuthorized: observableOf(true) setIsAuthorized(true, true);
});
paginationService = new PaginationServiceStub(); paginationService = new PaginationServiceStub();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
@@ -180,6 +200,81 @@ describe('GroupRegistryComponent', () => {
}); });
}); });
describe('edit buttons', () => {
describe('when the user is a general admin', () => {
beforeEach(fakeAsync(() => {
// NOTE: setting canManageGroup to false should not matter, since isAdmin takes priority
setIsAuthorized(true, false);
// force rerender after setup changes
component.search({ query: '' });
tick();
fixture.detectChanges();
}));
it('should be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse();
});
});
it('should not check the canManageGroup permissions', () => {
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
FeatureID.CanManageGroup, mockGroups[0].self
);
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
FeatureID.CanManageGroup, mockGroups[0].self, undefined // treated differently
);
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
FeatureID.CanManageGroup, mockGroups[1].self
);
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
FeatureID.CanManageGroup, mockGroups[1].self, undefined // treated differently
);
});
});
describe('when the user can edit the groups', () => {
beforeEach(fakeAsync(() => {
setIsAuthorized(false, true);
// force rerender after setup changes
component.search({ query: '' });
tick();
fixture.detectChanges();
}));
it('should be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeFalse();
});
});
});
describe('when the user can not edit the groups', () => {
beforeEach(fakeAsync(() => {
setIsAuthorized(false, false);
// force rerender after setup changes
component.search({ query: '' });
tick();
fixture.detectChanges();
}));
it('should not be active', () => {
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
expect(editButtonsFound.length).toEqual(2);
editButtonsFound.forEach((editButtonFound) => {
expect(editButtonFound.nativeElement.disabled).toBeTrue();
});
});
});
});
describe('search', () => { describe('search', () => {
describe('when searching with query', () => { describe('when searching with query', () => {
let groupIdsFound; let groupIdsFound;

View File

@@ -9,7 +9,7 @@ import {
of as observableOf, of as observableOf,
Subscription Subscription
} from 'rxjs'; } from 'rxjs';
import { catchError, map, switchMap, take } from 'rxjs/operators'; import { catchError, map, switchMap, take, tap } from 'rxjs/operators';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../core/data/feature-authorization/feature-id';
@@ -75,7 +75,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
/** /**
* A boolean representing if a search is pending * A boolean representing if a search is pending
*/ */
searching$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false); loading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
// Current search in groups registry // Current search in groups registry
currentSearchQuery: string; currentSearchQuery: string;
@@ -118,12 +118,12 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
* @param data Contains query param * @param data Contains query param
*/ */
search(data: any) { search(data: any) {
this.searching$.next(true);
if (hasValue(this.searchSub)) { if (hasValue(this.searchSub)) {
this.searchSub.unsubscribe(); this.searchSub.unsubscribe();
this.subs = this.subs.filter((sub: Subscription) => sub !== this.searchSub); this.subs = this.subs.filter((sub: Subscription) => sub !== this.searchSub);
} }
this.searchSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.searchSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
tap(() => this.loading$.next(true)),
switchMap((paginationOptions) => { switchMap((paginationOptions) => {
const query: string = data.query; const query: string = data.query;
if (query != null && this.currentSearchQuery !== query) { if (query != null && this.currentSearchQuery !== query) {
@@ -141,18 +141,22 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
if (groups.page.length === 0) { if (groups.page.length === 0) {
return observableOf(buildPaginatedList(groups.pageInfo, [])); return observableOf(buildPaginatedList(groups.pageInfo, []));
} }
return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe(
switchMap((isSiteAdmin: boolean) => {
return observableCombineLatest(groups.page.map((group: Group) => { return observableCombineLatest(groups.page.map((group: Group) => {
if (!this.deletedGroupsIds.includes(group.id)) { if (hasValue(group) && !this.deletedGroupsIds.includes(group.id)) {
return observableCombineLatest([ return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(group) ? group.self : undefined), this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self),
this.canManageGroup$(isSiteAdmin, group),
this.hasLinkedDSO(group), this.hasLinkedDSO(group),
this.getSubgroups(group), this.getSubgroups(group),
this.getMembers(group) this.getMembers(group)
]).pipe( ]).pipe(
map(([isAuthorized, hasLinkedDSO, subgroups, members]: map(([canDelete, canManageGroup, hasLinkedDSO, subgroups, members]:
[boolean, boolean, RemoteData<PaginatedList<Group>>, RemoteData<PaginatedList<EPerson>>]) => { [boolean, boolean, boolean, RemoteData<PaginatedList<Group>>, RemoteData<PaginatedList<EPerson>>]) => {
const groupDtoModel: GroupDtoModel = new GroupDtoModel(); const groupDtoModel: GroupDtoModel = new GroupDtoModel();
groupDtoModel.ableToDelete = isAuthorized && !hasLinkedDSO; groupDtoModel.ableToDelete = canDelete && !hasLinkedDSO;
groupDtoModel.ableToEdit = canManageGroup;
groupDtoModel.group = group; groupDtoModel.group = group;
groupDtoModel.subgroups = subgroups.payload; groupDtoModel.subgroups = subgroups.payload;
groupDtoModel.epersons = members.payload; groupDtoModel.epersons = members.payload;
@@ -165,15 +169,25 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
return buildPaginatedList(groups.pageInfo, dtos); return buildPaginatedList(groups.pageInfo, dtos);
})); }));
}) })
);
})
).subscribe((value: PaginatedList<GroupDtoModel>) => { ).subscribe((value: PaginatedList<GroupDtoModel>) => {
this.groupsDto$.next(value); this.groupsDto$.next(value);
this.pageInfoState$.next(value.pageInfo); this.pageInfoState$.next(value.pageInfo);
this.searching$.next(false); this.loading$.next(false);
}); });
this.subs.push(this.searchSub); this.subs.push(this.searchSub);
} }
canManageGroup$(isSiteAdmin: boolean, group: Group): Observable<boolean> {
if (isSiteAdmin) {
return observableOf(true);
} else {
return this.authorizationService.isAuthorized(FeatureID.CanManageGroup, group.self);
}
}
/** /**
* Delete Group * Delete Group
*/ */

View File

@@ -46,6 +46,7 @@ import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component
import { ThemedHeaderComponent } from './header/themed-header.component'; import { ThemedHeaderComponent } from './header/themed-header.component';
import { ThemedFooterComponent } from './footer/themed-footer.component'; import { ThemedFooterComponent } from './footer/themed-footer.component';
import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component'; import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component';
import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component';
export function getBase() { export function getBase() {
return environment.ui.nameSpace; return environment.ui.nameSpace;
@@ -129,6 +130,7 @@ const DECLARATIONS = [
HeaderComponent, HeaderComponent,
ThemedHeaderComponent, ThemedHeaderComponent,
HeaderNavbarWrapperComponent, HeaderNavbarWrapperComponent,
ThemedHeaderNavbarWrapperComponent,
AdminSidebarComponent, AdminSidebarComponent,
AdminSidebarSectionComponent, AdminSidebarSectionComponent,
ExpandableAdminSidebarSectionComponent, ExpandableAdminSidebarSectionComponent,

View File

@@ -174,8 +174,8 @@ export class CommunityListService {
direction: options.sort.direction direction: options.sort.direction
} }
}, },
followLink('subcommunities', this.configOnePage, true, true), followLink('subcommunities', { findListOptions: this.configOnePage }),
followLink('collections', this.configOnePage, true, true)) followLink('collections', { findListOptions: this.configOnePage }))
.pipe( .pipe(
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
map((results) => results.payload), map((results) => results.payload),
@@ -242,8 +242,8 @@ export class CommunityListService {
elementsPerPage: MAX_COMCOLS_PER_PAGE, elementsPerPage: MAX_COMCOLS_PER_PAGE,
currentPage: i currentPage: i
}, },
followLink('subcommunities', this.configOnePage, true, true), followLink('subcommunities', { findListOptions: this.configOnePage }),
followLink('collections', this.configOnePage, true, true)) followLink('collections', { findListOptions: this.configOnePage }))
.pipe( .pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
switchMap((rd: RemoteData<PaginatedList<Community>>) => { switchMap((rd: RemoteData<PaginatedList<Community>>) => {

View File

@@ -292,10 +292,13 @@ export class ResetAuthenticationMessagesAction implements Action {
export class RetrieveAuthMethodsAction implements Action { export class RetrieveAuthMethodsAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS; public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS;
payload: AuthStatus; payload: {
status: AuthStatus;
blocking: boolean;
};
constructor(authStatus: AuthStatus) { constructor(status: AuthStatus, blocking: boolean) {
this.payload = authStatus; this.payload = { status, blocking };
} }
} }
@@ -306,10 +309,14 @@ export class RetrieveAuthMethodsAction implements Action {
*/ */
export class RetrieveAuthMethodsSuccessAction implements Action { export class RetrieveAuthMethodsSuccessAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS; public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS;
payload: AuthMethod[];
constructor(authMethods: AuthMethod[] ) { payload: {
this.payload = authMethods; authMethods: AuthMethod[];
blocking: boolean;
};
constructor(authMethods: AuthMethod[], blocking: boolean ) {
this.payload = { authMethods, blocking };
} }
} }
@@ -320,6 +327,12 @@ export class RetrieveAuthMethodsSuccessAction implements Action {
*/ */
export class RetrieveAuthMethodsErrorAction implements Action { export class RetrieveAuthMethodsErrorAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR; public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR;
payload: boolean;
constructor(blocking: boolean) {
this.payload = blocking;
}
} }
/** /**

View File

@@ -43,10 +43,12 @@ describe('AuthEffects', () => {
let initialState; let initialState;
let token; let token;
let store: MockStore<AppState>; let store: MockStore<AppState>;
let authStatus;
function init() { function init() {
authServiceStub = new AuthServiceStub(); authServiceStub = new AuthServiceStub();
token = authServiceStub.getToken(); token = authServiceStub.getToken();
authStatus = Object.assign(new AuthStatus(), {});
initialState = { initialState = {
core: { core: {
auth: { auth: {
@@ -217,19 +219,41 @@ describe('AuthEffects', () => {
expect(authEffects.checkTokenCookie$).toBeObservable(expected); expect(authEffects.checkTokenCookie$).toBeObservable(expected);
}); });
describe('on CSR', () => {
it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
observableOf( observableOf(
{ authenticated: false }) { authenticated: false })
); );
spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue(
new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false)
);
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) }); const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false) });
expect(authEffects.checkTokenCookie$).toBeObservable(expected); expect(authEffects.checkTokenCookie$).toBeObservable(expected);
}); });
}); });
describe('on SSR', () => {
it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
observableOf(
{ authenticated: false })
);
spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue(
new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true)
);
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true) });
expect(authEffects.checkTokenCookie$).toBeObservable(expected);
});
});
});
describe('when check token failed', () => { describe('when check token failed', () => {
it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => { it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => {
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test'))); spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test')));
@@ -359,11 +383,17 @@ describe('AuthEffects', () => {
describe('retrieveMethods$', () => { describe('retrieveMethods$', () => {
describe('on CSR', () => {
describe('when retrieve authentication methods succeeded', () => { describe('when retrieve authentication methods succeeded', () => {
it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => { it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); actions = hot('--a-', { a:
{
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
payload: { status: authStatus, blocking: false}
}
});
const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) }); const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, false) });
expect(authEffects.retrieveMethods$).toBeObservable(expected); expect(authEffects.retrieveMethods$).toBeObservable(expected);
}); });
@@ -373,15 +403,56 @@ describe('AuthEffects', () => {
it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => { it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow('')); spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); actions = hot('--a-', { a:
{
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
payload: { status: authStatus, blocking: false}
}
});
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() }); const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(false) });
expect(authEffects.retrieveMethods$).toBeObservable(expected); expect(authEffects.retrieveMethods$).toBeObservable(expected);
}); });
}); });
}); });
describe('on SSR', () => {
describe('when retrieve authentication methods succeeded', () => {
it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
actions = hot('--a-', { a:
{
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
payload: { status: authStatus, blocking: true}
}
});
const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, true) });
expect(authEffects.retrieveMethods$).toBeObservable(expected);
});
});
describe('when retrieve authentication methods failed', () => {
it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
actions = hot('--a-', { a:
{
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
payload: { status: authStatus, blocking: true}
}
});
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(true) });
expect(authEffects.retrieveMethods$).toBeObservable(expected);
});
});
});
});
describe('clearInvalidTokenOnRehydrate$', () => { describe('clearInvalidTokenOnRehydrate$', () => {
beforeEach(() => { beforeEach(() => {

View File

@@ -145,7 +145,7 @@ export class AuthEffects {
if (response.authenticated) { if (response.authenticated) {
return new RetrieveTokenAction(); return new RetrieveTokenAction();
} else { } else {
return new RetrieveAuthMethodsAction(response); return this.authService.getRetrieveAuthMethodsAction(response);
} }
}), }),
catchError((error) => observableOf(new AuthenticatedErrorAction(error))) catchError((error) => observableOf(new AuthenticatedErrorAction(error)))
@@ -234,10 +234,10 @@ export class AuthEffects {
.pipe( .pipe(
ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS), ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS),
switchMap((action: RetrieveAuthMethodsAction) => { switchMap((action: RetrieveAuthMethodsAction) => {
return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload) return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload.status)
.pipe( .pipe(
map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)), map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels, action.payload.blocking)),
catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction(action.payload.blocking)))
); );
}) })
); );

View File

@@ -512,7 +512,7 @@ describe('authReducer', () => {
loading: false, loading: false,
authMethods: [] authMethods: []
}; };
const action = new RetrieveAuthMethodsAction(new AuthStatus()); const action = new RetrieveAuthMethodsAction(new AuthStatus(), true);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
state = { state = {
authenticated: false, authenticated: false,
@@ -536,7 +536,7 @@ describe('authReducer', () => {
new AuthMethod(AuthMethodType.Password), new AuthMethod(AuthMethodType.Password),
new AuthMethod(AuthMethodType.Shibboleth, 'location') new AuthMethod(AuthMethodType.Shibboleth, 'location')
]; ];
const action = new RetrieveAuthMethodsSuccessAction(authMethods); const action = new RetrieveAuthMethodsSuccessAction(authMethods, false);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
state = { state = {
authenticated: false, authenticated: false,
@@ -548,7 +548,31 @@ describe('authReducer', () => {
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => { it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action with blocking as true', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: true,
loading: true,
authMethods: []
};
const authMethods = [
new AuthMethod(AuthMethodType.Password),
new AuthMethod(AuthMethodType.Shibboleth, 'location')
];
const action = new RetrieveAuthMethodsSuccessAction(authMethods, true);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
blocking: true,
loading: false,
authMethods: authMethods
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action ', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
@@ -557,7 +581,7 @@ describe('authReducer', () => {
authMethods: [] authMethods: []
}; };
const action = new RetrieveAuthMethodsErrorAction(); const action = new RetrieveAuthMethodsErrorAction(false);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
state = { state = {
authenticated: false, authenticated: false,
@@ -568,4 +592,25 @@ describe('authReducer', () => {
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action with blocking as true', () => {
initialState = {
authenticated: false,
loaded: false,
blocking: true,
loading: true,
authMethods: []
};
const action = new RetrieveAuthMethodsErrorAction(true);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
loaded: false,
blocking: true,
loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)]
};
expect(newState).toEqual(state);
});
}); });

View File

@@ -10,6 +10,7 @@ import {
RedirectWhenTokenExpiredAction, RedirectWhenTokenExpiredAction,
RefreshTokenSuccessAction, RefreshTokenSuccessAction,
RetrieveAuthenticatedEpersonSuccessAction, RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction, RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction SetRedirectUrlAction
} from './auth.actions'; } from './auth.actions';
@@ -211,14 +212,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:
return Object.assign({}, state, { return Object.assign({}, state, {
loading: false, loading: false,
blocking: false, blocking: (action as RetrieveAuthMethodsSuccessAction).payload.blocking,
authMethods: (action as RetrieveAuthMethodsSuccessAction).payload authMethods: (action as RetrieveAuthMethodsSuccessAction).payload.authMethods
}); });
case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR:
return Object.assign({}, state, { return Object.assign({}, state, {
loading: false, loading: false,
blocking: false, blocking: (action as RetrieveAuthMethodsErrorAction).payload,
authMethods: [new AuthMethod(AuthMethodType.Password)] authMethods: [new AuthMethod(AuthMethodType.Password)]
}); });

View File

@@ -35,6 +35,7 @@ import { AppState } from '../../app.reducer';
import { import {
CheckAuthenticationTokenAction, CheckAuthenticationTokenAction,
ResetAuthenticationMessagesAction, ResetAuthenticationMessagesAction,
RetrieveAuthMethodsAction,
SetRedirectUrlAction SetRedirectUrlAction
} from './auth.actions'; } from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { NativeWindowRef, NativeWindowService } from '../services/window.service';
@@ -518,4 +519,13 @@ export class AuthService {
); );
} }
/**
* Return a new instance of RetrieveAuthMethodsAction
*
* @param authStatus The auth status
*/
getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
return new RetrieveAuthMethodsAction(authStatus, false);
}
} }

View File

@@ -10,6 +10,7 @@ import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthTokenInfo } from './models/auth-token-info.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { RetrieveAuthMethodsAction } from './auth.actions';
/** /**
* The auth service. * The auth service.
@@ -60,4 +61,13 @@ export class ServerAuthService extends AuthService {
map((rd: RemoteData<AuthStatus>) => Object.assign(new AuthStatus(), rd.payload)) map((rd: RemoteData<AuthStatus>) => Object.assign(new AuthStatus(), rd.payload))
); );
} }
/**
* Return a new instance of RetrieveAuthMethodsAction
*
* @param authStatus The auth status
*/
getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
return new RetrieveAuthMethodsAction(authStatus, true);
}
} }

View File

@@ -102,7 +102,7 @@ describe('LinkService', () => {
describe('resolveLink', () => { describe('resolveLink', () => {
describe(`when the linkdefinition concerns a single object`, () => { describe(`when the linkdefinition concerns a single object`, () => {
beforeEach(() => { beforeEach(() => {
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}); });
it('should call dataservice.findByHref with the correct href and nested links', () => { it('should call dataservice.findByHref with the correct href and nested links', () => {
expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor')); expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor'));
@@ -116,7 +116,7 @@ describe('LinkService', () => {
propertyName: 'predecessor', propertyName: 'predecessor',
isList: true isList: true
}); });
service.resolveLink(testModel, followLink('predecessor', { some: 'options ' } as any, true, true, true, followLink('successor'))); service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor')));
}); });
it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => { it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => {
expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor'));
@@ -124,7 +124,7 @@ describe('LinkService', () => {
}); });
describe('either way', () => { describe('either way', () => {
beforeEach(() => { beforeEach(() => {
result = service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}); });
it('should call getLinkDefinition with the correct model and link', () => { it('should call getLinkDefinition with the correct model and link', () => {
@@ -149,7 +149,7 @@ describe('LinkService', () => {
}); });
it('should throw an error', () => { it('should throw an error', () => {
expect(() => { expect(() => {
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}).toThrow(); }).toThrow();
}); });
}); });
@@ -160,7 +160,7 @@ describe('LinkService', () => {
}); });
it('should throw an error', () => { it('should throw an error', () => {
expect(() => { expect(() => {
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor'))); service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
}).toThrow(); }).toThrow();
}); });
}); });

View File

@@ -55,9 +55,7 @@ export class LinkService {
public resolveLinkWithoutAttaching<T extends HALResource, U extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): Observable<RemoteData<U>> { public resolveLinkWithoutAttaching<T extends HALResource, U extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): Observable<RemoteData<U>> {
const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name); const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name);
if (hasNoValue(matchingLinkDef)) { if (hasValue(matchingLinkDef)) {
throw new Error(`followLink('${linkToFollow.name}') was used for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`);
} else {
const provider = this.getDataServiceFor(matchingLinkDef.resourceType); const provider = this.getDataServiceFor(matchingLinkDef.resourceType);
if (hasNoValue(provider)) { if (hasNoValue(provider)) {
@@ -84,7 +82,10 @@ export class LinkService {
throw e; throw e;
} }
} }
} else if (!linkToFollow.isOptional) {
throw new Error(`followLink('${linkToFollow.name}') was used as a required link for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`);
} }
return EMPTY; return EMPTY;
} }

View File

@@ -523,7 +523,7 @@ describe('RemoteDataBuildService', () => {
let paginatedLinksToFollow; let paginatedLinksToFollow;
beforeEach(() => { beforeEach(() => {
paginatedLinksToFollow = [ paginatedLinksToFollow = [
followLink('page', undefined, true, true, true, ...linksToFollow), followLink('page', {}, ...linksToFollow),
...linksToFollow ...linksToFollow
]; ];
}); });

View File

@@ -271,7 +271,7 @@ export class RemoteDataBuildService {
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/ */
buildList<T extends HALResource>(href$: string | Observable<string>, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> { buildList<T extends HALResource>(href$: string | Observable<string>, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
return this.buildFromHref<PaginatedList<T>>(href$, followLink('page', undefined, false, true, true, ...linksToFollow)); return this.buildFromHref<PaginatedList<T>>(href$, followLink('page', { shouldEmbed: false }, ...linksToFollow));
} }
/** /**

View File

@@ -162,6 +162,7 @@ import { UsageReport } from './statistics/models/usage-report.model';
import { RootDataService } from './data/root-data.service'; import { RootDataService } from './data/root-data.service';
import { Root } from './data/root.model'; import { Root } from './data/root.model';
import { SearchConfig } from './shared/search/search-filters/search-config.model'; import { SearchConfig } from './shared/search/search-filters/search-config.model';
import { SequenceService } from './shared/sequence.service';
/** /**
* When not in production, endpoint responses can be mocked for testing purposes * When not in production, endpoint responses can be mocked for testing purposes
@@ -282,7 +283,8 @@ const PROVIDERS = [
FilteredDiscoveryPageResponseParsingService, FilteredDiscoveryPageResponseParsingService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory }, { provide: NativeWindowService, useFactory: NativeWindowFactory },
VocabularyService, VocabularyService,
VocabularyTreeviewService VocabularyTreeviewService,
SequenceService,
]; ];
/** /**

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators'; import { dataService } from '../cache/builders/build-decorators';
@@ -18,7 +18,7 @@ import { Item } from '../shared/item.model';
import { BundleDataService } from './bundle-data.service'; import { BundleDataService } from './bundle-data.service';
import { DataService } from './data.service'; import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { PaginatedList, buildPaginatedList } from './paginated-list.model'; import { buildPaginatedList, PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { FindListOptions, PutRequest } from './request.models'; import { FindListOptions, PutRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
@@ -28,7 +28,6 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import { sendRequest } from '../shared/operators'; import { sendRequest } from '../shared/operators';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { PageInfo } from '../shared/page-info.model'; import { PageInfo } from '../shared/page-info.model';
import { RequestEntryState } from './request.reducer';
/** /**
* A service to retrieve {@link Bitstream}s from the REST API * A service to retrieve {@link Bitstream}s from the REST API
@@ -75,92 +74,6 @@ export class BitstreamDataService extends DataService<Bitstream> {
return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/**
* Retrieves the thumbnail for the given item
* @returns {Observable<RemoteData<{@link Bitstream}>>} the first bitstream in the THUMBNAIL bundle
*/
// TODO should be implemented rest side. {@link Item} should get a thumbnail link
public getThumbnailFor(item: Item): Observable<RemoteData<Bitstream>> {
return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
switchMap((bundleRD: RemoteData<Bundle>) => {
if (isNotEmpty(bundleRD.payload)) {
return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 1 }).pipe(
map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {
return new RemoteData(
bitstreamRD.timeCompleted,
bitstreamRD.msToLive,
bitstreamRD.lastUpdated,
bitstreamRD.state,
bitstreamRD.errorMessage,
bitstreamRD.payload.page[0],
bitstreamRD.statusCode
);
} else {
return bitstreamRD as any;
}
})
);
} else {
return [bundleRD as any];
}
})
);
}
/**
* Retrieve the matching thumbnail for a {@link Bitstream}.
*
* The {@link Item} is technically redundant, but is available
* in all current use cases, and having it simplifies this method
*
* @param item The {@link Item} the {@link Bitstream} and its thumbnail are a part of
* @param bitstreamInOriginal The original {@link Bitstream} to find the thumbnail for
*/
// TODO should be implemented rest side
public getMatchingThumbnail(item: Item, bitstreamInOriginal: Bitstream): Observable<RemoteData<Bitstream>> {
return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
switchMap((bundleRD: RemoteData<Bundle>) => {
if (isNotEmpty(bundleRD.payload)) {
return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 9999 }).pipe(
map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {
const matchingThumbnail = bitstreamRD.payload.page.find((thumbnail: Bitstream) =>
thumbnail.name.startsWith(bitstreamInOriginal.name)
);
if (hasValue(matchingThumbnail)) {
return new RemoteData(
bitstreamRD.timeCompleted,
bitstreamRD.msToLive,
bitstreamRD.lastUpdated,
bitstreamRD.state,
bitstreamRD.errorMessage,
matchingThumbnail,
bitstreamRD.statusCode
);
} else {
return new RemoteData(
bitstreamRD.timeCompleted,
bitstreamRD.msToLive,
bitstreamRD.lastUpdated,
RequestEntryState.Error,
'No matching thumbnail found',
undefined,
404
);
}
} else {
return bitstreamRD as any;
}
})
);
} else {
return [bundleRD as any];
}
})
);
}
/** /**
* Retrieve all {@link Bitstream}s in a certain {@link Bundle}. * Retrieve all {@link Bitstream}s in a certain {@link Bundle}.
* *

View File

@@ -233,7 +233,7 @@ describe('DataService', () => {
const config: FindListOptions = Object.assign(new FindListOptions(), { const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 5 elementsPerPage: 5
}); });
(service as any).getFindAllHref({}, null, followLink('bundles', config, true, true, true)).subscribe((value) => { (service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -253,7 +253,7 @@ describe('DataService', () => {
elementsPerPage: 2 elementsPerPage: 2
}); });
(service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', config, true, true, true), followLink('templateItemOf')).subscribe((value) => { (service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -261,7 +261,13 @@ describe('DataService', () => {
it('should not include linksToFollow with shouldEmbed = false', () => { it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpoint}?embed=templateItemOf`; const expected = `${endpoint}?embed=templateItemOf`;
(service as any).getFindAllHref({}, null, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')).subscribe((value) => { (service as any).getFindAllHref(
{},
null,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf')
).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -269,7 +275,7 @@ describe('DataService', () => {
it('should include nested linksToFollow 3lvl', () => { it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`; const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`;
(service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))).subscribe((value) => { (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -279,7 +285,7 @@ describe('DataService', () => {
const config: FindListOptions = Object.assign(new FindListOptions(), { const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 4 elementsPerPage: 4
}); });
(service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', config, true, true, true))).subscribe((value) => { (service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => {
expect(value).toBe(expected); expect(value).toBe(expected);
}); });
}); });
@@ -308,13 +314,19 @@ describe('DataService', () => {
it('should not include linksToFollow with shouldEmbed = false', () => { it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`; const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')); const result = (service as any).getIDHref(
endpointMock,
resourceIdMock,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf')
);
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it('should include nested linksToFollow 3lvl', () => { it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`; const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', undefined, true, true, true,followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))); const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships'))));
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
}); });

View File

@@ -174,13 +174,29 @@ describe('DsoRedirectDataService', () => {
it('should not include linksToFollow with shouldEmbed = false', () => { it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${requestUUIDURL}&embed=templateItemOf`; const expected = `${requestUUIDURL}&embed=templateItemOf`;
const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')); const result = (service as any).getIDHref(
pidLink,
dsoUUID,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf')
);
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it('should include nested linksToFollow 3lvl', () => { it('should include nested linksToFollow 3lvl', () => {
const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`;
const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))); const result = (service as any).getIDHref(
pidLink,
dsoUUID,
followLink('owningCollection',
{},
followLink('itemtemplate',
{},
followLink('relationships')
)
)
);
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
}); });

View File

@@ -10,6 +10,7 @@ export enum FeatureID {
ReinstateItem = 'reinstateItem', ReinstateItem = 'reinstateItem',
EPersonRegistration = 'epersonRegistration', EPersonRegistration = 'epersonRegistration',
CanManageGroups = 'canManageGroups', CanManageGroups = 'canManageGroups',
CanManageGroup = 'canManageGroup',
IsCollectionAdmin = 'isCollectionAdmin', IsCollectionAdmin = 'isCollectionAdmin',
IsCommunityAdmin = 'isCommunityAdmin', IsCommunityAdmin = 'isCommunityAdmin',
CanDownload = 'canDownload', CanDownload = 'canDownload',

View File

@@ -23,14 +23,7 @@ import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { PaginatedList } from './paginated-list.model'; import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { import { DeleteRequest, FindListOptions, GetRequest, PostRequest, PutRequest, RestRequest } from './request.models';
DeleteRequest,
FindListOptions,
GetRequest,
PostRequest,
PutRequest,
RestRequest
} from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { Bundle } from '../shared/bundle.model'; import { Bundle } from '../shared/bundle.model';
@@ -38,6 +31,9 @@ import { MetadataMap } from '../shared/metadata.models';
import { BundleDataService } from './bundle-data.service'; import { BundleDataService } from './bundle-data.service';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { NoContent } from '../shared/NoContent.model'; import { NoContent } from '../shared/NoContent.model';
import { GenericConstructor } from '../shared/generic-constructor';
import { ResponseParsingService } from './parsing.service';
import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service';
@Injectable() @Injectable()
@dataService(ITEM) @dataService(ITEM)
@@ -229,7 +225,7 @@ export class ItemDataService extends DataService<Item> {
* @param itemId * @param itemId
* @param collection * @param collection
*/ */
public moveToCollection(itemId: string, collection: Collection): Observable<RemoteData<Collection>> { public moveToCollection(itemId: string, collection: Collection): Observable<RemoteData<any>> {
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
let headers = new HttpHeaders(); let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list'); headers = headers.append('Content-Type', 'text/uri-list');
@@ -242,9 +238,17 @@ export class ItemDataService extends DataService<Item> {
find((href: string) => hasValue(href)), find((href: string) => hasValue(href)),
map((href: string) => { map((href: string) => {
const request = new PutRequest(requestId, href, collection._links.self.href, options); const request = new PutRequest(requestId, href, collection._links.self.href, options);
this.requestService.send(request); Object.assign(request, {
// TODO: for now, the move Item endpoint returns a malformed collection -- only look at the status code
getResponseParser(): GenericConstructor<ResponseParsingService> {
return StatusCodeOnlyResponseParsingService;
}
});
return request;
}) })
).subscribe(); ).subscribe((request) => {
this.requestService.send(request);
});
return this.rdbService.buildFromRequestUUID(requestId); return this.rdbService.buildFromRequestUUID(requestId);
} }

View File

@@ -579,4 +579,19 @@ describe('RequestService', () => {
}); });
}); });
}); });
describe('uriEncodeBody', () => {
it('should properly encode the body', () => {
const body = {
'property1': 'multiple\nlines\nto\nsend',
'property2': 'sp&ci@l characters',
'sp&ci@l-chars in prop': 'test123',
};
const queryParams = service.uriEncodeBody(body);
expect(queryParams).toEqual(
'property1=multiple%0Alines%0Ato%0Asend&property2=sp%26ci%40l%20characters&sp%26ci%40l-chars%20in%20prop=test123'
);
});
});
}); });

View File

@@ -265,11 +265,13 @@ export class RequestService {
if (isNotEmpty(body) && typeof body === 'object') { if (isNotEmpty(body) && typeof body === 'object') {
Object.keys(body) Object.keys(body)
.forEach((param) => { .forEach((param) => {
const paramValue = `${param}=${body[param]}`; const encodedParam = encodeURIComponent(param);
const encodedBody = encodeURIComponent(body[param]);
const paramValue = `${encodedParam}=${encodedBody}`;
queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue); queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue);
}); });
} }
return encodeURI(queryParams); return queryParams;
} }
/** /**

View File

@@ -17,6 +17,11 @@ export class GroupDtoModel {
*/ */
public ableToDelete: boolean; public ableToDelete: boolean;
/**
* Whether or not the current user is able to edit the linked group
*/
public ableToEdit: boolean;
/** /**
* List of subgroups of this group * List of subgroups of this group
*/ */

View File

@@ -9,7 +9,7 @@ import {
import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner'; import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { dateToISOFormat } from '../../../shared/date.util'; import { dateToISOFormat, dateToString, isNgbDateStruct } from '../../../shared/date.util';
import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model'; import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model';
import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model';
import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model';
@@ -136,6 +136,8 @@ export class JsonPatchOperationsBuilder {
operationValue = new FormFieldMetadataValueObject(value.value, value.language); operationValue = new FormFieldMetadataValueObject(value.value, value.language);
} else if (value.hasOwnProperty('authority')) { } else if (value.hasOwnProperty('authority')) {
operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority); operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority);
} else if (isNgbDateStruct(value)) {
operationValue = new FormFieldMetadataValueObject(dateToString(value));
} else if (value.hasOwnProperty('value')) { } else if (value.hasOwnProperty('value')) {
operationValue = new FormFieldMetadataValueObject(value.value); operationValue = new FormFieldMetadataValueObject(value.value);
} else { } else {

View File

@@ -20,6 +20,7 @@ export const JsonPatchOperationsActionTypes = {
ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'), ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'),
FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'), FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'),
START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'), START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'),
DELETE_PENDING_JSON_PATCH_OPERATIONS: type('dspace/core/patch/DELETE_PENDING_JSON_PATCH_OPERATIONS'),
}; };
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
@@ -261,6 +262,13 @@ export class NewPatchReplaceOperationAction implements Action {
} }
} }
/**
* An ngrx action to delete all pending JSON Patch Operations.
*/
export class DeletePendingJsonPatchOperationsAction implements Action {
type = JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS;
}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */
/** /**
@@ -276,4 +284,5 @@ export type PatchOperationsActions
| NewPatchRemoveOperationAction | NewPatchRemoveOperationAction
| NewPatchReplaceOperationAction | NewPatchReplaceOperationAction
| RollbacktPatchOperationsAction | RollbacktPatchOperationsAction
| StartTransactionPatchOperationsAction; | StartTransactionPatchOperationsAction
| DeletePendingJsonPatchOperationsAction;

View File

@@ -1,7 +1,7 @@
import * as deepFreeze from 'deep-freeze'; import * as deepFreeze from 'deep-freeze';
import { import {
CommitPatchOperationsAction, CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction,
FlushPatchOperationsAction, FlushPatchOperationsAction,
NewPatchAddOperationAction, NewPatchAddOperationAction,
NewPatchRemoveOperationAction, NewPatchRemoveOperationAction,
@@ -323,4 +323,19 @@ describe('jsonPatchOperationsReducer test suite', () => {
}); });
describe('When DeletePendingJsonPatchOperationsAction has been dispatched', () => {
it('should set set the JsonPatchOperationsState to null ', () => {
const action = new DeletePendingJsonPatchOperationsAction();
initState = Object.assign({}, testState, {
[testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], {
transactionStartTime: startTimestamp,
commitPending: true
})
});
const newState = jsonPatchOperationsReducer(initState, action);
expect(newState).toBeNull();
});
});
}); });

View File

@@ -11,7 +11,8 @@ import {
NewPatchReplaceOperationAction, NewPatchReplaceOperationAction,
CommitPatchOperationsAction, CommitPatchOperationsAction,
StartTransactionPatchOperationsAction, StartTransactionPatchOperationsAction,
RollbacktPatchOperationsAction RollbacktPatchOperationsAction,
DeletePendingJsonPatchOperationsAction
} from './json-patch-operations.actions'; } from './json-patch-operations.actions';
import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model'; import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model';
@@ -101,6 +102,10 @@ export function jsonPatchOperationsReducer(state = initialState, action: PatchOp
return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction); return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction);
} }
case JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS: {
return deletePendingOperations(state, action as DeletePendingJsonPatchOperationsAction);
}
default: { default: {
return state; return state;
} }
@@ -178,6 +183,20 @@ function rollbackOperations(state: JsonPatchOperationsState, action: RollbacktPa
} }
} }
/**
* Set the JsonPatchOperationsState to its initial value.
*
* @param state
* the current state
* @param action
* an DeletePendingJsonPatchOperationsAction
* @return JsonPatchOperationsState
* the new state.
*/
function deletePendingOperations(state: JsonPatchOperationsState, action: DeletePendingJsonPatchOperationsAction): JsonPatchOperationsState {
return null;
}
/** /**
* Add new JSON patch operation list. * Add new JSON patch operation list.
* *

View File

@@ -17,6 +17,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer';
import { import {
CommitPatchOperationsAction, CommitPatchOperationsAction,
DeletePendingJsonPatchOperationsAction,
RollbacktPatchOperationsAction, RollbacktPatchOperationsAction,
StartTransactionPatchOperationsAction StartTransactionPatchOperationsAction
} from './json-patch-operations.actions'; } from './json-patch-operations.actions';
@@ -288,4 +289,19 @@ describe('JsonPatchOperationsService test suite', () => {
}); });
}); });
describe('deletePendingJsonPatchOperations', () => {
beforeEach(() => {
store.dispatch.and.callFake(() => { /* */ });
});
it('should dispatch a new DeletePendingJsonPatchOperationsAction', () => {
const expectedAction = new DeletePendingJsonPatchOperationsAction();
scheduler.schedule(() => service.deletePendingJsonPatchOperations());
scheduler.flush();
expect(store.dispatch).toHaveBeenCalledWith(expectedAction);
});
});
}); });

View File

@@ -10,7 +10,7 @@ import { CoreState } from '../core.reducers';
import { jsonPatchOperationsByResourceType } from './selectors'; import { jsonPatchOperationsByResourceType } from './selectors';
import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer'; import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer';
import { import {
CommitPatchOperationsAction, CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction,
RollbacktPatchOperationsAction, RollbacktPatchOperationsAction,
StartTransactionPatchOperationsAction StartTransactionPatchOperationsAction
} from './json-patch-operations.actions'; } from './json-patch-operations.actions';
@@ -105,6 +105,13 @@ export abstract class JsonPatchOperationsService<ResponseDefinitionDomain, Patch
); );
} }
/**
* Dispatch an action to delete all pending JSON patch Operations.
*/
public deletePendingJsonPatchOperations() {
this.store.dispatch(new DeletePendingJsonPatchOperationsAction());
}
/** /**
* Return an instance for RestRequest class * Return an instance for RestRequest class
* *

View File

@@ -43,13 +43,14 @@ export class Bitstream extends DSpaceObject implements HALResource {
bundle: HALLink; bundle: HALLink;
format: HALLink; format: HALLink;
content: HALLink; content: HALLink;
thumbnail: HALLink;
}; };
/** /**
* The thumbnail for this Bitstream * The thumbnail for this Bitstream
* Needs to be resolved first, but isn't available as a {@link HALLink} yet * Will be undefined unless the thumbnail {@link HALLink} has been resolved.
* Use BitstreamDataService.getThumbnailFor(…) for now.
*/ */
@link(BITSTREAM, false, 'thumbnail')
thumbnail?: Observable<RemoteData<Bitstream>>; thumbnail?: Observable<RemoteData<Bitstream>>;
/** /**

View File

@@ -1,4 +1,4 @@
import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize'; import { autoserialize, autoserializeAs, deserialize, deserializeAs, inheritSerialization } from 'cerialize';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { isEmpty } from '../../shared/empty.util'; import { isEmpty } from '../../shared/empty.util';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
@@ -19,6 +19,8 @@ import { ITEM } from './item.resource-type';
import { ChildHALResource } from './child-hal-resource.model'; import { ChildHALResource } from './child-hal-resource.model';
import { Version } from './version.model'; import { Version } from './version.model';
import { VERSION } from './version.resource-type'; import { VERSION } from './version.resource-type';
import { BITSTREAM } from './bitstream.resource-type';
import { Bitstream } from './bitstream.model';
/** /**
* Class representing a DSpace Item * Class representing a DSpace Item
@@ -37,7 +39,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
/** /**
* The Date of the last modification of this Item * The Date of the last modification of this Item
*/ */
@deserialize @deserializeAs(Date)
lastModified: Date; lastModified: Date;
/** /**
@@ -69,6 +71,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
owningCollection: HALLink; owningCollection: HALLink;
templateItemOf: HALLink; templateItemOf: HALLink;
version: HALLink; version: HALLink;
thumbnail: HALLink;
self: HALLink; self: HALLink;
}; };
@@ -100,6 +103,13 @@ export class Item extends DSpaceObject implements ChildHALResource {
@link(RELATIONSHIP, true) @link(RELATIONSHIP, true)
relationships?: Observable<RemoteData<PaginatedList<Relationship>>>; relationships?: Observable<RemoteData<PaginatedList<Relationship>>>;
/**
* The thumbnail for this Item
* Will be undefined unless the thumbnail {@link HALLink} has been resolved.
*/
@link(BITSTREAM, false, 'thumbnail')
thumbnail?: Observable<RemoteData<Bitstream>>;
/** /**
* Method that returns as which type of object this object should be rendered * Method that returns as which type of object this object should be rendered
*/ */

View File

@@ -41,6 +41,43 @@ import { SearchConfig } from './search-filters/search-config.model';
import { PaginationService } from '../../pagination/pagination.service'; import { PaginationService } from '../../pagination/pagination.service';
import { SearchConfigurationService } from './search-configuration.service'; import { SearchConfigurationService } from './search-configuration.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { DataService } from '../../data/data.service';
import { Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DSOChangeAnalyzer } from '../../data/dso-change-analyzer.service';
/* tslint:disable:max-classes-per-file */
/**
* A class that lets us delegate some methods to DataService
*/
class DataServiceImpl extends DataService<any> {
protected linkPath = 'discover';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<any>) {
super();
}
/**
* Adds the embed options to the link for the request
* @param href The href the params are to be added to
* @param args params for the query string
* @param linksToFollow links we want to embed in query string if shouldEmbed is true
*/
public addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig<any>[]) {
return super.addEmbedParams(href, args, ...linksToFollow);
}
}
/** /**
* Service that performs all general actions that have to do with the search page * Service that performs all general actions that have to do with the search page
@@ -78,6 +115,11 @@ export class SearchService implements OnDestroy {
*/ */
private sub; private sub;
/**
* Instance of DataServiceImpl that lets us delegate some methods to DataService
*/
private searchDataService: DataServiceImpl;
constructor(private router: Router, constructor(private router: Router,
private routeService: RouteService, private routeService: RouteService,
protected requestService: RequestService, protected requestService: RequestService,
@@ -89,6 +131,16 @@ export class SearchService implements OnDestroy {
private paginationService: PaginationService, private paginationService: PaginationService,
private searchConfigurationService: SearchConfigurationService private searchConfigurationService: SearchConfigurationService
) { ) {
this.searchDataService = new DataServiceImpl(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
} }
/** /**
@@ -131,7 +183,17 @@ export class SearchService implements OnDestroy {
search<T extends DSpaceObject>(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<SearchObjects<T>>> { search<T extends DSpaceObject>(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<SearchObjects<T>>> {
const href$ = this.getEndpoint(searchOptions); const href$ = this.getEndpoint(searchOptions);
href$.pipe(take(1)).subscribe((url: string) => { href$.pipe(
take(1),
map((href: string) => {
const args = this.searchDataService.addEmbedParams(href, [], ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
} else {
return href;
}
})
).subscribe((url: string) => {
const request = new this.request(this.requestService.generateRequestId(), url); const request = new this.request(this.requestService.generateRequestId(), url);
const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => { const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => {
@@ -399,9 +461,9 @@ export class SearchService implements OnDestroy {
let pageParams = { page: 1 }; let pageParams = { page: 1 };
const queryParams = { view: viewMode }; const queryParams = { view: viewMode };
if (viewMode === ViewMode.DetailedListElement) { if (viewMode === ViewMode.DetailedListElement) {
pageParams = Object.assign(pageParams, {pageSize: 1}); pageParams = Object.assign(pageParams, { pageSize: 1 });
} else if (config.pageSize === 1) { } else if (config.pageSize === 1) {
pageParams = Object.assign(pageParams, {pageSize: 10}); pageParams = Object.assign(pageParams, { pageSize: 10 });
} }
this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], pageParams, queryParams); this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], pageParams, queryParams);
}); });
@@ -413,7 +475,7 @@ export class SearchService implements OnDestroy {
* @param {string} configurationName the name of the configuration * @param {string} configurationName the name of the configuration
* @returns {Observable<RemoteData<SearchConfig[]>>} The found configuration * @returns {Observable<RemoteData<SearchConfig[]>>} The found configuration
*/ */
getSearchConfigurationFor(scope?: string, configurationName?: string ): Observable<RemoteData<SearchConfig>> { getSearchConfigurationFor(scope?: string, configurationName?: string): Observable<RemoteData<SearchConfig>> {
const href$ = this.halService.getEndpoint(this.configurationLinkPath).pipe( const href$ = this.halService.getEndpoint(this.configurationLinkPath).pipe(
map((url: string) => this.getConfigUrl(url, scope, configurationName)), map((url: string) => this.getConfigUrl(url, scope, configurationName)),
); );

View File

@@ -0,0 +1,22 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { SequenceService } from './sequence.service';
let service: SequenceService;
describe('SequenceService', () => {
beforeEach(() => {
service = new SequenceService();
});
it('should return sequential numbers on next(), starting with 1', () => {
const NUMBERS = [1,2,3,4,5];
const sequence = NUMBERS.map(() => service.next());
expect(sequence).toEqual(NUMBERS);
});
});

View File

@@ -0,0 +1,24 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
@Injectable()
/**
* Provides unique sequential numbers
*/
export class SequenceService {
private value: number;
constructor() {
this.value = 0;
}
public next(): number {
return ++this.value;
}
}

View File

@@ -173,7 +173,7 @@ export class VocabularyService {
); );
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved // TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe( return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
switchMap((vocabulary: Vocabulary) => vocabulary.entries), switchMap((vocabulary: Vocabulary) => vocabulary.entries),
); );
@@ -200,7 +200,7 @@ export class VocabularyService {
); );
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved // TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe( return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
switchMap((vocabulary: Vocabulary) => vocabulary.entries), switchMap((vocabulary: Vocabulary) => vocabulary.entries),
); );
@@ -249,7 +249,7 @@ export class VocabularyService {
); );
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved // TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe( return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
switchMap((vocabulary: Vocabulary) => vocabulary.entries), switchMap((vocabulary: Vocabulary) => vocabulary.entries),
getFirstSucceededRemoteListPayload(), getFirstSucceededRemoteListPayload(),

View File

@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>
<div class="card-body"> <div class="card-body">

View File

@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>
<div class="card-body"> <div class="card-body">

View File

@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>
<div class="card-body"> <div class="card-body">

View File

@@ -8,8 +8,8 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="object" <ds-generic-item-page-field [item]="object"
[fields]="['publicationvolume.volumeNumber']" [fields]="['publicationvolume.volumeNumber']"

View File

@@ -8,8 +8,8 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="object" <ds-generic-item-page-field [item]="object"
[fields]="['publicationvolume.volumeNumber']" [fields]="['publicationvolume.volumeNumber']"

View File

@@ -8,8 +8,8 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ds-generic-item-page-field class="item-page-fields" [item]="object" <ds-generic-item-page-field class="item-page-fields" [item]="object"
[fields]="['creativeworkseries.issn']" [fields]="['creativeworkseries.issn']"

View File

@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>
<div class="card-body"> <div class="card-body">

View File

@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>
<div class="card-body"> <div class="card-body">

View File

@@ -8,14 +8,14 @@
rel="noopener noreferrer" [routerLink]="[itemPageRoute]" rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
class="card-img-top full-width"> class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</a> </a>
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width"> <span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
<div> <div>
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async"> <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
</ds-grid-thumbnail> </ds-thumbnail>
</div> </div>
</span> </span>
<div class="card-body"> <div class="card-body">

View File

@@ -8,8 +8,13 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"
[defaultImage]="'assets/images/orgunit-placeholder.svg'"
[alt]="'thumbnail.orgunit.alt'"
[placeholder]="'thumbnail.orgunit.placeholder'"
>
</ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="object" <ds-generic-item-page-field [item]="object"
[fields]="['organization.foundingDate']" [fields]="['organization.foundingDate']"

View File

@@ -8,8 +8,12 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/person-placeholder.svg'"></ds-thumbnail> <ds-thumbnail [thumbnail]="object?.thumbnail | async"
[defaultImage]="'assets/images/person-placeholder.svg'"
[alt]="'thumbnail.person.alt'"
[placeholder]="'thumbnail.person.placeholder'">
</ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ds-generic-item-page-field [item]="object" <ds-generic-item-page-field [item]="object"
[fields]="['person.email']" [fields]="['person.email']"

View File

@@ -8,8 +8,13 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper [hideIfNoTextContent]="false">
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/project-placeholder.svg'"></ds-thumbnail> <ds-thumbnail
[thumbnail]="object?.thumbnail | async"
[defaultImage]="'assets/images/project-placeholder.svg'"
[alt]="'thumbnail.project.alt'"
[placeholder]="'thumbnail.project.placeholder'">
</ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<!--<ds-generic-item-page-field [item]="object"--> <!--<ds-generic-item-page-field [item]="object"-->
<!--[fields]="['project.identifier.status']"--> <!--[fields]="['project.identifier.status']"-->

View File

@@ -1,6 +1,6 @@
<div class="d-flex"> <div class="d-flex">
<!-- <div class="person-thumbnail pr-2">--> <!-- <div class="person-thumbnail pr-2">-->
<!-- <ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>--> <!-- <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>-->
<!-- </div>--> <!-- </div>-->
<div class="flex-grow-1"> <div class="flex-grow-1">
<ds-org-unit-input-suggestions *ngIf="useNameVariants" [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)" <ds-org-unit-input-suggestions *ngIf="useNameVariants" [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)"

View File

@@ -1,8 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
import { Bitstream } from '../../../../../core/shared/bitstream.model';
import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
@@ -113,11 +110,4 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
modalComp.value = value; modalComp.value = value;
return modalRef.result; return modalRef.result;
} }
// TODO refactor to return RemoteData, and thumbnail template to deal with loading
getThumbnail(): Observable<Bitstream> {
return this.bitstreamDataService.getThumbnailFor(this.dso).pipe(
getFirstSucceededRemoteDataPayload()
);
}
} }

View File

@@ -1,8 +1,5 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
import { Bitstream } from '../../../../../core/shared/bitstream.model';
import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
@@ -108,11 +105,4 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu
modalComp.value = value; modalComp.value = value;
return modalRef.result; return modalRef.result;
} }
// TODO refactor to return RemoteData, and thumbnail template to deal with loading
getThumbnail(): Observable<Bitstream> {
return this.bitstreamDataService.getThumbnailFor(this.dso).pipe(
getFirstSucceededRemoteDataPayload()
);
}
} }

View File

@@ -1,6 +1,7 @@
<footer class="top-footer text-lg-start"> <footer class="text-lg-start">
<div *ngIf="showTopFooter" class="top-footer">
<!-- Grid container --> <!-- Grid container -->
<div *ngIf="showTopFooter" class="container p-4"> <div class=" container p-4">
<!--Grid row--> <!--Grid row-->
<div class="row"> <div class="row">
@@ -48,25 +49,31 @@
</div> </div>
<!--Grid row--> <!--Grid row-->
</div> </div>
</div>
<!-- Grid container --> <!-- Grid container -->
<!-- Copyright --> <!-- Copyright -->
<div class="footer p-1 d-flex justify-content-center align-items-center text-white"> <div class="bottom-footer p-1 d-flex justify-content-center align-items-center text-white">
<div class="content-container"> <div class="content-container">
<p class="m-0"> <p class="m-0">
<a class="text-white" href="http://www.dspace.org/">{{ 'footer.link.dspace' | translate}}</a> <a class="text-white"
href="http://www.dspace.org/">{{ 'footer.link.dspace' | translate}}</a>
{{ 'footer.copyright' | translate:{year: dateObj | date:'y'} }} {{ 'footer.copyright' | translate:{year: dateObj | date:'y'} }}
<a class="text-white" href="https://www.lyrasis.org/">{{ 'footer.link.lyrasis' | translate}}</a> <a class="text-white"
href="https://www.lyrasis.org/">{{ 'footer.link.lyrasis' | translate}}</a>
</p> </p>
<ul class="footer-info list-unstyled small d-flex justify-content-center mb-0"> <ul class="footer-info list-unstyled small d-flex justify-content-center mb-0">
<li> <li>
<a class="text-white" href="#" (click)="showCookieSettings()">{{ 'footer.link.cookies' | translate}}</a> <a class="text-white" href="#"
(click)="showCookieSettings()">{{ 'footer.link.cookies' | translate}}</a>
</li> </li>
<li> <li>
<a class="text-white" routerLink="info/privacy">{{ 'footer.link.privacy-policy' | translate}}</a> <a class="text-white"
routerLink="info/privacy">{{ 'footer.link.privacy-policy' | translate}}</a>
</li> </li>
<li> <li>
<a class="text-white" routerLink="info/end-user-agreement">{{ 'footer.link.end-user-agreement' | translate}}</a> <a class="text-white"
routerLink="info/end-user-agreement">{{ 'footer.link.end-user-agreement' | translate}}</a>
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -1,11 +1,10 @@
:host { :host {
footer {
.top-footer { background-color: var(--ds-footer-bg);
background-color: var(--ds-top-footer-bg);
border-top: var(--ds-footer-border);
text-align: center; text-align: center;
padding: var(--ds-footer-padding);
z-index: var(--ds-footer-z-index); z-index: var(--ds-footer-z-index);
border-top: var(--ds-footer-border);
padding: var(--ds-footer-padding);
p { p {
margin: 0; margin: 0;
@@ -15,12 +14,18 @@
height: var(--ds-footer-logo-height); height: var(--ds-footer-logo-height);
} }
.footer {
background-color: var(--ds-footer-bg);
.top-footer {
background-color: var(--ds-top-footer-bg);
padding: var(--ds-footer-padding);
margin: calc(var(--ds-footer-padding) * -1);
}
.bottom-footer {
ul { ul {
li { li {
display: inline-flex; display: inline-flex;
a { a {
padding: 0 calc(var(--bs-spacer) / 2); padding: 0 calc(var(--bs-spacer) / 2);
color: inherit color: inherit

View File

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

View File

@@ -5,7 +5,3 @@
position: sticky; position: sticky;
} }
} }
:host {
z-index: var(--ds-nav-z-index);
}

View File

@@ -0,0 +1,3 @@
:host {
z-index: var(--ds-nav-z-index);
}

View File

@@ -0,0 +1,25 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../shared/theme-support/themed.component';
import { HeaderNavbarWrapperComponent } from './header-navbar-wrapper.component';
/**
* Themed wrapper for BreadcrumbsComponent
*/
@Component({
selector: 'ds-themed-header-navbar-wrapper',
styleUrls: ['./themed-header-navbar-wrapper.component.scss'],
templateUrl: '../shared/theme-support/themed.component.html',
})
export class ThemedHeaderNavbarWrapperComponent extends ThemedComponent<HeaderNavbarWrapperComponent> {
protected getComponentName(): string {
return 'HeaderNavbarWrapperComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../themes/${themeName}/app/header-nav-wrapper/header-navbar-wrapper.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./header-navbar-wrapper.component`);
}
}

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