mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-17 15:03:07 +00:00
Merge branch 'upstream-master' into Support-for-process-output
# Conflicts: # src/app/process-page/detail/process-detail.component.ts
This commit is contained in:
29
.codecov.yml
Normal file
29
.codecov.yml
Normal 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
|
5
.github/pull_request_template.md
vendored
5
.github/pull_request_template.md
vendored
@@ -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.
|
||||||
|
@@ -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
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
[](https://travis-ci.com/DSpace/dspace-angular) [](https://coveralls.io/github/DSpace/dspace-angular?branch=main) [](https://github.com/angular/universal)
|
[](https://travis-ci.com/DSpace/dspace-angular) [](https://codecov.io/gh/DSpace/dspace-angular) [](https://github.com/angular/universal)
|
||||||
|
|
||||||
dspace-angular
|
dspace-angular
|
||||||
==============
|
==============
|
||||||
|
@@ -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'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -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"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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",
|
||||||
|
@@ -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);
|
||||||
|
54
server.ts
54
server.ts
@@ -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,37 +130,7 @@ 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
|
|
||||||
// register the error handler for when it throws an error
|
|
||||||
Zone.current.fork({ name: 'CSR fallback', onHandleError }).run(() => {
|
|
||||||
res.render(DIST_FOLDER + '/index.html', {
|
res.render(DIST_FOLDER + '/index.html', {
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
@@ -171,16 +140,21 @@ function ngApp(req, res) {
|
|||||||
baseUrl: environment.ui.nameSpace,
|
baseUrl: environment.ui.nameSpace,
|
||||||
originUrl: environment.ui.baseUrl,
|
originUrl: environment.ui.baseUrl,
|
||||||
requestUrl: req.originalUrl
|
requestUrl: req.originalUrl
|
||||||
});
|
}, (err, data) => {
|
||||||
});
|
if (hasNoValue(err) && hasValue(data)) {
|
||||||
|
res.send(data);
|
||||||
} else {
|
} else {
|
||||||
// If preboot is disabled, just serve the client side ejs template and pass it the required
|
console.warn('Error in SSR, serving for direct CSR.');
|
||||||
// variables
|
if (hasValue(err)) {
|
||||||
|
console.warn('Error details : ', err);
|
||||||
|
}
|
||||||
|
res.sendFile(DIST_FOLDER + '/index.html');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// If preboot is disabled, just serve the client
|
||||||
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>`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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>
|
||||||
|
@@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
|
})
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
@@ -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) {
|
||||||
|
if (this.config.currentPage !== event) {
|
||||||
this.config.currentPage = event;
|
this.config.currentPage = event;
|
||||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
|
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;
|
||||||
|
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.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
|
||||||
this.forceUpdateEPeople();
|
this.reset();
|
||||||
} else {
|
} else {
|
||||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name }));
|
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);
|
||||||
}
|
}
|
||||||
this.epersonService.cancelEditEPerson();
|
|
||||||
this.isEPersonFormShown = false;
|
|
||||||
})
|
})
|
||||||
|
}}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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()">
|
||||||
|
@@ -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);
|
||||||
|
});
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
@@ -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,6 +174,13 @@ 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`),
|
||||||
@@ -247,6 +258,9 @@ 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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]
|
||||||
|
@@ -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')
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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']));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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 {
|
||||||
|
@@ -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(
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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 {
|
||||||
|
@@ -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),
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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 {
|
||||||
|
@@ -6,13 +6,14 @@
|
|||||||
<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>
|
||||||
|
@@ -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('.​'), value: mdField1.toString() },
|
{ displayValue: ('dc.' + mdField1.toString()).split('.').join('.​'), value: ('dc.' + mdField1.toString()) },
|
||||||
{ displayValue: mdField2.toString().split('.').join('.​'), value: mdField2.toString() },
|
{ displayValue: ('dc.' + mdField2.toString()).split('.').join('.​'), value: ('dc.' + mdField2.toString()) },
|
||||||
{ displayValue: mdField3.toString().split('.').join('.​'), value: mdField3.toString() }
|
{ displayValue: ('dc.' + mdField3.toString()).split('.').join('.​'), 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', () => {
|
||||||
|
@@ -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('.​'),
|
|
||||||
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('.​'),
|
||||||
|
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
|
||||||
|
@@ -16,7 +16,7 @@
|
|||||||
class="fas fa-undo-alt"></i>
|
class="fas fa-undo-alt"></i>
|
||||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"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"> {{"item.edit.metadata.save-button" | translate}}</span>
|
<span class="d-none d-sm-inline"> {{"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,
|
||||||
|
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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) {
|
||||||
|
this.notificationsService.error(this.getNotificationTitle('error'), (response as ErrorResponse).errorMessage);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
switchMap((response: DSOSuccessResponse) => {
|
||||||
|
if (isNotEmpty(response.resourceSelfLinks)) {
|
||||||
|
return this.itemService.findByHref(response.resourceSelfLinks[0]);
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
tap(() => this.updateService.commitUpdates()),
|
|
||||||
getSucceededRemoteData()
|
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)
|
||||||
*/
|
*/
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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>
|
||||||
|
@@ -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();
|
||||||
}));
|
}));
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
<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">
|
||||||
|
<div *ngIf="hasValuesInBundle(originals)">
|
||||||
<h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5>
|
<h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5>
|
||||||
<ds-pagination *ngIf="originals?.page?.length > 0"
|
<ds-pagination *ngIf="originals?.page?.length > 0"
|
||||||
[hideGear]="true"
|
[hideGear]="true"
|
||||||
@@ -11,7 +12,6 @@
|
|||||||
(pageChange)="switchOriginalPage($event)">
|
(pageChange)="switchOriginalPage($event)">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="file-section row" *ngFor="let file of originals?.page;">
|
<div class="file-section row" *ngFor="let file of originals?.page;">
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<dd class="col-md-8">{{file.name}}</dd>
|
<dd class="col-md-8">{{file.name}}</dd>
|
||||||
|
|
||||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||||
|
|
||||||
|
|
||||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||||
@@ -41,8 +41,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</ds-pagination>
|
</ds-pagination>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div *ngVar="(licenses$ | async)?.payload as licenses">
|
<div *ngVar="(licenses$ | async)?.payload as licenses">
|
||||||
|
<div *ngIf="hasValuesInBundle(licenses)">
|
||||||
<h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5>
|
<h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5>
|
||||||
<ds-pagination *ngIf="licenses?.page?.length > 0"
|
<ds-pagination *ngIf="licenses?.page?.length > 0"
|
||||||
[hideGear]="true"
|
[hideGear]="true"
|
||||||
@@ -54,7 +55,6 @@
|
|||||||
(pageChange)="switchLicensePage($event)">
|
(pageChange)="switchLicensePage($event)">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="file-section row" *ngFor="let file of licenses?.page;">
|
<div class="file-section row" *ngFor="let file of licenses?.page;">
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||||
@@ -65,8 +65,7 @@
|
|||||||
<dd class="col-md-8">{{file.name}}</dd>
|
<dd class="col-md-8">{{file.name}}</dd>
|
||||||
|
|
||||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||||
|
|
||||||
|
|
||||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||||
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
||||||
@@ -84,4 +83,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</ds-pagination>
|
</ds-pagination>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</ds-metadata-field-wrapper>
|
</ds-metadata-field-wrapper>
|
||||||
|
@@ -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]
|
||||||
|
@@ -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
|
||||||
@@ -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 {
|
||||||
@@ -62,7 +68,13 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
|||||||
'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(
|
||||||
@@ -71,7 +83,13 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
|||||||
'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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
30
src/app/+item-page/item-page-administrator.guard.ts
Normal file
30
src/app/+item-page/item-page-administrator.guard.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
})
|
})
|
||||||
|
@@ -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]
|
||||||
|
@@ -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>>) => {
|
||||||
|
if (bitstreamsRD.error) {
|
||||||
|
this.notificationsService.error(this.translateService.get('file-section.error.header'), `${bitstreamsRD.error.statusCode} ${bitstreamsRD.error.message}`);
|
||||||
|
} else if (hasValue(bitstreamsRD.payload)) {
|
||||||
const current: Bitstream[] = this.bitstreams$.getValue();
|
const current: Bitstream[] = this.bitstreams$.getValue();
|
||||||
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]);
|
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]);
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages;
|
this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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$);
|
||||||
}
|
}
|
||||||
|
@@ -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}`;
|
||||||
|
}
|
||||||
|
@@ -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,54 +13,68 @@ 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: '', canActivate: [AuthBlockingGuard],
|
||||||
|
children: [
|
||||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||||
{ path: 'reload/:rnd', redirectTo: '/home', pathMatch: 'full' },
|
{ path: 'reload/:rnd', component: PageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] },
|
||||||
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } },
|
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false }, canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' },
|
{ path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
|
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
|
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' },
|
{ path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] },
|
||||||
{ path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' },
|
{ path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
|
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
|
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
|
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' },
|
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{
|
{
|
||||||
path: 'mydspace',
|
path: 'mydspace',
|
||||||
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
|
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
|
||||||
canActivate: [AuthenticatedGuard]
|
canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
|
||||||
},
|
},
|
||||||
{ path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' },
|
{ path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'},
|
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] },
|
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
|
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
|
||||||
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
|
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
|
||||||
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
|
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{ path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule' },
|
{ path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||||
{
|
{
|
||||||
path: 'workspaceitems',
|
path: 'workspaceitems',
|
||||||
loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule'
|
loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule',
|
||||||
|
canActivate: [EndUserAgreementCurrentUserGuard]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: WORKFLOW_ITEM_MODULE_PATH,
|
path: WORKFLOW_ITEM_MODULE_PATH,
|
||||||
loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule'
|
loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule',
|
||||||
|
canActivate: [EndUserAgreementCurrentUserGuard]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: PROFILE_MODULE_PATH,
|
path: PROFILE_MODULE_PATH,
|
||||||
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard]
|
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
|
||||||
},
|
},
|
||||||
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] },
|
{ 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: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
|
||||||
|
{
|
||||||
|
path: 'statistics',
|
||||||
|
loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule',
|
||||||
|
},
|
||||||
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
|
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
|
||||||
|
]}
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
onSameUrlNavigation: 'reload',
|
onSameUrlNavigation: 'reload',
|
||||||
|
@@ -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>
|
||||||
|
@@ -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;
|
||||||
|
}
|
||||||
|
@@ -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) => {
|
||||||
|
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@@ -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,
|
||||||
|
62
src/app/core/auth/auth-blocking.guard.spec.ts
Normal file
62
src/app/core/auth/auth-blocking.guard.spec.ts
Normal 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 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
34
src/app/core/auth/auth-blocking.guard.ts
Normal file
34
src/app/core/auth/auth-blocking.guard.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -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;
|
||||||
|
@@ -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(
|
||||||
|
@@ -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)) {
|
||||||
|
@@ -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)]
|
||||||
};
|
};
|
||||||
|
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -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', () => {
|
||||||
|
@@ -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) => {
|
||||||
|
if (hasValue(urlFromStore)) {
|
||||||
|
return urlFromStore;
|
||||||
} else {
|
} else {
|
||||||
return this.store.pipe(select(getRedirectUrl));
|
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
|
||||||
*/
|
*/
|
||||||
|
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
@@ -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] || '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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) => {
|
||||||
|
if (hasValue(object)) {
|
||||||
const fullPath = state.url;
|
const fullPath = state.url;
|
||||||
const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid;
|
const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid;
|
||||||
return {provider: this.breadcrumbService, key: object, url: url};
|
return {provider: this.breadcrumbService, key: object, url: url};
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
6
src/app/core/cache/object-cache.service.ts
vendored
6
src/app/core/cache/object-cache.service.ts
vendored
@@ -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));
|
||||||
|
12
src/app/core/cache/response.models.ts
vendored
12
src/app/core/cache/response.models.ts
vendored
@@ -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,
|
||||||
|
@@ -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
|
||||||
@@ -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({
|
||||||
|
@@ -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];
|
||||||
}
|
}
|
||||||
|
@@ -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 }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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';
|
||||||
|
@@ -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,6 +72,7 @@ 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);
|
||||||
|
if (hasValue(matchingBundle)) {
|
||||||
return new RemoteData(
|
return new RemoteData(
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
@@ -78,6 +80,9 @@ export class BundleDataService extends DataService<Bundle> {
|
|||||||
undefined,
|
undefined,
|
||||||
matchingBundle
|
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;
|
||||||
}
|
}
|
||||||
|
@@ -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';
|
||||||
|
@@ -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(
|
||||||
|
@@ -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¶m2=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`;
|
||||||
|
|
||||||
|
@@ -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(),
|
|
||||||
mergeMap((oldVersion: T) => {
|
|
||||||
const operations = this.comparator.diff(oldVersion, object);
|
|
||||||
if (isNotEmpty(operations)) {
|
if (isNotEmpty(operations)) {
|
||||||
this.objectCache.addPatch(object._links.self.href, operations);
|
this.objectCache.addPatch(object._links.self.href, operations);
|
||||||
}
|
}
|
||||||
return this.findByHref(object._links.self.href);
|
return this.findByHref(object._links.self.href);
|
||||||
}
|
}
|
||||||
));
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
54
src/app/core/data/entries-response-parsing.service.ts
Normal file
54
src/app/core/data/entries-response-parsing.service.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -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', () => {
|
||||||
|
@@ -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);
|
||||||
})
|
})
|
||||||
|
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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',
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
|
@@ -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 });
|
||||||
}
|
}
|
||||||
|
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
@@ -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
Reference in New Issue
Block a user