Merge branch 'upstream-master' into Support-for-process-output

# Conflicts:
#	src/app/process-page/detail/process-detail.component.ts
This commit is contained in:
Marie Verdonck
2020-11-05 13:01:14 +01:00
353 changed files with 11970 additions and 3244 deletions

29
.codecov.yml Normal file
View File

@@ -0,0 +1,29 @@
# DSpace configuration for Codecov.io coverage reports
# These override the default YAML settings at
# https://docs.codecov.io/docs/codecov-yaml#section-default-yaml
# Can be validated via instructions at:
# https://docs.codecov.io/docs/codecov-yaml#validate-your-repository-yaml
# Settings related to code coverage analysis
coverage:
status:
# Configuration for project-level checks. This checks how the PR changes overall coverage.
project:
default:
# For each PR, auto compare coverage to previous commit.
# Require that overall (project) coverage does NOT drop more than 0.5%
target: auto
threshold: 0.5%
# Configuration for patch-level checks. This checks the relative coverage of the new PR code ONLY.
patch:
default:
# For each PR, make sure the coverage of the new code is within 1% of current overall coverage.
# We let 'patch' be more lenient as we only require *project* coverage to not drop significantly.
target: auto
threshold: 1%
# Turn PR comments "off". This feature adds the code coverage summary as a
# comment on each PR. See https://docs.codecov.io/docs/pull-request-comments
# However, this same info is available from the Codecov checks in the PR's
# "Checks" tab in GitHub. So, the comment is unnecessary.
comment: false

View File

@@ -1,7 +1,7 @@
## References ## References
_Add references/links to any related issues or PRs. These may include:_ _Add references/links to any related issues or PRs. These may include:_
* Fixes [GitHub issue](https://github.com/DSpace/dspace-angular/issues), if any * Fixes #[issue-number]
* Requires [REST API PR](https://github.com/DSpace/DSpace/pulls), if any * Requires DSpace/DSpace#[pr-number] (if a REST API PR is required to test this)
## Description ## Description
Short summary of changes (1-2 sentences). Short summary of changes (1-2 sentences).
@@ -20,6 +20,7 @@ _This checklist provides a reminder of what we are going to look for when review
- [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible. - [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible.
- [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint` - [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint`
- [ ] My PR doesn't introduce circular dependencies
- [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods. - [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods.
- [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide). - [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide).
- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. - [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.

View File

@@ -24,7 +24,7 @@ env:
# Direct that step to utilize a DSpace REST service that has been started in docker. # Direct that step to utilize a DSpace REST service that has been started in docker.
DSPACE_REST_HOST: localhost DSPACE_REST_HOST: localhost
DSPACE_REST_PORT: 8080 DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: '/server/api' DSPACE_REST_NAMESPACE: '/server'
DSPACE_REST_SSL: false DSPACE_REST_SSL: false
before_install: before_install:
@@ -60,7 +60,7 @@ after_script:
# Shutdown docker after everything runs # Shutdown docker after everything runs
- docker-compose -f ./docker/docker-compose-travis.yml down - docker-compose -f ./docker/docker-compose-travis.yml down
# After a successful build and test (see 'script'), send code coverage reports to coveralls.io # After a successful build and test (see 'script'), send code coverage reports to codecov.io
# These code coverage reports are generated by the coveralls node module in our package.json # These code coverage reports are generated by the codecov node module in our package.json
after_success: after_success:
- cat coverage/dspace-angular/lcov.info | ./node_modules/coveralls/bin/coveralls.js - codecov

View File

@@ -1,4 +1,4 @@
[![Build Status](https://travis-ci.com/DSpace/dspace-angular.svg?branch=main)](https://travis-ci.com/DSpace/dspace-angular) [![Coverage Status](https://coveralls.io/repos/github/DSpace/dspace-angular/badge.svg?branch=main)](https://coveralls.io/github/DSpace/dspace-angular?branch=main) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) [![Build Status](https://travis-ci.com/DSpace/dspace-angular.svg?branch=main)](https://travis-ci.com/DSpace/dspace-angular) [![Coverage Status](https://codecov.io/gh/DSpace/dspace-angular/branch/main/graph/badge.svg)](https://codecov.io/gh/DSpace/dspace-angular) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal)
dspace-angular dspace-angular
============== ==============

View File

@@ -13,6 +13,6 @@ export const environment = {
host: 'localhost', host: 'localhost',
port: 8080, port: 8080,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server/api' nameSpace: '/server'
} }
}; };

View File

@@ -42,7 +42,7 @@ export const environment = {
host: 'dspace7.4science.cloud', host: 'dspace7.4science.cloud',
port: 443, port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server/api' nameSpace: '/server'
} }
}; };
``` ```
@@ -52,7 +52,7 @@ Alternately you can set the following environment variables. If any of these are
DSPACE_REST_SSL=true DSPACE_REST_SSL=true
DSPACE_REST_HOST=dspace7.4science.cloud DSPACE_REST_HOST=dspace7.4science.cloud
DSPACE_REST_PORT=443 DSPACE_REST_PORT=443
DSPACE_REST_NAMESPACE=/server/api DSPACE_REST_NAMESPACE=/server
``` ```
## Supporting analytics services other than Google Analytics ## Supporting analytics services other than Google Analytics
@@ -63,3 +63,70 @@ Angulartics can be configured to work with a number of other services besides Go
In order to start using one of these services, select it from the [Angulartics Providers page](https://angulartics.github.io/angulartics2/#providers), and follow the instructions on how to configure it. In order to start using one of these services, select it from the [Angulartics Providers page](https://angulartics.github.io/angulartics2/#providers), and follow the instructions on how to configure it.
The Google Analytics script was added in [`main.browser.ts`](https://github.com/DSpace/dspace-angular/blob/ff04760f4af91ac3e7add5e7424a46cb2439e874/src/main.browser.ts#L33) instead of the `<head>` tag in `index.html` to ensure events get sent when the page is shown in a client's browser, and not when it's rendered on the universal server. Likely you'll want to do the same when adding a new service. The Google Analytics script was added in [`main.browser.ts`](https://github.com/DSpace/dspace-angular/blob/ff04760f4af91ac3e7add5e7424a46cb2439e874/src/main.browser.ts#L33) instead of the `<head>` tag in `index.html` to ensure events get sent when the page is shown in a client's browser, and not when it's rendered on the universal server. Likely you'll want to do the same when adding a new service.
## SEO when hosting REST Api and UI on different servers
Indexers such as Google Scholar require that files are hosted on the same domain as the page that links them. In DSpace 7, Bitstreams are served from the REST server. So if you use different servers for the REST api and the UI you'll want to ensure that Bitstream downloads are proxied through the UI server.
In order to achieve this we'll need to do two things:
- **Proxy the Bitstream downloads through the UI server.** You'll need to put a webserver such as httpd or nginx in front of the UI server in order to achieve this. [Below](#apache-http-server-config) you'll find a section explaining how to do it in httpd.
- **Update the URLs for Bitstream downloads to match the UI server.** This can be done using a setting in the UI environment file.
### UI config
If you set the property `rewriteDownloadUrls` to `true` in your `environment.prod.ts` file, the [origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin) of any download URL will be replaced by the origin of the UI. This will also happen for the `citation_pdf_url` `<meta>` tag on Item pages.
The app will determine the UI origin currently in use, so the external UI URL doesn't need to be configured anywhere and rewrites will still work if you host the UI from multiple domains.
### Apache HTTP Server config
#### Basics
In order to be able to host bitstreams from the UI Server you'll need to enable mod_proxy and add the following to the httpd config of your UI server:
```
ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "http://rest.api/server/api/core/bitstreams/$1/content"
ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "http://rest.api/server/api/core/bitstreams/$1/content"
```
Replace http://rest.api in with the correct origin for your REST server.
The `ProxyPassMatch` line forwards all requests matching the regular expression for a bitstream download URL to the corresponding path on the REST server
The `ProxyPassReverse` ensures that if the REST server were to return redirect response, httpd would also swap out its hostname for the hostname of the UI before forwarding the response to the client.
#### Using HTTPS
If your REST server uses https, you'll need to enable mod_ssl and ensure `SSLProxyEngine on` is part of your UI server's httpd config as well
If the UI hostname doesn't match the CN in the SSL certificate of the REST server (which is likely if they're on different domains), you'll also need to add the following lines
```
SSLProxyCheckPeerCN off
SSLProxyCheckPeerName off
```
These are two names for [the same directive](https://httpd.apache.org/docs/trunk/mod/mod_ssl.html#sslproxycheckpeername) that have been used for various versions of httpd, old versions need the former, then some in-between versions need both, and newer versions only need the latter. Keeping them both doesn't harm anything.
So the entire config becomes:
```
SSLProxyEngine on
SSLProxyCheckPeerCN off
SSLProxyCheckPeerName off
ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content"
ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content"
```
If you don't want httpd to verify the certificate of the REST server, you can also turn all checks off with the following config:
```
SSLProxyEngine on
SSLProxyVerify none
SSLProxyCheckPeerCN off
SSLProxyCheckPeerName off
SSLProxyCheckPeerExpire off
ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content"
ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content"
```

View File

@@ -97,6 +97,7 @@
"json5": "^2.1.0", "json5": "^2.1.0",
"jsonschema": "1.2.2", "jsonschema": "1.2.2",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"klaro": "^0.6.3",
"moment": "^2.22.1", "moment": "^2.22.1",
"morgan": "^1.9.1", "morgan": "^1.9.1",
"ng-mocks": "^8.1.0", "ng-mocks": "^8.1.0",
@@ -136,10 +137,10 @@
"@types/js-cookie": "2.1.0", "@types/js-cookie": "2.1.0",
"@types/lodash": "^4.14.110", "@types/lodash": "^4.14.110",
"@types/node": "11.15.3", "@types/node": "11.15.3",
"codecov": "^3.7.2",
"codelyzer": "^5.0.0", "codelyzer": "^5.0.0",
"compression-webpack-plugin": "^3.0.1", "compression-webpack-plugin": "^3.0.1",
"copy-webpack-plugin": "^5.1.1", "copy-webpack-plugin": "^5.1.1",
"coveralls": "^3.0.0",
"css-loader": "3.4.0", "css-loader": "3.4.0",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",

View File

@@ -57,8 +57,8 @@ function generateEnvironmentFile(file: GlobalConfig): void {
// TODO remove workaround in beta 5 // TODO remove workaround in beta 5
if (file.rest.nameSpace.match("(.*)/api/?$") !== null) { if (file.rest.nameSpace.match("(.*)/api/?$") !== null) {
const newValue = getNameSpace(file.rest.nameSpace); file.rest.nameSpace = getNameSpace(file.rest.nameSpace);
console.log(colors.white.bgMagenta.bold(`The rest.nameSpace property in your environment file or in your DSPACE_REST_NAMESPACE environment variable ends with '/api'.\nThis is deprecated. As '/api' isn't configurable on the rest side, it shouldn't be repeated in every environment file.\nPlease change the rest nameSpace to '${newValue}'`)); console.log(colors.white.bgMagenta.bold(`The rest.nameSpace property in your environment file or in your DSPACE_REST_NAMESPACE environment variable ends with '/api'.\nThis is deprecated. As '/api' isn't configurable on the rest side, it shouldn't be repeated in every environment file.\nPlease change the rest nameSpace to '${file.rest.nameSpace}'`));
} }
const contents = `export const environment = ` + JSON.stringify(file); const contents = `export const environment = ` + JSON.stringify(file);

View File

@@ -15,7 +15,6 @@
* import for `ngExpressEngine`. * import for `ngExpressEngine`.
*/ */
import 'zone.js/dist/zone-node';
import 'reflect-metadata'; import 'reflect-metadata';
import 'rxjs'; import 'rxjs';
@@ -34,6 +33,7 @@ import { enableProdMode, NgModuleFactory, Type } from '@angular/core';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { environment } from './src/environments/environment'; import { environment } from './src/environments/environment';
import { createProxyMiddleware } from 'http-proxy-middleware'; import { createProxyMiddleware } from 'http-proxy-middleware';
import { hasValue, hasNoValue } from './src/app/shared/empty.util';
/* /*
* Set path for the browser application's dist folder * Set path for the browser application's dist folder
@@ -99,7 +99,6 @@ app.engine('html', (_, options, callback) =>
/* /*
* Register the view engines for html and ejs * Register the view engines for html and ejs
*/ */
app.set('view engine', 'ejs');
app.set('view engine', 'html'); app.set('view engine', 'html');
/* /*
@@ -131,56 +130,31 @@ app.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false }));
* The callback function to serve server side angular * The callback function to serve server side angular
*/ */
function ngApp(req, res) { function ngApp(req, res) {
// Object to be set to window.dspace when CSR is used
// this allows us to pass the info in the original request
// to the dspace7-angular instance running in the client's browser
const dspace = {
originalRequest: {
headers: req.headers,
body: req.body,
method: req.method,
params: req.params,
reportProgress: req.reportProgress,
withCredentials: req.withCredentials,
responseType: req.responseType,
urlWithParams: req.urlWithParams
}
};
// callback function for the case when SSR throws an error.
function onHandleError(parentZoneDelegate, currentZone, targetZone, error) {
if (!res._headerSent) {
console.warn('Error in SSR, serving for direct CSR. Error details : ', error);
res.sendFile('index.csr.ejs', {
root: DIST_FOLDER,
scripts: `<script>window.dspace = ${JSON.stringify(dspace)}</script>`
});
}
}
if (environment.universal.preboot) { if (environment.universal.preboot) {
// If preboot is enabled, create a new zone for SSR, and res.render(DIST_FOLDER + '/index.html', {
// register the error handler for when it throws an error req,
Zone.current.fork({ name: 'CSR fallback', onHandleError }).run(() => { res,
res.render(DIST_FOLDER + '/index.html', { preboot: environment.universal.preboot,
req, async: environment.universal.async,
res, time: environment.universal.time,
preboot: environment.universal.preboot, baseUrl: environment.ui.nameSpace,
async: environment.universal.async, originUrl: environment.ui.baseUrl,
time: environment.universal.time, requestUrl: req.originalUrl
baseUrl: environment.ui.nameSpace, }, (err, data) => {
originUrl: environment.ui.baseUrl, if (hasNoValue(err) && hasValue(data)) {
requestUrl: req.originalUrl res.send(data);
}); } else {
}); console.warn('Error in SSR, serving for direct CSR.');
if (hasValue(err)) {
console.warn('Error details : ', err);
}
res.sendFile(DIST_FOLDER + '/index.html');
}
})
} else { } else {
// If preboot is disabled, just serve the client side ejs template and pass it the required // If preboot is disabled, just serve the client
// variables
console.log('Universal off, serving for direct CSR'); console.log('Universal off, serving for direct CSR');
res.render('index-csr.ejs', { res.sendFile(DIST_FOLDER + '/index.html');
root: DIST_FOLDER,
scripts: `<script>window.dspace = ${JSON.stringify(dspace)}</script>`
});
} }
} }

View File

@@ -4,7 +4,7 @@
<h2 id="header" class="border-bottom pb-2">{{labelPrefix + 'head' | translate}}</h2> <h2 id="header" class="border-bottom pb-2">{{labelPrefix + 'head' | translate}}</h2>
<ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="forceUpdateEPeople()" <ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="reset()"
(cancelForm)="isEPersonFormShown = false"></ds-eperson-form> (cancelForm)="isEPersonFormShown = false"></ds-eperson-form>
<div *ngIf="!isEPersonFormShown"> <div *ngIf="!isEPersonFormShown">
@@ -40,10 +40,10 @@
</form> </form>
<ds-pagination <ds-pagination
*ngIf="(ePeople | async)?.payload?.totalElements > 0" *ngIf="(ePeopleDto$ | async)?.totalElements > 0"
[paginationOptions]="config" [paginationOptions]="config"
[pageInfoState]="(ePeople | async)?.payload" [pageInfoState]="pageInfoState$"
[collectionSize]="(ePeople | async)?.payload?.totalElements" [collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true" [hideGear]="true"
[hidePagerWhenSinglePage]="true" [hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)"> (pageChange)="onPageChange($event)">
@@ -59,21 +59,21 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let eperson of (ePeople | async)?.payload?.page" <tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
[ngClass]="{'table-primary' : isActive(eperson) | async}"> [ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
<td>{{eperson.id}}</td> <td>{{epersonDto.eperson.id}}</td>
<td>{{eperson.name}}</td> <td>{{epersonDto.eperson.name}}</td>
<td>{{eperson.email}}</td> <td>{{epersonDto.eperson.email}}</td>
<td> <td>
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button (click)="toggleEditEPerson(eperson)" <button class="delete-button" (click)="toggleEditEPerson(epersonDto.eperson)"
class="btn btn-outline-primary btn-sm access-control-editEPersonButton" class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: eperson.name} }}"> title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: epersonDto.eperson.name} }}">
<i class="fas fa-edit fa-fw"></i> <i class="fas fa-edit fa-fw"></i>
</button> </button>
<button (click)="deleteEPerson(eperson)" <button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton" class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: eperson.name} }}"> title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: epersonDto.eperson.name} }}">
<i class="fas fa-trash-alt fa-fw"></i> <i class="fas fa-trash-alt fa-fw"></i>
</button> </button>
</div> </div>
@@ -85,7 +85,7 @@
</ds-pagination> </ds-pagination>
<div *ngIf="(ePeople | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert"> <div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
{{labelPrefix + 'no-items' | translate}} {{labelPrefix + 'no-items' | translate}}
</div> </div>
</div> </div>

View File

@@ -24,6 +24,8 @@ import { getMockTranslateService } from '../../../shared/mocks/translate.service
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { RouterStub } from '../../../shared/testing/router.stub'; import { RouterStub } from '../../../shared/testing/router.stub';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { RequestService } from '../../../core/data/request.service';
describe('EPeopleRegistryComponent', () => { describe('EPeopleRegistryComponent', () => {
let component: EPeopleRegistryComponent; let component: EPeopleRegistryComponent;
@@ -33,6 +35,8 @@ describe('EPeopleRegistryComponent', () => {
let mockEPeople; let mockEPeople;
let ePersonDataServiceStub: any; let ePersonDataServiceStub: any;
let authorizationService: AuthorizationDataService;
let modalService;
beforeEach(async(() => { beforeEach(async(() => {
mockEPeople = [EPersonMock, EPersonMock2]; mockEPeople = [EPersonMock, EPersonMock2];
@@ -82,6 +86,9 @@ describe('EPeopleRegistryComponent', () => {
return '/admin/access-control/epeople'; return '/admin/access-control/epeople';
} }
}; };
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
builderService = getMockFormBuilderService(); builderService = getMockFormBuilderService();
translateService = getMockTranslateService(); translateService = getMockTranslateService();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -94,11 +101,13 @@ describe('EPeopleRegistryComponent', () => {
}), }),
], ],
declarations: [EPeopleRegistryComponent], declarations: [EPeopleRegistryComponent],
providers: [EPeopleRegistryComponent, providers: [
{ provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: FormBuilderService, useValue: builderService }, { provide: FormBuilderService, useValue: builderService },
{ provide: Router, useValue: new RouterStub() }, { provide: Router, useValue: new RouterStub() },
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();
@@ -107,12 +116,14 @@ describe('EPeopleRegistryComponent', () => {
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(EPeopleRegistryComponent); fixture = TestBed.createComponent(EPeopleRegistryComponent);
component = fixture.componentInstance; component = fixture.componentInstance;
modalService = (component as any).modalService;
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should create EPeopleRegistryComponent', inject([EPeopleRegistryComponent], (comp: EPeopleRegistryComponent) => { it('should create EPeopleRegistryComponent', () => {
expect(comp).toBeDefined(); expect(component).toBeDefined();
})); });
it('should display list of ePeople', () => { it('should display list of ePeople', () => {
const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child'));
@@ -215,4 +226,20 @@ describe('EPeopleRegistryComponent', () => {
}); });
}); });
describe('delete EPerson button when the isAuthorized returns false', () => {
let ePeopleDeleteButton;
beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(false)
});
});
it ('should be disabled', () => {
ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button'));
ePeopleDeleteButton.forEach((deleteButton) => {
expect(deleteButton.nativeElement.disabled).toBe(true);
});
})
})
}); });

View File

@@ -2,9 +2,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { Subscription } from 'rxjs/internal/Subscription'; import { Subscription } from 'rxjs/internal/Subscription';
import { map, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../core/data/paginated-list'; import { PaginatedList } from '../../../core/data/paginated-list';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
@@ -12,6 +12,16 @@ import { EPerson } from '../../../core/eperson/models/eperson.model';
import { hasValue } from '../../../shared/empty.util'; import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { EpersonDtoModel } from '../../../core/eperson/models/eperson-dto.model';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { getAllSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { ErrorResponse, RestResponse } from '../../../core/cache/response.models';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RequestService } from '../../../core/data/request.service';
import { filter } from 'rxjs/internal/operators/filter';
import { PageInfo } from '../../../core/shared/page-info.model';
@Component({ @Component({
selector: 'ds-epeople-registry', selector: 'ds-epeople-registry',
@@ -28,7 +38,17 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
/** /**
* A list of all the current EPeople within the repository or the result of the search * A list of all the current EPeople within the repository or the result of the search
*/ */
ePeople: Observable<RemoteData<PaginatedList<EPerson>>>; ePeople$: BehaviorSubject<RemoteData<PaginatedList<EPerson>>> = new BehaviorSubject<RemoteData<PaginatedList<EPerson>>>({} as any);
/**
* A BehaviorSubject with the list of EpersonDtoModel objects made from the EPeople in the repository or
* as the result of the search
*/
ePeopleDto$: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject<PaginatedList<EpersonDtoModel>>({} as any);
/**
* An observable for the pageInfo, needed to pass to the pagination component
*/
pageInfoState$: BehaviorSubject<PageInfo> = new BehaviorSubject<PageInfo>(undefined);
/** /**
* Pagination config used to display the list of epeople * Pagination config used to display the list of epeople
@@ -59,8 +79,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
constructor(private epersonService: EPersonDataService, constructor(private epersonService: EPersonDataService,
private translateService: TranslateService, private translateService: TranslateService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private authorizationService: AuthorizationDataService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private router: Router) { private router: Router,
private modalService: NgbModal,
public requestService: RequestService) {
this.currentSearchQuery = ''; this.currentSearchQuery = '';
this.currentSearchScope = 'metadata'; this.currentSearchScope = 'metadata';
this.searchForm = this.formBuilder.group(({ this.searchForm = this.formBuilder.group(({
@@ -70,6 +93,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
} }
ngOnInit() { ngOnInit() {
this.initialisePage();
}
/**
* This method will initialise the page
*/
initialisePage() {
this.isEPersonFormShown = false; this.isEPersonFormShown = false;
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
@@ -84,18 +114,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
* @param event * @param event
*/ */
onPageChange(event) { onPageChange(event) {
this.config.currentPage = event; if (this.config.currentPage !== event) {
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }) this.config.currentPage = event;
} this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
}
/**
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
* a new REST call
*/
public forceUpdateEPeople() {
this.epersonService.clearEPersonRequests();
this.isEPersonFormShown = false;
this.search({ query: '', scope: 'metadata' })
} }
/** /**
@@ -115,10 +137,33 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.currentSearchScope = scope; this.currentSearchScope = scope;
this.config.currentPage = 1; this.config.currentPage = 1;
} }
this.ePeople = this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { this.subs.push(this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
currentPage: this.config.currentPage, currentPage: this.config.currentPage,
elementsPerPage: this.config.pageSize elementsPerPage: this.config.pageSize
}); }).subscribe((peopleRD) => {
this.ePeople$.next(peopleRD)
}
));
this.subs.push(this.ePeople$.pipe(
getAllSucceededRemoteDataPayload(),
switchMap((epeople) => {
return combineLatest(...epeople.page.map((eperson) => {
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
map((authorized) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
epersonDtoModel.ableToDelete = authorized;
epersonDtoModel.eperson = eperson;
return epersonDtoModel;
})
);
})).pipe(map((dtos: EpersonDtoModel[]) => {
return new PaginatedList(epeople.pageInfo, dtos);
}))
})).subscribe((value) => {
this.ePeopleDto$.next(value);
this.pageInfoState$.next(value.pageInfo);
}));
} }
/** /**
@@ -160,16 +205,26 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
*/ */
deleteEPerson(ePerson: EPerson) { deleteEPerson(ePerson: EPerson) {
if (hasValue(ePerson.id)) { if (hasValue(ePerson.id)) {
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => { const modalRef = this.modalService.open(ConfirmationModalComponent);
if (success) { modalRef.componentInstance.dso = ePerson;
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name })); modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
this.forceUpdateEPeople(); modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
} else { modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name })); modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
} modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
this.epersonService.cancelEditEPerson(); if (confirm) {
this.isEPersonFormShown = false; if (hasValue(ePerson.id)) {
}) this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((restResponse: RestResponse) => {
if (restResponse.isSuccessful) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
this.reset();
} else {
const errorResponse = restResponse as ErrorResponse;
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + errorResponse.statusCode + ' and message: ' + errorResponse.errorMessage);
}
})
}}
});
} }
} }
@@ -177,6 +232,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
* Unsub all subscriptions * Unsub all subscriptions
*/ */
ngOnDestroy(): void { ngOnDestroy(): void {
this.cleanupSubscribes();
}
cleanupSubscribes() {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
} }
@@ -199,4 +258,18 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
}); });
this.search({ query: '' }); this.search({ query: '' });
} }
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getBrowseEndpoint().pipe(
switchMap((href) => this.requestService.removeByHrefSubstring(href)),
filter((isCached) => isCached),
take(1)
).subscribe(() => {
this.cleanupSubscribes();
this.initialisePage();
});
}
} }

View File

@@ -17,7 +17,7 @@
<button class="btn btn-light" [disabled]="!(canReset$ | async)"> <button class="btn btn-light" [disabled]="!(canReset$ | async)">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}} <i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button> </button>
<button class="btn btn-light" [disabled]="!(canDelete$ | async)"> <button class="btn btn-light delete-button" [disabled]="!(canDelete$ | async)" (click)="delete()">
<i class="fa fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}} <i class="fa fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
</button> </button>
<button *ngIf="!isImpersonated" class="btn btn-light" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()"> <button *ngIf="!isImpersonated" class="btn btn-light" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()">

View File

@@ -1,34 +1,25 @@
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
import { RestResponse } from '../../../../core/cache/response.models'; import { RestResponse } from '../../../../core/cache/response.models';
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
import { PaginatedList } from '../../../../core/data/paginated-list'; import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { FindListOptions } from '../../../../core/data/request.models'; import { FindListOptions } from '../../../../core/data/request.models';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { EPerson } from '../../../../core/eperson/models/eperson.model'; import { EPerson } from '../../../../core/eperson/models/eperson.model';
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
import { PageInfo } from '../../../../core/shared/page-info.model'; import { PageInfo } from '../../../../core/shared/page-info.model';
import { UUIDService } from '../../../../core/shared/uuid.service';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { EPeopleRegistryComponent } from '../epeople-registry.component';
import { EPersonFormComponent } from './eperson-form.component'; import { EPersonFormComponent } from './eperson-form.component';
import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock'; import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock'; import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
import { AuthService } from '../../../../core/auth/auth.service'; import { AuthService } from '../../../../core/auth/auth.service';
@@ -36,11 +27,11 @@ import { AuthServiceStub } from '../../../../shared/testing/auth-service.stub';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { createPaginatedList } from '../../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../../shared/testing/utils.test';
import { RequestService } from '../../../../core/data/request.service';
describe('EPersonFormComponent', () => { describe('EPersonFormComponent', () => {
let component: EPersonFormComponent; let component: EPersonFormComponent;
let fixture: ComponentFixture<EPersonFormComponent>; let fixture: ComponentFixture<EPersonFormComponent>;
let translateService: TranslateService;
let builderService: FormBuilderService; let builderService: FormBuilderService;
let mockEPeople; let mockEPeople;
@@ -111,7 +102,6 @@ describe('EPersonFormComponent', () => {
} }
}; };
builderService = getMockFormBuilderService(); builderService = getMockFormBuilderService();
translateService = getMockTranslateService();
authService = new AuthServiceStub(); authService = new AuthServiceStub();
authorizationService = jasmine.createSpyObj('authorizationService', { authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true) isAuthorized: observableOf(true)
@@ -129,22 +119,15 @@ describe('EPersonFormComponent', () => {
} }
}), }),
], ],
declarations: [EPeopleRegistryComponent, EPersonFormComponent], declarations: [EPersonFormComponent],
providers: [EPersonFormComponent, providers: [
{ provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: GroupDataService, useValue: groupsDataService },
{ provide: FormBuilderService, useValue: builderService }, { provide: FormBuilderService, useValue: builderService },
{ provide: DSOChangeAnalyzer, useValue: {} }, { provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: HttpClient, useValue: {} },
{ provide: ObjectCacheService, useValue: {} },
{ provide: UUIDService, useValue: {} },
{ provide: Store, useValue: {} },
{ provide: RemoteDataBuildService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
{ provide: AuthService, useValue: authService }, { provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
{ provide: GroupDataService, useValue: groupsDataService }, { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}
EPeopleRegistryComponent
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents(); }).compileComponents();
@@ -156,9 +139,9 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should create EPersonFormComponent', inject([EPersonFormComponent], (comp: EPersonFormComponent) => { it('should create EPersonFormComponent', () => {
expect(comp).toBeDefined(); expect(component).toBeDefined();
})); });
describe('when submitting the form', () => { describe('when submitting the form', () => {
let firstName; let firstName;
@@ -283,4 +266,53 @@ describe('EPersonFormComponent', () => {
}); });
}); });
describe('delete', () => {
let ePersonId;
let eperson: EPerson;
let modalService;
beforeEach(() => {
spyOn(authService, 'impersonate').and.callThrough();
ePersonId = 'testEPersonId';
eperson = EPersonMock;
component.epersonInitial = eperson;
component.canDelete$ = observableOf(true);
spyOn(component.epersonService, 'getActiveEPerson').and.returnValue(observableOf(eperson));
modalService = (component as any).modalService;
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
fixture.detectChanges()
});
it ('the delete button should be active if the eperson can be deleted', () => {
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(false);
});
it ('the delete button should be disabled if the eperson cannot be deleted', () => {
component.canDelete$ = observableOf(false);
fixture.detectChanges()
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(true);
});
it ('should call the epersonFormComponent delete when clicked on the button' , () => {
spyOn(component, 'delete').and.stub();
spyOn(component.epersonService, 'deleteEPerson').and.returnValue(observableOf(new RestResponse(true, 204, 'No Content')));
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
deleteButton.triggerEventHandler('click', null);
expect(component.delete).toHaveBeenCalled();
});
it ('should call the epersonService delete when clicked on the button' , () => {
// ePersonDataServiceStub.activeEPerson = eperson;
spyOn(component.epersonService, 'deleteEPerson').and.returnValue(observableOf(new RestResponse(true, 204, 'No Content')));
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(false);
deleteButton.triggerEventHandler('click', null);
fixture.detectChanges()
expect(component.epersonService.deleteEPerson).toHaveBeenCalledWith(eperson);
});
})
}); });

View File

@@ -25,6 +25,9 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
import { AuthService } from '../../../../core/auth/auth.service'; import { AuthService } from '../../../../core/auth/auth.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';
import { ConfirmationModalComponent } from '../../../../shared/confirmation-modal/confirmation-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RequestService } from '../../../../core/data/request.service';
@Component({ @Component({
selector: 'ds-eperson-form', selector: 'ds-eperson-form',
@@ -116,9 +119,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
/** /**
* Observable whether or not the admin is allowed to delete the EPerson * Observable whether or not the admin is allowed to delete the EPerson
* TODO: Initialize the observable once the REST API supports this (currently hardcoded to return false)
*/ */
canDelete$: Observable<boolean> = of(false); canDelete$: Observable<boolean>;
/** /**
* Observable whether or not the admin is allowed to impersonate the EPerson * Observable whether or not the admin is allowed to impersonate the EPerson
@@ -160,7 +162,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
private translateService: TranslateService, private translateService: TranslateService,
private notificationsService: NotificationsService, private notificationsService: NotificationsService,
private authService: AuthService, private authService: AuthService,
private authorizationService: AuthorizationDataService) { private authorizationService: AuthorizationDataService,
private modalService: NgbModal,
public requestService: RequestService) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.epersonInitial = eperson; this.epersonInitial = eperson;
if (hasValue(eperson)) { if (hasValue(eperson)) {
@@ -170,13 +174,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
} }
ngOnInit() { ngOnInit() {
this.initialisePage();
}
/**
* This method will initialise the page
*/
initialisePage() {
combineLatest( combineLatest(
this.translateService.get(`${this.messagePrefix}.firstName`), this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`), this.translateService.get(`${this.messagePrefix}.lastName`),
this.translateService.get(`${this.messagePrefix}.email`), this.translateService.get(`${this.messagePrefix}.email`),
this.translateService.get(`${this.messagePrefix}.canLogIn`), this.translateService.get(`${this.messagePrefix}.canLogIn`),
this.translateService.get(`${this.messagePrefix}.requireCertificate`), this.translateService.get(`${this.messagePrefix}.requireCertificate`),
this.translateService.get(`${this.messagePrefix}.emailHint`), this.translateService.get(`${this.messagePrefix}.emailHint`),
).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => { ).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => {
this.firstName = new DynamicInputModel({ this.firstName = new DynamicInputModel({
id: 'firstName', id: 'firstName',
@@ -208,19 +219,19 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
hint: emailHint hint: emailHint
}); });
this.canLogIn = new DynamicCheckboxModel( this.canLogIn = new DynamicCheckboxModel(
{ {
id: 'canLogIn', id: 'canLogIn',
label: canLogIn, label: canLogIn,
name: 'canLogIn', name: 'canLogIn',
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true) value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true)
}); });
this.requireCertificate = new DynamicCheckboxModel( this.requireCertificate = new DynamicCheckboxModel(
{ {
id: 'requireCertificate', id: 'requireCertificate',
label: requireCertificate, label: requireCertificate,
name: 'requireCertificate', name: 'requireCertificate',
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false) value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false)
}); });
this.formModel = [ this.formModel = [
this.firstName, this.firstName,
this.lastName, this.lastName,
@@ -245,7 +256,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}); });
})); }));
this.canImpersonate$ = this.epersonService.getActiveEPerson().pipe( this.canImpersonate$ = this.epersonService.getActiveEPerson().pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined)) switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined))
);
this.canDelete$ = this.epersonService.getActiveEPerson().pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
); );
}); });
} }
@@ -405,6 +419,35 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.isImpersonated = true; this.isImpersonated = true;
} }
/**
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
* It'll either show a success or error message depending on whether the delete was successful or not.
*/
delete() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = eperson;
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
if (confirm) {
if (hasValue(eperson.id)) {
this.epersonService.deleteEPerson(eperson).pipe(take(1)).subscribe((restResponse: RestResponse) => {
if (restResponse.isSuccessful) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
this.reset();
} else {
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.statusText);
}
this.cancelForm.emit();
})
}}
});
})
}
/** /**
* Stop impersonating the EPerson * Stop impersonating the EPerson
*/ */
@@ -420,4 +463,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.onCancel(); this.onCancel();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
} }
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
this.requestService.removeByHrefSubstring(eperson.self);
});
this.initialisePage();
}
} }

View File

@@ -18,6 +18,7 @@ import { WorkflowItemSearchResult } from '../../../../../shared/object-collectio
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock'; import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock';
import { of as observableOf } from 'rxjs';
describe('WorkflowItemAdminWorkflowGridElementComponent', () => { describe('WorkflowItemAdminWorkflowGridElementComponent', () => {
let component: WorkflowItemSearchResultAdminWorkflowGridElementComponent; let component: WorkflowItemSearchResultAdminWorkflowGridElementComponent;
@@ -50,7 +51,9 @@ describe('WorkflowItemAdminWorkflowGridElementComponent', () => {
], ],
providers: [ providers: [
{ provide: LinkService, useValue: linkService }, { provide: LinkService, useValue: linkService },
{ provide: TruncatableService, useValue: {} }, { provide: TruncatableService, useValue: {
isCollapsed: () => observableOf(true),
} },
{ provide: BitstreamDataService, useValue: {} }, { provide: BitstreamDataService, useValue: {} },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]

View File

@@ -6,6 +6,7 @@ import { find } from 'rxjs/operators';
import { hasValue } from '../shared/empty.util'; import { hasValue } from '../shared/empty.util';
import { Bitstream } from '../core/shared/bitstream.model'; import { Bitstream } from '../core/shared/bitstream.model';
import { BitstreamDataService } from '../core/data/bitstream-data.service'; import { BitstreamDataService } from '../core/data/bitstream-data.service';
import {followLink, FollowLinkConfig} from '../shared/utils/follow-link-config.model';
/** /**
* This class represents a resolver that requests a specific bitstream before the route is activated * This class represents a resolver that requests a specific bitstream before the route is activated
@@ -23,9 +24,20 @@ export class BitstreamPageResolver implements Resolve<RemoteData<Bitstream>> {
* or an error if something went wrong * or an error if something went wrong
*/ */
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Bitstream>> { resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Bitstream>> {
return this.bitstreamService.findById(route.params.id) return this.bitstreamService.findById(route.params.id, ...this.followLinks)
.pipe( .pipe(
find((RD) => hasValue(RD.error) || RD.hasSucceeded), find((RD) => hasValue(RD.error) || RD.hasSucceeded),
); );
} }
/**
* Method that returns the follow links to already resolve
* The self links defined in this list are expected to be requested somewhere in the near future
* Requesting them as embeds will limit the number of requests
*/
get followLinks(): Array<FollowLinkConfig<Bitstream>> {
return [
followLink('bundle', undefined, true, followLink('item')),
followLink('format')
];
}
} }

View File

@@ -4,7 +4,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { ActivatedRoute } from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core'; import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { BitstreamDataService } from '../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../core/data/bitstream-data.service';
@@ -22,6 +22,11 @@ import { PageInfo } from '../../core/shared/page-info.model';
import { FileSizePipe } from '../../shared/utils/file-size-pipe'; import { FileSizePipe } from '../../shared/utils/file-size-pipe';
import { RestResponse } from '../../core/cache/response.models'; import { RestResponse } from '../../core/cache/response.models';
import { VarDirective } from '../../shared/utils/var.directive'; import { VarDirective } from '../../shared/utils/var.directive';
import {
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import {RouterStub} from '../../shared/testing/router.stub';
import { getItemEditRoute } from '../../+item-page/item-page-routing-paths';
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
@@ -34,6 +39,8 @@ let bitstreamFormatService: BitstreamFormatDataService;
let bitstream: Bitstream; let bitstream: Bitstream;
let selectedFormat: BitstreamFormat; let selectedFormat: BitstreamFormat;
let allFormats: BitstreamFormat[]; let allFormats: BitstreamFormat[];
let router: Router;
let routerStub;
describe('EditBitstreamPageComponent', () => { describe('EditBitstreamPageComponent', () => {
let comp: EditBitstreamPageComponent; let comp: EditBitstreamPageComponent;
@@ -105,7 +112,12 @@ describe('EditBitstreamPageComponent', () => {
format: observableOf(new RemoteData(false, false, true, null, selectedFormat)), format: observableOf(new RemoteData(false, false, true, null, selectedFormat)),
_links: { _links: {
self: 'bitstream-selflink' self: 'bitstream-selflink'
} },
bundle: createSuccessfulRemoteDataObject$({
item: createSuccessfulRemoteDataObject$({
uuid: 'some-uuid'
})
})
}); });
bitstreamService = jasmine.createSpyObj('bitstreamService', { bitstreamService = jasmine.createSpyObj('bitstreamService', {
findById: observableOf(new RemoteData(false, false, true, null, bitstream)), findById: observableOf(new RemoteData(false, false, true, null, bitstream)),
@@ -118,6 +130,10 @@ describe('EditBitstreamPageComponent', () => {
findAll: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), allFormats))) findAll: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), 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],
@@ -127,6 +143,7 @@ describe('EditBitstreamPageComponent', () => {
{ provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: new RemoteData(false, false, true, null, bitstream) }), snapshot: { queryParams: {} } } }, { provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: new RemoteData(false, false, true, null, 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]
@@ -138,6 +155,7 @@ 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;
}); });
describe('on startup', () => { describe('on startup', () => {
@@ -213,4 +231,25 @@ describe('EditBitstreamPageComponent', () => {
}); });
}); });
}); });
describe('when the cancel button is clicked', () => {
it('should call navigateToItemEditBitstreams method', () => {
spyOn(comp, 'navigateToItemEditBitstreams');
comp.onCancel();
expect(comp.navigateToItemEditBitstreams).toHaveBeenCalled();
});
});
describe('when navigateToItemEditBitstreams is called, and the component has an itemId', () => {
it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => {
comp.itemId = 'some-uuid1'
comp.navigateToItemEditBitstreams();
expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditRoute('some-uuid1'), 'bitstreams']);
});
});
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 ', () => {
comp.itemId = undefined;
comp.navigateToItemEditBitstreams();
expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditRoute('some-uuid'), 'bitstreams']);
});
});
}); });

View File

@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { Bitstream } from '../../core/shared/bitstream.model'; import { Bitstream } from '../../core/shared/bitstream.model';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { filter, map, switchMap } from 'rxjs/operators'; import { map, mergeMap, switchMap} from 'rxjs/operators';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { Subscription } from 'rxjs/internal/Subscription'; import { Subscription } from 'rxjs/internal/Subscription';
import { import {
@@ -19,7 +19,7 @@ import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-f
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { BitstreamDataService } from '../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import { import {
getAllSucceededRemoteData, getAllSucceededRemoteDataPayload, getAllSucceededRemoteDataPayload,
getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload,
getRemoteDataPayload, getRemoteDataPayload,
getSucceededRemoteData getSucceededRemoteData
@@ -35,8 +35,9 @@ import { Location } from '@angular/common';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list'; import { PaginatedList } from '../../core/data/paginated-list';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { getItemEditRoute } from '../../+item-page/item-page-routing-paths'; import { getItemEditRoute } from '../../+item-page/item-page-routing-paths';
import {Bundle} from '../../core/shared/bundle.model';
import {Item} from '../../core/shared/item.model';
@Component({ @Component({
selector: 'ds-edit-bitstream-page', selector: 'ds-edit-bitstream-page',
@@ -299,12 +300,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
const bitstream$ = this.bitstreamRD$.pipe( const bitstream$ = this.bitstreamRD$.pipe(
getSucceededRemoteData(), getSucceededRemoteData(),
getRemoteDataPayload(), getRemoteDataPayload()
switchMap((bitstream: Bitstream) => this.bitstreamService.findById(bitstream.id, followLink('format')).pipe(
getAllSucceededRemoteData(),
getRemoteDataPayload(),
filter((bs: Bitstream) => hasValue(bs)))
)
); );
const allFormats$ = this.bitstreamFormatsRD$.pipe( const allFormats$ = this.bitstreamFormatsRD$.pipe(
@@ -501,14 +497,18 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
} }
/** /**
* When the item ID is present, navigate back to the item's edit bitstreams page, otherwise go back to the previous * When the item ID is present, navigate back to the item's edit bitstreams page,
* page the user came from * otherwise retrieve the item ID based on the owning bundle's link
*/ */
navigateToItemEditBitstreams() { navigateToItemEditBitstreams() {
if (hasValue(this.itemId)) { if (hasValue(this.itemId)) {
this.router.navigate([getItemEditRoute(this.itemId), 'bitstreams']); this.router.navigate([getItemEditRoute(this.itemId), 'bitstreams']);
} else { } else {
this.location.back(); this.bitstream.bundle.pipe(getFirstSucceededRemoteDataPayload(),
mergeMap((bundle: Bundle) => bundle.item.pipe(getFirstSucceededRemoteDataPayload(), map((item: Item) => item.uuid))))
.subscribe((item) => {
this.router.navigate(([getItemEditRoute(item), 'bitstreams']));
});
} }
} }

View File

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

View File

@@ -19,6 +19,9 @@ import {
COLLECTION_EDIT_PATH, COLLECTION_EDIT_PATH,
COLLECTION_CREATE_PATH COLLECTION_CREATE_PATH
} from './collection-page-routing-paths'; } from './collection-page-routing-paths';
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -39,7 +42,7 @@ import {
{ {
path: COLLECTION_EDIT_PATH, path: COLLECTION_EDIT_PATH,
loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule', loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule',
canActivate: [AuthenticatedGuard] canActivate: [CollectionPageAdministratorGuard]
}, },
{ {
path: 'delete', path: 'delete',
@@ -68,7 +71,21 @@ import {
pathMatch: 'full', pathMatch: 'full',
canActivate: [AuthenticatedGuard] canActivate: [AuthenticatedGuard]
} }
] ],
data: {
menu: {
public: [{
id: 'statistics_collection_:id',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/collections/:id/',
} as LinkMenuItemModel,
}],
},
},
}, },
]) ])
], ],
@@ -78,7 +95,8 @@ import {
CollectionBreadcrumbResolver, CollectionBreadcrumbResolver,
DSOBreadcrumbsService, DSOBreadcrumbsService,
LinkService, LinkService,
CreateCollectionPageGuard CreateCollectionPageGuard,
CollectionPageAdministratorGuard
] ]
}) })
export class CollectionPageRoutingModule { export class CollectionPageRoutingModule {

View File

@@ -17,7 +17,7 @@ import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
import { Item } from '../core/shared/item.model'; import { Item } from '../core/shared/item.model';
import { import {
getSucceededRemoteData, getSucceededRemoteData,
redirectToPageNotFoundOn404, redirectOn404Or401,
toDSpaceObjectListRD toDSpaceObjectListRD
} from '../core/shared/operators'; } from '../core/shared/operators';
@@ -63,7 +63,7 @@ export class CollectionPageComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.collectionRD$ = this.route.data.pipe( this.collectionRD$ = this.route.data.pipe(
map((data) => data.dso as RemoteData<Collection>), map((data) => data.dso as RemoteData<Collection>),
redirectToPageNotFoundOn404(this.router), redirectOn404Or401(this.router),
take(1) take(1)
); );
this.logoRD$ = this.collectionRD$.pipe( this.logoRD$ = this.collectionRD$.pipe(

View File

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

View File

@@ -11,6 +11,9 @@ import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-bread
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service'; import { LinkService } from '../core/cache/builders/link.service';
import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths'; import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths';
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -31,7 +34,7 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
{ {
path: COMMUNITY_EDIT_PATH, path: COMMUNITY_EDIT_PATH,
loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule', loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule',
canActivate: [AuthenticatedGuard] canActivate: [CommunityPageAdministratorGuard]
}, },
{ {
path: 'delete', path: 'delete',
@@ -44,7 +47,21 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
component: CommunityPageComponent, component: CommunityPageComponent,
pathMatch: 'full', pathMatch: 'full',
} }
] ],
data: {
menu: {
public: [{
id: 'statistics_community_:id',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/communities/:id/',
} as LinkMenuItemModel,
}],
},
},
}, },
]) ])
], ],
@@ -53,7 +70,8 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
CommunityBreadcrumbResolver, CommunityBreadcrumbResolver,
DSOBreadcrumbsService, DSOBreadcrumbsService,
LinkService, LinkService,
CreateCommunityPageGuard CreateCommunityPageGuard,
CommunityPageAdministratorGuard
] ]
}) })
export class CommunityPageRoutingModule { export class CommunityPageRoutingModule {

View File

@@ -13,7 +13,7 @@ import { MetadataService } from '../core/metadata/metadata.service';
import { fadeInOut } from '../shared/animations/fade'; import { fadeInOut } from '../shared/animations/fade';
import { hasValue } from '../shared/empty.util'; import { hasValue } from '../shared/empty.util';
import { redirectToPageNotFoundOn404 } from '../core/shared/operators'; import { redirectOn404Or401 } from '../core/shared/operators';
@Component({ @Component({
selector: 'ds-community-page', selector: 'ds-community-page',
@@ -47,7 +47,7 @@ export class CommunityPageComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.communityRD$ = this.route.data.pipe( this.communityRD$ = this.route.data.pipe(
map((data) => data.dso as RemoteData<Community>), map((data) => data.dso as RemoteData<Community>),
redirectToPageNotFoundOn404(this.router) redirectOn404Or401(this.router)
); );
this.logoRD$ = this.communityRD$.pipe( this.logoRD$ = this.communityRD$.pipe(
map((rd: RemoteData<Community>) => rd.payload), map((rd: RemoteData<Community>) => rd.payload),

View File

@@ -3,6 +3,8 @@ import { RouterModule } from '@angular/router';
import { HomePageComponent } from './home-page.component'; import { HomePageComponent } from './home-page.component';
import { HomePageResolver } from './home-page.resolver'; import { HomePageResolver } from './home-page.resolver';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -11,7 +13,21 @@ import { HomePageResolver } from './home-page.resolver';
path: '', path: '',
component: HomePageComponent, component: HomePageComponent,
pathMatch: 'full', pathMatch: 'full',
data: {title: 'home.title'}, data: {
title: 'home.title',
menu: {
public: [{
id: 'statistics_site',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics',
} as LinkMenuItemModel,
}],
},
},
resolve: { resolve: {
site: HomePageResolver site: HomePageResolver
} }

View File

@@ -123,7 +123,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
/** /**
* Check if the current page is entirely valid * Check if the current page is entirely valid
*/ */
protected isValid() { public isValid() {
return this.objectUpdatesService.isValidPage(this.url); return this.objectUpdatesService.isValidPage(this.url);
} }

View File

@@ -29,6 +29,8 @@ import {
ITEM_EDIT_REINSTATE_PATH, ITEM_EDIT_REINSTATE_PATH,
ITEM_EDIT_WITHDRAW_PATH ITEM_EDIT_WITHDRAW_PATH
} from './edit-item-page.routing-paths'; } from './edit-item-page.routing-paths';
import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
/** /**
* Routing module that handles the routing for the Edit Item page administrator functionality * Routing module that handles the routing for the Edit Item page administrator functionality
@@ -98,10 +100,12 @@ import {
{ {
path: ITEM_EDIT_WITHDRAW_PATH, path: ITEM_EDIT_WITHDRAW_PATH,
component: ItemWithdrawComponent, component: ItemWithdrawComponent,
canActivate: [ItemPageWithdrawGuard]
}, },
{ {
path: ITEM_EDIT_REINSTATE_PATH, path: ITEM_EDIT_REINSTATE_PATH,
component: ItemReinstateComponent, component: ItemReinstateComponent,
canActivate: [ItemPageReinstateGuard]
}, },
{ {
path: ITEM_EDIT_PRIVATE_PATH, path: ITEM_EDIT_PRIVATE_PATH,
@@ -154,7 +158,9 @@ import {
I18nBreadcrumbResolver, I18nBreadcrumbResolver,
I18nBreadcrumbsService, I18nBreadcrumbsService,
ResourcePolicyResolver, ResourcePolicyResolver,
ResourcePolicyTargetResolver ResourcePolicyTargetResolver,
ItemPageReinstateGuard,
ItemPageWithdrawGuard
] ]
}) })
export class EditItemPageRoutingModule { export class EditItemPageRoutingModule {

View File

@@ -5,14 +5,15 @@
</div> </div>
<div *ngIf="(editable | async)" class="field-container"> <div *ngIf="(editable | async)" class="field-container">
<ds-filter-input-suggestions [suggestions]="(metadataFieldSuggestions | async)" <ds-filter-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
[(ngModel)]="metadata.key" [(ngModel)]="metadata.key"
[url]="this.url"
[metadata]="this.metadata"
(submitSuggestion)="update(suggestionControl)" (submitSuggestion)="update(suggestionControl)"
(clickSuggestion)="update(suggestionControl)" (clickSuggestion)="update(suggestionControl)"
(typeSuggestion)="update(suggestionControl)" (typeSuggestion)="update(suggestionControl)"
(dsClickOutside)="checkValidity(suggestionControl)" (dsClickOutside)="checkValidity(suggestionControl)"
(findSuggestions)="findMetadataFieldSuggestions($event)" (findSuggestions)="findMetadataFieldSuggestions($event)"
#suggestionControl="ngModel" #suggestionControl="ngModel"
[dsInListValidator]="metadataFields"
[valid]="(valid | async) !== false" [valid]="(valid | async) !== false"
dsAutoFocus autoFocusSelector=".suggestion_input" dsAutoFocus autoFocusSelector=".suggestion_input"
[ngModelOptions]="{standalone: true}" [ngModelOptions]="{standalone: true}"
@@ -46,12 +47,12 @@
</td> </td>
<td class="text-center"> <td class="text-center">
<div class="btn-group edit-field"> <div class="btn-group edit-field">
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)" <button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)"
(click)="setEditable(true)" class="btn btn-outline-primary btn-sm" (click)="setEditable(true)" class="btn btn-outline-primary btn-sm"
title="{{'item.edit.metadata.edit.buttons.edit' | translate}}"> title="{{'item.edit.metadata.edit.buttons.edit' | translate}}">
<i class="fas fa-edit fa-fw"></i> <i class="fas fa-edit fa-fw"></i>
</button> </button>
<button [disabled]="!(canSetUneditable() | async)" *ngIf="(editable | async)" <button [disabled]="!(canSetUneditable() | async) || (valid | async) === false" *ngIf="(editable | async)"
(click)="setEditable(false)" class="btn btn-outline-success btn-sm" (click)="setEditable(false)" class="btn btn-outline-success btn-sm"
title="{{'item.edit.metadata.edit.buttons.unedit' | translate}}"> title="{{'item.edit.metadata.edit.buttons.unedit' | translate}}">
<i class="fas fa-check fa-fw"></i> <i class="fas fa-check fa-fw"></i>

View File

@@ -1,11 +1,12 @@
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { getTestScheduler } from 'jasmine-marbles'; import { getTestScheduler } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { MetadataFieldDataService } from '../../../../core/data/metadata-field-data.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 { PaginatedList } from '../../../../core/data/paginated-list'; import { PaginatedList } from '../../../../core/data/paginated-list';
@@ -14,9 +15,14 @@ import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'
import { RegistryService } from '../../../../core/registry/registry.service'; import { RegistryService } from '../../../../core/registry/registry.service';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { SharedModule } from '../../../../shared/shared.module'; import {
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; createSuccessfulRemoteDataObject$
} from '../../../../shared/remote-data.utils';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { EditInPlaceFieldComponent } from './edit-in-place-field.component'; import { EditInPlaceFieldComponent } from './edit-in-place-field.component';
import { FilterInputSuggestionsComponent } from '../../../../shared/input-suggestions/filter-suggestions/filter-input-suggestions.component';
import { MockComponent, MockDirective } from 'ng-mocks';
import { DebounceDirective } from '../../../../shared/utils/debounce.directive';
let comp: EditInPlaceFieldComponent; let comp: EditInPlaceFieldComponent;
let fixture: ComponentFixture<EditInPlaceFieldComponent>; let fixture: ComponentFixture<EditInPlaceFieldComponent>;
@@ -25,17 +31,21 @@ let el: HTMLElement;
let metadataFieldService; let metadataFieldService;
let objectUpdatesService; let objectUpdatesService;
let paginatedMetadataFields; let paginatedMetadataFields;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' }) const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
const mdSchemaRD$ = createSuccessfulRemoteDataObject$(mdSchema);
const mdField1 = Object.assign(new MetadataField(), { const mdField1 = Object.assign(new MetadataField(), {
schema: mdSchema, schema: mdSchemaRD$,
element: 'contributor', element: 'contributor',
qualifier: 'author' qualifier: 'author'
}); });
const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' }); const mdField2 = Object.assign(new MetadataField(), {
schema: mdSchemaRD$,
element: 'title'
});
const mdField3 = Object.assign(new MetadataField(), { const mdField3 = Object.assign(new MetadataField(), {
schema: mdSchema, schema: mdSchemaRD$,
element: 'description', element: 'description',
qualifier: 'abstract' qualifier: 'abstract',
}); });
const metadatum = Object.assign(new MetadatumViewModel(), { const metadatum = Object.assign(new MetadatumViewModel(), {
@@ -74,11 +84,16 @@ describe('EditInPlaceFieldComponent', () => {
); );
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [FormsModule, SharedModule, TranslateModule.forRoot()], imports: [FormsModule, TranslateModule.forRoot()],
declarations: [EditInPlaceFieldComponent], declarations: [
EditInPlaceFieldComponent,
MockDirective(DebounceDirective),
MockComponent(FilterInputSuggestionsComponent)
],
providers: [ providers: [
{ provide: RegistryService, useValue: metadataFieldService }, { provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService }, { provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: MetadataFieldDataService, useValue: {} }
], schemas: [ ], schemas: [
CUSTOM_ELEMENTS_SCHEMA CUSTOM_ELEMENTS_SCHEMA
] ]
@@ -94,13 +109,12 @@ describe('EditInPlaceFieldComponent', () => {
comp.url = url; comp.url = url;
comp.fieldUpdate = fieldUpdate; comp.fieldUpdate = fieldUpdate;
comp.metadata = metadatum; comp.metadata = metadatum;
fixture.detectChanges();
}); });
describe('update', () => { describe('update', () => {
beforeEach(() => { beforeEach(() => {
comp.update(); comp.update();
fixture.detectChanges();
}); });
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
@@ -112,6 +126,7 @@ describe('EditInPlaceFieldComponent', () => {
const editable = false; const editable = false;
beforeEach(() => { beforeEach(() => {
comp.setEditable(editable); comp.setEditable(editable);
fixture.detectChanges();
}); });
it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => { it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => {
@@ -121,7 +136,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('editable is true', () => { describe('editable is true', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(true); objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should contain input fields or textareas', () => { it('the div should contain input fields or textareas', () => {
@@ -133,7 +148,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('editable is false', () => { describe('editable is false', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(false); objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should contain no input fields or textareas', () => { it('the div should contain no input fields or textareas', () => {
@@ -145,7 +160,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('isValid is true', () => { describe('isValid is true', () => {
beforeEach(() => { beforeEach(() => {
comp.valid = observableOf(true); objectUpdatesService.isValid.and.returnValue(observableOf(true));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should not contain an error message', () => { it('the div should not contain an error message', () => {
@@ -157,10 +172,10 @@ describe('EditInPlaceFieldComponent', () => {
describe('isValid is false', () => { describe('isValid is false', () => {
beforeEach(() => { beforeEach(() => {
comp.valid = observableOf(false); objectUpdatesService.isValid.and.returnValue(observableOf(false));
fixture.detectChanges(); fixture.detectChanges();
}); });
it('the div should contain no input fields or textareas', () => { it('there should be an error message', () => {
const errorMessages = de.queryAll(By.css('small.text-danger')); const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBeGreaterThan(0); expect(errorMessages.length).toBeGreaterThan(0);
@@ -170,6 +185,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('remove', () => { describe('remove', () => {
beforeEach(() => { beforeEach(() => {
comp.remove(); comp.remove();
fixture.detectChanges();
}); });
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => { it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
@@ -180,6 +196,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('removeChangesFromField', () => { describe('removeChangesFromField', () => {
beforeEach(() => { beforeEach(() => {
comp.removeChangesFromField(); comp.removeChangesFromField();
fixture.detectChanges();
}); });
it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => { it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => {
@@ -192,19 +209,19 @@ describe('EditInPlaceFieldComponent', () => {
const metadataFieldSuggestions: InputSuggestion[] = const metadataFieldSuggestions: InputSuggestion[] =
[ [
{ displayValue: mdField1.toString().split('.').join('.&#8203;'), value: mdField1.toString() }, { displayValue: ('dc.' + mdField1.toString()).split('.').join('.&#8203;'), value: ('dc.' + mdField1.toString()) },
{ displayValue: mdField2.toString().split('.').join('.&#8203;'), value: mdField2.toString() }, { displayValue: ('dc.' + mdField2.toString()).split('.').join('.&#8203;'), value: ('dc.' + mdField2.toString()) },
{ displayValue: mdField3.toString().split('.').join('.&#8203;'), value: mdField3.toString() } { displayValue: ('dc.' + mdField3.toString()).split('.').join('.&#8203;'), value: ('dc.' + mdField3.toString()) }
]; ];
beforeEach(() => { beforeEach(fakeAsync(() => {
comp.findMetadataFieldSuggestions(query); comp.findMetadataFieldSuggestions(query);
tick();
}); fixture.detectChanges();
}));
it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => { it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => {
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query, null, followLink('schema'));
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query);
}); });
it('it should set metadataFieldSuggestions to the right value', () => { it('it should set metadataFieldSuggestions to the right value', () => {
@@ -216,7 +233,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('canSetEditable', () => { describe('canSetEditable', () => {
describe('when editable is currently true', () => { describe('when editable is currently true', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(true); objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
}); });
it('canSetEditable should return an observable emitting false', () => { it('canSetEditable should return an observable emitting false', () => {
@@ -227,12 +245,14 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently false', () => { describe('when editable is currently false', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(false); objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
}); });
describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => { describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => {
beforeEach(() => { beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD; comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
}); });
it('canSetEditable should return an observable emitting true', () => { it('canSetEditable should return an observable emitting true', () => {
const expected = '(a|)'; const expected = '(a|)';
@@ -243,6 +263,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => { describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
beforeEach(() => { beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.REMOVE; comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
fixture.detectChanges();
}); });
it('canSetEditable should return an observable emitting false', () => { it('canSetEditable should return an observable emitting false', () => {
const expected = '(a|)'; const expected = '(a|)';
@@ -255,7 +276,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('canSetUneditable', () => { describe('canSetUneditable', () => {
describe('when editable is currently true', () => { describe('when editable is currently true', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(true); objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
}); });
it('canSetUneditable should return an observable emitting true', () => { it('canSetUneditable should return an observable emitting true', () => {
@@ -266,7 +288,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently false', () => { describe('when editable is currently false', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(false); objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
}); });
it('canSetUneditable should return an observable emitting false', () => { it('canSetUneditable should return an observable emitting false', () => {
@@ -278,7 +301,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetEditable emits true', () => { describe('when canSetEditable emits true', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(false); objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true)); spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true));
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -290,7 +313,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetEditable emits false', () => { describe('when canSetEditable emits false', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(false); objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false)); spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false));
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -302,7 +325,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetUneditable emits true', () => { describe('when canSetUneditable emits true', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(true); objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true)); spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true));
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -314,7 +337,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetUneditable emits false', () => { describe('when canSetUneditable emits false', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(true); objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false)); spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false));
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -372,6 +395,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => { describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => {
beforeEach(() => { beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.UPDATE; comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
fixture.detectChanges();
}); });
it('canRemove should return an observable emitting true', () => { it('canRemove should return an observable emitting true', () => {
const expected = '(a|)'; const expected = '(a|)';
@@ -382,6 +406,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently ADD', () => { describe('when the fieldUpdate\'s changeType is currently ADD', () => {
beforeEach(() => { beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD; comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
}); });
it('canRemove should return an observable emitting false', () => { it('canRemove should return an observable emitting false', () => {
const expected = '(a|)'; const expected = '(a|)';
@@ -394,7 +419,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently true', () => { describe('when editable is currently true', () => {
beforeEach(() => { beforeEach(() => {
comp.editable = observableOf(true); objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = undefined; comp.fieldUpdate.changeType = undefined;
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -408,6 +433,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => { describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => {
beforeEach(() => { beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD; comp.fieldUpdate.changeType = FieldChangeType.ADD;
fixture.detectChanges();
}); });
it('canUndo should return an observable emitting true', () => { it('canUndo should return an observable emitting true', () => {
@@ -419,6 +445,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently undefined', () => { describe('when the fieldUpdate\'s changeType is currently undefined', () => {
beforeEach(() => { beforeEach(() => {
comp.fieldUpdate.changeType = undefined; comp.fieldUpdate.changeType = undefined;
fixture.detectChanges();
}); });
it('canUndo should return an observable emitting false', () => { it('canUndo should return an observable emitting false', () => {

View File

@@ -1,4 +1,5 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core'; import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { metadataFieldsToString } from '../../../../core/shared/operators';
import { hasValue, isNotEmpty } from '../../../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { RegistryService } from '../../../../core/registry/registry.service'; import { RegistryService } from '../../../../core/registry/registry.service';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
@@ -9,8 +10,8 @@ import { FieldUpdate } from '../../../../core/data/object-updates/object-updates
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { NgModel } from '@angular/forms'; import { NgModel } from '@angular/forms';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { MetadataField } from '../../../../core/metadata/metadata-field.model';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { followLink } from '../../../../shared/utils/follow-link-config.model';
@Component({ @Component({
// tslint:disable-next-line:component-selector // tslint:disable-next-line:component-selector
@@ -32,15 +33,10 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
*/ */
@Input() url: string; @Input() url: string;
/**
* List of strings with all metadata field keys available
*/
@Input() metadataFields: string[];
/** /**
* The metadatum of this field * The metadatum of this field
*/ */
metadata: MetadatumViewModel; @Input() metadata: MetadatumViewModel;
/** /**
* Emits whether or not this field is currently editable * Emits whether or not this field is currently editable
@@ -126,27 +122,34 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
* Ignores fields from metadata schemas "relation" and "relationship" * Ignores fields from metadata schemas "relation" and "relationship"
* @param query The query to look for * @param query The query to look for
*/ */
findMetadataFieldSuggestions(query: string): void { findMetadataFieldSuggestions(query: string) {
if (isNotEmpty(query)) { if (isNotEmpty(query)) {
this.registryService.queryMetadataFields(query).pipe( return this.registryService.queryMetadataFields(query, null, followLink('schema')).pipe(
// getSucceededRemoteData(), metadataFieldsToString(),
take(1), take(1))
map((data) => data.payload.page) .subscribe((fieldNames: string[]) => {
).subscribe( this.setInputSuggestions(fieldNames);
(fields: MetadataField[]) => this.metadataFieldSuggestions.next( })
fields.map((field: MetadataField) => {
return {
displayValue: field.toString().split('.').join('.&#8203;'),
value: field.toString()
};
})
)
);
} else { } else {
this.metadataFieldSuggestions.next([]); this.metadataFieldSuggestions.next([]);
} }
} }
/**
* Set the list of input suggestion with the given Metadata fields, which all require a resolved MetadataSchema
* @param fields list of Metadata fields, which all require a resolved MetadataSchema
*/
setInputSuggestions(fields: string[]) {
this.metadataFieldSuggestions.next(
fields.map((fieldName: string) => {
return {
displayValue: fieldName.split('.').join('.&#8203;'),
value: fieldName
};
})
);
}
/** /**
* Check if a user should be allowed to edit this field * Check if a user should be allowed to edit this field
* @return an observable that emits true when the user should be able to edit this field and false when they should not * @return an observable that emits true when the user should be able to edit this field and false when they should not

View File

@@ -16,7 +16,7 @@
class="fas fa-undo-alt"></i> class="fas fa-undo-alt"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.reinstate-button" | translate}}</span>
</button> </button>
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)" <button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !(isValid() | async)"
(click)="submit()"><i (click)="submit()"><i
class="fas fa-save"></i> class="fas fa-save"></i>
<span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span> <span class="d-none d-sm-inline">&nbsp;{{"item.edit.metadata.save-button" | translate}}</span>
@@ -33,7 +33,6 @@
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate" <tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
ds-edit-in-place-field ds-edit-in-place-field
[fieldUpdate]="updateValue || {}" [fieldUpdate]="updateValue || {}"
[metadataFields]="metadataFields$ | async"
[url]="url" [url]="url"
[ngClass]="{ [ngClass]="{
'table-warning': updateValue.changeType === 0, 'table-warning': updateValue.changeType === 0,

View File

@@ -22,13 +22,14 @@ import { FieldChangeType } from '../../../core/data/object-updates/object-update
import { MetadatumViewModel } from '../../../core/shared/metadata.models'; import { MetadatumViewModel } from '../../../core/shared/metadata.models';
import { RegistryService } from '../../../core/registry/registry.service'; import { RegistryService } from '../../../core/registry/registry.service';
import { PaginatedList } from '../../../core/data/paginated-list'; import { PaginatedList } from '../../../core/data/paginated-list';
import { Metadata } from '../../../core/shared/metadata.utils';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model'; import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { MetadataField } from '../../../core/metadata/metadata-field.model'; import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { import {
createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$ createSuccessfulRemoteDataObject$
} from '../../../shared/remote-data.utils'; } from '../../../shared/remote-data.utils';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { DSOSuccessResponse } from '../../../core/cache/response.models';
let comp: any; let comp: any;
let fixture: ComponentFixture<ItemMetadataComponent>; let fixture: ComponentFixture<ItemMetadataComponent>;
@@ -43,6 +44,7 @@ const router = new RouterStub();
let metadataFieldService; let metadataFieldService;
let paginatedMetadataFields; let paginatedMetadataFields;
let routeStub; let routeStub;
let objectCacheService;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' }); const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
const mdField1 = Object.assign(new MetadataField(), { const mdField1 = Object.assign(new MetadataField(), {
@@ -101,6 +103,8 @@ const fieldUpdate3 = {
changeType: undefined changeType: undefined
}; };
const operation1 = { op: 'remove', path: '/metadata/dc.title/1' };
let scheduler: TestScheduler; let scheduler: TestScheduler;
let item; let item;
describe('ItemMetadataComponent', () => { describe('ItemMetadataComponent', () => {
@@ -119,7 +123,9 @@ describe('ItemMetadataComponent', () => {
; ;
itemService = jasmine.createSpyObj('itemService', { itemService = jasmine.createSpyObj('itemService', {
update: createSuccessfulRemoteDataObject$(item), update: createSuccessfulRemoteDataObject$(item),
commitUpdates: {} commitUpdates: {},
patch: observableOf(new DSOSuccessResponse(['item-selflink'], 200, 'OK')),
findByHref: createSuccessfulRemoteDataObject$(item)
}); });
routeStub = { routeStub = {
data: observableOf({}), data: observableOf({}),
@@ -148,9 +154,13 @@ describe('ItemMetadataComponent', () => {
getLastModified: observableOf(date), getLastModified: observableOf(date),
hasUpdates: observableOf(true), hasUpdates: observableOf(true),
isReinstatable: observableOf(false), // should always return something --> its in ngOnInit isReinstatable: observableOf(false), // should always return something --> its in ngOnInit
isValidPage: observableOf(true) isValidPage: observableOf(true),
createPatch: observableOf([
operation1
])
} }
); );
objectCacheService = jasmine.createSpyObj('objectCacheService', ['addPatch']);
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()], imports: [SharedModule, TranslateModule.forRoot()],
@@ -162,6 +172,7 @@ describe('ItemMetadataComponent', () => {
{ provide: ActivatedRoute, useValue: routeStub }, { provide: ActivatedRoute, useValue: routeStub },
{ provide: NotificationsService, useValue: notificationsService }, { provide: NotificationsService, useValue: notificationsService },
{ provide: RegistryService, useValue: metadataFieldService }, { provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectCacheService, useValue: objectCacheService },
], schemas: [ ], schemas: [
NO_ERRORS_SCHEMA NO_ERRORS_SCHEMA
] ]
@@ -215,8 +226,8 @@ describe('ItemMetadataComponent', () => {
}); });
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => { it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadataAsList); expect(objectUpdatesService.createPatch).toHaveBeenCalledWith(url);
expect(itemService.update).toHaveBeenCalledWith(Object.assign(comp.item, { metadata: Metadata.toMetadataMap(comp.item.metadataAsList) })); expect(itemService.patch).toHaveBeenCalledWith(comp.item, [ operation1 ]);
expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList); expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList);
}); });
}); });

View File

@@ -4,21 +4,19 @@ import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs'; import { first, switchMap, tap } from 'rxjs/operators';
import { Identifiable } from '../../../core/data/object-updates/object-updates.reducer';
import { first, map, switchMap, take, tap } from 'rxjs/operators';
import { getSucceededRemoteData } from '../../../core/shared/operators'; import { getSucceededRemoteData } from '../../../core/shared/operators';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
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 { RegistryService } from '../../../core/registry/registry.service';
import { MetadataValue, MetadatumViewModel } from '../../../core/shared/metadata.models'; import { MetadataValue, MetadatumViewModel } from '../../../core/shared/metadata.models';
import { Metadata } from '../../../core/shared/metadata.utils';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { UpdateDataService } from '../../../core/data/update-data.service'; import { UpdateDataService } from '../../../core/data/update-data.service';
import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
import { AlertType } from '../../../shared/alert/aletr-type'; import { AlertType } from '../../../shared/alert/aletr-type';
import { Operation } from 'fast-json-patch';
import { METADATA_PATCH_OPERATION_SERVICE_TOKEN } from '../../../core/data/object-updates/patch-operation-service/metadata-patch-operation.service';
import { DSOSuccessResponse, ErrorResponse } from '../../../core/cache/response.models';
@Component({ @Component({
selector: 'ds-item-metadata', selector: 'ds-item-metadata',
@@ -42,11 +40,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
*/ */
@Input() updateService: UpdateDataService<Item>; @Input() updateService: UpdateDataService<Item>;
/**
* Observable with a list of strings with all existing metadata field keys
*/
metadataFields$: Observable<string[]>;
constructor( constructor(
public itemService: ItemDataService, public itemService: ItemDataService,
public objectUpdatesService: ObjectUpdatesService, public objectUpdatesService: ObjectUpdatesService,
@@ -54,7 +47,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
public notificationsService: NotificationsService, public notificationsService: NotificationsService,
public translateService: TranslateService, public translateService: TranslateService,
public route: ActivatedRoute, public route: ActivatedRoute,
public metadataFieldService: RegistryService,
) { ) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, route); super(itemService, objectUpdatesService, router, notificationsService, translateService, route);
} }
@@ -64,7 +56,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
*/ */
ngOnInit(): void { ngOnInit(): void {
super.ngOnInit(); super.ngOnInit();
this.metadataFields$ = this.findMetadataFields();
if (hasNoValue(this.updateService)) { if (hasNoValue(this.updateService)) {
this.updateService = this.itemService; this.updateService = this.itemService;
} }
@@ -96,7 +87,7 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
* Sends all initial values of this item to the object updates service * Sends all initial values of this item to the object updates service
*/ */
public initializeOriginalFields() { public initializeOriginalFields() {
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified); this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified, METADATA_PATCH_OPERATION_SERVICE_TOKEN);
} }
/** /**
@@ -106,15 +97,23 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
public submit() { public submit() {
this.isValid().pipe(first()).subscribe((isValid) => { this.isValid().pipe(first()).subscribe((isValid) => {
if (isValid) { if (isValid) {
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>; this.objectUpdatesService.createPatch(this.url).pipe(
metadata$.pipe(
first(), first(),
switchMap((metadata: MetadatumViewModel[]) => { switchMap((patch: Operation[]) => {
const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata: Metadata.toMetadataMap(metadata) }); return this.updateService.patch(this.item, patch).pipe(
return this.updateService.update(updatedItem); tap((response) => {
}), if (!response.isSuccessful) {
tap(() => this.updateService.commitUpdates()), this.notificationsService.error(this.getNotificationTitle('error'), (response as ErrorResponse).errorMessage);
getSucceededRemoteData() }
}),
switchMap((response: DSOSuccessResponse) => {
if (isNotEmpty(response.resourceSelfLinks)) {
return this.itemService.findByHref(response.resourceSelfLinks[0]);
}
}),
getSucceededRemoteData()
);
})
).subscribe( ).subscribe(
(rd: RemoteData<Item>) => { (rd: RemoteData<Item>) => {
this.item = rd.payload; this.item = rd.payload;
@@ -130,16 +129,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
}); });
} }
/**
* Method to request all metadata fields and convert them to a list of strings
*/
findMetadataFields(): Observable<string[]> {
return this.metadataFieldService.getAllMetadataFields().pipe(
getSucceededRemoteData(),
take(1),
map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
}
/** /**
* Check for empty metadata UUIDs and fix them (empty UUIDs would break the object-update service) * Check for empty metadata UUIDs and fix them (empty UUIDs would break the object-update service)
*/ */

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import { By } from '@angular/platform-browser';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
describe('ItemStatusComponent', () => { describe('ItemStatusComponent', () => {
let comp: ItemStatusComponent; let comp: ItemStatusComponent;
@@ -20,7 +21,10 @@ describe('ItemStatusComponent', () => {
const mockItem = Object.assign(new Item(), { const mockItem = Object.assign(new Item(), {
id: 'fake-id', id: 'fake-id',
handle: 'fake/handle', handle: 'fake/handle',
lastModified: '2018' lastModified: '2018',
_links: {
self: { href: 'test-item-selflink' }
}
}); });
const itemPageUrl = `items/${mockItem.id}`; const itemPageUrl = `items/${mockItem.id}`;
@@ -31,13 +35,20 @@ describe('ItemStatusComponent', () => {
} }
}; };
let authorizationService: AuthorizationDataService;
beforeEach(async(() => { beforeEach(async(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemStatusComponent], declarations: [ItemStatusComponent],
providers: [ providers: [
{ provide: ActivatedRoute, useValue: routeStub }, { provide: ActivatedRoute, useValue: routeStub },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) } { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: AuthorizationDataService, useValue: authorizationService },
], schemas: [CUSTOM_ELEMENTS_SCHEMA] ], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -3,15 +3,19 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model'; import { ItemOperation } from '../item-operation/itemOperation.model';
import { first, map } from 'rxjs/operators'; import { distinctUntilChanged, first, map } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { hasValue } from '../../../shared/empty.util';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
@Component({ @Component({
selector: 'ds-item-status', selector: 'ds-item-status',
templateUrl: './item-status.component.html', templateUrl: './item-status.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.Default,
animations: [ animations: [
fadeIn, fadeIn,
fadeInOut fadeInOut
@@ -40,14 +44,15 @@ export class ItemStatusComponent implements OnInit {
* The possible actions that can be performed on the item * The possible actions that can be performed on the item
* key: id value: url to action's component * key: id value: url to action's component
*/ */
operations: ItemOperation[]; operations$: BehaviorSubject<ItemOperation[]> = new BehaviorSubject<ItemOperation[]>([]);
/** /**
* The keys of the actions (to loop over) * The keys of the actions (to loop over)
*/ */
actionsKeys; actionsKeys;
constructor(private route: ActivatedRoute) { constructor(private route: ActivatedRoute,
private authorizationService: AuthorizationDataService) {
} }
ngOnInit(): void { ngOnInit(): void {
@@ -67,21 +72,43 @@ export class ItemStatusComponent implements OnInit {
i18n example: 'item.edit.tabs.status.buttons.<key>.label' i18n example: 'item.edit.tabs.status.buttons.<key>.label'
The value is supposed to be a href for the button The value is supposed to be a href for the button
*/ */
this.operations = []; const operations = [];
this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations')); operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
if (item.isWithdrawn) { operations.push(undefined);
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); // Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously
} else { const indexOfWithdrawReinstate = operations.length - 1;
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'));
}
if (item.isDiscoverable) { if (item.isDiscoverable) {
this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
} else { } else {
this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
}
operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
this.operations$.next(operations);
if (item.isWithdrawn) {
this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
const newOperations = [...this.operations$.value];
if (authorized) {
newOperations[indexOfWithdrawReinstate] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate');
} else {
newOperations[indexOfWithdrawReinstate] = undefined;
}
this.operations$.next(newOperations);
});
} else {
this.authorizationService.isAuthorized(FeatureID.WithdrawItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
const newOperations = [...this.operations$.value];
if (authorized) {
newOperations[indexOfWithdrawReinstate] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw');
} else {
newOperations[indexOfWithdrawReinstate] = undefined;
}
this.operations$.next(newOperations);
});
} }
this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
this.operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
}); });
} }
@@ -102,4 +129,8 @@ export class ItemStatusComponent implements OnInit {
return getItemEditRoute(item.id); return getItemEditRoute(item.id);
} }
trackOperation(index: number, operation: ItemOperation) {
return hasValue(operation) ? operation.operationKey : undefined;
}
} }

View File

@@ -1,87 +1,87 @@
<ds-metadata-field-wrapper [label]="label | translate"> <ds-metadata-field-wrapper [label]="label | translate">
<div *ngVar="(originals$ | async)?.payload as originals"> <div *ngVar="(originals$ | async)?.payload as originals">
<h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5> <div *ngIf="hasValuesInBundle(originals)">
<ds-pagination *ngIf="originals?.page?.length > 0" <h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5>
[hideGear]="true" <ds-pagination *ngIf="originals?.page?.length > 0"
[hidePagerWhenSinglePage]="true" [hideGear]="true"
[paginationOptions]="originalOptions" [hidePagerWhenSinglePage]="true"
[pageInfoState]="originals" [paginationOptions]="originalOptions"
[collectionSize]="originals?.totalElements" [pageInfoState]="originals"
[disableRouteParameterUpdate]="true" [collectionSize]="originals?.totalElements"
(pageChange)="switchOriginalPage($event)"> [disableRouteParameterUpdate]="true"
(pageChange)="switchOriginalPage($event)">
<div class="file-section row" *ngFor="let file of originals?.page;">
<div class="col-3">
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
</div>
<div class="col-7">
<dl class="row">
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
<dd class="col-md-8">{{file.name}}</dd>
<div class="file-section row" *ngFor="let file of originals?.page;"> <dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
<div class="col-3"> <dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
</dl>
</div>
<div class="col-2">
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>
</div> </div>
<div class="col-7"> </ds-pagination>
<dl class="row"> </div>
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
<dd class="col-md-8">{{file.name}}</dd>
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
</dl>
</div>
<div class="col-2">
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>
</div>
</ds-pagination>
</div> </div>
<div *ngVar="(licenses$ | async)?.payload as licenses"> <div *ngVar="(licenses$ | async)?.payload as licenses">
<h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5> <div *ngIf="hasValuesInBundle(licenses)">
<ds-pagination *ngIf="licenses?.page?.length > 0" <h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5>
[hideGear]="true" <ds-pagination *ngIf="licenses?.page?.length > 0"
[hidePagerWhenSinglePage]="true" [hideGear]="true"
[paginationOptions]="licenseOptions" [hidePagerWhenSinglePage]="true"
[pageInfoState]="licenses" [paginationOptions]="licenseOptions"
[collectionSize]="licenses?.totalElements" [pageInfoState]="licenses"
[disableRouteParameterUpdate]="true" [collectionSize]="licenses?.totalElements"
(pageChange)="switchLicensePage($event)"> [disableRouteParameterUpdate]="true"
(pageChange)="switchLicensePage($event)">
<div class="file-section row" *ngFor="let file of licenses?.page;">
<div class="col-3">
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
</div>
<div class="col-7">
<dl class="row">
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
<dd class="col-md-8">{{file.name}}</dd>
<div class="file-section row" *ngFor="let file of licenses?.page;"> <dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
<div class="col-3"> <dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
</dl>
</div>
<div class="col-2">
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>
</div> </div>
<div class="col-7"> </ds-pagination>
<dl class="row"> </div>
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
<dd class="col-md-8">{{file.name}}</dd>
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
</dl>
</div>
<div class="col-2">
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>
</div>
</ds-pagination>
</div> </div>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>

View File

@@ -14,6 +14,8 @@ import {Bitstream} from '../../../../core/shared/bitstream.model';
import {of as observableOf} from 'rxjs'; import {of as observableOf} from 'rxjs';
import {MockBitstreamFormat1} from '../../../../shared/mocks/item.mock'; import {MockBitstreamFormat1} from '../../../../shared/mocks/item.mock';
import {By} from '@angular/platform-browser'; import {By} from '@angular/platform-browser';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
describe('FullFileSectionComponent', () => { describe('FullFileSectionComponent', () => {
let comp: FullFileSectionComponent; let comp: FullFileSectionComponent;
@@ -61,7 +63,8 @@ describe('FullFileSectionComponent', () => {
}), BrowserAnimationsModule], }), BrowserAnimationsModule],
declarations: [FullFileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent], declarations: [FullFileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent],
providers: [ providers: [
{provide: BitstreamDataService, useValue: bitstreamDataService} {provide: BitstreamDataService, useValue: bitstreamDataService},
{provide: NotificationsService, useValue: new NotificationsServiceStub()}
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]

View File

@@ -10,6 +10,10 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
import { PaginatedList } from '../../../../core/data/paginated-list'; import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { hasValue, isEmpty } from '../../../../shared/empty.util';
import { tap } from 'rxjs/internal/operators/tap';
/** /**
* This component renders the file section of the item * This component renders the file section of the item
@@ -31,14 +35,14 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
licenses$: Observable<RemoteData<PaginatedList<Bitstream>>>; licenses$: Observable<RemoteData<PaginatedList<Bitstream>>>;
pageSize = 5; pageSize = 5;
originalOptions = Object.assign(new PaginationComponentOptions(),{ originalOptions = Object.assign(new PaginationComponentOptions(), {
id: 'original-bitstreams-options', id: 'original-bitstreams-options',
currentPage: 1, currentPage: 1,
pageSize: this.pageSize pageSize: this.pageSize
}); });
originalCurrentPage$ = new BehaviorSubject<number>(1); originalCurrentPage$ = new BehaviorSubject<number>(1);
licenseOptions = Object.assign(new PaginationComponentOptions(),{ licenseOptions = Object.assign(new PaginationComponentOptions(), {
id: 'license-bitstreams-options', id: 'license-bitstreams-options',
currentPage: 1, currentPage: 1,
pageSize: this.pageSize pageSize: this.pageSize
@@ -46,9 +50,11 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
licenseCurrentPage$ = new BehaviorSubject<number>(1); licenseCurrentPage$ = new BehaviorSubject<number>(1);
constructor( constructor(
bitstreamDataService: BitstreamDataService bitstreamDataService: BitstreamDataService,
protected notificationsService: NotificationsService,
protected translateService: TranslateService
) { ) {
super(bitstreamDataService); super(bitstreamDataService, notificationsService, translateService);
} }
ngOnInit(): void { ngOnInit(): void {
@@ -57,21 +63,33 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
initialize(): void { initialize(): void {
this.originals$ = this.originalCurrentPage$.pipe( this.originals$ = this.originalCurrentPage$.pipe(
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName( switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
this.item, this.item,
'ORIGINAL', 'ORIGINAL',
{ elementsPerPage: this.pageSize, currentPage: pageNumber }, {elementsPerPage: this.pageSize, currentPage: pageNumber},
followLink( 'format') followLink('format')
)) )),
tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(rd.error)) {
this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.error.statusCode} ${rd.error.message}`);
}
}
)
); );
this.licenses$ = this.licenseCurrentPage$.pipe( this.licenses$ = this.licenseCurrentPage$.pipe(
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName( switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
this.item, this.item,
'LICENSE', 'LICENSE',
{ elementsPerPage: this.pageSize, currentPage: pageNumber }, {elementsPerPage: this.pageSize, currentPage: pageNumber},
followLink( 'format') followLink('format')
)) )),
tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(rd.error)) {
this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.error.statusCode} ${rd.error.message}`);
}
}
)
); );
} }
@@ -93,4 +111,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
this.licenseOptions.currentPage = page; this.licenseOptions.currentPage = page;
this.licenseCurrentPage$.next(page); this.licenseCurrentPage$.next(page);
} }
hasValuesInBundle(bundle: PaginatedList<Bitstream>) {
return hasValue(bundle) && !isEmpty(bundle.page);
}
} }

View File

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

View File

@@ -10,6 +10,9 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi
import { LinkService } from '../core/cache/builders/link.service'; import { LinkService } from '../core/cache/builders/link.service';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths'; import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths';
import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -34,7 +37,7 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
{ {
path: ITEM_EDIT_PATH, path: ITEM_EDIT_PATH,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
canActivate: [AuthenticatedGuard] canActivate: [ItemPageAdministratorGuard]
}, },
{ {
path: UPLOAD_BITSTREAM_PATH, path: UPLOAD_BITSTREAM_PATH,
@@ -42,6 +45,20 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
canActivate: [AuthenticatedGuard] canActivate: [AuthenticatedGuard]
} }
], ],
data: {
menu: {
public: [{
id: 'statistics_item_:id',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/items/:id/',
} as LinkMenuItemModel,
}],
},
},
} }
]) ])
], ],
@@ -49,7 +66,8 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
ItemPageResolver, ItemPageResolver,
ItemBreadcrumbResolver, ItemBreadcrumbResolver,
DSOBreadcrumbsService, DSOBreadcrumbsService,
LinkService LinkService,
ItemPageAdministratorGuard
] ]
}) })

View File

@@ -15,6 +15,8 @@ import {FileSizePipe} from '../../../../shared/utils/file-size-pipe';
import {PageInfo} from '../../../../core/shared/page-info.model'; import {PageInfo} from '../../../../core/shared/page-info.model';
import {MetadataFieldWrapperComponent} from '../../../field-components/metadata-field-wrapper/metadata-field-wrapper.component'; import {MetadataFieldWrapperComponent} from '../../../field-components/metadata-field-wrapper/metadata-field-wrapper.component';
import {createPaginatedList} from '../../../../shared/testing/utils.test'; import {createPaginatedList} from '../../../../shared/testing/utils.test';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
describe('FileSectionComponent', () => { describe('FileSectionComponent', () => {
let comp: FileSectionComponent; let comp: FileSectionComponent;
@@ -62,7 +64,8 @@ describe('FileSectionComponent', () => {
}), BrowserAnimationsModule], }), BrowserAnimationsModule],
declarations: [FileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent], declarations: [FileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent],
providers: [ providers: [
{provide: BitstreamDataService, useValue: bitstreamDataService} {provide: BitstreamDataService, useValue: bitstreamDataService},
{provide: NotificationsService, useValue: new NotificationsServiceStub()}
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]

View File

@@ -4,10 +4,12 @@ import { BitstreamDataService } from '../../../../core/data/bitstream-data.servi
import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Bitstream } from '../../../../core/shared/bitstream.model';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { filter, takeWhile } from 'rxjs/operators'; import { filter, take } from 'rxjs/operators';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { hasNoValue, hasValue } from '../../../../shared/empty.util'; import { hasValue } from '../../../../shared/empty.util';
import { PaginatedList } from '../../../../core/data/paginated-list'; import { PaginatedList } from '../../../../core/data/paginated-list';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
/** /**
* This component renders the file section of the item * This component renders the file section of the item
@@ -36,7 +38,9 @@ export class FileSectionComponent implements OnInit {
pageSize = 5; pageSize = 5;
constructor( constructor(
protected bitstreamDataService: BitstreamDataService protected bitstreamDataService: BitstreamDataService,
protected notificationsService: NotificationsService,
protected translateService: TranslateService
) { ) {
} }
@@ -58,14 +62,21 @@ export class FileSectionComponent implements OnInit {
} else { } else {
this.currentPage++; this.currentPage++;
} }
this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: this.currentPage, elementsPerPage: this.pageSize }).pipe( this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', {
filter((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasValue(bitstreamsRD)), currentPage: this.currentPage,
takeWhile((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasNoValue(bitstreamsRD.payload) && hasNoValue(bitstreamsRD.error), true) elementsPerPage: this.pageSize
}).pipe(
filter((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasValue(bitstreamsRD) && (hasValue(bitstreamsRD.error) || hasValue(bitstreamsRD.payload))),
take(1),
).subscribe((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => { ).subscribe((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => {
const current: Bitstream[] = this.bitstreams$.getValue(); if (bitstreamsRD.error) {
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]); this.notificationsService.error(this.translateService.get('file-section.error.header'), `${bitstreamsRD.error.statusCode} ${bitstreamsRD.error.message}`);
this.isLoading = false; } else if (hasValue(bitstreamsRD.payload)) {
this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages; const current: Bitstream[] = this.bitstreams$.getValue();
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]);
this.isLoading = false;
this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages;
}
}); });
} }
} }

View File

@@ -11,7 +11,7 @@ import { Item } from '../../core/shared/item.model';
import { MetadataService } from '../../core/metadata/metadata.service'; import { MetadataService } from '../../core/metadata/metadata.service';
import { fadeInOut } from '../../shared/animations/fade'; import { fadeInOut } from '../../shared/animations/fade';
import { redirectToPageNotFoundOn404 } from '../../core/shared/operators'; import { redirectOn404Or401 } from '../../core/shared/operators';
import { ViewMode } from '../../core/shared/view-mode.model'; import { ViewMode } from '../../core/shared/view-mode.model';
/** /**
@@ -56,7 +56,7 @@ export class ItemPageComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.data.pipe( this.itemRD$ = this.route.data.pipe(
map((data) => data.item as RemoteData<Item>), map((data) => data.item as RemoteData<Item>),
redirectToPageNotFoundOn404(this.router) redirectOn404Or401(this.router)
); );
this.metadataService.processRemoteData(this.itemRD$); this.metadataService.processRemoteData(this.itemRD$);
} }

View File

@@ -55,8 +55,19 @@ export function getDSORoute(dso: DSpaceObject): string {
} }
} }
export const UNAUTHORIZED_PATH = 'unauthorized'; export const UNAUTHORIZED_PATH = '401';
export function getUnauthorizedRoute() { export function getUnauthorizedRoute() {
return `/${UNAUTHORIZED_PATH}`; return `/${UNAUTHORIZED_PATH}`;
} }
export const PAGE_NOT_FOUND_PATH = '404';
export function getPageNotFoundRoute() {
return `/${PAGE_NOT_FOUND_PATH}`;
}
export const INFO_MODULE_PATH = 'info';
export function getInfoModulePath() {
return `/${INFO_MODULE_PATH}`;
}

View File

@@ -1,5 +1,6 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AuthBlockingGuard } from './core/auth/auth-blocking.guard';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { AuthenticatedGuard } from './core/auth/authenticated.guard'; import { AuthenticatedGuard } from './core/auth/authenticated.guard';
@@ -12,55 +13,69 @@ import {
REGISTER_PATH, REGISTER_PATH,
PROFILE_MODULE_PATH, PROFILE_MODULE_PATH,
ADMIN_MODULE_PATH, ADMIN_MODULE_PATH,
BITSTREAM_MODULE_PATH BITSTREAM_MODULE_PATH,
INFO_MODULE_PATH
} from './app-routing-paths'; } from './app-routing-paths';
import { COLLECTION_MODULE_PATH } from './+collection-page/collection-page-routing-paths'; import { COLLECTION_MODULE_PATH } from './+collection-page/collection-page-routing-paths';
import { COMMUNITY_MODULE_PATH } from './+community-page/community-page-routing-paths'; import { COMMUNITY_MODULE_PATH } from './+community-page/community-page-routing-paths';
import { ITEM_MODULE_PATH } from './+item-page/item-page-routing-paths'; import { ITEM_MODULE_PATH } from './+item-page/item-page-routing-paths';
import { ReloadGuard } from './core/reload/reload.guard';
import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard';
import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forRoot([ RouterModule.forRoot([
{ path: '', redirectTo: '/home', pathMatch: 'full' }, { path: '', canActivate: [AuthBlockingGuard],
{ path: 'reload/:rnd', redirectTo: '/home', pathMatch: 'full' }, children: [
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } }, { path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, { path: 'reload/:rnd', component: PageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] },
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false }, canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' }, { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' }, { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] },
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
path: 'mydspace', { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', {
canActivate: [AuthenticatedGuard] path: 'mydspace',
}, loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
{ path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' }, canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, },
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] }, { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule', canActivate: [EndUserAgreementCurrentUserGuard] },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, EndUserAgreementCurrentUserGuard] },
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
{ { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
path: 'workspaceitems', { path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' {
}, path: 'workspaceitems',
{ loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule',
path: WORKFLOW_ITEM_MODULE_PATH, canActivate: [EndUserAgreementCurrentUserGuard]
loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' },
}, {
{ path: WORKFLOW_ITEM_MODULE_PATH,
path: PROFILE_MODULE_PATH, loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule',
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] canActivate: [EndUserAgreementCurrentUserGuard]
}, },
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] }, {
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, path: PROFILE_MODULE_PATH,
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent }, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
], },
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] },
{ path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' },
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
{
path: 'statistics',
loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule',
},
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
]}
],
{ {
onSameUrlNavigation: 'reload', onSameUrlNavigation: 'reload',
}) })

View File

@@ -1,4 +1,4 @@
<div class="outer-wrapper"> <div class="outer-wrapper" *ngIf="isNotAuthBlocking$ | async; else authLoader">
<ds-admin-sidebar></ds-admin-sidebar> <ds-admin-sidebar></ds-admin-sidebar>
<div class="inner-wrapper" [@slideSidebarPadding]="{ <div class="inner-wrapper" [@slideSidebarPadding]="{
value: (!(sidebarVisible | async) ? 'hidden' : (slideSidebarOver | async) ? 'shown' : 'expanded'), value: (!(sidebarVisible | async) ? 'hidden' : (slideSidebarOver | async) ? 'shown' : 'expanded'),
@@ -23,3 +23,8 @@
<ds-footer></ds-footer> <ds-footer></ds-footer>
</div> </div>
</div> </div>
<ng-template #authLoader>
<div class="text-center ds-full-screen-loader d-flex align-items-center flex-column justify-content-center">
<ds-loading [showMessage]="false"></ds-loading>
</div>
</ng-template>

View File

@@ -47,3 +47,7 @@ ds-admin-sidebar {
position: fixed; position: fixed;
z-index: $sidebar-z-index; z-index: $sidebar-z-index;
} }
.ds-full-screen-loader {
height: 100vh;
}

View File

@@ -1,9 +1,8 @@
import * as ngrx from '@ngrx/store';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Store, StoreModule } from '@ngrx/store'; import { Store, StoreModule } from '@ngrx/store';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
@@ -32,11 +31,11 @@ import { RouterMock } from './shared/mocks/router.mock';
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { storeModuleConfig } from './app.reducer'; import { storeModuleConfig } from './app.reducer';
import { LocaleService } from './core/locale/locale.service'; import { LocaleService } from './core/locale/locale.service';
import { authReducer } from './core/auth/auth.reducer';
import { cold } from 'jasmine-marbles';
let comp: AppComponent; let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>; let fixture: ComponentFixture<AppComponent>;
let de: DebugElement;
let el: HTMLElement;
const menuService = new MenuServiceStub(); const menuService = new MenuServiceStub();
describe('App component', () => { describe('App component', () => {
@@ -52,7 +51,7 @@ describe('App component', () => {
return TestBed.configureTestingModule({ return TestBed.configureTestingModule({
imports: [ imports: [
CommonModule, CommonModule,
StoreModule.forRoot({}, storeModuleConfig), StoreModule.forRoot(authReducer, storeModuleConfig),
TranslateModule.forRoot({ TranslateModule.forRoot({
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
@@ -82,12 +81,19 @@ describe('App component', () => {
// synchronous beforeEach // synchronous beforeEach
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(AppComponent); spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => cold('a', {
a: {
core: { auth: { loading: false } }
}
})
};
});
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance; // component test instance comp = fixture.componentInstance; // component test instance
// query for the <div class='outer-wrapper'> by CSS element selector fixture.detectChanges();
de = fixture.debugElement.query(By.css('div.outer-wrapper'));
el = de.nativeElement;
}); });
it('should create component', inject([AppComponent], (app: AppComponent) => { it('should create component', inject([AppComponent], (app: AppComponent) => {

View File

@@ -1,11 +1,11 @@
import { delay, filter, map, take } from 'rxjs/operators'; import { delay, map, distinctUntilChanged, filter, take } from 'rxjs/operators';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
HostListener, HostListener,
Inject, Inject,
OnInit, OnInit, Optional,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
@@ -19,7 +19,7 @@ import { MetadataService } from './core/metadata/metadata.service';
import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowResizeAction } from './shared/host-window.actions';
import { HostWindowState } from './shared/search/host-window.reducer'; import { HostWindowState } from './shared/search/host-window.reducer';
import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
import { isAuthenticated } from './core/auth/selectors'; import { isAuthenticationBlocking } from './core/auth/selectors';
import { AuthService } from './core/auth/auth.service'; import { AuthService } from './core/auth/auth.service';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { MenuService } from './shared/menu/menu.service'; import { MenuService } from './shared/menu/menu.service';
@@ -31,8 +31,8 @@ import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { models } from './core/core.module'; import { models } from './core/core.module';
import { LocaleService } from './core/locale/locale.service'; import { LocaleService } from './core/locale/locale.service';
import { hasValue } from './shared/empty.util';
export const LANG_COOKIE = 'language_cookie'; import { KlaroService } from './shared/cookies/klaro.service';
@Component({ @Component({
selector: 'ds-app', selector: 'ds-app',
@@ -52,6 +52,11 @@ export class AppComponent implements OnInit, AfterViewInit {
notificationOptions = environment.notifications; notificationOptions = environment.notifications;
models; models;
/**
* Whether or not the authentication is currently blocking the UI
*/
isNotAuthBlocking$: Observable<boolean>;
constructor( constructor(
@Inject(NativeWindowService) private _window: NativeWindowRef, @Inject(NativeWindowService) private _window: NativeWindowRef,
private translate: TranslateService, private translate: TranslateService,
@@ -64,8 +69,10 @@ export class AppComponent implements OnInit, AfterViewInit {
private cssService: CSSVariableService, private cssService: CSSVariableService,
private menuService: MenuService, private menuService: MenuService,
private windowService: HostWindowService, private windowService: HostWindowService,
private localeService: LocaleService private localeService: LocaleService,
@Optional() private cookiesService: KlaroService
) { ) {
/* Use models object so all decorators are actually called */ /* Use models object so all decorators are actually called */
this.models = models; this.models = models;
// Load all the languages that are defined as active from the config file // Load all the languages that are defined as active from the config file
@@ -86,19 +93,25 @@ export class AppComponent implements OnInit, AfterViewInit {
console.info(environment); console.info(environment);
} }
this.storeCSSVariables(); this.storeCSSVariables();
} }
ngOnInit() { ngOnInit() {
this.isNotAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe(
map((isBlocking: boolean) => isBlocking === false),
distinctUntilChanged()
);
this.isNotAuthBlocking$
.pipe(
filter((notBlocking: boolean) => notBlocking),
take(1)
).subscribe(() => this.initializeKlaro());
const env: string = environment.production ? 'Production' : 'Development'; const env: string = environment.production ? 'Production' : 'Development';
const color: string = environment.production ? 'red' : 'green'; const color: string = environment.production ? 'red' : 'green';
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight);
// Whether is not authenticathed try to retrieve a possible stored auth token
this.store.pipe(select(isAuthenticated),
take(1),
filter((authenticated) => !authenticated)
).subscribe((authenticated) => this.authService.checkAuthenticationToken());
this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN);
this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth'); this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth');
@@ -154,4 +167,9 @@ export class AppComponent implements OnInit, AfterViewInit {
); );
} }
private initializeKlaro() {
if (hasValue(this.cookiesService)) {
this.cookiesService.initialize()
}
}
} }

View File

@@ -1,11 +1,11 @@
import { APP_BASE_HREF, CommonModule } from '@angular/common'; import { APP_BASE_HREF, CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { APP_INITIALIZER, NgModule } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@@ -21,6 +21,7 @@ import { AppComponent } from './app.component';
import { appEffects } from './app.effects'; import { appEffects } from './app.effects';
import { appMetaReducers, debugMetaReducers } from './app.metareducers'; import { appMetaReducers, debugMetaReducers } from './app.metareducers';
import { appReducers, AppState, storeModuleConfig } from './app.reducer'; import { appReducers, AppState, storeModuleConfig } from './app.reducer';
import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
import { CoreModule } from './core/core.module'; import { CoreModule } from './core/core.module';
import { ClientCookieService } from './core/services/client-cookie.service'; import { ClientCookieService } from './core/services/client-cookie.service';
@@ -91,6 +92,15 @@ const PROVIDERS = [
useClass: DSpaceRouterStateSerializer useClass: DSpaceRouterStateSerializer
}, },
ClientCookieService, ClientCookieService,
// Check the authentication token when the app initializes
{
provide: APP_INITIALIZER,
useFactory: (store: Store<AppState>,) => {
return () => store.dispatch(new CheckAuthenticationTokenAction());
},
deps: [ Store ],
multi: true
},
...DYNAMIC_MATCHER_PROVIDERS, ...DYNAMIC_MATCHER_PROVIDERS,
]; ];

View File

@@ -4,7 +4,6 @@ import { SharedModule } from '../shared/shared.module';
import { CommunityListPageComponent } from './community-list-page.component'; import { CommunityListPageComponent } from './community-list-page.component';
import { CommunityListPageRoutingModule } from './community-list-page.routing.module'; import { CommunityListPageRoutingModule } from './community-list-page.routing.module';
import { CommunityListComponent } from './community-list/community-list.component'; import { CommunityListComponent } from './community-list/community-list.component';
import { CdkTreeModule } from '@angular/cdk/tree';
/** /**
* The page which houses a title and the community list, as described in community-list.component * The page which houses a title and the community list, as described in community-list.component
@@ -13,8 +12,7 @@ import { CdkTreeModule } from '@angular/cdk/tree';
imports: [ imports: [
CommonModule, CommonModule,
SharedModule, SharedModule,
CommunityListPageRoutingModule, CommunityListPageRoutingModule
CdkTreeModule,
], ],
declarations: [ declarations: [
CommunityListPageComponent, CommunityListPageComponent,

View File

@@ -0,0 +1,62 @@
import { Store } from '@ngrx/store';
import * as ngrx from '@ngrx/store';
import { cold, getTestScheduler, initTestScheduler, resetTestScheduler } from 'jasmine-marbles/es6';
import { of as observableOf } from 'rxjs';
import { AppState } from '../../app.reducer';
import { AuthBlockingGuard } from './auth-blocking.guard';
describe('AuthBlockingGuard', () => {
let guard: AuthBlockingGuard;
beforeEach(() => {
guard = new AuthBlockingGuard(new Store<AppState>(undefined, undefined, undefined));
initTestScheduler();
});
afterEach(() => {
getTestScheduler().flush();
resetTestScheduler();
});
describe(`canActivate`, () => {
describe(`when authState.loading is undefined`, () => {
beforeEach(() => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(undefined);
};
})
});
it(`should not emit anything`, () => {
expect(guard.canActivate()).toBeObservable(cold('|'));
});
});
describe(`when authState.loading is true`, () => {
beforeEach(() => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(true);
};
})
});
it(`should not emit anything`, () => {
expect(guard.canActivate()).toBeObservable(cold('|'));
});
});
describe(`when authState.loading is false`, () => {
beforeEach(() => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(false);
};
})
});
it(`should succeed`, () => {
expect(guard.canActivate()).toBeObservable(cold('(a|)', { a: true }));
});
});
});
});

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, take } from 'rxjs/operators';
import { AppState } from '../../app.reducer';
import { isAuthenticationBlocking } from './selectors';
/**
* A guard that blocks the loading of any
* route until the authentication status has loaded.
* To ensure all rest requests get the correct auth header.
*/
@Injectable({
providedIn: 'root'
})
export class AuthBlockingGuard implements CanActivate {
constructor(private store: Store<AppState>) {
}
/**
* True when the authentication isn't blocking everything
*/
canActivate(): Observable<boolean> {
return this.store.pipe(select(isAuthenticationBlocking)).pipe(
map((isBlocking: boolean) => isBlocking === false),
distinctUntilChanged(),
filter((finished: boolean) => finished === true),
take(1),
);
}
}

View File

@@ -34,6 +34,7 @@ export const AuthActionTypes = {
RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'), RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'),
RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'), RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'),
RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'), RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'),
REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS')
}; };
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
@@ -335,6 +336,20 @@ export class SetRedirectUrlAction implements Action {
} }
} }
/**
* Start loading for a hard redirect
* @class StartHardRedirectLoadingAction
* @implements {Action}
*/
export class RedirectAfterLoginSuccessAction implements Action {
public type: string = AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS;
payload: string;
constructor(url: string) {
this.payload = url;
}
}
/** /**
* Retrieve the authenticated eperson. * Retrieve the authenticated eperson.
* @class RetrieveAuthenticatedEpersonAction * @class RetrieveAuthenticatedEpersonAction
@@ -402,8 +417,8 @@ export type AuthActions
| RetrieveAuthMethodsSuccessAction | RetrieveAuthMethodsSuccessAction
| RetrieveAuthMethodsErrorAction | RetrieveAuthMethodsErrorAction
| RetrieveTokenAction | RetrieveTokenAction
| ResetAuthenticationMessagesAction
| RetrieveAuthenticatedEpersonAction | RetrieveAuthenticatedEpersonAction
| RetrieveAuthenticatedEpersonErrorAction | RetrieveAuthenticatedEpersonErrorAction
| RetrieveAuthenticatedEpersonSuccessAction | RetrieveAuthenticatedEpersonSuccessAction
| SetRedirectUrlAction; | SetRedirectUrlAction
| RedirectAfterLoginSuccessAction;

View File

@@ -27,6 +27,7 @@ import {
CheckAuthenticationTokenCookieAction, CheckAuthenticationTokenCookieAction,
LogOutErrorAction, LogOutErrorAction,
LogOutSuccessAction, LogOutSuccessAction,
RedirectAfterLoginSuccessAction,
RefreshTokenAction, RefreshTokenAction,
RefreshTokenErrorAction, RefreshTokenErrorAction,
RefreshTokenSuccessAction, RefreshTokenSuccessAction,
@@ -79,7 +80,26 @@ export class AuthEffects {
public authenticatedSuccess$: Observable<Action> = this.actions$.pipe( public authenticatedSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)), tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)),
map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref)) switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe(
take(1),
map((redirectUrl: string) => [action, redirectUrl])
)),
map(([action, redirectUrl]: [AuthenticatedSuccessAction, string]) => {
if (hasValue(redirectUrl)) {
return new RedirectAfterLoginSuccessAction(redirectUrl);
} else {
return new RetrieveAuthenticatedEpersonAction(action.payload.userHref);
}
})
);
@Effect({ dispatch: false })
public redirectAfterLoginSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS),
tap((action: RedirectAfterLoginSuccessAction) => {
this.authService.clearRedirectUrl();
this.authService.navigateToRedirectUrl(action.payload);
})
); );
// It means "reacts to this action but don't send another" // It means "reacts to this action but don't send another"
@@ -201,13 +221,6 @@ export class AuthEffects {
tap(() => this.authService.refreshAfterLogout()) tap(() => this.authService.refreshAfterLogout())
); );
@Effect({ dispatch: false })
public redirectToLogin$: Observable<Action> = this.actions$
.pipe(ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED),
tap(() => this.authService.removeToken()),
tap(() => this.authService.redirectToLogin())
);
@Effect({ dispatch: false }) @Effect({ dispatch: false })
public redirectToLoginTokenExpired$: Observable<Action> = this.actions$ public redirectToLoginTokenExpired$: Observable<Action> = this.actions$
.pipe( .pipe(

View File

@@ -251,7 +251,6 @@ export class AuthInterceptor implements HttpInterceptor {
// Pass on the new request instead of the original request. // Pass on the new request instead of the original request.
return next.handle(newReq).pipe( return next.handle(newReq).pipe(
// tap((response) => console.log('next.handle: ', response)),
map((response) => { map((response) => {
// Intercept a Login/Logout response // Intercept a Login/Logout response
if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) { if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) {

View File

@@ -42,6 +42,7 @@ describe('authReducer', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true,
loading: false, loading: false,
}; };
const action = new AuthenticateAction('user', 'password'); const action = new AuthenticateAction('user', 'password');
@@ -49,6 +50,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true,
error: undefined, error: undefined,
loading: true, loading: true,
info: undefined info: undefined
@@ -62,6 +64,7 @@ describe('authReducer', () => {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
error: undefined, error: undefined,
blocking: true,
loading: true, loading: true,
info: undefined info: undefined
}; };
@@ -76,6 +79,7 @@ describe('authReducer', () => {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
error: undefined, error: undefined,
blocking: true,
loading: true, loading: true,
info: undefined info: undefined
}; };
@@ -84,6 +88,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
authToken: undefined, authToken: undefined,
@@ -96,6 +101,7 @@ describe('authReducer', () => {
it('should properly set the state, in response to a AUTHENTICATED action', () => { it('should properly set the state, in response to a AUTHENTICATED action', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
blocking: false,
loaded: false, loaded: false,
error: undefined, error: undefined,
loading: true, loading: true,
@@ -103,8 +109,15 @@ describe('authReducer', () => {
}; };
const action = new AuthenticatedAction(mockTokenInfo); const action = new AuthenticatedAction(mockTokenInfo);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
state = {
expect(newState).toEqual(initialState); authenticated: false,
blocking: true,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
expect(newState).toEqual(state);
}); });
it('should properly set the state, in response to a AUTHENTICATED_SUCCESS action', () => { it('should properly set the state, in response to a AUTHENTICATED_SUCCESS action', () => {
@@ -112,6 +125,7 @@ describe('authReducer', () => {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
error: undefined, error: undefined,
blocking: true,
loading: true, loading: true,
info: undefined info: undefined
}; };
@@ -122,6 +136,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: false, loaded: false,
error: undefined, error: undefined,
blocking: true,
loading: true, loading: true,
info: undefined info: undefined
}; };
@@ -133,6 +148,7 @@ describe('authReducer', () => {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
error: undefined, error: undefined,
blocking: true,
loading: true, loading: true,
info: undefined info: undefined
}; };
@@ -143,6 +159,7 @@ describe('authReducer', () => {
authToken: undefined, authToken: undefined,
error: 'Test error message', error: 'Test error message',
loaded: true, loaded: true,
blocking: false,
loading: false, loading: false,
info: undefined info: undefined
}; };
@@ -153,6 +170,7 @@ describe('authReducer', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
}; };
const action = new CheckAuthenticationTokenAction(); const action = new CheckAuthenticationTokenAction();
@@ -160,6 +178,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true,
loading: true, loading: true,
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
@@ -169,6 +188,7 @@ describe('authReducer', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: true, loading: true,
}; };
const action = new CheckAuthenticationTokenCookieAction(); const action = new CheckAuthenticationTokenCookieAction();
@@ -176,6 +196,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true,
loading: true, loading: true,
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
@@ -187,6 +208,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id
@@ -204,6 +226,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id
@@ -216,7 +239,8 @@ describe('authReducer', () => {
authToken: undefined, authToken: undefined,
error: undefined, error: undefined,
loaded: false, loaded: false,
loading: false, blocking: true,
loading: true,
info: undefined, info: undefined,
refreshing: false, refreshing: false,
userId: undefined userId: undefined
@@ -230,6 +254,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id
@@ -242,6 +267,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: 'Test error message', error: 'Test error message',
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id
@@ -255,6 +281,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: false, loaded: false,
error: undefined, error: undefined,
blocking: true,
loading: true, loading: true,
info: undefined info: undefined
}; };
@@ -265,6 +292,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id
@@ -277,6 +305,7 @@ describe('authReducer', () => {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
error: undefined, error: undefined,
blocking: true,
loading: true, loading: true,
info: undefined info: undefined
}; };
@@ -287,6 +316,7 @@ describe('authReducer', () => {
authToken: undefined, authToken: undefined,
error: 'Test error message', error: 'Test error message',
loaded: true, loaded: true,
blocking: false,
loading: false, loading: false,
info: undefined info: undefined
}; };
@@ -299,6 +329,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id
@@ -311,6 +342,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id, userId: EPersonMock.id,
@@ -325,6 +357,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id, userId: EPersonMock.id,
@@ -338,6 +371,7 @@ describe('authReducer', () => {
authToken: newTokenInfo, authToken: newTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id, userId: EPersonMock.id,
@@ -352,6 +386,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id, userId: EPersonMock.id,
@@ -364,6 +399,7 @@ describe('authReducer', () => {
authToken: undefined, authToken: undefined,
error: undefined, error: undefined,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
refreshing: false, refreshing: false,
@@ -378,6 +414,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: true,
error: undefined, error: undefined,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
userId: EPersonMock.id userId: EPersonMock.id
@@ -387,6 +424,7 @@ describe('authReducer', () => {
authenticated: false, authenticated: false,
authToken: undefined, authToken: undefined,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
error: undefined, error: undefined,
info: 'Message', info: 'Message',
@@ -410,6 +448,7 @@ describe('authReducer', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
}; };
const action = new AddAuthenticationMessageAction('Message'); const action = new AddAuthenticationMessageAction('Message');
@@ -417,6 +456,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
info: 'Message' info: 'Message'
}; };
@@ -427,6 +467,7 @@ describe('authReducer', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
error: 'Error', error: 'Error',
info: 'Message' info: 'Message'
@@ -436,6 +477,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
error: undefined, error: undefined,
info: undefined info: undefined
@@ -447,6 +489,7 @@ describe('authReducer', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false loading: false
}; };
const action = new SetRedirectUrlAction('redirect.url'); const action = new SetRedirectUrlAction('redirect.url');
@@ -454,6 +497,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
redirectUrl: 'redirect.url' redirectUrl: 'redirect.url'
}; };
@@ -464,6 +508,7 @@ describe('authReducer', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
authMethods: [] authMethods: []
}; };
@@ -472,6 +517,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true,
loading: true, loading: true,
authMethods: [] authMethods: []
}; };
@@ -482,6 +528,7 @@ describe('authReducer', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true,
loading: true, loading: true,
authMethods: [] authMethods: []
}; };
@@ -494,6 +541,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
authMethods: authMethods authMethods: authMethods
}; };
@@ -504,6 +552,7 @@ describe('authReducer', () => {
initialState = { initialState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true,
loading: true, loading: true,
authMethods: [] authMethods: []
}; };
@@ -513,6 +562,7 @@ describe('authReducer', () => {
state = { state = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)] authMethods: [new AuthMethod(AuthMethodType.Password)]
}; };

View File

@@ -39,6 +39,10 @@ export interface AuthState {
// true when loading // true when loading
loading: boolean; loading: boolean;
// true when everything else should wait for authorization
// to complete
blocking: boolean;
// info message // info message
info?: string; info?: string;
@@ -62,6 +66,7 @@ export interface AuthState {
const initialState: AuthState = { const initialState: AuthState = {
authenticated: false, authenticated: false,
loaded: false, loaded: false,
blocking: true,
loading: false, loading: false,
authMethods: [] authMethods: []
}; };
@@ -86,7 +91,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE:
return Object.assign({}, state, { return Object.assign({}, state, {
loading: true loading: true,
blocking: true
}); });
case AuthActionTypes.AUTHENTICATED_ERROR: case AuthActionTypes.AUTHENTICATED_ERROR:
@@ -96,6 +102,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
authToken: undefined, authToken: undefined,
error: (action as AuthenticationErrorAction).payload.message, error: (action as AuthenticationErrorAction).payload.message,
loaded: true, loaded: true,
blocking: false,
loading: false loading: false
}); });
@@ -110,6 +117,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
loaded: true, loaded: true,
error: undefined, error: undefined,
loading: false, loading: false,
blocking: false,
info: undefined, info: undefined,
userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload
}); });
@@ -119,6 +127,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
authenticated: false, authenticated: false,
authToken: undefined, authToken: undefined,
error: (action as AuthenticationErrorAction).payload.message, error: (action as AuthenticationErrorAction).payload.message,
blocking: false,
loading: false loading: false
}); });
@@ -132,25 +141,39 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
error: (action as LogOutErrorAction).payload.message error: (action as LogOutErrorAction).payload.message
}); });
case AuthActionTypes.LOG_OUT_SUCCESS:
case AuthActionTypes.REFRESH_TOKEN_ERROR: case AuthActionTypes.REFRESH_TOKEN_ERROR:
return Object.assign({}, state, { return Object.assign({}, state, {
authenticated: false, authenticated: false,
authToken: undefined, authToken: undefined,
error: undefined, error: undefined,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
info: undefined, info: undefined,
refreshing: false, refreshing: false,
userId: undefined userId: undefined
}); });
case AuthActionTypes.LOG_OUT_SUCCESS:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
error: undefined,
loaded: false,
blocking: true,
loading: true,
info: undefined,
refreshing: false,
userId: undefined
});
case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED: case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED:
case AuthActionTypes.REDIRECT_TOKEN_EXPIRED: case AuthActionTypes.REDIRECT_TOKEN_EXPIRED:
return Object.assign({}, state, { return Object.assign({}, state, {
authenticated: false, authenticated: false,
authToken: undefined, authToken: undefined,
loaded: false, loaded: false,
blocking: false,
loading: false, loading: false,
info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload, info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload,
userId: undefined userId: undefined
@@ -181,18 +204,21 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
// next three cases are used by dynamic rendering of login methods // next three cases are used by dynamic rendering of login methods
case AuthActionTypes.RETRIEVE_AUTH_METHODS: case AuthActionTypes.RETRIEVE_AUTH_METHODS:
return Object.assign({}, state, { return Object.assign({}, state, {
loading: true loading: true,
blocking: true
}); });
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,
authMethods: (action as RetrieveAuthMethodsSuccessAction).payload authMethods: (action as RetrieveAuthMethodsSuccessAction).payload
}); });
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,
authMethods: [new AuthMethod(AuthMethodType.Password)] authMethods: [new AuthMethod(AuthMethodType.Password)]
}); });
@@ -201,6 +227,12 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
redirectUrl: (action as SetRedirectUrlAction).payload, redirectUrl: (action as SetRedirectUrlAction).payload,
}); });
case AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS:
return Object.assign({}, state, {
loading: true,
blocking: true,
});
default: default:
return state; return state;
} }

View File

@@ -27,6 +27,7 @@ import { EPersonDataService } from '../eperson/eperson-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { authMethodsMock } from '../../shared/testing/auth-service.stub'; import { authMethodsMock } from '../../shared/testing/auth-service.stub';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
import { HardRedirectService } from '../services/hard-redirect.service';
describe('AuthService test', () => { describe('AuthService test', () => {
@@ -48,6 +49,7 @@ describe('AuthService test', () => {
let authenticatedState; let authenticatedState;
let unAuthenticatedState; let unAuthenticatedState;
let linkService; let linkService;
let hardRedirectService;
function init() { function init() {
mockStore = jasmine.createSpyObj('store', { mockStore = jasmine.createSpyObj('store', {
@@ -77,6 +79,7 @@ describe('AuthService test', () => {
linkService = { linkService = {
resolveLinks: {} resolveLinks: {}
}; };
hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']);
spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) }); spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) });
} }
@@ -104,6 +107,7 @@ describe('AuthService test', () => {
{ provide: ActivatedRoute, useValue: routeStub }, { provide: ActivatedRoute, useValue: routeStub },
{ provide: Store, useValue: mockStore }, { provide: Store, useValue: mockStore },
{ provide: EPersonDataService, useValue: mockEpersonDataService }, { provide: EPersonDataService, useValue: mockEpersonDataService },
{ provide: HardRedirectService, useValue: hardRedirectService },
CookieService, CookieService,
AuthService AuthService
], ],
@@ -210,7 +214,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({}); (state as any).core = Object.create({});
(state as any).core.auth = authenticatedState; (state as any).core.auth = authenticatedState;
}); });
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
})); }));
it('should return true when user is logged in', () => { it('should return true when user is logged in', () => {
@@ -289,7 +293,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({}); (state as any).core = Object.create({});
(state as any).core.auth = authenticatedState; (state as any).core.auth = authenticatedState;
}); });
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
storage = (authService as any).storage; storage = (authService as any).storage;
routeServiceMock = TestBed.get(RouteService); routeServiceMock = TestBed.get(RouteService);
routerStub = TestBed.get(Router); routerStub = TestBed.get(Router);
@@ -318,36 +322,28 @@ describe('AuthService test', () => {
expect(storage.remove).toHaveBeenCalled(); expect(storage.remove).toHaveBeenCalled();
}); });
it('should set redirect url to previous page', () => { it('should redirect to reload with redirect url', () => {
spyOn(routeServiceMock, 'getHistory').and.callThrough(); authService.navigateToRedirectUrl('/collection/123');
spyOn(routerStub, 'navigateByUrl'); // Reload with redirect URL set to /collection/123
authService.redirectAfterLoginSuccess(true); expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123'))));
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/collection/123');
}); });
it('should set redirect url to current page', () => { it('should redirect to reload with /home', () => {
spyOn(routeServiceMock, 'getHistory').and.callThrough(); authService.navigateToRedirectUrl('/home');
spyOn(routerStub, 'navigateByUrl'); // Reload with redirect URL set to /home
authService.redirectAfterLoginSuccess(false); expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home'))));
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/home');
}); });
it('should redirect to / and not to /login', () => { it('should redirect to regular reload and not to /login', () => {
spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login'])); authService.navigateToRedirectUrl('/login');
spyOn(routerStub, 'navigateByUrl'); // Reload without a redirect URL
authService.redirectAfterLoginSuccess(true); expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$')));
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/');
}); });
it('should redirect to / when no redirect url is found', () => { it('should redirect to regular reload when no redirect url is found', () => {
spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf([''])); authService.navigateToRedirectUrl(undefined);
spyOn(routerStub, 'navigateByUrl'); // Reload without a redirect URL
authService.redirectAfterLoginSuccess(true); expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$')));
expect(routeServiceMock.getHistory).toHaveBeenCalled();
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/');
}); });
describe('impersonate', () => { describe('impersonate', () => {
@@ -464,6 +460,14 @@ describe('AuthService test', () => {
}); });
}); });
}); });
describe('refreshAfterLogout', () => {
it('should call navigateToRedirectUrl with no url', () => {
spyOn(authService as any, 'navigateToRedirectUrl').and.stub();
authService.refreshAfterLogout();
expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled();
});
});
}); });
describe('when user is not logged in', () => { describe('when user is not logged in', () => {
@@ -496,7 +500,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({}); (state as any).core = Object.create({});
(state as any).core.auth = unAuthenticatedState; (state as any).core.auth = unAuthenticatedState;
}); });
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
})); }));
it('should return null for the shortlived token', () => { it('should return null for the shortlived token', () => {

View File

@@ -1,11 +1,10 @@
import { Inject, Injectable, Optional } from '@angular/core'; import { Inject, Injectable, Optional } from '@angular/core';
import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router'; import { Router } from '@angular/router';
import { HttpHeaders } from '@angular/common/http'; import { HttpHeaders } from '@angular/common/http';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { map, startWith, switchMap, take } from 'rxjs/operators';
import { RouterReducerState } from '@ngrx/router-store';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { CookieAttributes } from 'js-cookie'; import { CookieAttributes } from 'js-cookie';
@@ -14,7 +13,15 @@ import { AuthRequestService } from './auth-request.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model';
import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import {
hasValue,
hasValueOperator,
isEmpty,
isNotEmpty,
isNotNull,
isNotUndefined,
hasNoValue
} from '../../shared/empty.util';
import { CookieService } from '../services/cookie.service'; import { CookieService } from '../services/cookie.service';
import { import {
getAuthenticatedUserId, getAuthenticatedUserId,
@@ -24,7 +31,7 @@ import {
isTokenRefreshing, isTokenRefreshing,
isAuthenticatedLoaded isAuthenticatedLoaded
} from './selectors'; } from './selectors';
import { AppState, routerStateSelector } from '../../app.reducer'; import { AppState } from '../../app.reducer';
import { import {
CheckAuthenticationTokenAction, CheckAuthenticationTokenAction,
ResetAuthenticationMessagesAction, ResetAuthenticationMessagesAction,
@@ -36,6 +43,7 @@ import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.service'; import { EPersonDataService } from '../eperson/eperson-data.service';
import { getAllSucceededRemoteDataPayload } from '../shared/operators'; import { getAllSucceededRemoteDataPayload } from '../shared/operators';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
import { HardRedirectService } from '../services/hard-redirect.service';
export const LOGIN_ROUTE = '/login'; export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout'; export const LOGOUT_ROUTE = '/logout';
@@ -62,43 +70,13 @@ export class AuthService {
protected router: Router, protected router: Router,
protected routeService: RouteService, protected routeService: RouteService,
protected storage: CookieService, protected storage: CookieService,
protected store: Store<AppState> protected store: Store<AppState>,
protected hardRedirectService: HardRedirectService
) { ) {
this.store.pipe( this.store.pipe(
select(isAuthenticated), select(isAuthenticated),
startWith(false) startWith(false)
).subscribe((authenticated: boolean) => this._authenticated = authenticated); ).subscribe((authenticated: boolean) => this._authenticated = authenticated);
// If current route is different from the one setted in authentication guard
// and is not the login route, clear redirect url and messages
const routeUrl$ = this.store.pipe(
select(routerStateSelector),
filter((routerState: RouterReducerState) => isNotUndefined(routerState)
&& isNotUndefined(routerState.state) && isNotEmpty(routerState.state.url)),
filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url)),
map((routerState: RouterReducerState) => routerState.state.url)
);
const redirectUrl$ = this.store.pipe(select(getRedirectUrl), distinctUntilChanged());
routeUrl$.pipe(
withLatestFrom(redirectUrl$),
map(([routeUrl, redirectUrl]) => [routeUrl, redirectUrl])
).pipe(filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl)))
.subscribe(() => {
this.clearRedirectUrl();
});
}
/**
* Check if is a login page route
*
* @param {string} url
* @returns {Boolean}.
*/
protected isLoginRoute(url: string) {
const urlTree: UrlTree = this.router.parseUrl(url);
const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const segment = '/' + g.toString();
return segment === LOGIN_ROUTE;
} }
/** /**
@@ -409,69 +387,38 @@ export class AuthService {
} }
/** /**
* Redirect to the route navigated before the login * Perform a hard redirect to the URL
* @param redirectUrl
*/ */
public redirectAfterLoginSuccess(isStandalonePage: boolean) { public navigateToRedirectUrl(redirectUrl: string) {
this.getRedirectUrl().pipe( let url = `/reload/${new Date().getTime()}`;
take(1)) if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) {
.subscribe((redirectUrl) => { url += `?redirect=${encodeURIComponent(redirectUrl)}`;
if (isNotEmpty(redirectUrl)) {
this.clearRedirectUrl();
this.router.onSameUrlNavigation = 'reload';
this.navigateToRedirectUrl(redirectUrl);
} else {
// If redirectUrl is empty use history.
this.routeService.getHistory().pipe(
take(1)
).subscribe((history) => {
let redirUrl;
if (isStandalonePage) {
// For standalone login pages, use the previous route.
redirUrl = history[history.length - 2] || '';
} else {
redirUrl = history[history.length - 1] || '';
}
this.navigateToRedirectUrl(redirUrl);
});
}
});
}
protected navigateToRedirectUrl(redirectUrl: string) {
const url = decodeURIComponent(redirectUrl);
// in case the user navigates directly to /login (via bookmark, etc), or the route history is not found.
if (isEmpty(url) || url.startsWith(LOGIN_ROUTE)) {
this.router.navigateByUrl('/');
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = '/';
} else {
/* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
// this._window.nativeWindow.location.href = url;
this.router.navigateByUrl(url);
} }
this.hardRedirectService.redirect(url);
} }
/** /**
* Refresh route navigated * Refresh route navigated
*/ */
public refreshAfterLogout() { public refreshAfterLogout() {
// Hard redirect to the reload page with a unique number behind it this.navigateToRedirectUrl(undefined);
// so that all state is definitely lost
this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`;
} }
/** /**
* Get redirect url * Get redirect url
*/ */
getRedirectUrl(): Observable<string> { getRedirectUrl(): Observable<string> {
const redirectUrl = this.storage.get(REDIRECT_COOKIE); return this.store.pipe(
if (isNotEmpty(redirectUrl)) { select(getRedirectUrl),
return observableOf(redirectUrl); map((urlFromStore: string) => {
} else { if (hasValue(urlFromStore)) {
return this.store.pipe(select(getRedirectUrl)); return urlFromStore;
} } else {
return this.storage.get(REDIRECT_COOKIE);
}
})
);
} }
/** /**
@@ -488,6 +435,20 @@ export class AuthService {
this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : ''));
} }
/**
* Set the redirect url if the current one has not been set yet
* @param newRedirectUrl
*/
setRedirectUrlIfNotSet(newRedirectUrl: string) {
this.getRedirectUrl().pipe(
take(1))
.subscribe((currentRedirectUrl) => {
if (hasNoValue(currentRedirectUrl)) {
this.setRedirectUrl(newRedirectUrl);
}
})
}
/** /**
* Clear redirect url * Clear redirect url
*/ */

View File

@@ -1,21 +1,26 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; import {
ActivatedRouteSnapshot,
CanActivate,
Router,
RouterStateSnapshot,
UrlTree
} from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { take } from 'rxjs/operators'; import { map, find, switchMap } from 'rxjs/operators';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { CoreState } from '../core.reducers'; import { CoreState } from '../core.reducers';
import { isAuthenticated } from './selectors'; import { isAuthenticated, isAuthenticationLoading } from './selectors';
import { AuthService } from './auth.service'; import { AuthService, LOGIN_ROUTE } from './auth.service';
import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions';
/** /**
* Prevent unauthorized activating and loading of routes * Prevent unauthorized activating and loading of routes
* @class AuthenticatedGuard * @class AuthenticatedGuard
*/ */
@Injectable() @Injectable()
export class AuthenticatedGuard implements CanActivate, CanLoad { export class AuthenticatedGuard implements CanActivate {
/** /**
* @constructor * @constructor
@@ -24,46 +29,37 @@ export class AuthenticatedGuard implements CanActivate, CanLoad {
/** /**
* True when user is authenticated * True when user is authenticated
* UrlTree with redirect to login page when user isn't authenticated
* @method canActivate * @method canActivate
*/ */
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
const url = state.url; const url = state.url;
return this.handleAuth(url); return this.handleAuth(url);
} }
/** /**
* True when user is authenticated * True when user is authenticated
* UrlTree with redirect to login page when user isn't authenticated
* @method canActivateChild * @method canActivateChild
*/ */
canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> { canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return this.canActivate(route, state); return this.canActivate(route, state);
} }
/** private handleAuth(url: string): Observable<boolean | UrlTree> {
* True when user is authenticated
* @method canLoad
*/
canLoad(route: Route): Observable<boolean> {
const url = `/${route.path}`;
return this.handleAuth(url);
}
private handleAuth(url: string): Observable<boolean> {
// get observable
const observable = this.store.pipe(select(isAuthenticated));
// redirect to sign in page if user is not authenticated // redirect to sign in page if user is not authenticated
observable.pipe( return this.store.pipe(select(isAuthenticationLoading)).pipe(
// .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url) find((isLoading: boolean) => isLoading === false),
take(1)) switchMap(() => this.store.pipe(select(isAuthenticated))),
.subscribe((authenticated) => { map((authenticated) => {
if (!authenticated) { if (authenticated) {
return authenticated;
} else {
this.authService.setRedirectUrl(url); this.authService.setRedirectUrl(url);
this.store.dispatch(new RedirectWhenAuthenticationIsRequiredAction('Login required')); this.authService.removeToken();
return this.router.createUrlTree([LOGIN_ROUTE]);
} }
}); })
);
return observable;
} }
} }

View File

@@ -65,6 +65,14 @@ const _getAuthenticationInfo = (state: AuthState) => state.info;
*/ */
const _isLoading = (state: AuthState) => state.loading; const _isLoading = (state: AuthState) => state.loading;
/**
* Returns true if everything else should wait for authentication.
* @function _isBlocking
* @param {State} state
* @returns {boolean}
*/
const _isBlocking = (state: AuthState) => state.blocking;
/** /**
* Returns true if a refresh token request is in progress. * Returns true if a refresh token request is in progress.
* @function _isRefreshing * @function _isRefreshing
@@ -170,6 +178,16 @@ export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticat
*/ */
export const isAuthenticationLoading = createSelector(getAuthState, _isLoading); export const isAuthenticationLoading = createSelector(getAuthState, _isLoading);
/**
* Returns true if the authentication should block everything else
*
* @function isAuthenticationBlocking
* @param {AuthState} state
* @param {any} props
* @return {boolean}
*/
export const isAuthenticationBlocking = createSelector(getAuthState, _isBlocking);
/** /**
* Returns true if the refresh token request is loading. * Returns true if the refresh token request is loading.
* @function isTokenRefreshing * @function isTokenRefreshing

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpHeaders } from '@angular/common/http'; import { HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { filter, map, take } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
@@ -58,32 +58,4 @@ export class ServerAuthService extends AuthService {
map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) map((status: AuthStatus) => Object.assign(new AuthStatus(), status))
); );
} }
/**
* Redirect to the route navigated before the login
*/
public redirectAfterLoginSuccess(isStandalonePage: boolean) {
this.getRedirectUrl().pipe(
take(1))
.subscribe((redirectUrl) => {
if (isNotEmpty(redirectUrl)) {
// override the route reuse strategy
this.router.routeReuseStrategy.shouldReuseRoute = () => {
return false;
};
this.router.navigated = false;
const url = decodeURIComponent(redirectUrl);
this.router.navigateByUrl(url);
} else {
// If redirectUrl is empty use history. For ssr the history array should contain the requested url.
this.routeService.getHistory().pipe(
filter((history) => history.length > 0),
take(1)
).subscribe((history) => {
this.navigateToRedirectUrl(history[history.length - 1] || '');
});
}
})
}
} }

View File

@@ -3,12 +3,13 @@ import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; import { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
import { DataService } from '../data/data.service'; import { DataService } from '../data/data.service';
import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators'; import { getRemoteDataPayload } from '../shared/operators';
import { map } from 'rxjs/operators'; import { filter, map, take } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { ChildHALResource } from '../shared/child-hal-resource.model'; import { ChildHALResource } from '../shared/child-hal-resource.model';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { hasValue } from '../../shared/empty.util';
/** /**
* The class that resolves the BreadcrumbConfig object for a DSpaceObject * The class that resolves the BreadcrumbConfig object for a DSpaceObject
@@ -29,12 +30,17 @@ export abstract class DSOBreadcrumbResolver<T extends ChildHALResource & DSpaceO
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<BreadcrumbConfig<T>> { resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<BreadcrumbConfig<T>> {
const uuid = route.params.id; const uuid = route.params.id;
return this.dataService.findById(uuid, ...this.followLinks).pipe( return this.dataService.findById(uuid, ...this.followLinks).pipe(
getSucceededRemoteData(), filter((rd) => hasValue(rd.error) || hasValue(rd.payload)),
take(1),
getRemoteDataPayload(), getRemoteDataPayload(),
map((object: T) => { map((object: T) => {
const fullPath = state.url; if (hasValue(object)) {
const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid; const fullPath = state.url;
return { provider: this.breadcrumbService, key: object, url: url }; const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid;
return {provider: this.breadcrumbService, key: object, url: url};
} else {
return undefined;
}
}) })
); );
} }

View File

@@ -270,7 +270,7 @@ export class ObjectCacheService {
/** /**
* Add operations to the existing list of operations for an ObjectCacheEntry * Add operations to the existing list of operations for an ObjectCacheEntry
* Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated * Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated
* @param {string} uuid * @param selfLink
* the uuid of the ObjectCacheEntry * the uuid of the ObjectCacheEntry
* @param {Operation[]} patch * @param {Operation[]} patch
* list of operations to perform * list of operations to perform
@@ -295,8 +295,8 @@ export class ObjectCacheService {
/** /**
* Apply the existing operations on an ObjectCacheEntry in the store * Apply the existing operations on an ObjectCacheEntry in the store
* NB: this does not make any server side changes * NB: this does not make any server side changes
* @param {string} uuid * @param selfLink
* the uuid of the ObjectCacheEntry * the link of the ObjectCacheEntry
*/ */
private applyPatchesToCachedObject(selfLink: string) { private applyPatchesToCachedObject(selfLink: string) {
this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink)); this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink));

View File

@@ -5,7 +5,6 @@ import { PageInfo } from '../shared/page-info.model';
import { ConfigObject } from '../config/models/config.model'; import { ConfigObject } from '../config/models/config.model';
import { FacetValue } from '../../shared/search/facet-value.model'; import { FacetValue } from '../../shared/search/facet-value.model';
import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; import { SearchFilterConfig } from '../../shared/search/search-filter-config.model';
import { IntegrationModel } from '../integration/models/integration.model';
import { PaginatedList } from '../data/paginated-list'; import { PaginatedList } from '../data/paginated-list';
import { SubmissionObject } from '../submission/models/submission-object.model'; import { SubmissionObject } from '../submission/models/submission-object.model';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
@@ -181,17 +180,6 @@ export class TokenResponse extends RestResponse {
} }
} }
export class IntegrationSuccessResponse extends RestResponse {
constructor(
public dataDefinition: PaginatedList<IntegrationModel>,
public statusCode: number,
public statusText: string,
public pageInfo?: PageInfo
) {
super(true, statusCode, statusText);
}
}
export class PostPatchSuccessResponse extends RestResponse { export class PostPatchSuccessResponse extends RestResponse {
constructor( constructor(
public dataDefinition: any, public dataDefinition: any,

View File

@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
@@ -16,8 +17,8 @@ import { MenuService } from '../shared/menu/menu.service';
import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service';
import { import {
MOCK_RESPONSE_MAP, MOCK_RESPONSE_MAP,
ResponseMapMock, mockResponseMap,
mockResponseMap ResponseMapMock
} from '../shared/mocks/dspace-rest-v2/mocks/response-map.mock'; } from '../shared/mocks/dspace-rest-v2/mocks/response-map.mock';
import { NotificationsService } from '../shared/notifications/notifications.service'; import { NotificationsService } from '../shared/notifications/notifications.service';
import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service'; import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service';
@@ -82,9 +83,6 @@ import { EPersonDataService } from './eperson/eperson-data.service';
import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service'; import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service';
import { EPerson } from './eperson/models/eperson.model'; import { EPerson } from './eperson/models/eperson.model';
import { Group } from './eperson/models/group.model'; import { Group } from './eperson/models/group.model';
import { AuthorityService } from './integration/authority.service';
import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service';
import { AuthorityValue } from './integration/models/authority.value';
import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder';
import { MetadataField } from './metadata/metadata-field.model'; import { MetadataField } from './metadata/metadata-field.model';
import { MetadataSchema } from './metadata/metadata-schema.model'; import { MetadataSchema } from './metadata/metadata-schema.model';
@@ -162,8 +160,20 @@ import { SubmissionCcLicenseDataService } from './submission/submission-cc-licen
import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; import { SubmissionCcLicence } from './submission/models/submission-cc-license.model';
import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model'; import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model';
import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service';
import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model';
import { Vocabulary } from './submission/vocabularies/models/vocabulary.model';
import { VocabularyEntriesResponseParsingService } from './submission/vocabularies/vocabulary-entries-response-parsing.service';
import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model';
import { VocabularyService } from './submission/vocabularies/vocabulary.service';
import { VocabularyTreeviewService } from '../shared/vocabulary-treeview/vocabulary-treeview.service';
import { ConfigurationDataService } from './data/configuration-data.service'; import { ConfigurationDataService } from './data/configuration-data.service';
import { ConfigurationProperty } from './shared/configuration-property.model'; import { ConfigurationProperty } from './shared/configuration-property.model';
import { ReloadGuard } from './reload/reload.guard';
import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard';
import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard';
import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service';
import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard';
import { UsageReport } from './statistics/models/usage-report.model';
/** /**
* When not in production, endpoint responses can be mocked for testing purposes * When not in production, endpoint responses can be mocked for testing purposes
@@ -197,7 +207,7 @@ const PROVIDERS = [
SiteDataService, SiteDataService,
DSOResponseParsingService, DSOResponseParsingService,
{ provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap },
{ provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient]}, { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] },
DynamicFormLayoutService, DynamicFormLayoutService,
DynamicFormService, DynamicFormService,
DynamicFormValidationService, DynamicFormValidationService,
@@ -239,8 +249,6 @@ const PROVIDERS = [
SubmissionResponseParsingService, SubmissionResponseParsingService,
SubmissionJsonPatchOperationsService, SubmissionJsonPatchOperationsService,
JsonPatchOperationsBuilder, JsonPatchOperationsBuilder,
AuthorityService,
IntegrationResponseParsingService,
UploaderService, UploaderService,
UUIDService, UUIDService,
NotificationsService, NotificationsService,
@@ -289,9 +297,14 @@ const PROVIDERS = [
FeatureDataService, FeatureDataService,
AuthorizationDataService, AuthorizationDataService,
SiteAdministratorGuard, SiteAdministratorGuard,
SiteRegisterGuard,
MetadataSchemaDataService, MetadataSchemaDataService,
MetadataFieldDataService, MetadataFieldDataService,
TokenResponseParsingService, TokenResponseParsingService,
ReloadGuard,
EndUserAgreementCurrentUserGuard,
EndUserAgreementCookieGuard,
EndUserAgreementService,
// register AuthInterceptor as HttpInterceptor // register AuthInterceptor as HttpInterceptor
{ {
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,
@@ -306,7 +319,10 @@ const PROVIDERS = [
}, },
NotificationsService, NotificationsService,
FilteredDiscoveryPageResponseParsingService, FilteredDiscoveryPageResponseParsingService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory } { provide: NativeWindowService, useFactory: NativeWindowFactory },
VocabularyService,
VocabularyEntriesResponseParsingService,
VocabularyTreeviewService
]; ];
/** /**
@@ -337,7 +353,6 @@ export const models =
SubmissionSectionModel, SubmissionSectionModel,
SubmissionUploadsModel, SubmissionUploadsModel,
AuthStatus, AuthStatus,
AuthorityValue,
BrowseEntry, BrowseEntry,
BrowseDefinition, BrowseDefinition,
ClaimedTask, ClaimedTask,
@@ -358,7 +373,11 @@ export const models =
Feature, Feature,
Authorization, Authorization,
Registration, Registration,
ConfigurationProperty Vocabulary,
VocabularyEntry,
VocabularyEntryDetail,
ConfigurationProperty,
UsageReport,
]; ];
@NgModule({ @NgModule({

View File

@@ -30,6 +30,8 @@ import { RestResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { configureRequest, getResponseFromEntry } from '../shared/operators'; import { configureRequest, getResponseFromEntry } from '../shared/operators';
import { combineLatest as observableCombineLatest } from 'rxjs'; import { combineLatest as observableCombineLatest } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { PageInfo } from '../shared/page-info.model';
/** /**
* A service to retrieve {@link Bitstream}s from the REST API * A service to retrieve {@link Bitstream}s from the REST API
@@ -165,8 +167,10 @@ export class BitstreamDataService extends DataService<Bitstream> {
public findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<Bitstream>>): Observable<RemoteData<PaginatedList<Bitstream>>> { public findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<Bitstream>>): Observable<RemoteData<PaginatedList<Bitstream>>> {
return this.bundleService.findByItemAndName(item, bundleName).pipe( return this.bundleService.findByItemAndName(item, bundleName).pipe(
switchMap((bundleRD: RemoteData<Bundle>) => { switchMap((bundleRD: RemoteData<Bundle>) => {
if (hasValue(bundleRD.payload)) { if (bundleRD.hasSucceeded && hasValue(bundleRD.payload)) {
return this.findAllByBundle(bundleRD.payload, options, ...linksToFollow); return this.findAllByBundle(bundleRD.payload, options, ...linksToFollow);
} else if (!bundleRD.hasSucceeded && bundleRD.error.statusCode === 404) {
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
} else { } else {
return [bundleRD as any]; return [bundleRD as any];
} }

View File

@@ -1,40 +1,22 @@
import { Inject, Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { isNotEmpty } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { BrowseEntry } from '../shared/browse-entry.model'; import { BrowseEntry } from '../shared/browse-entry.model';
import { BaseResponseParsingService } from './base-response-parsing.service'; import { EntriesResponseParsingService } from './entries-response-parsing.service';
import { ResponseParsingService } from './parsing.service'; import { GenericConstructor } from '../shared/generic-constructor';
import { RestRequest } from './request.models';
@Injectable() @Injectable()
export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { export class BrowseEntriesResponseParsingService extends EntriesResponseParsingService<BrowseEntry> {
protected toCache = false; protected toCache = false;
constructor( constructor(
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
) { super(); ) {
super(objectCache);
} }
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { getSerializerModel(): GenericConstructor<BrowseEntry> {
if (isNotEmpty(data.payload)) { return BrowseEntry;
let browseEntries = [];
if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
const serializer = new DSpaceSerializer(BrowseEntry);
browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
}
return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload));
} else {
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from browse endpoint'),
{ statusCode: data.statusCode, statusText: data.statusText }
)
);
}
} }
} }

View File

@@ -1,21 +1,11 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { compare, Operation } from 'fast-json-patch'; import { compare, Operation } from 'fast-json-patch';
import { Observable, of as observableOf } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SortDirection, SortOptions } from '../cache/models/sort-options.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers'; import { CoreState } from '../core.reducers';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { ChangeAnalyzer } from './change-analyzer'; import { ChangeAnalyzer } from './change-analyzer';
import { DataService } from './data.service';
import { FindListOptions, PatchRequest } from './request.models';
import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { BundleDataService } from './bundle-data.service'; import { BundleDataService } from './bundle-data.service';

View File

@@ -22,6 +22,7 @@ import { FindListOptions, GetRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { Bitstream } from '../shared/bitstream.model'; import { Bitstream } from '../shared/bitstream.model';
import { RemoteDataError } from './remote-data-error';
/** /**
* A service to retrieve {@link Bundle}s from the REST API * A service to retrieve {@link Bundle}s from the REST API
@@ -71,13 +72,17 @@ export class BundleDataService extends DataService<Bundle> {
if (hasValue(rd.payload) && hasValue(rd.payload.page)) { if (hasValue(rd.payload) && hasValue(rd.payload.page)) {
const matchingBundle = rd.payload.page.find((bundle: Bundle) => const matchingBundle = rd.payload.page.find((bundle: Bundle) =>
bundle.name === bundleName); bundle.name === bundleName);
return new RemoteData( if (hasValue(matchingBundle)) {
false, return new RemoteData(
false, false,
true, false,
undefined, true,
matchingBundle undefined,
); matchingBundle
);
} else {
return new RemoteData(false, false, false, new RemoteDataError(404, 'Not found', `The bundle with name ${bundleName} was not found.` ))
}
} else { } else {
return rd as any; return rd as any;
} }

View File

@@ -6,18 +6,18 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { fakeAsync, tick } from '@angular/core/testing'; import { fakeAsync, tick } from '@angular/core/testing';
import { ContentSourceRequest, GetRequest, RequestError, UpdateContentSourceRequest } from './request.models'; import { ContentSourceRequest, GetRequest, UpdateContentSourceRequest } from './request.models';
import { ContentSource } from '../shared/content-source.model'; import { ContentSource } from '../shared/content-source.model';
import { of as observableOf } from 'rxjs/internal/observable/of'; import { of as observableOf } from 'rxjs/internal/observable/of';
import { RequestEntry } from './request.reducer'; import { RequestEntry } from './request.reducer';
import { ErrorResponse, RestResponse } from '../cache/response.models'; import { ErrorResponse } from '../cache/response.models';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Collection } from '../shared/collection.model'; import { Collection } from '../shared/collection.model';
import { PageInfo } from '../shared/page-info.model'; import { PageInfo } from '../shared/page-info.model';
import { PaginatedList } from './paginated-list'; import { PaginatedList } from './paginated-list';
import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils';
import { hot, getTestScheduler, cold } from 'jasmine-marbles'; import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
const url = 'fake-url'; const url = 'fake-url';

View File

@@ -24,7 +24,7 @@ import { RequestService } from './request.service';
@dataService(COMMUNITY) @dataService(COMMUNITY)
export class CommunityDataService extends ComColDataService<Community> { export class CommunityDataService extends ComColDataService<Community> {
protected linkPath = 'communities'; protected linkPath = 'communities';
protected topLinkPath = 'communities/search/top'; protected topLinkPath = 'search/top';
protected cds = this; protected cds = this;
constructor( constructor(

View File

@@ -18,6 +18,7 @@ import { FindListOptions, PatchRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { RequestParam } from '../cache/models/request-param.model';
const endpoint = 'https://rest.api/core'; const endpoint = 'https://rest.api/core';
@@ -150,7 +151,8 @@ describe('DataService', () => {
currentPage: 6, currentPage: 6,
elementsPerPage: 10, elementsPerPage: 10,
sort: sortOptions, sort: sortOptions,
startsWith: 'ab' startsWith: 'ab',
}; };
const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` +
`&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`;
@@ -160,6 +162,26 @@ describe('DataService', () => {
}); });
}); });
it('should include all searchParams in href if any provided in options', () => {
options = { searchParams: [
new RequestParam('param1', 'test'),
new RequestParam('param2', 'test2'),
] };
const expected = `${endpoint}?param1=test&param2=test2`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include linkPath in href if any provided', () => {
const expected = `${endpoint}/test/entries`;
(service as any).getFindAllHref({}, 'test/entries').subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include single linksToFollow as embed', () => { it('should include single linksToFollow as embed', () => {
const expected = `${endpoint}?embed=bundles`; const expected = `${endpoint}?embed=bundles`;

View File

@@ -3,7 +3,7 @@ import { Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -71,13 +71,17 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* Return an observable that emits created HREF * Return an observable that emits created HREF
* @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
*/ */
protected getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<string> { public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<string> {
let result$: Observable<string>; let endpoint$: Observable<string>;
const args = []; const args = [];
result$ = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); endpoint$ = this.getBrowseEndpoint(options).pipe(
filter((href: string) => isNotEmpty(href)),
map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href),
distinctUntilChanged()
);
return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
} }
/** /**
@@ -89,18 +93,12 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* Return an observable that emits created HREF * Return an observable that emits created HREF
* @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
*/ */
protected getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<string> { public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<string> {
let result$: Observable<string>; let result$: Observable<string>;
const args = []; const args = [];
result$ = this.getSearchEndpoint(searchMethod); result$ = this.getSearchEndpoint(searchMethod);
if (hasValue(options.searchParams)) {
options.searchParams.forEach((param: RequestParam) => {
args.push(`${param.fieldName}=${param.fieldValue}`);
})
}
return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
} }
@@ -114,7 +112,7 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* Return an observable that emits created HREF * Return an observable that emits created HREF
* @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
*/ */
protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array<FollowLinkConfig<T>>): string { public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array<FollowLinkConfig<T>>): string {
let args = [...extraArgs]; let args = [...extraArgs];
if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { if (hasValue(options.currentPage) && typeof options.currentPage === 'number') {
@@ -130,6 +128,11 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
if (hasValue(options.startsWith)) { if (hasValue(options.startsWith)) {
args = [...args, `startsWith=${options.startsWith}`]; args = [...args, `startsWith=${options.startsWith}`];
} }
if (hasValue(options.searchParams)) {
options.searchParams.forEach((param: RequestParam) => {
args = [...args, `${param.fieldName}=${param.fieldValue}`];
})
}
args = this.addEmbedParams(args, ...linksToFollow); args = this.addEmbedParams(args, ...linksToFollow);
if (isNotEmpty(args)) { if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString(); return new URLCombiner(href, `?${args.join('&')}`).toString();
@@ -373,11 +376,20 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
).subscribe(); ).subscribe();
return this.requestService.getByUUID(requestId).pipe( return this.requestService.getByUUID(requestId).pipe(
hasValueOperator(),
find((request: RequestEntry) => request.completed), find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response) map((request: RequestEntry) => request.response)
); );
} }
createPatchFromCache(object: T): Observable<Operation[]> {
const oldVersion$ = this.findByHref(object._links.self.href);
return oldVersion$.pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((oldVersion: T) => this.comparator.diff(oldVersion, object)));
}
/** /**
* Send a PUT request for the specified object * Send a PUT request for the specified object
* *
@@ -406,18 +418,16 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
* @param {DSpaceObject} object The given object * @param {DSpaceObject} object The given object
*/ */
update(object: T): Observable<RemoteData<T>> { update(object: T): Observable<RemoteData<T>> {
const oldVersion$ = this.findByHref(object._links.self.href); return this.createPatchFromCache(object)
return oldVersion$.pipe( .pipe(
getSucceededRemoteData(), mergeMap((operations: Operation[]) => {
getRemoteDataPayload(), if (isNotEmpty(operations)) {
mergeMap((oldVersion: T) => { this.objectCache.addPatch(object._links.self.href, operations);
const operations = this.comparator.diff(oldVersion, object); }
if (isNotEmpty(operations)) { return this.findByHref(object._links.self.href);
this.objectCache.addPatch(object._links.self.href, operations);
} }
return this.findByHref(object._links.self.href); )
} );
));
} }
/** /**

View File

@@ -0,0 +1,54 @@
import { isNotEmpty } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { BaseResponseParsingService } from './base-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { CacheableObject } from '../cache/object-cache.reducer';
import { GenericConstructor } from '../shared/generic-constructor';
/**
* An abstract class to extend, responsible for parsing data for an entries response
*/
export abstract class EntriesResponseParsingService<T extends CacheableObject> extends BaseResponseParsingService implements ResponseParsingService {
protected toCache = false;
constructor(
protected objectCache: ObjectCacheService,
) {
super();
}
/**
* Abstract method to implement that must return the dspace serializer Constructor to use during parse
*/
abstract getSerializerModel(): GenericConstructor<T>;
/**
* Parse response
*
* @param request
* @param data
*/
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload)) {
let entries = [];
if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
const serializer = new DSpaceSerializer(this.getSerializerModel());
entries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
}
return new GenericSuccessResponse(entries, data.statusCode, data.statusText, this.processPageInfo(data.payload));
} else {
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from browse endpoint'),
{ statusCode: data.statusCode, statusText: data.statusText }
)
);
}
}
}

View File

@@ -63,33 +63,33 @@ describe('AuthorizationDataService', () => {
return Object.assign(new FindListOptions(), { searchParams }); return Object.assign(new FindListOptions(), { searchParams });
} }
describe('when no arguments are provided and a user is authenticated', () => { describe('when no arguments are provided', () => {
beforeEach(() => { beforeEach(() => {
service.searchByObject().subscribe(); service.searchByObject().subscribe();
}); });
it('should call searchBy with the site\'s url and authenticated user\'s uuid', () => { it('should call searchBy with the site\'s url', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid)); expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self));
}); });
}); });
describe('when no arguments except for a feature are provided and a user is authenticated', () => { describe('when no arguments except for a feature are provided', () => {
beforeEach(() => { beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe(); service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe();
}); });
it('should call searchBy with the site\'s url, authenticated user\'s uuid and the feature', () => { it('should call searchBy with the site\'s url and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid, FeatureID.LoginOnBehalfOf)); expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, null, FeatureID.LoginOnBehalfOf));
}); });
}); });
describe('when a feature and object url are provided, but no user uuid and a user is authenticated', () => { describe('when a feature and object url are provided', () => {
beforeEach(() => { beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe(); service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe();
}); });
it('should call searchBy with the object\'s url, authenticated user\'s uuid and the feature', () => { it('should call searchBy with the object\'s url and the feature', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePerson.uuid, FeatureID.LoginOnBehalfOf)); expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, null, FeatureID.LoginOnBehalfOf));
}); });
}); });
@@ -102,17 +102,6 @@ describe('AuthorizationDataService', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf)); expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf));
}); });
}); });
describe('when no arguments are provided and no user is authenticated', () => {
beforeEach(() => {
spyOn(authService, 'isAuthenticated').and.returnValue(observableOf(false));
service.searchByObject().subscribe();
});
it('should call searchBy with the site\'s url', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self));
});
});
}); });
describe('isAuthorized', () => { describe('isAuthorized', () => {

View File

@@ -25,7 +25,6 @@ import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { RequestParam } from '../../cache/models/request-param.model'; import { RequestParam } from '../../cache/models/request-param.model';
import { AuthorizationSearchParams } from './authorization-search-params'; import { AuthorizationSearchParams } from './authorization-search-params';
import { import {
addAuthenticatedUserUuidIfEmpty,
addSiteObjectUrlIfEmpty, addSiteObjectUrlIfEmpty,
oneAuthorizationMatchesFeature oneAuthorizationMatchesFeature
} from './authorization-utils'; } from './authorization-utils';
@@ -90,7 +89,6 @@ export class AuthorizationDataService extends DataService<Authorization> {
searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Authorization>>): Observable<RemoteData<PaginatedList<Authorization>>> { searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Authorization>>): Observable<RemoteData<PaginatedList<Authorization>>> {
return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe( return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe(
addSiteObjectUrlIfEmpty(this.siteService), addSiteObjectUrlIfEmpty(this.siteService),
addAuthenticatedUserUuidIfEmpty(this.authService),
switchMap((params: AuthorizationSearchParams) => { switchMap((params: AuthorizationSearchParams) => {
return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), ...linksToFollow); return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), ...linksToFollow);
}) })

View File

@@ -0,0 +1,63 @@
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { RemoteData } from '../../remote-data';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { DsoPageFeatureGuard } from './dso-page-feature.guard';
import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs/internal/Observable';
/**
* Test implementation of abstract class DsoPageAdministratorGuard
*/
class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard<any> {
constructor(protected resolver: Resolve<RemoteData<any>>,
protected authorizationService: AuthorizationDataService,
protected router: Router,
protected featureID: FeatureID) {
super(resolver, authorizationService, router);
}
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(this.featureID);
}
}
describe('DsoPageAdministratorGuard', () => {
let guard: DsoPageFeatureGuard<any>;
let authorizationService: AuthorizationDataService;
let router: Router;
let resolver: Resolve<RemoteData<any>>;
let object: DSpaceObject;
function init() {
object = {
self: 'test-selflink'
} as DSpaceObject;
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
router = jasmine.createSpyObj('router', {
parseUrl: {}
});
resolver = jasmine.createSpyObj('resolver', {
resolve: createSuccessfulRemoteDataObject$(object)
});
guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, undefined);
}
beforeEach(() => {
init();
});
describe('getObjectUrl', () => {
it('should return the resolved object\'s selflink', (done) => {
guard.getObjectUrl(undefined, undefined).subscribe((selflink) => {
expect(selflink).toEqual(object.self);
done();
});
});
});
});

View File

@@ -0,0 +1,30 @@
import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
import { RemoteData } from '../../remote-data';
import { AuthorizationDataService } from '../authorization-data.service';
import { Observable } from 'rxjs/internal/Observable';
import { getAllSucceededRemoteDataPayload } from '../../../shared/operators';
import { map } from 'rxjs/operators';
import { DSpaceObject } from '../../../shared/dspace-object.model';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
/**
* Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature
* This guard utilizes a resolver to retrieve the relevant object to check authorizations for
*/
export abstract class DsoPageFeatureGuard<T extends DSpaceObject> extends FeatureAuthorizationGuard {
constructor(protected resolver: Resolve<RemoteData<T>>,
protected authorizationService: AuthorizationDataService,
protected router: Router) {
super(authorizationService, router);
}
/**
* Check authorization rights for the object resolved using the provided resolver
*/
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return (this.resolver.resolve(route, state) as Observable<RemoteData<T>>).pipe(
getAllSucceededRemoteDataPayload(),
map((dso) => dso.self)
);
}
}

View File

@@ -2,7 +2,8 @@ import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service'; import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id'; import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { Router } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
/** /**
* Test implementation of abstract class FeatureAuthorizationGuard * Test implementation of abstract class FeatureAuthorizationGuard
@@ -17,16 +18,16 @@ class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
super(authorizationService, router); super(authorizationService, router);
} }
getFeatureID(): FeatureID { getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return this.featureId; return observableOf(this.featureId);
} }
getObjectUrl(): string { getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return this.objectUrl; return observableOf(this.objectUrl);
} }
getEPersonUuid(): string { getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return this.ePersonUuid; return observableOf(this.ePersonUuid);
} }
} }

View File

@@ -9,6 +9,8 @@ import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id'; import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators'; import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { switchMap } from 'rxjs/operators';
/** /**
* Abstract Guard for preventing unauthorized activating and loading of routes when a user * Abstract Guard for preventing unauthorized activating and loading of routes when a user
@@ -24,29 +26,32 @@ export abstract class FeatureAuthorizationGuard implements CanActivate {
* True when user has authorization rights for the feature and object provided * True when user has authorization rights for the feature and object provided
* Redirect the user to the unauthorized page when he/she's not authorized for the given feature * Redirect the user to the unauthorized page when he/she's not authorized for the given feature
*/ */
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return this.authorizationService.isAuthorized(this.getFeatureID(), this.getObjectUrl(), this.getEPersonUuid()).pipe(returnUnauthorizedUrlTreeOnFalse(this.router)); return observableCombineLatest(this.getFeatureID(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(
switchMap(([featureID, objectUrl, ePersonUuid]) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)),
returnUnauthorizedUrlTreeOnFalse(this.router)
);
} }
/** /**
* The type of feature to check authorization for * The type of feature to check authorization for
* Override this method to define a feature * Override this method to define a feature
*/ */
abstract getFeatureID(): FeatureID; abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID>;
/** /**
* The URL of the object to check if the user has authorized rights for * The URL of the object to check if the user has authorized rights for
* Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used * Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used
*/ */
getObjectUrl(): string { getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return undefined; return observableOf(undefined);
} }
/** /**
* The UUID of the user to check authorization rights for * The UUID of the user to check authorization rights for
* Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used. * Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used.
*/ */
getEPersonUuid(): string { getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
return undefined; return observableOf(undefined);
} }
} }

View File

@@ -2,7 +2,9 @@ import { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard'; import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { FeatureID } from '../feature-id'; import { FeatureID } from '../feature-id';
import { AuthorizationDataService } from '../authorization-data.service'; import { AuthorizationDataService } from '../authorization-data.service';
import { Router } from '@angular/router'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
/** /**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator
@@ -19,7 +21,7 @@ export class SiteAdministratorGuard extends FeatureAuthorizationGuard {
/** /**
* Check administrator authorization rights * Check administrator authorization rights
*/ */
getFeatureID(): FeatureID { getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return FeatureID.AdministratorOf; return observableOf(FeatureID.AdministratorOf);
} }
} }

View File

@@ -0,0 +1,27 @@
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { Injectable } from '@angular/core';
import { AuthorizationDataService } from '../authorization-data.service';
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs/internal/Observable';
import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration
* rights to the {@link Site}
*/
@Injectable({
providedIn: 'root'
})
export class SiteRegisterGuard extends FeatureAuthorizationGuard {
constructor(protected authorizationService: AuthorizationDataService, protected router: Router) {
super(authorizationService, router);
}
/**
* Check registration authorization rights
*/
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
return observableOf(FeatureID.EPersonRegistration);
}
}

View File

@@ -3,5 +3,9 @@
*/ */
export enum FeatureID { export enum FeatureID {
LoginOnBehalfOf = 'loginOnBehalfOf', LoginOnBehalfOf = 'loginOnBehalfOf',
AdministratorOf = 'administratorOf' AdministratorOf = 'administratorOf',
CanDelete = 'canDelete',
WithdrawItem = 'withdrawItem',
ReinstateItem = 'reinstateItem',
EPersonRegistration = 'epersonRegistration',
} }

View File

@@ -20,6 +20,7 @@ import { switchMap, map } from 'rxjs/operators';
import { BundleDataService } from './bundle-data.service'; import { BundleDataService } from './bundle-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RestResponse } from '../cache/response.models'; import { RestResponse } from '../cache/response.models';
import { Operation } from 'fast-json-patch';
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
/** /**
@@ -165,6 +166,10 @@ export class ItemTemplateDataService implements UpdateDataService<Item> {
return this.dataService.update(object); return this.dataService.update(object);
} }
patch(dso: Item, operations: Operation[]): Observable<RestResponse> {
return this.dataService.patch(dso, operations);
}
/** /**
* Find an item template by collection ID * Find an item template by collection ID
* @param collectionID * @param collectionID

View File

@@ -1,6 +1,9 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { hasValue } from '../../shared/empty.util';
import { dataService } from '../cache/builders/build-decorators'; import { dataService } from '../cache/builders/build-decorators';
import { DataService } from './data.service'; import { DataService } from './data.service';
import { PaginatedList } from './paginated-list';
import { RemoteData } from './remote-data';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -27,6 +30,7 @@ import { RequestParam } from '../cache/models/request-param.model';
export class MetadataFieldDataService extends DataService<MetadataField> { export class MetadataFieldDataService extends DataService<MetadataField> {
protected linkPath = 'metadatafields'; protected linkPath = 'metadatafields';
protected searchBySchemaLinkPath = 'bySchema'; protected searchBySchemaLinkPath = 'bySchema';
protected searchByFieldNameLinkPath = 'byFieldName';
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
@@ -53,6 +57,43 @@ export class MetadataFieldDataService extends DataService<MetadataField> {
return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow); return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow);
} }
/**
* Find metadata fields with either the partial metadata field name (e.g. "dc.ti") as query or an exact match to
* at least the schema, element or qualifier
* @param schema optional; an exact match of the prefix of the metadata schema (e.g. "dc", "dcterms", "eperson")
* @param element optional; an exact match of the field's element (e.g. "contributor", "title")
* @param qualifier optional; an exact match of the field's qualifier (e.g. "author", "alternative")
* @param query optional (if any of schema, element or qualifier used) - part of the fully qualified field,
* should start with the start of the schema, element or qualifier (e.g. “dc.ti”, “contributor”, “auth”, “contributor.ot”)
* @param exactName optional; the exact fully qualified field, should use the syntax schema.element.qualifier or
* schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value
* if there's an exact match
* @param options The options info used to retrieve the fields
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
searchByFieldNameParams(schema: string, element: string, qualifier: string, query: string, exactName: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<MetadataField>>): Observable<RemoteData<PaginatedList<MetadataField>>> {
const optionParams = Object.assign(new FindListOptions(), options, {
searchParams: [
new RequestParam('schema', hasValue(schema) ? schema : ''),
new RequestParam('element', hasValue(element) ? element : ''),
new RequestParam('qualifier', hasValue(qualifier) ? qualifier : ''),
new RequestParam('query', hasValue(query) ? query : ''),
new RequestParam('exactName', hasValue(exactName) ? exactName : '')
]
});
return this.searchBy(this.searchByFieldNameLinkPath, optionParams, ...linksToFollow);
}
/**
* Finds a specific metadata field by name.
* @param exactFieldName The exact fully qualified field, should use the syntax schema.element.qualifier or
* schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value
* if there's an exact match, empty list if there is no exact match.
*/
findByExactFieldName(exactFieldName: string): Observable<RemoteData<PaginatedList<MetadataField>>> {
return this.searchByFieldNameParams(null, null, null, null, exactFieldName, null);
}
/** /**
* Clear all metadata field requests * Clear all metadata field requests
* Used for refreshing lists after adding/updating/removing a metadata field from a metadata schema * Used for refreshing lists after adding/updating/removing a metadata field from a metadata schema

View File

@@ -2,6 +2,8 @@ import {type} from '../../../shared/ngrx/type';
import {Action} from '@ngrx/store'; import {Action} from '@ngrx/store';
import {Identifiable} from './object-updates.reducer'; import {Identifiable} from './object-updates.reducer';
import {INotification} from '../../../shared/notifications/models/notification.model'; import {INotification} from '../../../shared/notifications/models/notification.model';
import { InjectionToken } from '@angular/core';
import { PatchOperationService } from './patch-operation-service/patch-operation.service';
/** /**
* The list of ObjectUpdatesAction type definitions * The list of ObjectUpdatesAction type definitions
@@ -38,7 +40,8 @@ export class InitializeFieldsAction implements Action {
payload: { payload: {
url: string, url: string,
fields: Identifiable[], fields: Identifiable[],
lastModified: Date lastModified: Date,
patchOperationServiceToken?: InjectionToken<PatchOperationService>
}; };
/** /**
@@ -48,16 +51,15 @@ export class InitializeFieldsAction implements Action {
* the unique url of the page for which the fields are being initialized * the unique url of the page for which the fields are being initialized
* @param fields The identifiable fields of which the updates are kept track of * @param fields The identifiable fields of which the updates are kept track of
* @param lastModified The last modified date of the object that belongs to the page * @param lastModified The last modified date of the object that belongs to the page
* @param order A custom order to keep track of objects moving around * @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch
* @param pageSize The page size used to fill empty pages for the custom order
* @param page The first page to populate in the custom order
*/ */
constructor( constructor(
url: string, url: string,
fields: Identifiable[], fields: Identifiable[],
lastModified: Date lastModified: Date,
patchOperationServiceToken?: InjectionToken<PatchOperationService>
) { ) {
this.payload = { url, fields, lastModified }; this.payload = { url, fields, lastModified, patchOperationServiceToken };
} }
} }

View File

@@ -231,7 +231,8 @@ describe('objectUpdatesReducer', () => {
}, },
fieldUpdates: {}, fieldUpdates: {},
virtualMetadataSources: {}, virtualMetadataSources: {},
lastModified: modDate lastModified: modDate,
patchOperationServiceToken: undefined
} }
}; };
const newState = objectUpdatesReducer(testState, action); const newState = objectUpdatesReducer(testState, action);

View File

@@ -14,6 +14,8 @@ import {
} from './object-updates.actions'; } from './object-updates.actions';
import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { hasNoValue, hasValue } from '../../../shared/empty.util';
import {Relationship} from '../../shared/item-relationships/relationship.model'; import {Relationship} from '../../shared/item-relationships/relationship.model';
import { InjectionToken } from '@angular/core';
import { PatchOperationService } from './patch-operation-service/patch-operation.service';
/** /**
* Path where discarded objects are saved * Path where discarded objects are saved
@@ -48,7 +50,7 @@ export interface Identifiable {
*/ */
export interface FieldUpdate { export interface FieldUpdate {
field: Identifiable, field: Identifiable,
changeType: FieldChangeType changeType: FieldChangeType,
} }
/** /**
@@ -89,6 +91,7 @@ export interface ObjectUpdatesEntry {
fieldUpdates: FieldUpdates; fieldUpdates: FieldUpdates;
virtualMetadataSources: VirtualMetadataSources; virtualMetadataSources: VirtualMetadataSources;
lastModified: Date; lastModified: Date;
patchOperationServiceToken?: InjectionToken<PatchOperationService>;
} }
/** /**
@@ -163,6 +166,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
const url: string = action.payload.url; const url: string = action.payload.url;
const fields: Identifiable[] = action.payload.fields; const fields: Identifiable[] = action.payload.fields;
const lastModifiedServer: Date = action.payload.lastModified; const lastModifiedServer: Date = action.payload.lastModified;
const patchOperationServiceToken: InjectionToken<PatchOperationService> = action.payload.patchOperationServiceToken;
const fieldStates = createInitialFieldStates(fields); const fieldStates = createInitialFieldStates(fields);
const newPageState = Object.assign( const newPageState = Object.assign(
{}, {},
@@ -170,7 +174,8 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
{ fieldStates: fieldStates }, { fieldStates: fieldStates },
{ fieldUpdates: {} }, { fieldUpdates: {} },
{ virtualMetadataSources: {} }, { virtualMetadataSources: {} },
{ lastModified: lastModifiedServer } { lastModified: lastModifiedServer },
{ patchOperationServiceToken }
); );
return Object.assign({}, state, { [url]: newPageState }); return Object.assign({}, state, { [url]: newPageState });
} }

View File

@@ -12,6 +12,7 @@ import { Notification } from '../../../shared/notifications/models/notification.
import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { NotificationType } from '../../../shared/notifications/models/notification-type';
import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer';
import {Relationship} from '../../shared/item-relationships/relationship.model'; import {Relationship} from '../../shared/item-relationships/relationship.model';
import { Injector } from '@angular/core';
describe('ObjectUpdatesService', () => { describe('ObjectUpdatesService', () => {
let service: ObjectUpdatesService; let service: ObjectUpdatesService;
@@ -31,6 +32,9 @@ describe('ObjectUpdatesService', () => {
}; };
const modDate = new Date(2010, 2, 11); const modDate = new Date(2010, 2, 11);
const injectionToken = 'fake-injection-token';
let patchOperationService;
let injector: Injector;
beforeEach(() => { beforeEach(() => {
const fieldStates = { const fieldStates = {
@@ -40,11 +44,17 @@ describe('ObjectUpdatesService', () => {
}; };
const objectEntry = { const objectEntry = {
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {} fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationServiceToken: injectionToken
}; };
store = new Store<CoreState>(undefined, undefined, undefined); store = new Store<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch'); spyOn(store, 'dispatch');
service = new ObjectUpdatesService(store); patchOperationService = jasmine.createSpyObj('patchOperationService', {
fieldUpdatesToPatchOperations: []
});
injector = jasmine.createSpyObj('injector', {
get: patchOperationService
});
service = new ObjectUpdatesService(store, injector);
spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry));
spyOn(service as any, 'getFieldState').and.callFake((uuid) => { spyOn(service as any, 'getFieldState').and.callFake((uuid) => {
@@ -277,4 +287,26 @@ describe('ObjectUpdatesService', () => {
}); });
}); });
describe('createPatch', () => {
let result$;
beforeEach(() => {
result$ = service.createPatch(url);
});
it('should inject the service using the token stored in the entry', (done) => {
result$.subscribe(() => {
expect(injector.get).toHaveBeenCalledWith(injectionToken);
done();
});
});
it('should create a patch from the fieldUpdates using the injected service', (done) => {
result$.subscribe(() => {
expect(patchOperationService.fieldUpdatesToPatchOperations).toHaveBeenCalledWith(fieldUpdates);
done();
});
});
});
}); });

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { Injectable, InjectionToken, Injector } from '@angular/core';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers'; import { CoreState } from '../../core.reducers';
import { coreSelector } from '../../core.selectors'; import { coreSelector } from '../../core.selectors';
@@ -26,6 +26,8 @@ import {
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
import { INotification } from '../../../shared/notifications/models/notification.model'; import { INotification } from '../../../shared/notifications/models/notification.model';
import { Operation } from 'fast-json-patch';
import { PatchOperationService } from './patch-operation-service/patch-operation.service';
function objectUpdatesStateSelector(): MemoizedSelector<CoreState, ObjectUpdatesState> { function objectUpdatesStateSelector(): MemoizedSelector<CoreState, ObjectUpdatesState> {
return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']);
@@ -48,7 +50,8 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel
*/ */
@Injectable() @Injectable()
export class ObjectUpdatesService { export class ObjectUpdatesService {
constructor(private store: Store<CoreState>) { constructor(private store: Store<CoreState>,
private injector: Injector) {
} }
/** /**
@@ -56,9 +59,10 @@ export class ObjectUpdatesService {
* @param url The page's URL for which the changes are being mapped * @param url The page's URL for which the changes are being mapped
* @param fields The initial fields for the page's object * @param fields The initial fields for the page's object
* @param lastModified The date the object was last modified * @param lastModified The date the object was last modified
* @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch
*/ */
initialize(url, fields: Identifiable[], lastModified: Date): void { initialize(url, fields: Identifiable[], lastModified: Date, patchOperationServiceToken?: InjectionToken<PatchOperationService>): void {
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, patchOperationServiceToken));
} }
/** /**
@@ -339,4 +343,22 @@ export class ObjectUpdatesService {
getLastModified(url: string): Observable<Date> { getLastModified(url: string): Observable<Date> {
return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified));
} }
/**
* Create a patch from the current object-updates state
* The {@link ObjectUpdatesEntry} should contain a patchOperationServiceToken, in order to define how a patch should
* be created. If it doesn't, an empty patch will be returned.
* @param url The URL of the page for which the patch should be created
*/
createPatch(url: string): Observable<Operation[]> {
return this.getObjectEntry(url).pipe(
map((entry) => {
let patch = [];
if (hasValue(entry.patchOperationServiceToken)) {
patch = this.injector.get(entry.patchOperationServiceToken).fieldUpdatesToPatchOperations(entry.fieldUpdates);
}
return patch;
})
);
}
} }

View File

@@ -0,0 +1,252 @@
import { MetadataPatchOperationService } from './metadata-patch-operation.service';
import { FieldUpdates } from '../object-updates.reducer';
import { Operation } from 'fast-json-patch';
import { FieldChangeType } from '../object-updates.actions';
import { MetadatumViewModel } from '../../../shared/metadata.models';
describe('MetadataPatchOperationService', () => {
let service: MetadataPatchOperationService;
beforeEach(() => {
service = new MetadataPatchOperationService();
});
describe('fieldUpdatesToPatchOperations', () => {
let fieldUpdates: FieldUpdates;
let expected: Operation[];
let result: Operation[];
describe('when fieldUpdates contains a single remove', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain a single remove operation with the correct path', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains a single add', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Added title',
place: 0
}),
changeType: FieldChangeType.ADD
}
});
expected = [
{ op: 'add', path: '/metadata/dc.title/-', value: [ { value: 'Added title', language: undefined } ] }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain a single add operation with the correct path', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains a single update', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Changed title',
place: 0
}),
changeType: FieldChangeType.UPDATE
}
});
expected = [
{ op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain a single replace operation with the correct path', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains multiple removes with incrementing indexes', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'First deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
},
update2: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Second deleted title',
place: 1
}),
changeType: FieldChangeType.REMOVE
},
update3: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Third deleted title',
place: 2
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/0' },
{ op: 'remove', path: '/metadata/dc.title/0' },
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain all the remove operations on the same index', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains multiple removes with decreasing indexes', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Third deleted title',
place: 2
}),
changeType: FieldChangeType.REMOVE
},
update2: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Second deleted title',
place: 1
}),
changeType: FieldChangeType.REMOVE
},
update3: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'First deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/2' },
{ op: 'remove', path: '/metadata/dc.title/1' },
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain all the remove operations with their corresponding indexes', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains multiple removes with random indexes', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Second deleted title',
place: 1
}),
changeType: FieldChangeType.REMOVE
},
update2: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Third deleted title',
place: 2
}),
changeType: FieldChangeType.REMOVE
},
update3: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'First deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/1' },
{ op: 'remove', path: '/metadata/dc.title/1' },
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain all the remove operations with the correct indexes taking previous operations into account', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains multiple removes and updates with random indexes', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Second deleted title',
place: 1
}),
changeType: FieldChangeType.REMOVE
},
update2: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Third changed title',
place: 2
}),
changeType: FieldChangeType.UPDATE
},
update3: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'First deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/1' },
{ op: 'replace', path: '/metadata/dc.title/1', value: { value: 'Third changed title', language: undefined } },
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain all the remove and replace operations with the correct indexes taking previous remove operations into account', () => {
expect(result).toEqual(expected);
});
});
});
});

View File

@@ -0,0 +1,106 @@
import { PatchOperationService } from './patch-operation.service';
import { MetadatumViewModel } from '../../../shared/metadata.models';
import { FieldUpdates } from '../object-updates.reducer';
import { Operation } from 'fast-json-patch';
import { FieldChangeType } from '../object-updates.actions';
import { InjectionToken } from '@angular/core';
import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model';
import { hasValue } from '../../../../shared/empty.util';
import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-operation.model';
import { MetadataPatchRemoveOperation } from './operations/metadata/metadata-patch-remove-operation.model';
import { MetadataPatchReplaceOperation } from './operations/metadata/metadata-patch-replace-operation.model';
/**
* Token to use for injecting this service anywhere you want
* This token can used to store in the object-updates store
*/
export const METADATA_PATCH_OPERATION_SERVICE_TOKEN = new InjectionToken<MetadataPatchOperationService>('MetadataPatchOperationService', {
providedIn: 'root',
factory: () => new MetadataPatchOperationService(),
});
/**
* Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values
* This expects the fields within every {@link FieldUpdate} to be {@link MetadatumViewModel}s
*/
export class MetadataPatchOperationService implements PatchOperationService {
/**
* Transform a {@link FieldUpdates} object into an array of fast-json-patch Operations for metadata values
* This method first creates an array of {@link MetadataPatchOperation} wrapper operations, which are then
* iterated over to create the actual patch operations. While iterating, it has the ability to check for previous
* operations that would modify the operation's position and act accordingly.
* @param fieldUpdates
*/
fieldUpdatesToPatchOperations(fieldUpdates: FieldUpdates): Operation[] {
const metadataPatch = this.fieldUpdatesToMetadataPatchOperations(fieldUpdates);
// This map stores what metadata fields had a value deleted at which places
// This is used to modify the place of operations to match previous operations
const metadataRemoveMap = new Map<string, number[]>();
const patch = [];
metadataPatch.forEach((operation) => {
// If this operation is removing or editing an existing value, first check the map for previous operations
// If the map contains remove operations before this operation's place, lower the place by 1 for each
if ((operation.op === MetadataPatchRemoveOperation.operationType || operation.op === MetadataPatchReplaceOperation.operationType) && hasValue((operation as any).place)) {
if (metadataRemoveMap.has(operation.field)) {
metadataRemoveMap.get(operation.field).forEach((index) => {
if (index < (operation as any).place) {
(operation as any).place--;
}
});
}
}
// If this is a remove operation, add its (updated) place to the map, so we can adjust following operations accordingly
if (operation.op === MetadataPatchRemoveOperation.operationType && hasValue((operation as any).place)) {
if (!metadataRemoveMap.has(operation.field)) {
metadataRemoveMap.set(operation.field, []);
}
metadataRemoveMap.get(operation.field).push((operation as any).place);
}
// Transform the updated operation into a fast-json-patch Operation and add it to the patch
patch.push(operation.toOperation());
});
return patch;
}
/**
* Transform a {@link FieldUpdates} object into an array of {@link MetadataPatchOperation} wrapper objects
* These wrapper objects contain detailed information about the patch operation that needs to be creates for each update
* This information can then be modified before creating the actual patch
* @param fieldUpdates
*/
fieldUpdatesToMetadataPatchOperations(fieldUpdates: FieldUpdates): MetadataPatchOperation[] {
const metadataPatch = [];
Object.keys(fieldUpdates).forEach((uuid) => {
const update = fieldUpdates[uuid];
const metadatum = update.field as MetadatumViewModel;
const val = {
value: metadatum.value,
language: metadatum.language
}
let operation: MetadataPatchOperation;
switch (update.changeType) {
case FieldChangeType.ADD:
operation = new MetadataPatchAddOperation(metadatum.key, [ val ]);
break;
case FieldChangeType.REMOVE:
operation = new MetadataPatchRemoveOperation(metadatum.key, metadatum.place);
break;
case FieldChangeType.UPDATE:
operation = new MetadataPatchReplaceOperation(metadatum.key, metadatum.place, val);
break;
}
metadataPatch.push(operation);
});
return metadataPatch;
}
}

View File

@@ -0,0 +1,27 @@
import { MetadataPatchOperation } from './metadata-patch-operation.model';
import { Operation } from 'fast-json-patch';
/**
* Wrapper object for a metadata patch add Operation
*/
export class MetadataPatchAddOperation extends MetadataPatchOperation {
static operationType = 'add';
/**
* The metadata value(s) to add to the field
*/
value: any;
constructor(field: string, value: any) {
super(MetadataPatchAddOperation.operationType, field);
this.value = value;
}
/**
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
* using the information provided.
*/
toOperation(): Operation {
return { op: this.op as any, path: `/metadata/${this.field}/-`, value: this.value };
}
}

View File

@@ -0,0 +1,29 @@
import { Operation } from 'fast-json-patch';
/**
* Wrapper object for metadata patch Operations
* It should contain at least the operation type and metadata field. An abstract method to transform this object
* into a fast-json-patch Operation is defined in each instance extending from this.
*/
export abstract class MetadataPatchOperation {
/**
* The operation to perform
*/
op: string;
/**
* The metadata field this operation is intended for
*/
field: string;
constructor(op: string, field: string) {
this.op = op;
this.field = field;
}
/**
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
* using the information provided.
*/
abstract toOperation(): Operation;
}

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