mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-16 22:43:03 +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
|
||||
_Add references/links to any related issues or PRs. These may include:_
|
||||
* Fixes [GitHub issue](https://github.com/DSpace/dspace-angular/issues), if any
|
||||
* Requires [REST API PR](https://github.com/DSpace/DSpace/pulls), if any
|
||||
* Fixes #[issue-number]
|
||||
* Requires DSpace/DSpace#[pr-number] (if a REST API PR is required to test this)
|
||||
|
||||
## Description
|
||||
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 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 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.
|
||||
|
@@ -24,7 +24,7 @@ env:
|
||||
# Direct that step to utilize a DSpace REST service that has been started in docker.
|
||||
DSPACE_REST_HOST: localhost
|
||||
DSPACE_REST_PORT: 8080
|
||||
DSPACE_REST_NAMESPACE: '/server/api'
|
||||
DSPACE_REST_NAMESPACE: '/server'
|
||||
DSPACE_REST_SSL: false
|
||||
|
||||
before_install:
|
||||
@@ -60,7 +60,7 @@ after_script:
|
||||
# Shutdown docker after everything runs
|
||||
- docker-compose -f ./docker/docker-compose-travis.yml down
|
||||
|
||||
# After a successful build and test (see 'script'), send code coverage reports to coveralls.io
|
||||
# These code coverage reports are generated by the coveralls node module in our package.json
|
||||
# After a successful build and test (see 'script'), send code coverage reports to codecov.io
|
||||
# These code coverage reports are generated by the codecov node module in our package.json
|
||||
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
|
||||
==============
|
||||
|
@@ -13,6 +13,6 @@ export const environment = {
|
||||
host: 'localhost',
|
||||
port: 8080,
|
||||
// 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',
|
||||
port: 443,
|
||||
// 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_HOST=dspace7.4science.cloud
|
||||
DSPACE_REST_PORT=443
|
||||
DSPACE_REST_NAMESPACE=/server/api
|
||||
DSPACE_REST_NAMESPACE=/server
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
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",
|
||||
"jsonschema": "1.2.2",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"klaro": "^0.6.3",
|
||||
"moment": "^2.22.1",
|
||||
"morgan": "^1.9.1",
|
||||
"ng-mocks": "^8.1.0",
|
||||
@@ -136,10 +137,10 @@
|
||||
"@types/js-cookie": "2.1.0",
|
||||
"@types/lodash": "^4.14.110",
|
||||
"@types/node": "11.15.3",
|
||||
"codecov": "^3.7.2",
|
||||
"codelyzer": "^5.0.0",
|
||||
"compression-webpack-plugin": "^3.0.1",
|
||||
"copy-webpack-plugin": "^5.1.1",
|
||||
"coveralls": "^3.0.0",
|
||||
"css-loader": "3.4.0",
|
||||
"cssnano": "^4.1.10",
|
||||
"deep-freeze": "0.0.1",
|
||||
|
@@ -57,8 +57,8 @@ function generateEnvironmentFile(file: GlobalConfig): void {
|
||||
|
||||
// TODO remove workaround in beta 5
|
||||
if (file.rest.nameSpace.match("(.*)/api/?$") !== null) {
|
||||
const newValue = 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}'`));
|
||||
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 '${file.rest.nameSpace}'`));
|
||||
}
|
||||
|
||||
const contents = `export const environment = ` + JSON.stringify(file);
|
||||
|
72
server.ts
72
server.ts
@@ -15,7 +15,6 @@
|
||||
* import for `ngExpressEngine`.
|
||||
*/
|
||||
|
||||
import 'zone.js/dist/zone-node';
|
||||
import 'reflect-metadata';
|
||||
import 'rxjs';
|
||||
|
||||
@@ -34,6 +33,7 @@ import { enableProdMode, NgModuleFactory, Type } from '@angular/core';
|
||||
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
|
||||
import { environment } from './src/environments/environment';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { hasValue, hasNoValue } from './src/app/shared/empty.util';
|
||||
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('view engine', 'html');
|
||||
|
||||
/*
|
||||
@@ -131,56 +130,31 @@ app.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false }));
|
||||
* The callback function to serve server side angular
|
||||
*/
|
||||
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 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', {
|
||||
req,
|
||||
res,
|
||||
preboot: environment.universal.preboot,
|
||||
async: environment.universal.async,
|
||||
time: environment.universal.time,
|
||||
baseUrl: environment.ui.nameSpace,
|
||||
originUrl: environment.ui.baseUrl,
|
||||
requestUrl: req.originalUrl
|
||||
});
|
||||
});
|
||||
res.render(DIST_FOLDER + '/index.html', {
|
||||
req,
|
||||
res,
|
||||
preboot: environment.universal.preboot,
|
||||
async: environment.universal.async,
|
||||
time: environment.universal.time,
|
||||
baseUrl: environment.ui.nameSpace,
|
||||
originUrl: environment.ui.baseUrl,
|
||||
requestUrl: req.originalUrl
|
||||
}, (err, data) => {
|
||||
if (hasNoValue(err) && hasValue(data)) {
|
||||
res.send(data);
|
||||
} else {
|
||||
console.warn('Error in SSR, serving for direct CSR.');
|
||||
if (hasValue(err)) {
|
||||
console.warn('Error details : ', err);
|
||||
}
|
||||
res.sendFile(DIST_FOLDER + '/index.html');
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// If preboot is disabled, just serve the client side ejs template and pass it the required
|
||||
// variables
|
||||
// If preboot is disabled, just serve the client
|
||||
console.log('Universal off, serving for direct CSR');
|
||||
res.render('index-csr.ejs', {
|
||||
root: DIST_FOLDER,
|
||||
scripts: `<script>window.dspace = ${JSON.stringify(dspace)}</script>`
|
||||
});
|
||||
res.sendFile(DIST_FOLDER + '/index.html');
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -4,7 +4,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
<div *ngIf="!isEPersonFormShown">
|
||||
@@ -40,10 +40,10 @@
|
||||
</form>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(ePeople | async)?.payload?.totalElements > 0"
|
||||
*ngIf="(ePeopleDto$ | async)?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(ePeople | async)?.payload"
|
||||
[collectionSize]="(ePeople | async)?.payload?.totalElements"
|
||||
[pageInfoState]="pageInfoState$"
|
||||
[collectionSize]="(pageInfoState$ | async)?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
@@ -59,21 +59,21 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let eperson of (ePeople | async)?.payload?.page"
|
||||
[ngClass]="{'table-primary' : isActive(eperson) | async}">
|
||||
<td>{{eperson.id}}</td>
|
||||
<td>{{eperson.name}}</td>
|
||||
<td>{{eperson.email}}</td>
|
||||
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
|
||||
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
|
||||
<td>{{epersonDto.eperson.id}}</td>
|
||||
<td>{{epersonDto.eperson.name}}</td>
|
||||
<td>{{epersonDto.eperson.email}}</td>
|
||||
<td>
|
||||
<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"
|
||||
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>
|
||||
</button>
|
||||
<button (click)="deleteEPerson(eperson)"
|
||||
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
</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}}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -24,6 +24,8 @@ import { getMockTranslateService } from '../../../shared/mocks/translate.service
|
||||
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
|
||||
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.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', () => {
|
||||
let component: EPeopleRegistryComponent;
|
||||
@@ -33,6 +35,8 @@ describe('EPeopleRegistryComponent', () => {
|
||||
|
||||
let mockEPeople;
|
||||
let ePersonDataServiceStub: any;
|
||||
let authorizationService: AuthorizationDataService;
|
||||
let modalService;
|
||||
|
||||
beforeEach(async(() => {
|
||||
mockEPeople = [EPersonMock, EPersonMock2];
|
||||
@@ -82,6 +86,9 @@ describe('EPeopleRegistryComponent', () => {
|
||||
return '/admin/access-control/epeople';
|
||||
}
|
||||
};
|
||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||
isAuthorized: observableOf(true)
|
||||
});
|
||||
builderService = getMockFormBuilderService();
|
||||
translateService = getMockTranslateService();
|
||||
TestBed.configureTestingModule({
|
||||
@@ -94,11 +101,13 @@ describe('EPeopleRegistryComponent', () => {
|
||||
}),
|
||||
],
|
||||
declarations: [EPeopleRegistryComponent],
|
||||
providers: [EPeopleRegistryComponent,
|
||||
providers: [
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: AuthorizationDataService, useValue: authorizationService },
|
||||
{ provide: FormBuilderService, useValue: builderService },
|
||||
{ provide: Router, useValue: new RouterStub() },
|
||||
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
@@ -107,12 +116,14 @@ describe('EPeopleRegistryComponent', () => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EPeopleRegistryComponent);
|
||||
component = fixture.componentInstance;
|
||||
modalService = (component as any).modalService;
|
||||
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create EPeopleRegistryComponent', inject([EPeopleRegistryComponent], (comp: EPeopleRegistryComponent) => {
|
||||
expect(comp).toBeDefined();
|
||||
}));
|
||||
it('should create EPeopleRegistryComponent', () => {
|
||||
expect(component).toBeDefined();
|
||||
});
|
||||
|
||||
it('should display list of ePeople', () => {
|
||||
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 { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
|
||||
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 { RemoteData } from '../../../core/data/remote-data';
|
||||
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 { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { EpersonDtoModel } from '../../../core/eperson/models/eperson-dto.model';
|
||||
import { 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({
|
||||
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
|
||||
*/
|
||||
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
|
||||
@@ -59,8 +79,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
constructor(private epersonService: EPersonDataService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private authorizationService: AuthorizationDataService,
|
||||
private formBuilder: FormBuilder,
|
||||
private router: Router) {
|
||||
private router: Router,
|
||||
private modalService: NgbModal,
|
||||
public requestService: RequestService) {
|
||||
this.currentSearchQuery = '';
|
||||
this.currentSearchScope = 'metadata';
|
||||
this.searchForm = this.formBuilder.group(({
|
||||
@@ -70,6 +93,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.initialisePage();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will initialise the page
|
||||
*/
|
||||
initialisePage() {
|
||||
this.isEPersonFormShown = false;
|
||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
|
||||
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||
@@ -84,18 +114,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
|
||||
* a new REST call
|
||||
*/
|
||||
public forceUpdateEPeople() {
|
||||
this.epersonService.clearEPersonRequests();
|
||||
this.isEPersonFormShown = false;
|
||||
this.search({ query: '', scope: 'metadata' })
|
||||
if (this.config.currentPage !== event) {
|
||||
this.config.currentPage = event;
|
||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,10 +137,33 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
this.currentSearchScope = scope;
|
||||
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,
|
||||
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) {
|
||||
if (hasValue(ePerson.id)) {
|
||||
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => {
|
||||
if (success) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
|
||||
this.forceUpdateEPeople();
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name }));
|
||||
}
|
||||
this.epersonService.cancelEditEPerson();
|
||||
this.isEPersonFormShown = false;
|
||||
})
|
||||
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 {
|
||||
const errorResponse = restResponse as ErrorResponse;
|
||||
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + errorResponse.statusCode + ' and message: ' + errorResponse.errorMessage);
|
||||
}
|
||||
})
|
||||
}}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +232,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
* Unsub all subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.cleanupSubscribes();
|
||||
}
|
||||
|
||||
cleanupSubscribes() {
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
@@ -199,4 +258,18 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
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)">
|
||||
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
|
||||
</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}}
|
||||
</button>
|
||||
<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 { CommonModule } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
||||
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 { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
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 { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { FindListOptions } from '../../../../core/data/request.models';
|
||||
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||
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 { UUIDService } from '../../../../core/shared/uuid.service';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { EPeopleRegistryComponent } from '../epeople-registry.component';
|
||||
import { EPersonFormComponent } from './eperson-form.component';
|
||||
import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
||||
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 { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
|
||||
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 { GroupDataService } from '../../../../core/eperson/group-data.service';
|
||||
import { createPaginatedList } from '../../../../shared/testing/utils.test';
|
||||
import { RequestService } from '../../../../core/data/request.service';
|
||||
|
||||
describe('EPersonFormComponent', () => {
|
||||
let component: EPersonFormComponent;
|
||||
let fixture: ComponentFixture<EPersonFormComponent>;
|
||||
let translateService: TranslateService;
|
||||
let builderService: FormBuilderService;
|
||||
|
||||
let mockEPeople;
|
||||
@@ -111,7 +102,6 @@ describe('EPersonFormComponent', () => {
|
||||
}
|
||||
};
|
||||
builderService = getMockFormBuilderService();
|
||||
translateService = getMockTranslateService();
|
||||
authService = new AuthServiceStub();
|
||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||
isAuthorized: observableOf(true)
|
||||
@@ -129,22 +119,15 @@ describe('EPersonFormComponent', () => {
|
||||
}
|
||||
}),
|
||||
],
|
||||
declarations: [EPeopleRegistryComponent, EPersonFormComponent],
|
||||
providers: [EPersonFormComponent,
|
||||
declarations: [EPersonFormComponent],
|
||||
providers: [
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: GroupDataService, useValue: groupsDataService },
|
||||
{ provide: FormBuilderService, useValue: builderService },
|
||||
{ provide: DSOChangeAnalyzer, useValue: {} },
|
||||
{ provide: HttpClient, useValue: {} },
|
||||
{ provide: ObjectCacheService, useValue: {} },
|
||||
{ provide: UUIDService, useValue: {} },
|
||||
{ provide: Store, useValue: {} },
|
||||
{ provide: RemoteDataBuildService, useValue: {} },
|
||||
{ provide: HALEndpointService, useValue: {} },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: AuthorizationDataService, useValue: authorizationService },
|
||||
{ provide: GroupDataService, useValue: groupsDataService },
|
||||
EPeopleRegistryComponent
|
||||
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
@@ -156,9 +139,9 @@ describe('EPersonFormComponent', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create EPersonFormComponent', inject([EPersonFormComponent], (comp: EPersonFormComponent) => {
|
||||
expect(comp).toBeDefined();
|
||||
}));
|
||||
it('should create EPersonFormComponent', () => {
|
||||
expect(component).toBeDefined();
|
||||
});
|
||||
|
||||
describe('when submitting the form', () => {
|
||||
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 { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
|
||||
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({
|
||||
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
|
||||
* 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
|
||||
@@ -160,7 +162,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
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.epersonInitial = eperson;
|
||||
if (hasValue(eperson)) {
|
||||
@@ -170,13 +174,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.initialisePage();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will initialise the page
|
||||
*/
|
||||
initialisePage() {
|
||||
combineLatest(
|
||||
this.translateService.get(`${this.messagePrefix}.firstName`),
|
||||
this.translateService.get(`${this.messagePrefix}.lastName`),
|
||||
this.translateService.get(`${this.messagePrefix}.email`),
|
||||
this.translateService.get(`${this.messagePrefix}.canLogIn`),
|
||||
this.translateService.get(`${this.messagePrefix}.requireCertificate`),
|
||||
this.translateService.get(`${this.messagePrefix}.emailHint`),
|
||||
this.translateService.get(`${this.messagePrefix}.firstName`),
|
||||
this.translateService.get(`${this.messagePrefix}.lastName`),
|
||||
this.translateService.get(`${this.messagePrefix}.email`),
|
||||
this.translateService.get(`${this.messagePrefix}.canLogIn`),
|
||||
this.translateService.get(`${this.messagePrefix}.requireCertificate`),
|
||||
this.translateService.get(`${this.messagePrefix}.emailHint`),
|
||||
).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => {
|
||||
this.firstName = new DynamicInputModel({
|
||||
id: 'firstName',
|
||||
@@ -208,19 +219,19 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
hint: emailHint
|
||||
});
|
||||
this.canLogIn = new DynamicCheckboxModel(
|
||||
{
|
||||
id: 'canLogIn',
|
||||
label: canLogIn,
|
||||
name: 'canLogIn',
|
||||
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true)
|
||||
});
|
||||
{
|
||||
id: 'canLogIn',
|
||||
label: canLogIn,
|
||||
name: 'canLogIn',
|
||||
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true)
|
||||
});
|
||||
this.requireCertificate = new DynamicCheckboxModel(
|
||||
{
|
||||
id: 'requireCertificate',
|
||||
label: requireCertificate,
|
||||
name: 'requireCertificate',
|
||||
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false)
|
||||
});
|
||||
{
|
||||
id: 'requireCertificate',
|
||||
label: requireCertificate,
|
||||
name: 'requireCertificate',
|
||||
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false)
|
||||
});
|
||||
this.formModel = [
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
@@ -245,7 +256,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}));
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -420,4 +463,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
this.onCancel();
|
||||
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 { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
|
||||
import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
|
||||
describe('WorkflowItemAdminWorkflowGridElementComponent', () => {
|
||||
let component: WorkflowItemSearchResultAdminWorkflowGridElementComponent;
|
||||
@@ -50,7 +51,9 @@ describe('WorkflowItemAdminWorkflowGridElementComponent', () => {
|
||||
],
|
||||
providers: [
|
||||
{ provide: LinkService, useValue: linkService },
|
||||
{ provide: TruncatableService, useValue: {} },
|
||||
{ provide: TruncatableService, useValue: {
|
||||
isCollapsed: () => observableOf(true),
|
||||
} },
|
||||
{ provide: BitstreamDataService, useValue: {} },
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
|
@@ -6,6 +6,7 @@ import { find } from 'rxjs/operators';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
import { Bitstream } from '../core/shared/bitstream.model';
|
||||
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
|
||||
@@ -23,9 +24,20 @@ export class BitstreamPageResolver implements Resolve<RemoteData<Bitstream>> {
|
||||
* or an error if something went wrong
|
||||
*/
|
||||
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(
|
||||
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 { RemoteData } from '../../core/data/remote-data';
|
||||
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 { NotificationsService } from '../../shared/notifications/notifications.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 { RestResponse } from '../../core/cache/response.models';
|
||||
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 warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
|
||||
@@ -34,6 +39,8 @@ let bitstreamFormatService: BitstreamFormatDataService;
|
||||
let bitstream: Bitstream;
|
||||
let selectedFormat: BitstreamFormat;
|
||||
let allFormats: BitstreamFormat[];
|
||||
let router: Router;
|
||||
let routerStub;
|
||||
|
||||
describe('EditBitstreamPageComponent', () => {
|
||||
let comp: EditBitstreamPageComponent;
|
||||
@@ -105,7 +112,12 @@ describe('EditBitstreamPageComponent', () => {
|
||||
format: observableOf(new RemoteData(false, false, true, null, selectedFormat)),
|
||||
_links: {
|
||||
self: 'bitstream-selflink'
|
||||
}
|
||||
},
|
||||
bundle: createSuccessfulRemoteDataObject$({
|
||||
item: createSuccessfulRemoteDataObject$({
|
||||
uuid: 'some-uuid'
|
||||
})
|
||||
})
|
||||
});
|
||||
bitstreamService = jasmine.createSpyObj('bitstreamService', {
|
||||
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)))
|
||||
});
|
||||
|
||||
const itemPageUrl = `fake-url/some-uuid`;
|
||||
routerStub = Object.assign(new RouterStub(), {
|
||||
url: `${itemPageUrl}`
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||
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: BitstreamDataService, useValue: bitstreamService },
|
||||
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
|
||||
{ provide: Router, useValue: routerStub },
|
||||
ChangeDetectorRef
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
@@ -138,6 +155,7 @@ describe('EditBitstreamPageComponent', () => {
|
||||
fixture = TestBed.createComponent(EditBitstreamPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
router = (comp as any).router;
|
||||
});
|
||||
|
||||
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 { Bitstream } from '../../core/shared/bitstream.model';
|
||||
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 { Subscription } from 'rxjs/internal/Subscription';
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-f
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||
import {
|
||||
getAllSucceededRemoteData, getAllSucceededRemoteDataPayload,
|
||||
getAllSucceededRemoteDataPayload,
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getRemoteDataPayload,
|
||||
getSucceededRemoteData
|
||||
@@ -35,8 +35,9 @@ import { Location } from '@angular/common';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
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 {Bundle} from '../../core/shared/bundle.model';
|
||||
import {Item} from '../../core/shared/item.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-edit-bitstream-page',
|
||||
@@ -299,12 +300,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
const bitstream$ = this.bitstreamRD$.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
switchMap((bitstream: Bitstream) => this.bitstreamService.findById(bitstream.id, followLink('format')).pipe(
|
||||
getAllSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
filter((bs: Bitstream) => hasValue(bs)))
|
||||
)
|
||||
getRemoteDataPayload()
|
||||
);
|
||||
|
||||
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
|
||||
* page the user came from
|
||||
* When the item ID is present, navigate back to the item's edit bitstreams page,
|
||||
* otherwise retrieve the item ID based on the owning bundle's link
|
||||
*/
|
||||
navigateToItemEditBitstreams() {
|
||||
if (hasValue(this.itemId)) {
|
||||
this.router.navigate([getItemEditRoute(this.itemId), 'bitstreams']);
|
||||
} 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_CREATE_PATH
|
||||
} 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({
|
||||
imports: [
|
||||
@@ -39,7 +42,7 @@ import {
|
||||
{
|
||||
path: COLLECTION_EDIT_PATH,
|
||||
loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule',
|
||||
canActivate: [AuthenticatedGuard]
|
||||
canActivate: [CollectionPageAdministratorGuard]
|
||||
},
|
||||
{
|
||||
path: 'delete',
|
||||
@@ -68,7 +71,21 @@ import {
|
||||
pathMatch: 'full',
|
||||
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,
|
||||
DSOBreadcrumbsService,
|
||||
LinkService,
|
||||
CreateCollectionPageGuard
|
||||
CreateCollectionPageGuard,
|
||||
CollectionPageAdministratorGuard
|
||||
]
|
||||
})
|
||||
export class CollectionPageRoutingModule {
|
||||
|
@@ -17,7 +17,7 @@ import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
import { Item } from '../core/shared/item.model';
|
||||
import {
|
||||
getSucceededRemoteData,
|
||||
redirectToPageNotFoundOn404,
|
||||
redirectOn404Or401,
|
||||
toDSpaceObjectListRD
|
||||
} from '../core/shared/operators';
|
||||
|
||||
@@ -63,7 +63,7 @@ export class CollectionPageComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.collectionRD$ = this.route.data.pipe(
|
||||
map((data) => data.dso as RemoteData<Collection>),
|
||||
redirectToPageNotFoundOn404(this.router),
|
||||
redirectOn404Or401(this.router),
|
||||
take(1)
|
||||
);
|
||||
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 { LinkService } from '../core/cache/builders/link.service';
|
||||
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({
|
||||
imports: [
|
||||
@@ -31,7 +34,7 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
|
||||
{
|
||||
path: COMMUNITY_EDIT_PATH,
|
||||
loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule',
|
||||
canActivate: [AuthenticatedGuard]
|
||||
canActivate: [CommunityPageAdministratorGuard]
|
||||
},
|
||||
{
|
||||
path: 'delete',
|
||||
@@ -44,7 +47,21 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
|
||||
component: CommunityPageComponent,
|
||||
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,
|
||||
DSOBreadcrumbsService,
|
||||
LinkService,
|
||||
CreateCommunityPageGuard
|
||||
CreateCommunityPageGuard,
|
||||
CommunityPageAdministratorGuard
|
||||
]
|
||||
})
|
||||
export class CommunityPageRoutingModule {
|
||||
|
@@ -13,7 +13,7 @@ import { MetadataService } from '../core/metadata/metadata.service';
|
||||
|
||||
import { fadeInOut } from '../shared/animations/fade';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
import { redirectToPageNotFoundOn404 } from '../core/shared/operators';
|
||||
import { redirectOn404Or401 } from '../core/shared/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-community-page',
|
||||
@@ -47,7 +47,7 @@ export class CommunityPageComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.communityRD$ = this.route.data.pipe(
|
||||
map((data) => data.dso as RemoteData<Community>),
|
||||
redirectToPageNotFoundOn404(this.router)
|
||||
redirectOn404Or401(this.router)
|
||||
);
|
||||
this.logoRD$ = this.communityRD$.pipe(
|
||||
map((rd: RemoteData<Community>) => rd.payload),
|
||||
|
@@ -3,6 +3,8 @@ import { RouterModule } from '@angular/router';
|
||||
|
||||
import { HomePageComponent } from './home-page.component';
|
||||
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({
|
||||
imports: [
|
||||
@@ -11,7 +13,21 @@ import { HomePageResolver } from './home-page.resolver';
|
||||
path: '',
|
||||
component: HomePageComponent,
|
||||
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: {
|
||||
site: HomePageResolver
|
||||
}
|
||||
|
@@ -123,7 +123,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
|
||||
/**
|
||||
* Check if the current page is entirely valid
|
||||
*/
|
||||
protected isValid() {
|
||||
public isValid() {
|
||||
return this.objectUpdatesService.isValidPage(this.url);
|
||||
}
|
||||
|
||||
|
@@ -29,6 +29,8 @@ import {
|
||||
ITEM_EDIT_REINSTATE_PATH,
|
||||
ITEM_EDIT_WITHDRAW_PATH
|
||||
} 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
|
||||
@@ -98,10 +100,12 @@ import {
|
||||
{
|
||||
path: ITEM_EDIT_WITHDRAW_PATH,
|
||||
component: ItemWithdrawComponent,
|
||||
canActivate: [ItemPageWithdrawGuard]
|
||||
},
|
||||
{
|
||||
path: ITEM_EDIT_REINSTATE_PATH,
|
||||
component: ItemReinstateComponent,
|
||||
canActivate: [ItemPageReinstateGuard]
|
||||
},
|
||||
{
|
||||
path: ITEM_EDIT_PRIVATE_PATH,
|
||||
@@ -154,7 +158,9 @@ import {
|
||||
I18nBreadcrumbResolver,
|
||||
I18nBreadcrumbsService,
|
||||
ResourcePolicyResolver,
|
||||
ResourcePolicyTargetResolver
|
||||
ResourcePolicyTargetResolver,
|
||||
ItemPageReinstateGuard,
|
||||
ItemPageWithdrawGuard
|
||||
]
|
||||
})
|
||||
export class EditItemPageRoutingModule {
|
||||
|
@@ -5,14 +5,15 @@
|
||||
</div>
|
||||
<div *ngIf="(editable | async)" class="field-container">
|
||||
<ds-filter-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
|
||||
[(ngModel)]="metadata.key"
|
||||
[(ngModel)]="metadata.key"
|
||||
[url]="this.url"
|
||||
[metadata]="this.metadata"
|
||||
(submitSuggestion)="update(suggestionControl)"
|
||||
(clickSuggestion)="update(suggestionControl)"
|
||||
(typeSuggestion)="update(suggestionControl)"
|
||||
(dsClickOutside)="checkValidity(suggestionControl)"
|
||||
(findSuggestions)="findMetadataFieldSuggestions($event)"
|
||||
#suggestionControl="ngModel"
|
||||
[dsInListValidator]="metadataFields"
|
||||
[valid]="(valid | async) !== false"
|
||||
dsAutoFocus autoFocusSelector=".suggestion_input"
|
||||
[ngModelOptions]="{standalone: true}"
|
||||
@@ -46,12 +47,12 @@
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<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"
|
||||
title="{{'item.edit.metadata.edit.buttons.edit' | translate}}">
|
||||
<i class="fas fa-edit fa-fw"></i>
|
||||
</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"
|
||||
title="{{'item.edit.metadata.edit.buttons.unedit' | translate}}">
|
||||
<i class="fas fa-check fa-fw"></i>
|
||||
|
@@ -1,11 +1,12 @@
|
||||
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 { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { getTestScheduler } from 'jasmine-marbles';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
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 { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||
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 { MetadatumViewModel } from '../../../../core/shared/metadata.models';
|
||||
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
|
||||
import { SharedModule } from '../../../../shared/shared.module';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
||||
import {
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../../../../shared/remote-data.utils';
|
||||
import { followLink } from '../../../../shared/utils/follow-link-config.model';
|
||||
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 fixture: ComponentFixture<EditInPlaceFieldComponent>;
|
||||
@@ -25,17 +31,21 @@ let el: HTMLElement;
|
||||
let metadataFieldService;
|
||||
let objectUpdatesService;
|
||||
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(), {
|
||||
schema: mdSchema,
|
||||
schema: mdSchemaRD$,
|
||||
element: 'contributor',
|
||||
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(), {
|
||||
schema: mdSchema,
|
||||
schema: mdSchemaRD$,
|
||||
element: 'description',
|
||||
qualifier: 'abstract'
|
||||
qualifier: 'abstract',
|
||||
});
|
||||
|
||||
const metadatum = Object.assign(new MetadatumViewModel(), {
|
||||
@@ -74,11 +84,16 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [FormsModule, SharedModule, TranslateModule.forRoot()],
|
||||
declarations: [EditInPlaceFieldComponent],
|
||||
imports: [FormsModule, TranslateModule.forRoot()],
|
||||
declarations: [
|
||||
EditInPlaceFieldComponent,
|
||||
MockDirective(DebounceDirective),
|
||||
MockComponent(FilterInputSuggestionsComponent)
|
||||
],
|
||||
providers: [
|
||||
{ provide: RegistryService, useValue: metadataFieldService },
|
||||
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
|
||||
{ provide: MetadataFieldDataService, useValue: {} }
|
||||
], schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
]
|
||||
@@ -94,13 +109,12 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
comp.url = url;
|
||||
comp.fieldUpdate = fieldUpdate;
|
||||
comp.metadata = metadatum;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
beforeEach(() => {
|
||||
comp.update();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
|
||||
@@ -112,6 +126,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
const editable = false;
|
||||
beforeEach(() => {
|
||||
comp.setEditable(editable);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should contain input fields or textareas', () => {
|
||||
@@ -133,7 +148,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('editable is false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should contain no input fields or textareas', () => {
|
||||
@@ -145,7 +160,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('isValid is true', () => {
|
||||
beforeEach(() => {
|
||||
comp.valid = observableOf(true);
|
||||
objectUpdatesService.isValid.and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should not contain an error message', () => {
|
||||
@@ -157,10 +172,10 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('isValid is false', () => {
|
||||
beforeEach(() => {
|
||||
comp.valid = observableOf(false);
|
||||
objectUpdatesService.isValid.and.returnValue(observableOf(false));
|
||||
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'));
|
||||
expect(errorMessages.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -170,6 +185,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
describe('remove', () => {
|
||||
beforeEach(() => {
|
||||
comp.remove();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
|
||||
@@ -180,6 +196,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
describe('removeChangesFromField', () => {
|
||||
beforeEach(() => {
|
||||
comp.removeChangesFromField();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => {
|
||||
@@ -192,19 +209,19 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
const metadataFieldSuggestions: InputSuggestion[] =
|
||||
[
|
||||
{ displayValue: mdField1.toString().split('.').join('.​'), value: mdField1.toString() },
|
||||
{ displayValue: mdField2.toString().split('.').join('.​'), value: mdField2.toString() },
|
||||
{ displayValue: mdField3.toString().split('.').join('.​'), value: mdField3.toString() }
|
||||
{ displayValue: ('dc.' + mdField1.toString()).split('.').join('.​'), value: ('dc.' + mdField1.toString()) },
|
||||
{ displayValue: ('dc.' + mdField2.toString()).split('.').join('.​'), value: ('dc.' + mdField2.toString()) },
|
||||
{ displayValue: ('dc.' + mdField3.toString()).split('.').join('.​'), value: ('dc.' + mdField3.toString()) }
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
comp.findMetadataFieldSuggestions(query);
|
||||
|
||||
});
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => {
|
||||
|
||||
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query);
|
||||
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query, null, followLink('schema'));
|
||||
});
|
||||
|
||||
it('it should set metadataFieldSuggestions to the right value', () => {
|
||||
@@ -216,7 +233,8 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
describe('canSetEditable', () => {
|
||||
describe('when editable is currently true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('canSetEditable should return an observable emitting false', () => {
|
||||
@@ -227,12 +245,14 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('when editable is currently false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.ADD;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('canSetEditable should return an observable emitting true', () => {
|
||||
const expected = '(a|)';
|
||||
@@ -243,6 +263,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('canSetEditable should return an observable emitting false', () => {
|
||||
const expected = '(a|)';
|
||||
@@ -255,7 +276,8 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
describe('canSetUneditable', () => {
|
||||
describe('when editable is currently true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('canSetUneditable should return an observable emitting true', () => {
|
||||
@@ -266,7 +288,8 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('when editable is currently false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('canSetUneditable should return an observable emitting false', () => {
|
||||
@@ -278,7 +301,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('when canSetEditable emits true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
|
||||
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -290,7 +313,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('when canSetEditable emits false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(false));
|
||||
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -302,7 +325,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('when canSetUneditable emits true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
|
||||
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -314,7 +337,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('when canSetUneditable emits false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
|
||||
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -372,6 +395,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('canRemove should return an observable emitting true', () => {
|
||||
const expected = '(a|)';
|
||||
@@ -382,6 +406,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.ADD;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('canRemove should return an observable emitting false', () => {
|
||||
const expected = '(a|)';
|
||||
@@ -394,7 +419,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
describe('when editable is currently true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
objectUpdatesService.isEditable.and.returnValue(observableOf(true));
|
||||
comp.fieldUpdate.changeType = undefined;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -408,6 +433,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.ADD;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('canUndo should return an observable emitting true', () => {
|
||||
@@ -419,6 +445,7 @@ describe('EditInPlaceFieldComponent', () => {
|
||||
describe('when the fieldUpdate\'s changeType is currently undefined', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = undefined;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('canUndo should return an observable emitting false', () => {
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { Component, Input, OnChanges, OnInit } from '@angular/core';
|
||||
import { metadataFieldsToString } from '../../../../core/shared/operators';
|
||||
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
|
||||
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||
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 { NgModel } from '@angular/forms';
|
||||
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 { followLink } from '../../../../shared/utils/follow-link-config.model';
|
||||
|
||||
@Component({
|
||||
// tslint:disable-next-line:component-selector
|
||||
@@ -32,15 +33,10 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
|
||||
*/
|
||||
@Input() url: string;
|
||||
|
||||
/**
|
||||
* List of strings with all metadata field keys available
|
||||
*/
|
||||
@Input() metadataFields: string[];
|
||||
|
||||
/**
|
||||
* The metadatum of this field
|
||||
*/
|
||||
metadata: MetadatumViewModel;
|
||||
@Input() metadata: MetadatumViewModel;
|
||||
|
||||
/**
|
||||
* 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"
|
||||
* @param query The query to look for
|
||||
*/
|
||||
findMetadataFieldSuggestions(query: string): void {
|
||||
findMetadataFieldSuggestions(query: string) {
|
||||
if (isNotEmpty(query)) {
|
||||
this.registryService.queryMetadataFields(query).pipe(
|
||||
// getSucceededRemoteData(),
|
||||
take(1),
|
||||
map((data) => data.payload.page)
|
||||
).subscribe(
|
||||
(fields: MetadataField[]) => this.metadataFieldSuggestions.next(
|
||||
fields.map((field: MetadataField) => {
|
||||
return {
|
||||
displayValue: field.toString().split('.').join('.​'),
|
||||
value: field.toString()
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
return this.registryService.queryMetadataFields(query, null, followLink('schema')).pipe(
|
||||
metadataFieldsToString(),
|
||||
take(1))
|
||||
.subscribe((fieldNames: string[]) => {
|
||||
this.setInputSuggestions(fieldNames);
|
||||
})
|
||||
} else {
|
||||
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
|
||||
* @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>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async) || !(isValid() | async)"
|
||||
(click)="submit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<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"
|
||||
ds-edit-in-place-field
|
||||
[fieldUpdate]="updateValue || {}"
|
||||
[metadataFields]="metadataFields$ | async"
|
||||
[url]="url"
|
||||
[ngClass]="{
|
||||
'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 { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { Metadata } from '../../../core/shared/metadata.utils';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
|
||||
import { MetadataField } from '../../../core/metadata/metadata-field.model';
|
||||
import {
|
||||
createSuccessfulRemoteDataObject,
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../../../shared/remote-data.utils';
|
||||
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
|
||||
import { DSOSuccessResponse } from '../../../core/cache/response.models';
|
||||
|
||||
let comp: any;
|
||||
let fixture: ComponentFixture<ItemMetadataComponent>;
|
||||
@@ -43,6 +44,7 @@ const router = new RouterStub();
|
||||
let metadataFieldService;
|
||||
let paginatedMetadataFields;
|
||||
let routeStub;
|
||||
let objectCacheService;
|
||||
|
||||
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
|
||||
const mdField1 = Object.assign(new MetadataField(), {
|
||||
@@ -101,6 +103,8 @@ const fieldUpdate3 = {
|
||||
changeType: undefined
|
||||
};
|
||||
|
||||
const operation1 = { op: 'remove', path: '/metadata/dc.title/1' };
|
||||
|
||||
let scheduler: TestScheduler;
|
||||
let item;
|
||||
describe('ItemMetadataComponent', () => {
|
||||
@@ -119,7 +123,9 @@ describe('ItemMetadataComponent', () => {
|
||||
;
|
||||
itemService = jasmine.createSpyObj('itemService', {
|
||||
update: createSuccessfulRemoteDataObject$(item),
|
||||
commitUpdates: {}
|
||||
commitUpdates: {},
|
||||
patch: observableOf(new DSOSuccessResponse(['item-selflink'], 200, 'OK')),
|
||||
findByHref: createSuccessfulRemoteDataObject$(item)
|
||||
});
|
||||
routeStub = {
|
||||
data: observableOf({}),
|
||||
@@ -148,9 +154,13 @@ describe('ItemMetadataComponent', () => {
|
||||
getLastModified: observableOf(date),
|
||||
hasUpdates: observableOf(true),
|
||||
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({
|
||||
imports: [SharedModule, TranslateModule.forRoot()],
|
||||
@@ -162,6 +172,7 @@ describe('ItemMetadataComponent', () => {
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: NotificationsService, useValue: notificationsService },
|
||||
{ provide: RegistryService, useValue: metadataFieldService },
|
||||
{ provide: ObjectCacheService, useValue: objectCacheService },
|
||||
], schemas: [
|
||||
NO_ERRORS_SCHEMA
|
||||
]
|
||||
@@ -215,8 +226,8 @@ describe('ItemMetadataComponent', () => {
|
||||
});
|
||||
|
||||
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => {
|
||||
expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadataAsList);
|
||||
expect(itemService.update).toHaveBeenCalledWith(Object.assign(comp.item, { metadata: Metadata.toMetadataMap(comp.item.metadataAsList) }));
|
||||
expect(objectUpdatesService.createPatch).toHaveBeenCalledWith(url);
|
||||
expect(itemService.patch).toHaveBeenCalledWith(comp.item, [ operation1 ]);
|
||||
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 { ActivatedRoute, Router } from '@angular/router';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Identifiable } from '../../../core/data/object-updates/object-updates.reducer';
|
||||
import { first, map, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { first, switchMap, tap } from 'rxjs/operators';
|
||||
import { getSucceededRemoteData } from '../../../core/shared/operators';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
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 { MetadataField } from '../../../core/metadata/metadata-field.model';
|
||||
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 { 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({
|
||||
selector: 'ds-item-metadata',
|
||||
@@ -42,11 +40,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
||||
*/
|
||||
@Input() updateService: UpdateDataService<Item>;
|
||||
|
||||
/**
|
||||
* Observable with a list of strings with all existing metadata field keys
|
||||
*/
|
||||
metadataFields$: Observable<string[]>;
|
||||
|
||||
constructor(
|
||||
public itemService: ItemDataService,
|
||||
public objectUpdatesService: ObjectUpdatesService,
|
||||
@@ -54,7 +47,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
||||
public notificationsService: NotificationsService,
|
||||
public translateService: TranslateService,
|
||||
public route: ActivatedRoute,
|
||||
public metadataFieldService: RegistryService,
|
||||
) {
|
||||
super(itemService, objectUpdatesService, router, notificationsService, translateService, route);
|
||||
}
|
||||
@@ -64,7 +56,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit();
|
||||
this.metadataFields$ = this.findMetadataFields();
|
||||
if (hasNoValue(this.updateService)) {
|
||||
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
|
||||
*/
|
||||
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() {
|
||||
this.isValid().pipe(first()).subscribe((isValid) => {
|
||||
if (isValid) {
|
||||
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>;
|
||||
metadata$.pipe(
|
||||
this.objectUpdatesService.createPatch(this.url).pipe(
|
||||
first(),
|
||||
switchMap((metadata: MetadatumViewModel[]) => {
|
||||
const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata: Metadata.toMetadataMap(metadata) });
|
||||
return this.updateService.update(updatedItem);
|
||||
}),
|
||||
tap(() => this.updateService.commitUpdates()),
|
||||
getSucceededRemoteData()
|
||||
switchMap((patch: Operation[]) => {
|
||||
return this.updateService.patch(this.item, patch).pipe(
|
||||
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]);
|
||||
}
|
||||
}),
|
||||
getSucceededRemoteData()
|
||||
);
|
||||
})
|
||||
).subscribe(
|
||||
(rd: RemoteData<Item>) => {
|
||||
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)
|
||||
*/
|
||||
|
@@ -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>
|
||||
</div>
|
||||
|
||||
<div *ngFor="let operation of operations" class="w-100 pt-3">
|
||||
<ds-item-operation [operation]="operation"></ds-item-operation>
|
||||
<div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}">
|
||||
<ds-item-operation *ngIf="operation" [operation]="operation"></ds-item-operation>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -12,6 +12,7 @@ import { By } from '@angular/platform-browser';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
|
||||
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
|
||||
|
||||
describe('ItemStatusComponent', () => {
|
||||
let comp: ItemStatusComponent;
|
||||
@@ -20,7 +21,10 @@ describe('ItemStatusComponent', () => {
|
||||
const mockItem = Object.assign(new Item(), {
|
||||
id: 'fake-id',
|
||||
handle: 'fake/handle',
|
||||
lastModified: '2018'
|
||||
lastModified: '2018',
|
||||
_links: {
|
||||
self: { href: 'test-item-selflink' }
|
||||
}
|
||||
});
|
||||
|
||||
const itemPageUrl = `items/${mockItem.id}`;
|
||||
@@ -31,13 +35,20 @@ describe('ItemStatusComponent', () => {
|
||||
}
|
||||
};
|
||||
|
||||
let authorizationService: AuthorizationDataService;
|
||||
|
||||
beforeEach(async(() => {
|
||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||
isAuthorized: observableOf(true)
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
|
||||
declarations: [ItemStatusComponent],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
|
||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
|
||||
{ provide: AuthorizationDataService, useValue: authorizationService },
|
||||
], schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
@@ -3,15 +3,19 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
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 { RemoteData } from '../../../core/data/remote-data';
|
||||
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
|
||||
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
|
||||
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-status',
|
||||
templateUrl: './item-status.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
changeDetection: ChangeDetectionStrategy.Default,
|
||||
animations: [
|
||||
fadeIn,
|
||||
fadeInOut
|
||||
@@ -40,14 +44,15 @@ export class ItemStatusComponent implements OnInit {
|
||||
* The possible actions that can be performed on the item
|
||||
* key: id value: url to action's component
|
||||
*/
|
||||
operations: ItemOperation[];
|
||||
operations$: BehaviorSubject<ItemOperation[]> = new BehaviorSubject<ItemOperation[]>([]);
|
||||
|
||||
/**
|
||||
* The keys of the actions (to loop over)
|
||||
*/
|
||||
actionsKeys;
|
||||
|
||||
constructor(private route: ActivatedRoute) {
|
||||
constructor(private route: ActivatedRoute,
|
||||
private authorizationService: AuthorizationDataService) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -67,21 +72,43 @@ export class ItemStatusComponent implements OnInit {
|
||||
i18n example: 'item.edit.tabs.status.buttons.<key>.label'
|
||||
The value is supposed to be a href for the button
|
||||
*/
|
||||
this.operations = [];
|
||||
this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
|
||||
this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
|
||||
if (item.isWithdrawn) {
|
||||
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'));
|
||||
} else {
|
||||
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'));
|
||||
}
|
||||
const operations = [];
|
||||
operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
|
||||
operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
|
||||
operations.push(undefined);
|
||||
// Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously
|
||||
const indexOfWithdrawReinstate = operations.length - 1;
|
||||
if (item.isDiscoverable) {
|
||||
this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
|
||||
operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
|
||||
} 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);
|
||||
}
|
||||
|
||||
trackOperation(index: number, operation: ItemOperation) {
|
||||
return hasValue(operation) ? operation.operationKey : undefined;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,87 +1,87 @@
|
||||
<ds-metadata-field-wrapper [label]="label | translate">
|
||||
<div *ngVar="(originals$ | async)?.payload as originals">
|
||||
<h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5>
|
||||
<ds-pagination *ngIf="originals?.page?.length > 0"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
[paginationOptions]="originalOptions"
|
||||
[pageInfoState]="originals"
|
||||
[collectionSize]="originals?.totalElements"
|
||||
[disableRouteParameterUpdate]="true"
|
||||
(pageChange)="switchOriginalPage($event)">
|
||||
<div *ngIf="hasValuesInBundle(originals)">
|
||||
<h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5>
|
||||
<ds-pagination *ngIf="originals?.page?.length > 0"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
[paginationOptions]="originalOptions"
|
||||
[pageInfoState]="originals"
|
||||
[collectionSize]="originals?.totalElements"
|
||||
[disableRouteParameterUpdate]="true"
|
||||
(pageChange)="switchOriginalPage($event)">
|
||||
|
||||
|
||||
<div class="file-section row" *ngFor="let file of originals?.page;">
|
||||
<div class="col-3">
|
||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<dl class="row">
|
||||
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.name}}</dd>
|
||||
|
||||
<div class="file-section row" *ngFor="let file of originals?.page;">
|
||||
<div class="col-3">
|
||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<dl class="row">
|
||||
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.name}}</dd>
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
</ds-pagination>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngVar="(licenses$ | async)?.payload as licenses">
|
||||
<h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5>
|
||||
<ds-pagination *ngIf="licenses?.page?.length > 0"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
[paginationOptions]="licenseOptions"
|
||||
[pageInfoState]="licenses"
|
||||
[collectionSize]="licenses?.totalElements"
|
||||
[disableRouteParameterUpdate]="true"
|
||||
(pageChange)="switchLicensePage($event)">
|
||||
<div *ngIf="hasValuesInBundle(licenses)">
|
||||
<h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5>
|
||||
<ds-pagination *ngIf="licenses?.page?.length > 0"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
[paginationOptions]="licenseOptions"
|
||||
[pageInfoState]="licenses"
|
||||
[collectionSize]="licenses?.totalElements"
|
||||
[disableRouteParameterUpdate]="true"
|
||||
(pageChange)="switchLicensePage($event)">
|
||||
|
||||
|
||||
<div class="file-section row" *ngFor="let file of licenses?.page;">
|
||||
<div class="col-3">
|
||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<dl class="row">
|
||||
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.name}}</dd>
|
||||
|
||||
<div class="file-section row" *ngFor="let file of licenses?.page;">
|
||||
<div class="col-3">
|
||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<dl class="row">
|
||||
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.name}}</dd>
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
</ds-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</ds-metadata-field-wrapper>
|
||||
|
@@ -14,6 +14,8 @@ import {Bitstream} from '../../../../core/shared/bitstream.model';
|
||||
import {of as observableOf} from 'rxjs';
|
||||
import {MockBitstreamFormat1} from '../../../../shared/mocks/item.mock';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
|
||||
|
||||
describe('FullFileSectionComponent', () => {
|
||||
let comp: FullFileSectionComponent;
|
||||
@@ -61,7 +63,8 @@ describe('FullFileSectionComponent', () => {
|
||||
}), BrowserAnimationsModule],
|
||||
declarations: [FullFileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent],
|
||||
providers: [
|
||||
{provide: BitstreamDataService, useValue: bitstreamDataService}
|
||||
{provide: BitstreamDataService, useValue: bitstreamDataService},
|
||||
{provide: NotificationsService, useValue: new NotificationsServiceStub()}
|
||||
],
|
||||
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
|
@@ -10,6 +10,10 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
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
|
||||
@@ -31,14 +35,14 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
||||
licenses$: Observable<RemoteData<PaginatedList<Bitstream>>>;
|
||||
|
||||
pageSize = 5;
|
||||
originalOptions = Object.assign(new PaginationComponentOptions(),{
|
||||
originalOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'original-bitstreams-options',
|
||||
currentPage: 1,
|
||||
pageSize: this.pageSize
|
||||
});
|
||||
originalCurrentPage$ = new BehaviorSubject<number>(1);
|
||||
|
||||
licenseOptions = Object.assign(new PaginationComponentOptions(),{
|
||||
licenseOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'license-bitstreams-options',
|
||||
currentPage: 1,
|
||||
pageSize: this.pageSize
|
||||
@@ -46,9 +50,11 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
||||
licenseCurrentPage$ = new BehaviorSubject<number>(1);
|
||||
|
||||
constructor(
|
||||
bitstreamDataService: BitstreamDataService
|
||||
bitstreamDataService: BitstreamDataService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected translateService: TranslateService
|
||||
) {
|
||||
super(bitstreamDataService);
|
||||
super(bitstreamDataService, notificationsService, translateService);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -57,21 +63,33 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
||||
|
||||
initialize(): void {
|
||||
this.originals$ = this.originalCurrentPage$.pipe(
|
||||
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
|
||||
this.item,
|
||||
'ORIGINAL',
|
||||
{ elementsPerPage: this.pageSize, currentPage: pageNumber },
|
||||
followLink( 'format')
|
||||
))
|
||||
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
|
||||
this.item,
|
||||
'ORIGINAL',
|
||||
{elementsPerPage: this.pageSize, currentPage: pageNumber},
|
||||
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(
|
||||
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
|
||||
this.item,
|
||||
'LICENSE',
|
||||
{ elementsPerPage: this.pageSize, currentPage: pageNumber },
|
||||
followLink( 'format')
|
||||
))
|
||||
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
|
||||
this.item,
|
||||
'LICENSE',
|
||||
{elementsPerPage: this.pageSize, currentPage: pageNumber},
|
||||
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.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 { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
|
||||
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({
|
||||
imports: [
|
||||
@@ -34,7 +37,7 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
|
||||
{
|
||||
path: ITEM_EDIT_PATH,
|
||||
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
|
||||
canActivate: [AuthenticatedGuard]
|
||||
canActivate: [ItemPageAdministratorGuard]
|
||||
},
|
||||
{
|
||||
path: UPLOAD_BITSTREAM_PATH,
|
||||
@@ -42,6 +45,20 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
|
||||
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,
|
||||
ItemBreadcrumbResolver,
|
||||
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 {MetadataFieldWrapperComponent} from '../../../field-components/metadata-field-wrapper/metadata-field-wrapper.component';
|
||||
import {createPaginatedList} from '../../../../shared/testing/utils.test';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
|
||||
|
||||
describe('FileSectionComponent', () => {
|
||||
let comp: FileSectionComponent;
|
||||
@@ -62,7 +64,8 @@ describe('FileSectionComponent', () => {
|
||||
}), BrowserAnimationsModule],
|
||||
declarations: [FileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent],
|
||||
providers: [
|
||||
{provide: BitstreamDataService, useValue: bitstreamDataService}
|
||||
{provide: BitstreamDataService, useValue: bitstreamDataService},
|
||||
{provide: NotificationsService, useValue: new NotificationsServiceStub()}
|
||||
],
|
||||
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
|
@@ -4,10 +4,12 @@ import { BitstreamDataService } from '../../../../core/data/bitstream-data.servi
|
||||
|
||||
import { Bitstream } from '../../../../core/shared/bitstream.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 { hasNoValue, hasValue } from '../../../../shared/empty.util';
|
||||
import { hasValue } from '../../../../shared/empty.util';
|
||||
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
|
||||
@@ -36,7 +38,9 @@ export class FileSectionComponent implements OnInit {
|
||||
pageSize = 5;
|
||||
|
||||
constructor(
|
||||
protected bitstreamDataService: BitstreamDataService
|
||||
protected bitstreamDataService: BitstreamDataService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected translateService: TranslateService
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -58,14 +62,21 @@ export class FileSectionComponent implements OnInit {
|
||||
} else {
|
||||
this.currentPage++;
|
||||
}
|
||||
this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: this.currentPage, elementsPerPage: this.pageSize }).pipe(
|
||||
filter((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasValue(bitstreamsRD)),
|
||||
takeWhile((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasNoValue(bitstreamsRD.payload) && hasNoValue(bitstreamsRD.error), true)
|
||||
this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', {
|
||||
currentPage: this.currentPage,
|
||||
elementsPerPage: this.pageSize
|
||||
}).pipe(
|
||||
filter((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasValue(bitstreamsRD) && (hasValue(bitstreamsRD.error) || hasValue(bitstreamsRD.payload))),
|
||||
take(1),
|
||||
).subscribe((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
const current: Bitstream[] = this.bitstreams$.getValue();
|
||||
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]);
|
||||
this.isLoading = false;
|
||||
this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages;
|
||||
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();
|
||||
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]);
|
||||
this.isLoading = false;
|
||||
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 { 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';
|
||||
|
||||
/**
|
||||
@@ -56,7 +56,7 @@ export class ItemPageComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.itemRD$ = this.route.data.pipe(
|
||||
map((data) => data.item as RemoteData<Item>),
|
||||
redirectToPageNotFoundOn404(this.router)
|
||||
redirectOn404Or401(this.router)
|
||||
);
|
||||
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() {
|
||||
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 { RouterModule } from '@angular/router';
|
||||
import { AuthBlockingGuard } from './core/auth/auth-blocking.guard';
|
||||
|
||||
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
|
||||
import { AuthenticatedGuard } from './core/auth/authenticated.guard';
|
||||
@@ -12,55 +13,69 @@ import {
|
||||
REGISTER_PATH,
|
||||
PROFILE_MODULE_PATH,
|
||||
ADMIN_MODULE_PATH,
|
||||
BITSTREAM_MODULE_PATH
|
||||
BITSTREAM_MODULE_PATH,
|
||||
INFO_MODULE_PATH
|
||||
} from './app-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 { 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({
|
||||
imports: [
|
||||
RouterModule.forRoot([
|
||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||
{ path: 'reload/:rnd', redirectTo: '/home', pathMatch: 'full' },
|
||||
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } },
|
||||
{ path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' },
|
||||
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
|
||||
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
|
||||
{ path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' },
|
||||
{ path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' },
|
||||
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
|
||||
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
|
||||
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
|
||||
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' },
|
||||
{
|
||||
path: 'mydspace',
|
||||
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
|
||||
canActivate: [AuthenticatedGuard]
|
||||
},
|
||||
{ path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' },
|
||||
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'},
|
||||
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] },
|
||||
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
|
||||
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
|
||||
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
|
||||
{ path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule' },
|
||||
{
|
||||
path: 'workspaceitems',
|
||||
loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule'
|
||||
},
|
||||
{
|
||||
path: WORKFLOW_ITEM_MODULE_PATH,
|
||||
loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule'
|
||||
},
|
||||
{
|
||||
path: PROFILE_MODULE_PATH,
|
||||
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard]
|
||||
},
|
||||
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] },
|
||||
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
|
||||
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
|
||||
],
|
||||
{ path: '', canActivate: [AuthBlockingGuard],
|
||||
children: [
|
||||
{ path: '', 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 }, canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ 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', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] },
|
||||
{ path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{
|
||||
path: 'mydspace',
|
||||
loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
|
||||
canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
|
||||
},
|
||||
{ path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, EndUserAgreementCurrentUserGuard] },
|
||||
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
|
||||
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
|
||||
{ path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{ path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
|
||||
{
|
||||
path: 'workspaceitems',
|
||||
loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule',
|
||||
canActivate: [EndUserAgreementCurrentUserGuard]
|
||||
},
|
||||
{
|
||||
path: WORKFLOW_ITEM_MODULE_PATH,
|
||||
loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule',
|
||||
canActivate: [EndUserAgreementCurrentUserGuard]
|
||||
},
|
||||
{
|
||||
path: PROFILE_MODULE_PATH,
|
||||
loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
|
||||
},
|
||||
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] },
|
||||
{ path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' },
|
||||
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
|
||||
{
|
||||
path: 'statistics',
|
||||
loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule',
|
||||
},
|
||||
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
|
||||
]}
|
||||
],
|
||||
{
|
||||
onSameUrlNavigation: 'reload',
|
||||
})
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<div class="outer-wrapper">
|
||||
<div class="outer-wrapper" *ngIf="isNotAuthBlocking$ | async; else authLoader">
|
||||
<ds-admin-sidebar></ds-admin-sidebar>
|
||||
<div class="inner-wrapper" [@slideSidebarPadding]="{
|
||||
value: (!(sidebarVisible | async) ? 'hidden' : (slideSidebarOver | async) ? 'shown' : 'expanded'),
|
||||
@@ -23,3 +23,8 @@
|
||||
<ds-footer></ds-footer>
|
||||
</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;
|
||||
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 { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
import { Store, StoreModule } from '@ngrx/store';
|
||||
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
|
||||
@@ -32,11 +31,11 @@ import { RouterMock } from './shared/mocks/router.mock';
|
||||
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
|
||||
import { storeModuleConfig } from './app.reducer';
|
||||
import { LocaleService } from './core/locale/locale.service';
|
||||
import { authReducer } from './core/auth/auth.reducer';
|
||||
import { cold } from 'jasmine-marbles';
|
||||
|
||||
let comp: AppComponent;
|
||||
let fixture: ComponentFixture<AppComponent>;
|
||||
let de: DebugElement;
|
||||
let el: HTMLElement;
|
||||
const menuService = new MenuServiceStub();
|
||||
|
||||
describe('App component', () => {
|
||||
@@ -52,7 +51,7 @@ describe('App component', () => {
|
||||
return TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
StoreModule.forRoot({}, storeModuleConfig),
|
||||
StoreModule.forRoot(authReducer, storeModuleConfig),
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
@@ -82,12 +81,19 @@ describe('App component', () => {
|
||||
|
||||
// synchronous 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
|
||||
// query for the <div class='outer-wrapper'> by CSS element selector
|
||||
de = fixture.debugElement.query(By.css('div.outer-wrapper'));
|
||||
el = de.nativeElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
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 {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
Inject,
|
||||
OnInit,
|
||||
OnInit, Optional,
|
||||
ViewEncapsulation
|
||||
} from '@angular/core';
|
||||
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 { HostWindowState } from './shared/search/host-window.reducer';
|
||||
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 { CSSVariableService } from './shared/sass-helper/sass-helper.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 { models } from './core/core.module';
|
||||
import { LocaleService } from './core/locale/locale.service';
|
||||
|
||||
export const LANG_COOKIE = 'language_cookie';
|
||||
import { hasValue } from './shared/empty.util';
|
||||
import { KlaroService } from './shared/cookies/klaro.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-app',
|
||||
@@ -52,6 +52,11 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
notificationOptions = environment.notifications;
|
||||
models;
|
||||
|
||||
/**
|
||||
* Whether or not the authentication is currently blocking the UI
|
||||
*/
|
||||
isNotAuthBlocking$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
@Inject(NativeWindowService) private _window: NativeWindowRef,
|
||||
private translate: TranslateService,
|
||||
@@ -64,8 +69,10 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||
private cssService: CSSVariableService,
|
||||
private menuService: MenuService,
|
||||
private windowService: HostWindowService,
|
||||
private localeService: LocaleService
|
||||
private localeService: LocaleService,
|
||||
@Optional() private cookiesService: KlaroService
|
||||
) {
|
||||
|
||||
/* Use models object so all decorators are actually called */
|
||||
this.models = models;
|
||||
// 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);
|
||||
}
|
||||
this.storeCSSVariables();
|
||||
|
||||
}
|
||||
|
||||
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 color: string = environment.production ? 'red' : 'green';
|
||||
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
|
||||
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.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 { 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 { EffectsModule } from '@ngrx/effects';
|
||||
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 { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -21,6 +21,7 @@ import { AppComponent } from './app.component';
|
||||
import { appEffects } from './app.effects';
|
||||
import { appMetaReducers, debugMetaReducers } from './app.metareducers';
|
||||
import { appReducers, AppState, storeModuleConfig } from './app.reducer';
|
||||
import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
|
||||
|
||||
import { CoreModule } from './core/core.module';
|
||||
import { ClientCookieService } from './core/services/client-cookie.service';
|
||||
@@ -91,6 +92,15 @@ const PROVIDERS = [
|
||||
useClass: DSpaceRouterStateSerializer
|
||||
},
|
||||
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,
|
||||
];
|
||||
|
||||
|
@@ -4,7 +4,6 @@ import { SharedModule } from '../shared/shared.module';
|
||||
import { CommunityListPageComponent } from './community-list-page.component';
|
||||
import { CommunityListPageRoutingModule } from './community-list-page.routing.module';
|
||||
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
|
||||
@@ -13,8 +12,7 @@ import { CdkTreeModule } from '@angular/cdk/tree';
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
CommunityListPageRoutingModule,
|
||||
CdkTreeModule,
|
||||
CommunityListPageRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
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_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'),
|
||||
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 */
|
||||
@@ -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.
|
||||
* @class RetrieveAuthenticatedEpersonAction
|
||||
@@ -402,8 +417,8 @@ export type AuthActions
|
||||
| RetrieveAuthMethodsSuccessAction
|
||||
| RetrieveAuthMethodsErrorAction
|
||||
| RetrieveTokenAction
|
||||
| ResetAuthenticationMessagesAction
|
||||
| RetrieveAuthenticatedEpersonAction
|
||||
| RetrieveAuthenticatedEpersonErrorAction
|
||||
| RetrieveAuthenticatedEpersonSuccessAction
|
||||
| SetRedirectUrlAction;
|
||||
| SetRedirectUrlAction
|
||||
| RedirectAfterLoginSuccessAction;
|
||||
|
@@ -27,6 +27,7 @@ import {
|
||||
CheckAuthenticationTokenCookieAction,
|
||||
LogOutErrorAction,
|
||||
LogOutSuccessAction,
|
||||
RedirectAfterLoginSuccessAction,
|
||||
RefreshTokenAction,
|
||||
RefreshTokenErrorAction,
|
||||
RefreshTokenSuccessAction,
|
||||
@@ -79,7 +80,26 @@ export class AuthEffects {
|
||||
public authenticatedSuccess$: Observable<Action> = this.actions$.pipe(
|
||||
ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
|
||||
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"
|
||||
@@ -201,13 +221,6 @@ export class AuthEffects {
|
||||
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 })
|
||||
public redirectToLoginTokenExpired$: Observable<Action> = this.actions$
|
||||
.pipe(
|
||||
|
@@ -251,7 +251,6 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
|
||||
// Pass on the new request instead of the original request.
|
||||
return next.handle(newReq).pipe(
|
||||
// tap((response) => console.log('next.handle: ', response)),
|
||||
map((response) => {
|
||||
// Intercept a Login/Logout response
|
||||
if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) {
|
||||
|
@@ -42,6 +42,7 @@ describe('authReducer', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: false,
|
||||
};
|
||||
const action = new AuthenticateAction('user', 'password');
|
||||
@@ -49,6 +50,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
error: undefined,
|
||||
loading: true,
|
||||
info: undefined
|
||||
@@ -62,6 +64,7 @@ describe('authReducer', () => {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
info: undefined
|
||||
};
|
||||
@@ -76,6 +79,7 @@ describe('authReducer', () => {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
info: undefined
|
||||
};
|
||||
@@ -84,6 +88,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
authToken: undefined,
|
||||
@@ -96,6 +101,7 @@ describe('authReducer', () => {
|
||||
it('should properly set the state, in response to a AUTHENTICATED action', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
blocking: false,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
loading: true,
|
||||
@@ -103,8 +109,15 @@ describe('authReducer', () => {
|
||||
};
|
||||
const action = new AuthenticatedAction(mockTokenInfo);
|
||||
const newState = authReducer(initialState, action);
|
||||
|
||||
expect(newState).toEqual(initialState);
|
||||
state = {
|
||||
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', () => {
|
||||
@@ -112,6 +125,7 @@ describe('authReducer', () => {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
info: undefined
|
||||
};
|
||||
@@ -122,6 +136,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
info: undefined
|
||||
};
|
||||
@@ -133,6 +148,7 @@ describe('authReducer', () => {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
info: undefined
|
||||
};
|
||||
@@ -143,6 +159,7 @@ describe('authReducer', () => {
|
||||
authToken: undefined,
|
||||
error: 'Test error message',
|
||||
loaded: true,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined
|
||||
};
|
||||
@@ -153,6 +170,7 @@ describe('authReducer', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
};
|
||||
const action = new CheckAuthenticationTokenAction();
|
||||
@@ -160,6 +178,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
};
|
||||
expect(newState).toEqual(state);
|
||||
@@ -169,6 +188,7 @@ describe('authReducer', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: true,
|
||||
};
|
||||
const action = new CheckAuthenticationTokenCookieAction();
|
||||
@@ -176,6 +196,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
};
|
||||
expect(newState).toEqual(state);
|
||||
@@ -187,6 +208,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id
|
||||
@@ -204,6 +226,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id
|
||||
@@ -216,7 +239,8 @@ describe('authReducer', () => {
|
||||
authToken: undefined,
|
||||
error: undefined,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
info: undefined,
|
||||
refreshing: false,
|
||||
userId: undefined
|
||||
@@ -230,6 +254,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id
|
||||
@@ -242,6 +267,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: 'Test error message',
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id
|
||||
@@ -255,6 +281,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
info: undefined
|
||||
};
|
||||
@@ -265,6 +292,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id
|
||||
@@ -277,6 +305,7 @@ describe('authReducer', () => {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
error: undefined,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
info: undefined
|
||||
};
|
||||
@@ -287,6 +316,7 @@ describe('authReducer', () => {
|
||||
authToken: undefined,
|
||||
error: 'Test error message',
|
||||
loaded: true,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined
|
||||
};
|
||||
@@ -299,6 +329,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id
|
||||
@@ -311,6 +342,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id,
|
||||
@@ -325,6 +357,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id,
|
||||
@@ -338,6 +371,7 @@ describe('authReducer', () => {
|
||||
authToken: newTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id,
|
||||
@@ -352,6 +386,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id,
|
||||
@@ -364,6 +399,7 @@ describe('authReducer', () => {
|
||||
authToken: undefined,
|
||||
error: undefined,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
refreshing: false,
|
||||
@@ -378,6 +414,7 @@ describe('authReducer', () => {
|
||||
authToken: mockTokenInfo,
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
userId: EPersonMock.id
|
||||
@@ -387,6 +424,7 @@ describe('authReducer', () => {
|
||||
authenticated: false,
|
||||
authToken: undefined,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
info: 'Message',
|
||||
@@ -410,6 +448,7 @@ describe('authReducer', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
};
|
||||
const action = new AddAuthenticationMessageAction('Message');
|
||||
@@ -417,6 +456,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: 'Message'
|
||||
};
|
||||
@@ -427,6 +467,7 @@ describe('authReducer', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
error: 'Error',
|
||||
info: 'Message'
|
||||
@@ -436,6 +477,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
info: undefined
|
||||
@@ -447,6 +489,7 @@ describe('authReducer', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false
|
||||
};
|
||||
const action = new SetRedirectUrlAction('redirect.url');
|
||||
@@ -454,6 +497,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
redirectUrl: 'redirect.url'
|
||||
};
|
||||
@@ -464,6 +508,7 @@ describe('authReducer', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
authMethods: []
|
||||
};
|
||||
@@ -472,6 +517,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
authMethods: []
|
||||
};
|
||||
@@ -482,6 +528,7 @@ describe('authReducer', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
authMethods: []
|
||||
};
|
||||
@@ -494,6 +541,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
authMethods: authMethods
|
||||
};
|
||||
@@ -504,6 +552,7 @@ describe('authReducer', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
authMethods: []
|
||||
};
|
||||
@@ -513,6 +562,7 @@ describe('authReducer', () => {
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
authMethods: [new AuthMethod(AuthMethodType.Password)]
|
||||
};
|
||||
|
@@ -39,6 +39,10 @@ export interface AuthState {
|
||||
// true when loading
|
||||
loading: boolean;
|
||||
|
||||
// true when everything else should wait for authorization
|
||||
// to complete
|
||||
blocking: boolean;
|
||||
|
||||
// info message
|
||||
info?: string;
|
||||
|
||||
@@ -62,6 +66,7 @@ export interface AuthState {
|
||||
const initialState: AuthState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: false,
|
||||
authMethods: []
|
||||
};
|
||||
@@ -86,7 +91,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
||||
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
|
||||
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE:
|
||||
return Object.assign({}, state, {
|
||||
loading: true
|
||||
loading: true,
|
||||
blocking: true
|
||||
});
|
||||
|
||||
case AuthActionTypes.AUTHENTICATED_ERROR:
|
||||
@@ -96,6 +102,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
||||
authToken: undefined,
|
||||
error: (action as AuthenticationErrorAction).payload.message,
|
||||
loaded: true,
|
||||
blocking: false,
|
||||
loading: false
|
||||
});
|
||||
|
||||
@@ -110,6 +117,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
||||
loaded: true,
|
||||
error: undefined,
|
||||
loading: false,
|
||||
blocking: false,
|
||||
info: undefined,
|
||||
userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload
|
||||
});
|
||||
@@ -119,6 +127,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
||||
authenticated: false,
|
||||
authToken: undefined,
|
||||
error: (action as AuthenticationErrorAction).payload.message,
|
||||
blocking: false,
|
||||
loading: false
|
||||
});
|
||||
|
||||
@@ -132,25 +141,39 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
||||
error: (action as LogOutErrorAction).payload.message
|
||||
});
|
||||
|
||||
case AuthActionTypes.LOG_OUT_SUCCESS:
|
||||
case AuthActionTypes.REFRESH_TOKEN_ERROR:
|
||||
return Object.assign({}, state, {
|
||||
authenticated: false,
|
||||
authToken: undefined,
|
||||
error: undefined,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: undefined,
|
||||
refreshing: false,
|
||||
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_TOKEN_EXPIRED:
|
||||
return Object.assign({}, state, {
|
||||
authenticated: false,
|
||||
authToken: undefined,
|
||||
loaded: false,
|
||||
blocking: false,
|
||||
loading: false,
|
||||
info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload,
|
||||
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
|
||||
case AuthActionTypes.RETRIEVE_AUTH_METHODS:
|
||||
return Object.assign({}, state, {
|
||||
loading: true
|
||||
loading: true,
|
||||
blocking: true
|
||||
});
|
||||
|
||||
case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:
|
||||
return Object.assign({}, state, {
|
||||
loading: false,
|
||||
blocking: false,
|
||||
authMethods: (action as RetrieveAuthMethodsSuccessAction).payload
|
||||
});
|
||||
|
||||
case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR:
|
||||
return Object.assign({}, state, {
|
||||
loading: false,
|
||||
blocking: false,
|
||||
authMethods: [new AuthMethod(AuthMethodType.Password)]
|
||||
});
|
||||
|
||||
@@ -201,6 +227,12 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
||||
redirectUrl: (action as SetRedirectUrlAction).payload,
|
||||
});
|
||||
|
||||
case AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS:
|
||||
return Object.assign({}, state, {
|
||||
loading: true,
|
||||
blocking: true,
|
||||
});
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
@@ -27,6 +27,7 @@ import { EPersonDataService } from '../eperson/eperson-data.service';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { authMethodsMock } from '../../shared/testing/auth-service.stub';
|
||||
import { AuthMethod } from './models/auth.method';
|
||||
import { HardRedirectService } from '../services/hard-redirect.service';
|
||||
|
||||
describe('AuthService test', () => {
|
||||
|
||||
@@ -48,6 +49,7 @@ describe('AuthService test', () => {
|
||||
let authenticatedState;
|
||||
let unAuthenticatedState;
|
||||
let linkService;
|
||||
let hardRedirectService;
|
||||
|
||||
function init() {
|
||||
mockStore = jasmine.createSpyObj('store', {
|
||||
@@ -77,6 +79,7 @@ describe('AuthService test', () => {
|
||||
linkService = {
|
||||
resolveLinks: {}
|
||||
};
|
||||
hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']);
|
||||
spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) });
|
||||
|
||||
}
|
||||
@@ -104,6 +107,7 @@ describe('AuthService test', () => {
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: Store, useValue: mockStore },
|
||||
{ provide: EPersonDataService, useValue: mockEpersonDataService },
|
||||
{ provide: HardRedirectService, useValue: hardRedirectService },
|
||||
CookieService,
|
||||
AuthService
|
||||
],
|
||||
@@ -210,7 +214,7 @@ describe('AuthService test', () => {
|
||||
(state as any).core = Object.create({});
|
||||
(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', () => {
|
||||
@@ -289,7 +293,7 @@ describe('AuthService test', () => {
|
||||
(state as any).core = Object.create({});
|
||||
(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;
|
||||
routeServiceMock = TestBed.get(RouteService);
|
||||
routerStub = TestBed.get(Router);
|
||||
@@ -318,36 +322,28 @@ describe('AuthService test', () => {
|
||||
expect(storage.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set redirect url to previous page', () => {
|
||||
spyOn(routeServiceMock, 'getHistory').and.callThrough();
|
||||
spyOn(routerStub, 'navigateByUrl');
|
||||
authService.redirectAfterLoginSuccess(true);
|
||||
expect(routeServiceMock.getHistory).toHaveBeenCalled();
|
||||
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/collection/123');
|
||||
it('should redirect to reload with redirect url', () => {
|
||||
authService.navigateToRedirectUrl('/collection/123');
|
||||
// Reload with redirect URL set to /collection/123
|
||||
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123'))));
|
||||
});
|
||||
|
||||
it('should set redirect url to current page', () => {
|
||||
spyOn(routeServiceMock, 'getHistory').and.callThrough();
|
||||
spyOn(routerStub, 'navigateByUrl');
|
||||
authService.redirectAfterLoginSuccess(false);
|
||||
expect(routeServiceMock.getHistory).toHaveBeenCalled();
|
||||
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/home');
|
||||
it('should redirect to reload with /home', () => {
|
||||
authService.navigateToRedirectUrl('/home');
|
||||
// Reload with redirect URL set to /home
|
||||
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home'))));
|
||||
});
|
||||
|
||||
it('should redirect to / and not to /login', () => {
|
||||
spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login']));
|
||||
spyOn(routerStub, 'navigateByUrl');
|
||||
authService.redirectAfterLoginSuccess(true);
|
||||
expect(routeServiceMock.getHistory).toHaveBeenCalled();
|
||||
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/');
|
||||
it('should redirect to regular reload and not to /login', () => {
|
||||
authService.navigateToRedirectUrl('/login');
|
||||
// Reload without a redirect URL
|
||||
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$')));
|
||||
});
|
||||
|
||||
it('should redirect to / when no redirect url is found', () => {
|
||||
spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['']));
|
||||
spyOn(routerStub, 'navigateByUrl');
|
||||
authService.redirectAfterLoginSuccess(true);
|
||||
expect(routeServiceMock.getHistory).toHaveBeenCalled();
|
||||
expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/');
|
||||
it('should redirect to regular reload when no redirect url is found', () => {
|
||||
authService.navigateToRedirectUrl(undefined);
|
||||
// Reload without a redirect URL
|
||||
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$')));
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -496,7 +500,7 @@ describe('AuthService test', () => {
|
||||
(state as any).core = Object.create({});
|
||||
(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', () => {
|
||||
|
@@ -1,11 +1,10 @@
|
||||
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 { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
|
||||
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators';
|
||||
import { RouterReducerState } from '@ngrx/router-store';
|
||||
import { map, startWith, switchMap, take } from 'rxjs/operators';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
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 { AuthStatus } from './models/auth-status.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 {
|
||||
getAuthenticatedUserId,
|
||||
@@ -24,7 +31,7 @@ import {
|
||||
isTokenRefreshing,
|
||||
isAuthenticatedLoaded
|
||||
} from './selectors';
|
||||
import { AppState, routerStateSelector } from '../../app.reducer';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import {
|
||||
CheckAuthenticationTokenAction,
|
||||
ResetAuthenticationMessagesAction,
|
||||
@@ -36,6 +43,7 @@ import { RouteService } from '../services/route.service';
|
||||
import { EPersonDataService } from '../eperson/eperson-data.service';
|
||||
import { getAllSucceededRemoteDataPayload } from '../shared/operators';
|
||||
import { AuthMethod } from './models/auth.method';
|
||||
import { HardRedirectService } from '../services/hard-redirect.service';
|
||||
|
||||
export const LOGIN_ROUTE = '/login';
|
||||
export const LOGOUT_ROUTE = '/logout';
|
||||
@@ -62,43 +70,13 @@ export class AuthService {
|
||||
protected router: Router,
|
||||
protected routeService: RouteService,
|
||||
protected storage: CookieService,
|
||||
protected store: Store<AppState>
|
||||
protected store: Store<AppState>,
|
||||
protected hardRedirectService: HardRedirectService
|
||||
) {
|
||||
this.store.pipe(
|
||||
select(isAuthenticated),
|
||||
startWith(false)
|
||||
).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) {
|
||||
this.getRedirectUrl().pipe(
|
||||
take(1))
|
||||
.subscribe((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);
|
||||
public navigateToRedirectUrl(redirectUrl: string) {
|
||||
let url = `/reload/${new Date().getTime()}`;
|
||||
if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) {
|
||||
url += `?redirect=${encodeURIComponent(redirectUrl)}`;
|
||||
}
|
||||
this.hardRedirectService.redirect(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh route navigated
|
||||
*/
|
||||
public refreshAfterLogout() {
|
||||
// Hard redirect to the reload page with a unique number behind it
|
||||
// so that all state is definitely lost
|
||||
this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`;
|
||||
this.navigateToRedirectUrl(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redirect url
|
||||
*/
|
||||
getRedirectUrl(): Observable<string> {
|
||||
const redirectUrl = this.storage.get(REDIRECT_COOKIE);
|
||||
if (isNotEmpty(redirectUrl)) {
|
||||
return observableOf(redirectUrl);
|
||||
} else {
|
||||
return this.store.pipe(select(getRedirectUrl));
|
||||
}
|
||||
return this.store.pipe(
|
||||
select(getRedirectUrl),
|
||||
map((urlFromStore: string) => {
|
||||
if (hasValue(urlFromStore)) {
|
||||
return urlFromStore;
|
||||
} else {
|
||||
return this.storage.get(REDIRECT_COOKIE);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -488,6 +435,20 @@ export class AuthService {
|
||||
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
|
||||
*/
|
||||
|
@@ -1,21 +1,26 @@
|
||||
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 { take } from 'rxjs/operators';
|
||||
import { map, find, switchMap } from 'rxjs/operators';
|
||||
import { select, Store } from '@ngrx/store';
|
||||
|
||||
import { CoreState } from '../core.reducers';
|
||||
import { isAuthenticated } from './selectors';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions';
|
||||
import { isAuthenticated, isAuthenticationLoading } from './selectors';
|
||||
import { AuthService, LOGIN_ROUTE } from './auth.service';
|
||||
|
||||
/**
|
||||
* Prevent unauthorized activating and loading of routes
|
||||
* @class AuthenticatedGuard
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthenticatedGuard implements CanActivate, CanLoad {
|
||||
export class AuthenticatedGuard implements CanActivate {
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
@@ -24,46 +29,37 @@ export class AuthenticatedGuard implements CanActivate, CanLoad {
|
||||
|
||||
/**
|
||||
* True when user is authenticated
|
||||
* UrlTree with redirect to login page when user isn't authenticated
|
||||
* @method canActivate
|
||||
*/
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
|
||||
const url = state.url;
|
||||
return this.handleAuth(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when user is authenticated
|
||||
* UrlTree with redirect to login page when user isn't authenticated
|
||||
* @method canActivateChild
|
||||
*/
|
||||
canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
|
||||
canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
|
||||
return this.canActivate(route, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
|
||||
private handleAuth(url: string): Observable<boolean | UrlTree> {
|
||||
// redirect to sign in page if user is not authenticated
|
||||
observable.pipe(
|
||||
// .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url)
|
||||
take(1))
|
||||
.subscribe((authenticated) => {
|
||||
if (!authenticated) {
|
||||
return this.store.pipe(select(isAuthenticationLoading)).pipe(
|
||||
find((isLoading: boolean) => isLoading === false),
|
||||
switchMap(() => this.store.pipe(select(isAuthenticated))),
|
||||
map((authenticated) => {
|
||||
if (authenticated) {
|
||||
return authenticated;
|
||||
} else {
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @function _isRefreshing
|
||||
@@ -170,6 +178,16 @@ export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticat
|
||||
*/
|
||||
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.
|
||||
* @function isTokenRefreshing
|
||||
|
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
||||
import { HttpHeaders } from '@angular/common/http';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { filter, map, take } from 'rxjs/operators';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { isNotEmpty } from '../../shared/empty.util';
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
|
||||
import { DataService } from '../data/data.service';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { getRemoteDataPayload } from '../shared/operators';
|
||||
import { filter, map, take } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs';
|
||||
import { DSpaceObject } from '../shared/dspace-object.model';
|
||||
import { ChildHALResource } from '../shared/child-hal-resource.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
|
||||
@@ -29,12 +30,17 @@ export abstract class DSOBreadcrumbResolver<T extends ChildHALResource & DSpaceO
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<BreadcrumbConfig<T>> {
|
||||
const uuid = route.params.id;
|
||||
return this.dataService.findById(uuid, ...this.followLinks).pipe(
|
||||
getSucceededRemoteData(),
|
||||
filter((rd) => hasValue(rd.error) || hasValue(rd.payload)),
|
||||
take(1),
|
||||
getRemoteDataPayload(),
|
||||
map((object: T) => {
|
||||
const fullPath = state.url;
|
||||
const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid;
|
||||
return { provider: this.breadcrumbService, key: object, url: url };
|
||||
if (hasValue(object)) {
|
||||
const fullPath = state.url;
|
||||
const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid;
|
||||
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
|
||||
* Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated
|
||||
* @param {string} uuid
|
||||
* @param selfLink
|
||||
* the uuid of the ObjectCacheEntry
|
||||
* @param {Operation[]} patch
|
||||
* list of operations to perform
|
||||
@@ -295,8 +295,8 @@ export class ObjectCacheService {
|
||||
/**
|
||||
* Apply the existing operations on an ObjectCacheEntry in the store
|
||||
* NB: this does not make any server side changes
|
||||
* @param {string} uuid
|
||||
* the uuid of the ObjectCacheEntry
|
||||
* @param selfLink
|
||||
* the link of the ObjectCacheEntry
|
||||
*/
|
||||
private applyPatchesToCachedObject(selfLink: string) {
|
||||
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 { FacetValue } from '../../shared/search/facet-value.model';
|
||||
import { SearchFilterConfig } from '../../shared/search/search-filter-config.model';
|
||||
import { IntegrationModel } from '../integration/models/integration.model';
|
||||
import { PaginatedList } from '../data/paginated-list';
|
||||
import { SubmissionObject } from '../submission/models/submission-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 {
|
||||
constructor(
|
||||
public dataDefinition: any,
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
|
||||
|
||||
import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
|
||||
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 {
|
||||
MOCK_RESPONSE_MAP,
|
||||
ResponseMapMock,
|
||||
mockResponseMap
|
||||
mockResponseMap,
|
||||
ResponseMapMock
|
||||
} from '../shared/mocks/dspace-rest-v2/mocks/response-map.mock';
|
||||
import { NotificationsService } from '../shared/notifications/notifications.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 { EPerson } from './eperson/models/eperson.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 { MetadataField } from './metadata/metadata-field.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 { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model';
|
||||
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 { 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
|
||||
@@ -197,7 +207,7 @@ const PROVIDERS = [
|
||||
SiteDataService,
|
||||
DSOResponseParsingService,
|
||||
{ provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap },
|
||||
{ provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient]},
|
||||
{ provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] },
|
||||
DynamicFormLayoutService,
|
||||
DynamicFormService,
|
||||
DynamicFormValidationService,
|
||||
@@ -239,8 +249,6 @@ const PROVIDERS = [
|
||||
SubmissionResponseParsingService,
|
||||
SubmissionJsonPatchOperationsService,
|
||||
JsonPatchOperationsBuilder,
|
||||
AuthorityService,
|
||||
IntegrationResponseParsingService,
|
||||
UploaderService,
|
||||
UUIDService,
|
||||
NotificationsService,
|
||||
@@ -289,9 +297,14 @@ const PROVIDERS = [
|
||||
FeatureDataService,
|
||||
AuthorizationDataService,
|
||||
SiteAdministratorGuard,
|
||||
SiteRegisterGuard,
|
||||
MetadataSchemaDataService,
|
||||
MetadataFieldDataService,
|
||||
TokenResponseParsingService,
|
||||
ReloadGuard,
|
||||
EndUserAgreementCurrentUserGuard,
|
||||
EndUserAgreementCookieGuard,
|
||||
EndUserAgreementService,
|
||||
// register AuthInterceptor as HttpInterceptor
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
@@ -306,7 +319,10 @@ const PROVIDERS = [
|
||||
},
|
||||
NotificationsService,
|
||||
FilteredDiscoveryPageResponseParsingService,
|
||||
{ provide: NativeWindowService, useFactory: NativeWindowFactory }
|
||||
{ provide: NativeWindowService, useFactory: NativeWindowFactory },
|
||||
VocabularyService,
|
||||
VocabularyEntriesResponseParsingService,
|
||||
VocabularyTreeviewService
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -337,7 +353,6 @@ export const models =
|
||||
SubmissionSectionModel,
|
||||
SubmissionUploadsModel,
|
||||
AuthStatus,
|
||||
AuthorityValue,
|
||||
BrowseEntry,
|
||||
BrowseDefinition,
|
||||
ClaimedTask,
|
||||
@@ -358,7 +373,11 @@ export const models =
|
||||
Feature,
|
||||
Authorization,
|
||||
Registration,
|
||||
ConfigurationProperty
|
||||
Vocabulary,
|
||||
VocabularyEntry,
|
||||
VocabularyEntryDetail,
|
||||
ConfigurationProperty,
|
||||
UsageReport,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@@ -30,6 +30,8 @@ import { RestResponse } from '../cache/response.models';
|
||||
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
|
||||
import { configureRequest, getResponseFromEntry } from '../shared/operators';
|
||||
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
|
||||
@@ -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>>> {
|
||||
return this.bundleService.findByItemAndName(item, bundleName).pipe(
|
||||
switchMap((bundleRD: RemoteData<Bundle>) => {
|
||||
if (hasValue(bundleRD.payload)) {
|
||||
if (bundleRD.hasSucceeded && hasValue(bundleRD.payload)) {
|
||||
return this.findAllByBundle(bundleRD.payload, options, ...linksToFollow);
|
||||
} else if (!bundleRD.hasSucceeded && bundleRD.error.statusCode === 404) {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||
} else {
|
||||
return [bundleRD as any];
|
||||
}
|
||||
|
@@ -1,40 +1,22 @@
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { isNotEmpty } from '../../shared/empty.util';
|
||||
import { Injectable } from '@angular/core';
|
||||
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 { BaseResponseParsingService } from './base-response-parsing.service';
|
||||
import { ResponseParsingService } from './parsing.service';
|
||||
import { RestRequest } from './request.models';
|
||||
import { EntriesResponseParsingService } from './entries-response-parsing.service';
|
||||
import { GenericConstructor } from '../shared/generic-constructor';
|
||||
|
||||
@Injectable()
|
||||
export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
|
||||
export class BrowseEntriesResponseParsingService extends EntriesResponseParsingService<BrowseEntry> {
|
||||
|
||||
protected toCache = false;
|
||||
|
||||
constructor(
|
||||
protected objectCache: ObjectCacheService,
|
||||
) { super();
|
||||
) {
|
||||
super(objectCache);
|
||||
}
|
||||
|
||||
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
|
||||
if (isNotEmpty(data.payload)) {
|
||||
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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
getSerializerModel(): GenericConstructor<BrowseEntry> {
|
||||
return BrowseEntry;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,21 +1,11 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { compare, Operation } from 'fast-json-patch';
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
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 { SortDirection, SortOptions } from '../cache/models/sort-options.model';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
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 { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
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 { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
|
||||
import { BundleDataService } from './bundle-data.service';
|
||||
|
@@ -22,6 +22,7 @@ import { FindListOptions, GetRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
|
||||
import { Bitstream } from '../shared/bitstream.model';
|
||||
import { RemoteDataError } from './remote-data-error';
|
||||
|
||||
/**
|
||||
* A service to retrieve {@link Bundle}s from the REST API
|
||||
@@ -71,13 +72,17 @@ export class BundleDataService extends DataService<Bundle> {
|
||||
if (hasValue(rd.payload) && hasValue(rd.payload.page)) {
|
||||
const matchingBundle = rd.payload.page.find((bundle: Bundle) =>
|
||||
bundle.name === bundleName);
|
||||
return new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
matchingBundle
|
||||
);
|
||||
if (hasValue(matchingBundle)) {
|
||||
return new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
matchingBundle
|
||||
);
|
||||
} else {
|
||||
return new RemoteData(false, false, false, new RemoteDataError(404, 'Not found', `The bundle with name ${bundleName} was not found.` ))
|
||||
}
|
||||
} else {
|
||||
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 { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
|
||||
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 { of as observableOf } from 'rxjs/internal/observable/of';
|
||||
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 { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { Collection } from '../shared/collection.model';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
import { PaginatedList } from './paginated-list';
|
||||
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';
|
||||
|
||||
const url = 'fake-url';
|
||||
|
@@ -24,7 +24,7 @@ import { RequestService } from './request.service';
|
||||
@dataService(COMMUNITY)
|
||||
export class CommunityDataService extends ComColDataService<Community> {
|
||||
protected linkPath = 'communities';
|
||||
protected topLinkPath = 'communities/search/top';
|
||||
protected topLinkPath = 'search/top';
|
||||
protected cds = this;
|
||||
|
||||
constructor(
|
||||
|
@@ -18,6 +18,7 @@ import { FindListOptions, PatchRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
|
||||
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
|
||||
import { RequestParam } from '../cache/models/request-param.model';
|
||||
|
||||
const endpoint = 'https://rest.api/core';
|
||||
|
||||
@@ -150,7 +151,8 @@ describe('DataService', () => {
|
||||
currentPage: 6,
|
||||
elementsPerPage: 10,
|
||||
sort: sortOptions,
|
||||
startsWith: 'ab'
|
||||
startsWith: 'ab',
|
||||
|
||||
};
|
||||
const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` +
|
||||
`&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', () => {
|
||||
const expected = `${endpoint}?embed=bundles`;
|
||||
|
||||
|
@@ -3,7 +3,7 @@ import { Store } from '@ngrx/store';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { Observable } from 'rxjs';
|
||||
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 { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
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
|
||||
* @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> {
|
||||
let result$: Observable<string>;
|
||||
public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<string> {
|
||||
let endpoint$: Observable<string>;
|
||||
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
|
||||
* @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>;
|
||||
const args = [];
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -114,7 +112,7 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
|
||||
* Return an observable that emits created HREF
|
||||
* @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];
|
||||
|
||||
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)) {
|
||||
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);
|
||||
if (isNotEmpty(args)) {
|
||||
return new URLCombiner(href, `?${args.join('&')}`).toString();
|
||||
@@ -373,11 +376,20 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
|
||||
).subscribe();
|
||||
|
||||
return this.requestService.getByUUID(requestId).pipe(
|
||||
hasValueOperator(),
|
||||
find((request: RequestEntry) => request.completed),
|
||||
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
|
||||
*
|
||||
@@ -406,18 +418,16 @@ export abstract class DataService<T extends CacheableObject> implements UpdateDa
|
||||
* @param {DSpaceObject} object The given object
|
||||
*/
|
||||
update(object: T): Observable<RemoteData<T>> {
|
||||
const oldVersion$ = this.findByHref(object._links.self.href);
|
||||
return oldVersion$.pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
mergeMap((oldVersion: T) => {
|
||||
const operations = this.comparator.diff(oldVersion, object);
|
||||
if (isNotEmpty(operations)) {
|
||||
this.objectCache.addPatch(object._links.self.href, operations);
|
||||
return this.createPatchFromCache(object)
|
||||
.pipe(
|
||||
mergeMap((operations: Operation[]) => {
|
||||
if (isNotEmpty(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 });
|
||||
}
|
||||
|
||||
describe('when no arguments are provided and a user is authenticated', () => {
|
||||
describe('when no arguments are provided', () => {
|
||||
beforeEach(() => {
|
||||
service.searchByObject().subscribe();
|
||||
});
|
||||
|
||||
it('should call searchBy with the site\'s url and authenticated user\'s uuid', () => {
|
||||
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid));
|
||||
it('should call searchBy with the site\'s url', () => {
|
||||
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(() => {
|
||||
service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe();
|
||||
});
|
||||
|
||||
it('should call searchBy with the site\'s url, authenticated user\'s uuid and the feature', () => {
|
||||
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid, FeatureID.LoginOnBehalfOf));
|
||||
it('should call searchBy with the site\'s url and the feature', () => {
|
||||
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(() => {
|
||||
service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe();
|
||||
});
|
||||
|
||||
it('should call searchBy with the object\'s url, authenticated user\'s uuid and the feature', () => {
|
||||
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePerson.uuid, FeatureID.LoginOnBehalfOf));
|
||||
it('should call searchBy with the object\'s url and the feature', () => {
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
@@ -25,7 +25,6 @@ import { hasValue, isNotEmpty } from '../../../shared/empty.util';
|
||||
import { RequestParam } from '../../cache/models/request-param.model';
|
||||
import { AuthorizationSearchParams } from './authorization-search-params';
|
||||
import {
|
||||
addAuthenticatedUserUuidIfEmpty,
|
||||
addSiteObjectUrlIfEmpty,
|
||||
oneAuthorizationMatchesFeature
|
||||
} 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>>> {
|
||||
return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe(
|
||||
addSiteObjectUrlIfEmpty(this.siteService),
|
||||
addAuthenticatedUserUuidIfEmpty(this.authService),
|
||||
switchMap((params: AuthorizationSearchParams) => {
|
||||
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 { FeatureID } from '../feature-id';
|
||||
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
|
||||
@@ -17,16 +18,16 @@ class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
|
||||
super(authorizationService, router);
|
||||
}
|
||||
|
||||
getFeatureID(): FeatureID {
|
||||
return this.featureId;
|
||||
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
|
||||
return observableOf(this.featureId);
|
||||
}
|
||||
|
||||
getObjectUrl(): string {
|
||||
return this.objectUrl;
|
||||
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
|
||||
return observableOf(this.objectUrl);
|
||||
}
|
||||
|
||||
getEPersonUuid(): string {
|
||||
return this.ePersonUuid;
|
||||
getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
|
||||
return observableOf(this.ePersonUuid);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -9,6 +9,8 @@ import { AuthorizationDataService } from '../authorization-data.service';
|
||||
import { FeatureID } from '../feature-id';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
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
|
||||
@@ -24,29 +26,32 @@ export abstract class FeatureAuthorizationGuard implements CanActivate {
|
||||
* 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
|
||||
*/
|
||||
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
|
||||
return this.authorizationService.isAuthorized(this.getFeatureID(), this.getObjectUrl(), this.getEPersonUuid()).pipe(returnUnauthorizedUrlTreeOnFalse(this.router));
|
||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
|
||||
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
|
||||
* 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
|
||||
* Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used
|
||||
*/
|
||||
getObjectUrl(): string {
|
||||
return undefined;
|
||||
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
|
||||
return observableOf(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
getEPersonUuid(): string {
|
||||
return undefined;
|
||||
getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
|
||||
return observableOf(undefined);
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,9 @@ import { Injectable } from '@angular/core';
|
||||
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
|
||||
import { FeatureID } from '../feature-id';
|
||||
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
|
||||
@@ -19,7 +21,7 @@ export class SiteAdministratorGuard extends FeatureAuthorizationGuard {
|
||||
/**
|
||||
* Check administrator authorization rights
|
||||
*/
|
||||
getFeatureID(): FeatureID {
|
||||
return FeatureID.AdministratorOf;
|
||||
getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID> {
|
||||
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 {
|
||||
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 { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { RestResponse } from '../cache/response.models';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
/**
|
||||
@@ -165,6 +166,10 @@ export class ItemTemplateDataService implements UpdateDataService<Item> {
|
||||
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
|
||||
* @param collectionID
|
||||
|
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { dataService } from '../cache/builders/build-decorators';
|
||||
import { DataService } from './data.service';
|
||||
import { PaginatedList } from './paginated-list';
|
||||
import { RemoteData } from './remote-data';
|
||||
import { RequestService } from './request.service';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
@@ -27,6 +30,7 @@ import { RequestParam } from '../cache/models/request-param.model';
|
||||
export class MetadataFieldDataService extends DataService<MetadataField> {
|
||||
protected linkPath = 'metadatafields';
|
||||
protected searchBySchemaLinkPath = 'bySchema';
|
||||
protected searchByFieldNameLinkPath = 'byFieldName';
|
||||
|
||||
constructor(
|
||||
protected requestService: RequestService,
|
||||
@@ -53,6 +57,43 @@ export class MetadataFieldDataService extends DataService<MetadataField> {
|
||||
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
|
||||
* 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 {Identifiable} from './object-updates.reducer';
|
||||
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
|
||||
@@ -38,7 +40,8 @@ export class InitializeFieldsAction implements Action {
|
||||
payload: {
|
||||
url: string,
|
||||
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
|
||||
* @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 order A custom order to keep track of objects moving around
|
||||
* @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
|
||||
* @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
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: {},
|
||||
virtualMetadataSources: {},
|
||||
lastModified: modDate
|
||||
lastModified: modDate,
|
||||
patchOperationServiceToken: undefined
|
||||
}
|
||||
};
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
|
@@ -14,6 +14,8 @@ import {
|
||||
} from './object-updates.actions';
|
||||
import { hasNoValue, hasValue } from '../../../shared/empty.util';
|
||||
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
|
||||
@@ -48,7 +50,7 @@ export interface Identifiable {
|
||||
*/
|
||||
export interface FieldUpdate {
|
||||
field: Identifiable,
|
||||
changeType: FieldChangeType
|
||||
changeType: FieldChangeType,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +91,7 @@ export interface ObjectUpdatesEntry {
|
||||
fieldUpdates: FieldUpdates;
|
||||
virtualMetadataSources: VirtualMetadataSources;
|
||||
lastModified: Date;
|
||||
patchOperationServiceToken?: InjectionToken<PatchOperationService>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +166,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
|
||||
const url: string = action.payload.url;
|
||||
const fields: Identifiable[] = action.payload.fields;
|
||||
const lastModifiedServer: Date = action.payload.lastModified;
|
||||
const patchOperationServiceToken: InjectionToken<PatchOperationService> = action.payload.patchOperationServiceToken;
|
||||
const fieldStates = createInitialFieldStates(fields);
|
||||
const newPageState = Object.assign(
|
||||
{},
|
||||
@@ -170,7 +174,8 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
|
||||
{ fieldStates: fieldStates },
|
||||
{ fieldUpdates: {} },
|
||||
{ virtualMetadataSources: {} },
|
||||
{ lastModified: lastModifiedServer }
|
||||
{ lastModified: lastModifiedServer },
|
||||
{ patchOperationServiceToken }
|
||||
);
|
||||
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 { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer';
|
||||
import {Relationship} from '../../shared/item-relationships/relationship.model';
|
||||
import { Injector } from '@angular/core';
|
||||
|
||||
describe('ObjectUpdatesService', () => {
|
||||
let service: ObjectUpdatesService;
|
||||
@@ -31,6 +32,9 @@ describe('ObjectUpdatesService', () => {
|
||||
};
|
||||
|
||||
const modDate = new Date(2010, 2, 11);
|
||||
const injectionToken = 'fake-injection-token';
|
||||
let patchOperationService;
|
||||
let injector: Injector;
|
||||
|
||||
beforeEach(() => {
|
||||
const fieldStates = {
|
||||
@@ -40,11 +44,17 @@ describe('ObjectUpdatesService', () => {
|
||||
};
|
||||
|
||||
const objectEntry = {
|
||||
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}
|
||||
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationServiceToken: injectionToken
|
||||
};
|
||||
store = new Store<CoreState>(undefined, undefined, undefined);
|
||||
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, '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 { CoreState } from '../../core.reducers';
|
||||
import { coreSelector } from '../../core.selectors';
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
|
||||
import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
|
||||
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> {
|
||||
return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']);
|
||||
@@ -48,7 +50,8 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel
|
||||
*/
|
||||
@Injectable()
|
||||
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 fields The initial fields for the page's object
|
||||
* @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 {
|
||||
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified));
|
||||
initialize(url, fields: Identifiable[], lastModified: Date, patchOperationServiceToken?: InjectionToken<PatchOperationService>): void {
|
||||
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, patchOperationServiceToken));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,4 +343,22 @@ export class ObjectUpdatesService {
|
||||
getLastModified(url: string): Observable<Date> {
|
||||
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