diff --git a/angular.json b/angular.json index b3fbd82f02..92c0f27d2b 100644 --- a/angular.json +++ b/angular.json @@ -21,7 +21,7 @@ "path": "./webpack/webpack.common.ts", "mergeStrategies": { "loaders": "prepend" - } + }, }, "outputPath": "dist/browser", "index": "src/index.html", diff --git a/docker/README.md b/docker/README.md index f7b4b04848..ed0def0480 100644 --- a/docker/README.md +++ b/docker/README.md @@ -11,7 +11,7 @@ - Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container. - cli.assetstore.yml - Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing. -- environment.dev.js +- environment.dev.ts - Environment file for running DSpace Angular in Docker - local.cfg - Environment file for running the DSpace 7 REST API in Docker. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 05376cfb36..33268778f3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -23,4 +23,4 @@ services: stdin_open: true tty: true volumes: - - ./environment.dev.js:/app/src/environments/environment.dev.ts + - ./environment.dev.ts:/app/src/environments/environment.dev.ts diff --git a/docker/environment.dev.ts b/docker/environment.dev.ts index e8c88112fa..573c8ebb67 100644 --- a/docker/environment.dev.ts +++ b/docker/environment.dev.ts @@ -1,13 +1,13 @@ -/* +/** * The contents of this file are subject to the license and copyright * detailed in the LICENSE and NOTICE files at the root of the source * tree and available online at * * http://www.dspace.org/license/ */ -import { GlobalConfig } from '../src/config/global-config.interface'; - -export const environment: Partial = { +// This file is based on environment.template.ts provided by Angular UI +export const environment = { + // Default to using the local REST API (running in Docker) rest: { ssl: false, host: 'localhost', diff --git a/docker/local.cfg b/docker/local.cfg index 70bc45c112..a511c25789 100644 --- a/docker/local.cfg +++ b/docker/local.cfg @@ -1,5 +1,6 @@ dspace.dir=/dspace db.url=jdbc:postgresql://dspacedb:5432/dspace dspace.server.url=http://localhost:8080/server +dspace.ui.url=http://localhost:4000 dspace.name=DSpace Started with Docker Compose solr.server=http://dspacesolr:8983/solr diff --git a/docs/Configuration.md b/docs/Configuration.md index f523a9a1a1..4be21d046d 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -1,9 +1,9 @@ # Configuration -Default configuration file is located in `config/` folder. All configuration options should be listed in the default configuration file `config/environment.default.js`. Please do not change this file directly! To change the default configuration values, create local files that override the parameters you need to change: +Default configuration file is located in `src/environments/` folder. All configuration options should be listed in the default configuration file `src/environments/environment.common.ts`. Please do not change this file directly! To change the default configuration values, create local files that override the parameters you need to change. You can use `environment.template.ts` as a starting point. -- Create a new `environment.dev.js` file in `config/` for `devel` environment; -- Create a new `environment.prod.js` file in `config/` for `production` environment; +- Create a new `environment.dev.ts` file in `src/environments/` for `development` environment; +- Create a new `environment.prod.ts` file in `src/environments/` for `production` environment; Some few configuration options can be overridden by setting environment variables. These and the variable names are listed below. @@ -12,8 +12,8 @@ When you start dspace-angular on node, it spins up an http server on which it li To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above): ``` -module.exports = { - // Angular Universal server settings. +export const environment = { + // Angular UI settings. ui: { ssl: false, host: 'localhost', @@ -35,14 +35,14 @@ Alternately you can set the following environment variables. If any of these are dspace-angular connects to your DSpace installation by using its REST endpoint. To do so, you have to define the ip address, port and if ssl should be enabled. You can do this in a configuration file (see above) by adding the following options: ``` -module.exports = { +export const environment = { // The REST API server settings. rest: { ssl: true, - host: 'dspace7.4science.it', + host: 'dspace7.4science.cloud', port: 443, // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - nameSpace: '/dspace-spring-rest/api' + nameSpace: '/server/api' } }; ``` @@ -50,9 +50,9 @@ module.exports = { Alternately you can set the following environment variables. If any of these are set, it will override all configuration files: ``` DSPACE_REST_SSL=true - DSPACE_REST_HOST=localhost - DSPACE_REST_PORT=4000 - DSPACE_REST_NAMESPACE=/ + DSPACE_REST_HOST=dspace7.4science.cloud + DSPACE_REST_PORT=443 + DSPACE_REST_NAMESPACE=/server/api ``` ## Supporting analytics services other than Google Analytics diff --git a/package.json b/package.json index 6fd39420b5..36462cc724 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "pree2e": "yarn run config:prod", "pree2e:ci": "yarn run config:prod", "start": "yarn run start:prod", - "serve": "ng serve", + "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", "start:dev": "npm-run-all --parallel config:dev:watch serve", "start:prod": "yarn run build:prod && yarn run serve:ssr", "build": "ng build", @@ -43,7 +43,7 @@ "clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld", "clean": "yarn run clean:prod && yarn run clean:node && yarn run clean:env", "clean:env": "rimraf src/environments/environment.ts", - "sync-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts" + "sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts" }, "browser": { "fs": false, @@ -52,6 +52,9 @@ "https": false }, "private": true, + "resolutions": { + "minimist": "^1.2.5" + }, "dependencies": { "@angular/animations": "~8.2.14", "@angular/cdk": "8.2.3", @@ -146,7 +149,7 @@ "jasmine-core": "^3.3.0", "jasmine-marbles": "0.3.1", "jasmine-spec-reporter": "~4.2.1", - "karma": "~4.1.0", + "karma": "^5.0.9", "karma-chrome-launcher": "~2.2.0", "karma-coverage-istanbul-reporter": "~2.0.1", "karma-jasmine": "2.0.1", @@ -157,10 +160,10 @@ "optimize-css-assets-webpack-plugin": "^5.0.1", "postcss-apply": "0.11.0", "postcss-cssnext": "3.1.0", + "postcss-import": "^12.0.1", "postcss-loader": "^3.0.0", "postcss-responsive-type": "1.0.0", - "postcss-smart-import": "^0.7.6", - "protractor": "~5.4.0", + "protractor": "^7.0.0", "protractor-istanbul-plugin": "2.0.0", "raw-loader": "0.5.1", "rimraf": "^3.0.2", diff --git a/postcss.config.js b/postcss.config.js index c499f9da90..1c46e245ea 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,6 @@ module.exports = { plugins: [ - require('postcss-smart-import')(), + require('postcss-import')(), require('postcss-cssnext')(), require('postcss-apply')(), require('postcss-responsive-type')() diff --git a/scripts/serve.ts b/scripts/serve.ts new file mode 100644 index 0000000000..c69f8e8a21 --- /dev/null +++ b/scripts/serve.ts @@ -0,0 +1,11 @@ +import { environment } from '../src/environments/environment'; + +import * as child from 'child_process'; + +/** + * Calls `ng serve` with the following arguments configured for the UI in the environment file: host, port, nameSpace, ssl + */ +child.spawn( + `ng serve --host ${environment.ui.host} --port ${environment.ui.port} --servePath ${environment.ui.nameSpace} --ssl ${environment.ui.ssl}`, + { stdio:'inherit', shell: true } +); diff --git a/server.ts b/server.ts index 31cefe4ec5..a5d47d8bd7 100644 --- a/server.ts +++ b/server.ts @@ -17,26 +17,67 @@ import 'zone.js/dist/zone-node'; import 'reflect-metadata'; +import 'rxjs'; +import * as fs from 'fs'; +import * as pem from 'pem'; +import * as https from 'https'; +import * as morgan from 'morgan'; import * as express from 'express'; -import { join } from 'path'; -import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import * as bodyParser from 'body-parser'; +import * as compression from 'compression'; import * as cookieParser from 'cookie-parser'; +import { join } from 'path'; + +import { enableProdMode, NgModuleFactory, Type } from '@angular/core'; + +import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; -// Express server -const app = express(); - -const PORT = environment.ui.port || 4000; +/* + * Set path for the browser application's dist folder + */ const DIST_FOLDER = join(process.cwd(), 'dist/browser'); // * NOTE :: leave this as require() since this file is built Dynamically from webpack const { ServerAppModuleNgFactory, LAZY_MODULE_MAP, ngExpressEngine, provideModuleMap } = require('./dist/server/main'); +/* + * Create a new express application + */ +const app = express(); + +/* + * If production mode is enabled in the environment file: + * - Enable Angular's production mode + * - Enable compression for response bodies. See [compression](https://github.com/expressjs/compression) + */ +if (environment.production) { + enableProdMode(); + app.use(compression()); +} + +/* + * Enable request logging + * See [morgan](https://github.com/expressjs/morgan) + */ +app.use(morgan('dev')); + +/* + * Add cookie parser middleware + * See [morgan](https://github.com/expressjs/cookie-parser) + */ app.use(cookieParser()); + +/* + * Add parser for request bodies + * See [morgan](https://github.com/expressjs/body-parser) + */ app.use(bodyParser.json()); +/* + * Render html pages by running angular server side + */ app.engine('html', (_, options, callback) => ngExpressEngine({ bootstrap: ServerAppModuleNgFactory, @@ -51,25 +92,155 @@ app.engine('html', (_, options, callback) => }, provideModuleMap(LAZY_MODULE_MAP) ], - })(_, options, callback) + })(_, (options as any), callback) ); +/* + * Register the view engines for html and ejs + */ +app.set('view engine', 'ejs'); app.set('view engine', 'html'); + +/* + * Set views folder path to directory where template files are stored + */ app.set('views', DIST_FOLDER); -// Example Express Rest API endpoints -// app.get('/api/**', (req, res) => { }); -// Serve static files from /browser -app.get('*.*', express.static(DIST_FOLDER, { - maxAge: '1y' -})); +/* + * Adds a cache control header to the response + * The cache control value can be configured in the environments file and defaults to max-age=60 + */ +function cacheControl(req, res, next) { + // instruct browser to revalidate + res.header('Cache-Control', environment.cache.control || 'max-age=60'); + next(); +} -// All regular routes use the Universal engine -app.get('*', (req, res) => { - res.render('index', { req }); -}); +/* + * Serve static resources (images, i18n messages, …) + */ +app.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false })); -// Start up the Node server -app.listen(PORT, () => { - console.log(`Node Express server listening on http://localhost:${PORT}`); -}); +/* + * 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: `` + }); + } + } + + 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 + }); + }); + } else { + // If preboot is disabled, just serve the client side ejs template and pass it the required + // variables + console.log('Universal off, serving for direct CSR'); + res.render('index-csr.ejs', { + root: DIST_FOLDER, + scripts: `` + }); + } +} + +// Register the ngApp callback function to handle incoming requests +app.get('*', ngApp); + +/* + * Callback function for when the server has started + */ +function serverStarted() { + console.log(`[${new Date().toTimeString()}] Listening at ${environment.ui.baseUrl}`); +} + +/* + * Create an HTTPS server with the configured port and host + * @param keys SSL credentials + */ +function createHttpsServer(keys) { + https.createServer({ + key: keys.serviceKey, + cert: keys.certificate + }, app).listen(environment.ui.port, environment.ui.host, () => { + serverStarted(); + }); +} + +/* + * If SSL is enabled + * - Read credentials from configuration files + * - Call script to start an HTTPS server with these credentials + * When SSL is disabled + * - Start an HTTP server on the configured port and host + */ +if (environment.ui.ssl) { + let serviceKey; + try { + serviceKey = fs.readFileSync('./config/ssl/key.pem'); + } catch (e) { + console.warn('Service key not found at ./config/ssl/key.pem'); + } + + let certificate; + try { + certificate = fs.readFileSync('./config/ssl/cert.pem'); + } catch (e) { + console.warn('Certificate not found at ./config/ssl/key.pem'); + } + + if (serviceKey && certificate) { + createHttpsServer({ + serviceKey: serviceKey, + certificate: certificate + }); + } else { + + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + + pem.createCertificate({ + days: 1, + selfSigned: true + }, (error, keys) => { + createHttpsServer(keys); + }); + } +} else { + app.listen(environment.ui.port, environment.ui.host, () => { + serverStarted(); + }); +} diff --git a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts index 5af18c778f..f61a3c2f71 100644 --- a/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts +++ b/src/app/+admin/admin-access-control/admin-access-control-routing.module.ts @@ -6,7 +6,7 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { getAccessControlModulePath } from '../admin-routing.module'; -const GROUP_EDIT_PATH = 'groups'; +export const GROUP_EDIT_PATH = 'groups'; export function getGroupEditPath(id: string) { return new URLCombiner(getAccessControlModulePath(), GROUP_EDIT_PATH, id).toString(); diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html index 578862b561..b87b3e0848 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -14,6 +14,18 @@ [formLayout]="formLayout" (cancel)="onCancel()" (submitForm)="onSubmit()"> + + + +
diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index 292b49ac6b..693f3cf916 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -31,6 +31,8 @@ import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder 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'; +import { AuthServiceStub } from '../../../../shared/testing/auth-service.stub'; describe('EPersonFormComponent', () => { let component: EPersonFormComponent; @@ -40,6 +42,7 @@ describe('EPersonFormComponent', () => { let mockEPeople; let ePersonDataServiceStub: any; + let authService: AuthServiceStub; beforeEach(async(() => { mockEPeople = [EPersonMock, EPersonMock2]; @@ -104,6 +107,7 @@ describe('EPersonFormComponent', () => { }; builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); + authService = new AuthServiceStub(); TestBed.configureTestingModule({ imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule, TranslateModule.forRoot({ @@ -125,6 +129,7 @@ describe('EPersonFormComponent', () => { { provide: Store, useValue: {} }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, + { provide: AuthService, useValue: authService }, EPeopleRegistryComponent ], schemas: [NO_ERRORS_SCHEMA] @@ -228,4 +233,40 @@ describe('EPersonFormComponent', () => { }); }); + describe('impersonate', () => { + let ePersonId; + + beforeEach(() => { + spyOn(authService, 'impersonate').and.callThrough(); + ePersonId = 'testEPersonId'; + component.epersonInitial = Object.assign(new EPerson(), { + id: ePersonId + }); + component.impersonate(); + }); + + it('should call authService.impersonate', () => { + expect(authService.impersonate).toHaveBeenCalledWith(ePersonId); + }); + + it('should set isImpersonated to true', () => { + expect(component.isImpersonated).toBe(true); + }); + }); + + describe('stopImpersonating', () => { + beforeEach(() => { + spyOn(authService, 'stopImpersonatingAndRefresh').and.callThrough(); + component.stopImpersonating(); + }); + + it('should call authService.stopImpersonatingAndRefresh', () => { + expect(authService.stopImpersonatingAndRefresh).toHaveBeenCalled(); + }); + + it('should set isImpersonated to false', () => { + expect(component.isImpersonated).toBe(false); + }); + }); + }); diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts index cbcaef78dc..9e3bcc88c0 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -7,7 +7,7 @@ import { DynamicInputModel } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; -import { Subscription, combineLatest } from 'rxjs'; +import { Subscription, combineLatest, of } from 'rxjs'; import { Observable } from 'rxjs/internal/Observable'; import { take } from 'rxjs/operators'; import { RestResponse } from '../../../../core/cache/response.models'; @@ -22,6 +22,7 @@ import { hasValue } from '../../../../shared/empty.util'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { AuthService } from '../../../../core/auth/auth.service'; @Component({ selector: 'ds-eperson-form', @@ -105,6 +106,24 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ @Output() cancelForm: EventEmitter = new EventEmitter(); + /** + * Observable whether or not the admin is allowed to reset the EPerson's password + * TODO: Initialize the observable once the REST API supports this (currently hardcoded to return false) + */ + canReset$: Observable = of(false); + + /** + * 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 = of(false); + + /** + * Observable whether or not the admin is allowed to impersonate the EPerson + * TODO: Initialize the observable once the REST API supports this (currently hardcoded to return true) + */ + canImpersonate$: Observable = of(true); + /** * List of subscriptions */ @@ -129,13 +148,22 @@ export class EPersonFormComponent implements OnInit, OnDestroy { */ epersonInitial: EPerson; + /** + * Whether or not this EPerson is currently being impersonated + */ + isImpersonated = false; + constructor(public epersonService: EPersonDataService, public groupsDataService: GroupDataService, private formBuilderService: FormBuilderService, private translateService: TranslateService, - private notificationsService: NotificationsService,) { + private notificationsService: NotificationsService, + private authService: AuthService) { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.epersonInitial = eperson; + if (hasValue(eperson)) { + this.isImpersonated = this.authService.isImpersonatingUser(eperson.id); + } })); } @@ -364,6 +392,22 @@ export class EPersonFormComponent implements OnInit, OnDestroy { })); } + /** + * Start impersonating the EPerson + */ + impersonate() { + this.authService.impersonate(this.epersonInitial.id); + this.isImpersonated = true; + } + + /** + * Stop impersonating the EPerson + */ + stopImpersonating() { + this.authService.stopImpersonatingAndRefresh(); + this.isImpersonated = false; + } + /** * Cancel the current edit when component is destroyed & unsub all subscriptions */ diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts index aa47c93102..b199129c4e 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -3,10 +3,12 @@ import { RouterModule } from '@angular/router'; import { getAdminModulePath } from '../app-routing.module'; import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; +import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; import { URLCombiner } from '../core/url-combiner/url-combiner'; const REGISTRIES_MODULE_PATH = 'registries'; -const ACCESS_CONTROL_MODULE_PATH = 'access-control'; +export const ACCESS_CONTROL_MODULE_PATH = 'access-control'; export function getRegistriesModulePath() { return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString(); @@ -32,8 +34,18 @@ export function getAccessControlModulePath() { resolve: { breadcrumb: I18nBreadcrumbResolver }, component: AdminSearchPageComponent, data: { title: 'admin.search.title', breadcrumbKey: 'admin.search' } - } - ]), + }, + { + path: 'workflow', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + component: AdminWorkflowPageComponent, + data: { title: 'admin.workflow.title', breadcrumbKey: 'admin.workflow' } + }, + ]) + ], + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService ] }) export class AdminRoutingModule { diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index 79aad4599d..5d885b918b 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -439,6 +439,19 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { icon: 'cogs', index: 9 }, + /* Workflow */ + { + id: 'workflow', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.workflow', + link: '/admin/workflow' + } as LinkMenuItemModel, + icon: 'user-check', + index: 10 + }, ]; menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection)); diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-page.component.html b/src/app/+admin/admin-workflow-page/admin-workflow-page.component.html new file mode 100644 index 0000000000..404af131d1 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-page.component.scss b/src/app/+admin/admin-workflow-page/admin-workflow-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-page.component.spec.ts b/src/app/+admin/admin-workflow-page/admin-workflow-page.component.spec.ts new file mode 100644 index 0000000000..d329497473 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-page.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminWorkflowPageComponent } from './admin-workflow-page.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +describe('AdminSearchPageComponent', () => { + let component: AdminWorkflowPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AdminWorkflowPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminWorkflowPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-page.component.ts b/src/app/+admin/admin-workflow-page/admin-workflow-page.component.ts new file mode 100644 index 0000000000..8c86c8ec98 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-page.component.ts @@ -0,0 +1,18 @@ +import { Component, OnInit } from '@angular/core'; +import { Context } from '../../core/shared/context.model'; + +@Component({ + selector: 'ds-admin-workflow-page', + templateUrl: './admin-workflow-page.component.html', + styleUrls: ['./admin-workflow-page.component.scss'] +}) + +/** + * Component that represents a workflow item search page for administrators + */ +export class AdminWorkflowPageComponent { + /** + * The context of this page + */ + context: Context = Context.AdminWorkflowSearch; +} diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.html b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.html new file mode 100644 index 0000000000..87bae0c261 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.html @@ -0,0 +1,12 @@ + + +
+
+ {{ "admin.workflow.item.workflow" | translate }} +
+
+
    +
  • + +
  • +
diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.scss b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts new file mode 100644 index 0000000000..2f3f88fa70 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts @@ -0,0 +1,84 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { RouterTestingModule } from '@angular/router/testing'; +import { WorkflowItemSearchResultAdminWorkflowGridElementComponent } from './workflow-item-search-result-admin-workflow-grid-element.component'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { followLink } from '../../../../../shared/utils/follow-link-config.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { PublicationGridElementComponent } from '../../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component'; +import { ListableObjectDirective } from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive'; +import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model'; +import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; +import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock'; + +describe('WorkflowItemAdminWorkflowGridElementComponent', () => { + let component: WorkflowItemSearchResultAdminWorkflowGridElementComponent; + let fixture: ComponentFixture; + let id; + let wfi; + let itemRD$; + let linkService; + let object; + + function init() { + itemRD$ = createSuccessfulRemoteDataObject$(new Item()); + id = '780b2588-bda5-4112-a1cd-0b15000a5339'; + object = new WorkflowItemSearchResult() + wfi = new WorkflowItem(); + wfi.item = itemRD$; + object.indexableObject = wfi; + linkService = getMockLinkService(); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule( + { + declarations: [WorkflowItemSearchResultAdminWorkflowGridElementComponent, PublicationGridElementComponent, ListableObjectDirective], + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]), + ], + providers: [ + { provide: LinkService, useValue: linkService }, + { provide: TruncatableService, useValue: {} }, + { provide: BitstreamDataService, useValue: {} }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .overrideComponent(WorkflowItemSearchResultAdminWorkflowGridElementComponent, { + set: { + entryComponents: [PublicationGridElementComponent] + } + }) + .compileComponents(); + })); + + beforeEach(() => { + linkService.resolveLink.and.callFake((a) => a); + fixture = TestBed.createComponent(WorkflowItemSearchResultAdminWorkflowGridElementComponent); + component = fixture.componentInstance; + component.object = object; + component.linkTypes = CollectionElementLinkType; + component.index = 0; + component.viewModes = ViewMode; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should retrieve the item using the link service', () => { + expect(linkService.resolveLink).toHaveBeenCalledWith(wfi, followLink('item')); + }); +}); diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts new file mode 100644 index 0000000000..7abe99cf52 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.ts @@ -0,0 +1,98 @@ +import { Component, ComponentFactoryResolver, ElementRef, ViewChild } from '@angular/core'; +import { Item } from '../../../../../core/shared/item.model'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { getListableObjectComponent, listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { Context } from '../../../../../core/shared/context.model'; +import { SearchResultGridElementComponent } from '../../../../../shared/object-grid/search-result-grid-element/search-result-grid-element.component'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; +import { GenericConstructor } from '../../../../../core/shared/generic-constructor'; +import { ListableObjectDirective } from '../../../../../shared/object-collection/shared/listable-object/listable-object.directive'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { Observable } from 'rxjs'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { followLink } from '../../../../../shared/utils/follow-link-config.model'; +import { RemoteData } from '../../../../../core/data/remote-data'; +import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../../core/shared/operators'; +import { take } from 'rxjs/operators'; +import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model'; + +@listableObjectComponent(WorkflowItemSearchResult, ViewMode.GridElement, Context.AdminWorkflowSearch) +@Component({ + selector: 'ds-workflow-item-search-result-admin-workflow-grid-element', + styleUrls: ['./workflow-item-search-result-admin-workflow-grid-element.component.scss'], + templateUrl: './workflow-item-search-result-admin-workflow-grid-element.component.html' +}) +/** + * The component for displaying a grid element for an workflow item on the admin workflow search page + */ +export class WorkflowItemSearchResultAdminWorkflowGridElementComponent extends SearchResultGridElementComponent { + /** + * Directive used to render the dynamic component in + */ + @ViewChild(ListableObjectDirective, { static: true }) listableObjectDirective: ListableObjectDirective; + + /** + * The html child that contains the badges html + */ + @ViewChild('badges', { static: true }) badges: ElementRef; + + /** + * The html child that contains the button html + */ + @ViewChild('buttons', { static: true }) buttons: ElementRef; + + /** + * The item linked to the workflow item + */ + public item$: Observable; + + constructor( + private componentFactoryResolver: ComponentFactoryResolver, + private linkService: LinkService, + protected truncatableService: TruncatableService, + protected bitstreamDataService: BitstreamDataService + ) { + super(truncatableService, bitstreamDataService); + } + + /** + * Setup the dynamic child component + * Initialize the item object from the workflow item + */ + ngOnInit(): void { + super.ngOnInit(); + this.dso = this.linkService.resolveLink(this.dso, followLink('item')); + this.item$ = (this.dso.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()); + this.item$.pipe(take(1)).subscribe((item: Item) => { + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.getComponent(item)); + + const viewContainerRef = this.listableObjectDirective.viewContainerRef; + viewContainerRef.clear(); + + const componentRef = viewContainerRef.createComponent( + componentFactory, + 0, + undefined, + [ + [this.badges.nativeElement], + [this.buttons.nativeElement] + ]); + (componentRef.instance as any).object = item; + (componentRef.instance as any).index = this.index; + (componentRef.instance as any).linkType = this.linkType; + (componentRef.instance as any).listID = this.listID; + componentRef.changeDetectorRef.detectChanges(); + } + ) + } + + /** + * Fetch the component depending on the item's relationship type, view mode and context + * @returns {GenericConstructor} + */ + private getComponent(item: Item): GenericConstructor { + return getListableObjectComponent(item.getRenderTypes(), ViewMode.GridElement, undefined) + } + +} diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.html b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.html new file mode 100644 index 0000000000..192cc751f2 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.html @@ -0,0 +1,10 @@ +
+ {{ "admin.workflow.item.workflow" | translate }} +
+ + diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.scss b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.spec.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.spec.ts new file mode 100644 index 0000000000..53f81f96db --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.spec.ts @@ -0,0 +1,76 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { mockTruncatableService } from '../../../../../shared/mocks/mock-trucatable.service'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { RouterTestingModule } from '@angular/router/testing'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { WorkflowItemSearchResultAdminWorkflowListElementComponent } from './workflow-item-search-result-admin-workflow-list-element.component'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { followLink } from '../../../../../shared/utils/follow-link-config.model'; +import { Item } from '../../../../../core/shared/item.model'; +import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; +import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock'; + +describe('WorkflowItemAdminWorkflowListElementComponent', () => { + let component: WorkflowItemSearchResultAdminWorkflowListElementComponent; + let fixture: ComponentFixture; + let id; + let wfi; + let itemRD$; + let linkService; + let object; + + function init() { + itemRD$ = createSuccessfulRemoteDataObject$(new Item()); + id = '780b2588-bda5-4112-a1cd-0b15000a5339'; + object = new WorkflowItemSearchResult() + wfi = new WorkflowItem(); + wfi.item = itemRD$; + object.indexableObject = wfi; + linkService = getMockLinkService(); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule( + { + declarations: [WorkflowItemSearchResultAdminWorkflowListElementComponent], + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]), + ], + providers: [ + { provide: TruncatableService, useValue: mockTruncatableService }, + { provide: LinkService, useValue: linkService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + linkService.resolveLink.and.callFake((a) => a); + fixture = TestBed.createComponent(WorkflowItemSearchResultAdminWorkflowListElementComponent); + component = fixture.componentInstance; + component.object = object; + component.linkTypes = CollectionElementLinkType; + component.index = 0; + component.viewModes = ViewMode; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should retrieve the item using the link service', () => { + expect(linkService.resolveLink).toHaveBeenCalledWith(wfi, followLink('item')); + }); +}); diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.ts new file mode 100644 index 0000000000..80225db09f --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component.ts @@ -0,0 +1,44 @@ +import { Component, OnInit } from '@angular/core'; +import { ViewMode } from '../../../../../core/shared/view-mode.model'; +import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; +import { Context } from '../../../../../core/shared/context.model'; +import { WorkflowItem } from '../../../../../core/submission/models/workflowitem.model'; +import { Observable } from 'rxjs'; +import { LinkService } from '../../../../../core/cache/builders/link.service'; +import { followLink } from '../../../../../shared/utils/follow-link-config.model'; +import { RemoteData } from '../../../../../core/data/remote-data'; +import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../../core/shared/operators'; +import { Item } from '../../../../../core/shared/item.model'; +import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { WorkflowItemSearchResult } from '../../../../../shared/object-collection/shared/workflow-item-search-result.model'; + +@listableObjectComponent(WorkflowItemSearchResult, ViewMode.ListElement, Context.AdminWorkflowSearch) +@Component({ + selector: 'ds-workflow-item-search-result-admin-workflow-list-element', + styleUrls: ['./workflow-item-search-result-admin-workflow-list-element.component.scss'], + templateUrl: './workflow-item-search-result-admin-workflow-list-element.component.html' +}) +/** + * The component for displaying a list element for an workflow item on the admin workflow search page + */ +export class WorkflowItemSearchResultAdminWorkflowListElementComponent extends SearchResultListElementComponent implements OnInit { + + /** + * The item linked to the workflow item + */ + public item$: Observable; + + constructor(private linkService: LinkService, protected truncatableService: TruncatableService) { + super(truncatableService); + } + + /** + * Initialize the item object from the workflow item + */ + ngOnInit(): void { + super.ngOnInit(); + this.dso = this.linkService.resolveLink(this.dso, followLink('item')); + this.item$ = (this.dso.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()); + } +} diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.html b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.html new file mode 100644 index 0000000000..1a90a4cff4 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.html @@ -0,0 +1,7 @@ + + {{"admin.workflow.item.delete" | translate}} + + + + {{"admin.workflow.item.send-back" | translate}} + diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.scss b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.scss new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.scss @@ -0,0 +1 @@ + diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.spec.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.spec.ts new file mode 100644 index 0000000000..bca2684364 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.spec.ts @@ -0,0 +1,68 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Item } from '../../../core/shared/item.model'; +import { + ITEM_EDIT_DELETE_PATH, + ITEM_EDIT_MOVE_PATH, + ITEM_EDIT_PRIVATE_PATH, + ITEM_EDIT_PUBLIC_PATH, + ITEM_EDIT_REINSTATE_PATH, + ITEM_EDIT_WITHDRAW_PATH +} from '../../../+item-page/edit-item-page/edit-item-page.routing.module'; +import { getItemEditPath } from '../../../+item-page/item-page-routing.module'; +import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { WorkflowItemAdminWorkflowActionsComponent } from './workflow-item-admin-workflow-actions.component'; +import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; +import { getWorkflowItemDeletePath, getWorkflowItemSendBackPath } from '../../../+workflowitems-edit-page/workflowitems-edit-page-routing.module'; + +describe('WorkflowItemAdminWorkflowActionsComponent', () => { + let component: WorkflowItemAdminWorkflowActionsComponent; + let fixture: ComponentFixture; + let id; + let wfi; + + function init() { + id = '780b2588-bda5-4112-a1cd-0b15000a5339'; + wfi = new WorkflowItem(); + wfi.id = id; + } + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]) + ], + declarations: [WorkflowItemAdminWorkflowActionsComponent], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WorkflowItemAdminWorkflowActionsComponent); + component = fixture.componentInstance; + component.wfi = wfi; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render a delete button with the correct link', () => { + const button = fixture.debugElement.query(By.css('a.delete-link')); + const link = button.nativeElement.href; + expect(link).toContain(new URLCombiner(getWorkflowItemDeletePath(wfi.id)).toString()); + }); + + it('should render a move button with the correct link', () => { + const a = fixture.debugElement.query(By.css('a.send-back-link')); + const link = a.nativeElement.href; + expect(link).toContain(new URLCombiner(getWorkflowItemSendBackPath(wfi.id)).toString()); + }); +}); diff --git a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.ts new file mode 100644 index 0000000000..d44f870b14 --- /dev/null +++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.ts @@ -0,0 +1,39 @@ +import { Component, Input } from '@angular/core'; +import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; +import { getWorkflowItemDeletePath, getWorkflowItemSendBackPath } from '../../../+workflowitems-edit-page/workflowitems-edit-page-routing.module'; + +@Component({ + selector: 'ds-workflow-item-admin-workflow-actions-element', + styleUrls: ['./workflow-item-admin-workflow-actions.component.scss'], + templateUrl: './workflow-item-admin-workflow-actions.component.html' +}) +/** + * The component for displaying the actions for a list element for an item on the admin workflow search page + */ +export class WorkflowItemAdminWorkflowActionsComponent { + + /** + * The workflow item to perform the actions on + */ + @Input() public wfi: WorkflowItem; + + /** + * Whether or not to use small buttons + */ + @Input() public small: boolean; + + /** + * Returns the path to the delete page of this workflow item + */ + getDeletePath(): string { + + return getWorkflowItemDeletePath(this.wfi.id) + } + + /** + * Returns the path to the send back page of this workflow item + */ + getSendBackPath(): string { + return getWorkflowItemSendBackPath(this.wfi.id); + } +} diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts index fa2480a6ad..25b8bd4648 100644 --- a/src/app/+admin/admin.module.ts +++ b/src/app/+admin/admin.module.ts @@ -12,6 +12,10 @@ import { ItemAdminSearchResultGridElementComponent } from './admin-search-page/a import { CommunityAdminSearchResultGridElementComponent } from './admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component'; import { CollectionAdminSearchResultGridElementComponent } from './admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component'; import { ItemAdminSearchResultActionsComponent } from './admin-search-page/admin-search-results/item-admin-search-result-actions.component'; +import { WorkflowItemSearchResultAdminWorkflowGridElementComponent } from './admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component'; +import { WorkflowItemAdminWorkflowActionsComponent } from './admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component'; +import { WorkflowItemSearchResultAdminWorkflowListElementComponent } from './admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-list-element/workflow-item/workflow-item-search-result-admin-workflow-list-element.component'; +import { AdminWorkflowPageComponent } from './admin-workflow-page/admin-workflow-page.component'; @NgModule({ imports: [ @@ -23,13 +27,19 @@ import { ItemAdminSearchResultActionsComponent } from './admin-search-page/admin ], declarations: [ AdminSearchPageComponent, + AdminWorkflowPageComponent, ItemAdminSearchResultListElementComponent, CommunityAdminSearchResultListElementComponent, CollectionAdminSearchResultListElementComponent, ItemAdminSearchResultGridElementComponent, CommunityAdminSearchResultGridElementComponent, CollectionAdminSearchResultGridElementComponent, - ItemAdminSearchResultActionsComponent + ItemAdminSearchResultActionsComponent, + + WorkflowItemSearchResultAdminWorkflowListElementComponent, + WorkflowItemSearchResultAdminWorkflowGridElementComponent, + WorkflowItemAdminWorkflowActionsComponent + ], entryComponents: [ ItemAdminSearchResultListElementComponent, @@ -38,7 +48,11 @@ import { ItemAdminSearchResultActionsComponent } from './admin-search-page/admin ItemAdminSearchResultGridElementComponent, CommunityAdminSearchResultGridElementComponent, CollectionAdminSearchResultGridElementComponent, - ItemAdminSearchResultActionsComponent + ItemAdminSearchResultActionsComponent, + + WorkflowItemSearchResultAdminWorkflowListElementComponent, + WorkflowItemSearchResultAdminWorkflowGridElementComponent, + WorkflowItemAdminWorkflowActionsComponent ] }) export class AdminModule { diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index d02aafcfa1..acb23fe592 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -5,7 +5,6 @@ import { EditItemPageRoutingModule } from './edit-item-page.routing.module'; import { EditItemPageComponent } from './edit-item-page.component'; import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemOperationComponent } from './item-operation/item-operation.component'; -import { ModifyItemOverviewComponent } from './modify-item-overview/modify-item-overview.component'; import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component'; import { ItemReinstateComponent } from './item-reinstate/item-reinstate.component'; import { AbstractSimpleItemActionComponent } from './simple-item-action/abstract-simple-item-action.component'; @@ -30,6 +29,9 @@ import { ItemEditBitstreamDragHandleComponent } from './item-bitstreams/item-edi import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component'; import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component'; import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; +import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component'; +import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; +import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -47,7 +49,6 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version ItemOperationComponent, AbstractSimpleItemActionComponent, AbstractItemUpdateComponent, - ModifyItemOverviewComponent, ItemWithdrawComponent, ItemReinstateComponent, ItemPrivateComponent, @@ -69,6 +70,9 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version ItemMoveComponent, ItemEditBitstreamDragHandleComponent, VirtualMetadataComponent, + ItemAuthorizationsComponent, + ResourcePolicyEditComponent, + ResourcePolicyCreateComponent, ], providers: [ BundleDataService diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index e4b1b06730..87b4b7a592 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -14,6 +14,12 @@ import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; +import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component'; +import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver'; +import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver'; +import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component'; +import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component'; +import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service'; export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; export const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; @@ -21,6 +27,7 @@ export const ITEM_EDIT_PRIVATE_PATH = 'private'; export const ITEM_EDIT_PUBLIC_PATH = 'public'; export const ITEM_EDIT_DELETE_PATH = 'delete'; export const ITEM_EDIT_MOVE_PATH = 'move'; +export const ITEM_EDIT_AUTHORIZATIONS_PATH = 'authorizations'; /** * Routing module that handles the routing for the Edit Item page administrator functionality @@ -111,12 +118,43 @@ export const ITEM_EDIT_MOVE_PATH = 'move'; path: ITEM_EDIT_MOVE_PATH, component: ItemMoveComponent, data: { title: 'item.edit.move.title' }, + }, + { + path: ITEM_EDIT_AUTHORIZATIONS_PATH, + children: [ + { + path: 'create', + resolve: { + resourcePolicyTarget: ResourcePolicyTargetResolver + }, + component: ResourcePolicyCreateComponent, + data: { title: 'resource-policies.create.page.title' } + }, + { + path: 'edit', + resolve: { + resourcePolicy: ResourcePolicyResolver + }, + component: ResourcePolicyEditComponent, + data: { title: 'resource-policies.edit.page.title' } + }, + { + path: '', + component: ItemAuthorizationsComponent, + data: { title: 'item.edit.authorizations.title' } + } + ] } ] } ]) ], - providers: [] + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService, + ResourcePolicyResolver, + ResourcePolicyTargetResolver + ] }) export class EditItemPageRoutingModule { diff --git a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.html b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.html new file mode 100644 index 0000000000..71aa7b44de --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.html @@ -0,0 +1,13 @@ +
+ + + + + + + + +
+ diff --git a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts new file mode 100644 index 0000000000..c687c829eb --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.spec.ts @@ -0,0 +1,183 @@ +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { of as observableOf } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { cold } from 'jasmine-marbles'; + +import { ItemAuthorizationsComponent } from './item-authorizations.component'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { createMockRDPaginatedObs } from '../item-bitstreams/item-bitstreams.component.spec'; +import { Item } from '../../../core/shared/item.model'; +import { LinkService } from '../../../core/cache/builders/link.service'; +import { getMockLinkService } from '../../../shared/mocks/link-service.mock'; +import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { createTestComponent } from '../../../shared/testing/utils.test'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; + +describe('ItemAuthorizationsComponent test suite', () => { + let comp: ItemAuthorizationsComponent; + let compAsAny: any; + let fixture: ComponentFixture; + let de; + + const linkService: any = getMockLinkService(); + + const bitstream1 = Object.assign(new Bitstream(), { + id: 'bitstream1', + uuid: 'bitstream1' + }); + const bitstream2 = Object.assign(new Bitstream(), { + id: 'bitstream2', + uuid: 'bitstream2' + }); + const bitstream3 = Object.assign(new Bitstream(), { + id: 'bitstream3', + uuid: 'bitstream3' + }); + const bitstream4 = Object.assign(new Bitstream(), { + id: 'bitstream4', + uuid: 'bitstream4' + }); + const bundle1 = Object.assign(new Bundle(), { + id: 'bundle1', + uuid: 'bundle1', + _links: { + self: { href: 'bundle1-selflink' } + }, + bitstreams: createMockRDPaginatedObs([bitstream1, bitstream2]) + }); + const bundle2 = Object.assign(new Bundle(), { + id: 'bundle2', + uuid: 'bundle2', + _links: { + self: { href: 'bundle2-selflink' } + }, + bitstreams: createMockRDPaginatedObs([bitstream3, bitstream4]) + }); + const bundles = [bundle1, bundle2]; + const bitstreamList1: PaginatedList = new PaginatedList(new PageInfo(), [bitstream1, bitstream2]); + const bitstreamList2: PaginatedList = new PaginatedList(new PageInfo(), [bitstream3, bitstream4]); + + const item = Object.assign(new Item(), { + uuid: 'item', + id: 'item', + _links: { + self: { href: 'item-selflink' } + }, + bundles: createMockRDPaginatedObs([bundle1, bundle2]) + }); + + const routeStub = { + data: observableOf({ + item: createSuccessfulRemoteDataObject(item) + }) + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + TranslateModule.forRoot() + ], + declarations: [ + ItemAuthorizationsComponent, + TestComponent + ], + providers: [ + { provide: LinkService, useValue: linkService }, + { provide: ActivatedRoute, useValue: routeStub }, + ItemAuthorizationsComponent + ], + schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` + `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create ItemAuthorizationsComponent', inject([ItemAuthorizationsComponent], (app: ItemAuthorizationsComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(ItemAuthorizationsComponent); + comp = fixture.componentInstance; + compAsAny = fixture.componentInstance; + linkService.resolveLink.and.callFake((object, link) => object); + fixture.detectChanges(); + }); + + afterEach(() => { + comp = null; + compAsAny = null; + de = null; + fixture.destroy(); + }); + + it('should init bundles and bitstreams map properly', () => { + expect(compAsAny.subs.length).toBe(2); + expect(compAsAny.bundles$.value).toEqual(bundles); + expect(compAsAny.bundleBitstreamsMap.has('bundle1')).toBeTruthy(); + expect(compAsAny.bundleBitstreamsMap.has('bundle2')).toBeTruthy(); + let bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle1'); + expect(bitstreamList).toBeObservable(cold('(a|)', { + a: bitstreamList1 + })); + + bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle2'); + expect(bitstreamList).toBeObservable(cold('(a|)', { + a: bitstreamList2 + })); + }); + + it('should get the item UUID', () => { + + expect(comp.getItemUUID()).toBeObservable(cold('(a|)', { + a: item.id + })); + + }); + + it('should get the item\'s bundle', () => { + + expect(comp.getItemBundles()).toBeObservable(cold('a', { + a: bundles + })); + + }); + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + +} diff --git a/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts new file mode 100644 index 0000000000..8153990a02 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-authorizations/item-authorizations.component.ts @@ -0,0 +1,155 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; +import { catchError, filter, first, flatMap, map, take } from 'rxjs/operators'; + +import { PaginatedList } from '../../../core/data/paginated-list'; +import { + getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteDataWithNotEmptyPayload +} from '../../../core/shared/operators'; +import { Item } from '../../../core/shared/item.model'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { LinkService } from '../../../core/cache/builders/link.service'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { FindListOptions } from '../../../core/data/request.models'; + +/** + * Interface for a bundle's bitstream map entry + */ +interface BundleBitstreamsMapEntry { + id: string; + bitstreams: Observable> +} + +@Component({ + selector: 'ds-item-authorizations', + templateUrl: './item-authorizations.component.html' +}) +/** + * Component that handles the item Authorizations + */ +export class ItemAuthorizationsComponent implements OnInit, OnDestroy { + + /** + * A map that contains all bitstream of the item's bundles + * @type {Observable>>>} + */ + public bundleBitstreamsMap: Map>> = new Map>>(); + + /** + * The list of bundle for the item + * @type {Observable>} + */ + private bundles$: BehaviorSubject = new BehaviorSubject([]); + + /** + * The target editing item + * @type {Observable} + */ + private item$: Observable; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + private subs: Subscription[] = []; + + /** + * Initialize instance variables + * + * @param {LinkService} linkService + * @param {ActivatedRoute} route + */ + constructor( + private linkService: LinkService, + private route: ActivatedRoute + ) { + } + + /** + * Initialize the component, setting up the bundle and bitstream within the item + */ + ngOnInit(): void { + this.item$ = this.route.data.pipe( + map((data) => data.item), + getFirstSucceededRemoteDataWithNotEmptyPayload(), + map((item: Item) => this.linkService.resolveLink( + item, + followLink('bundles', new FindListOptions(), true, followLink('bitstreams')) + )) + ) as Observable; + + const bundles$: Observable> = this.item$.pipe( + filter((item: Item) => isNotEmpty(item.bundles)), + flatMap((item: Item) => item.bundles), + getFirstSucceededRemoteDataWithNotEmptyPayload(), + catchError((error) => { + console.error(error); + return observableOf(new PaginatedList(null, [])) + }) + ); + + this.subs.push( + bundles$.pipe( + take(1), + map((list: PaginatedList) => list.page) + ).subscribe((bundles: Bundle[]) => { + this.bundles$.next(bundles); + }), + bundles$.pipe( + take(1), + flatMap((list: PaginatedList) => list.page), + map((bundle: Bundle) => ({ id: bundle.id, bitstreams: this.getBundleBitstreams(bundle) })) + ).subscribe((entry: BundleBitstreamsMapEntry) => { + this.bundleBitstreamsMap.set(entry.id, entry.bitstreams) + }) + ) + } + + /** + * Return the item's UUID + */ + getItemUUID(): Observable { + return this.item$.pipe( + map((item: Item) => item.id), + first((UUID: string) => isNotEmpty(UUID)) + ) + } + + /** + * Return all item's bundles + * + * @return an observable that emits all item's bundles + */ + getItemBundles(): Observable { + return this.bundles$.asObservable(); + } + + /** + * Return all bundle's bitstreams + * + * @return an observable that emits all item's bundles + */ + private getBundleBitstreams(bundle: Bundle): Observable> { + return bundle.bitstreams.pipe( + getFirstSucceededRemoteDataPayload(), + catchError((error) => { + console.error(error); + return observableOf(new PaginatedList(null, [])) + }) + ) + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()) + } +} diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index e63154918b..1be13e3a7a 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -68,6 +68,7 @@ export class ItemStatusComponent implements OnInit { 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')); diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index 501bb34d2c..63a560778b 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -7,6 +7,7 @@ import { Item } from '../core/shared/item.model'; import { hasValue } from '../shared/empty.util'; import { find } from 'rxjs/operators'; import { followLink } from '../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../core/data/request.models'; /** * This class represents a resolver that requests a specific item before the route is activated @@ -26,7 +27,7 @@ export class ItemPageResolver implements Resolve> { resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { return this.itemService.findById(route.params.id, followLink('owningCollection'), - followLink('bundles'), + followLink('bundles', new FindListOptions(), true, followLink('bitstreams')), followLink('relationships'), followLink('version', undefined, true, followLink('versionhistory')), ).pipe( diff --git a/src/app/+login-page/login-page-routing.module.ts b/src/app/+login-page/login-page-routing.module.ts index cd023da55c..9fa4a9e5ad 100644 --- a/src/app/+login-page/login-page-routing.module.ts +++ b/src/app/+login-page/login-page-routing.module.ts @@ -3,12 +3,17 @@ import { RouterModule } from '@angular/router'; import { LoginPageComponent } from './login-page.component'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; @NgModule({ imports: [ RouterModule.forChild([ { path: '', pathMatch: 'full', component: LoginPageComponent, resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { breadcrumbKey: 'login', title: 'login.title' } } ]) + ], + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService ] }) export class LoginPageRoutingModule { diff --git a/src/app/+search-page/search-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts index 6e36883394..f71c7b45ee 100644 --- a/src/app/+search-page/search-page-routing.module.ts +++ b/src/app/+search-page/search-page-routing.module.ts @@ -6,9 +6,11 @@ import { ConfigurationSearchPageComponent } from './configuration-search-page.co import { SearchPageComponent } from './search-page.component'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; +import { SearchPageModule } from './search-page.module'; @NgModule({ imports: [ + SearchPageModule, RouterModule.forChild([{ path: '', resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { title: 'search.title', breadcrumbKey: 'search' }, diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index b69dcaf935..00c990c665 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -2,10 +2,8 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CoreModule } from '../core/core.module'; import { SharedModule } from '../shared/shared.module'; -import { SearchPageRoutingModule } from './search-page-routing.module'; import { SearchComponent } from './search.component'; import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { EffectsModule } from '@ngrx/effects'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; import { SearchTrackerComponent } from './search-tracker.component'; @@ -14,7 +12,6 @@ import { SearchPageComponent } from './search-page.component'; import { SidebarFilterService } from '../shared/sidebar/filter/sidebar-filter.service'; import { SearchFilterService } from '../core/shared/search/search-filter.service'; import { SearchConfigurationService } from '../core/shared/search/search-configuration.service'; -import { TranslateModule } from '@ngx-translate/core'; const components = [ SearchPageComponent, @@ -25,7 +22,6 @@ const components = [ @NgModule({ imports: [ - SearchPageRoutingModule, CommonModule, SharedModule, CoreModule.forRoot(), diff --git a/src/app/+workflowitems-edit-page/workflow-item-action-page.component.html b/src/app/+workflowitems-edit-page/workflow-item-action-page.component.html new file mode 100644 index 0000000000..76808c7e14 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflow-item-action-page.component.html @@ -0,0 +1,6 @@ +
+

{{'workflow-item.' + type + '.header' | translate}}

+ + + +
diff --git a/src/app/+workflowitems-edit-page/workflow-item-action-page.component.spec.ts b/src/app/+workflowitems-edit-page/workflow-item-action-page.component.spec.ts new file mode 100644 index 0000000000..979476bf03 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflow-item-action-page.component.spec.ts @@ -0,0 +1,124 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { WorkflowItemActionPageComponent } from './workflow-item-action-page.component'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { RouteService } from '../core/services/route.service'; +import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; +import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { WorkflowItem } from '../core/submission/models/workflowitem.model'; +import { Observable, of as observableOf } from 'rxjs'; +import { VarDirective } from '../shared/utils/var.directive'; +import { By } from '@angular/platform-browser'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; +import { TranslateLoaderMock } from '../shared/mocks/translate-loader.mock'; +import { ActivatedRouteStub } from '../shared/testing/active-router.stub'; +import { RouterStub } from '../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../shared/testing/notifications-service.stub'; + +const type = 'testType'; +describe('WorkflowItemActionPageComponent', () => { + let component: WorkflowItemActionPageComponent; + let fixture: ComponentFixture; + let wfiService; + let wfi; + let itemRD$; + let id; + + function init() { + wfiService = jasmine.createSpyObj('workflowItemService', { + sendBack: observableOf(true) + }); + itemRD$ = createSuccessfulRemoteDataObject$(itemRD$); + wfi = new WorkflowItem(); + wfi.item = itemRD$; + id = 'de11b5e5-064a-4e98-a7ac-a1a6a65ddf80'; + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + declarations: [TestComponent, VarDirective], + providers: [ + { provide: ActivatedRoute, useValue: new ActivatedRouteStub({}, { wfi: createSuccessfulRemoteDataObject(wfi) }) }, + { provide: Router, useClass: RouterStub }, + { provide: RouteService, useValue: {} }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: WorkflowItemDataService, useValue: wfiService }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set the initial type correctly', () => { + expect(component.type).toEqual(type); + }); + + describe('clicking the button with class btn-danger', () => { + beforeEach(() => { + spyOn(component, 'performAction'); + }); + + it('should call performAction on clicking the btn-danger', () => { + const button = fixture.debugElement.query(By.css('.btn-danger')).nativeElement; + button.click(); + fixture.detectChanges(); + expect(component.performAction).toHaveBeenCalled(); + }); + }); + + describe('clicking the button with class btn-default', () => { + beforeEach(() => { + spyOn(component, 'previousPage'); + }); + + it('should call performAction on clicking the btn-default', () => { + const button = fixture.debugElement.query(By.css('.btn-default')).nativeElement; + button.click(); + fixture.detectChanges(); + expect(component.previousPage).toHaveBeenCalled(); + }); + }); +}); + +@Component({ + selector: 'ds-workflow-item-test-action-page', + templateUrl: 'workflow-item-action-page.component.html' + } +) +class TestComponent extends WorkflowItemActionPageComponent { + constructor(protected route: ActivatedRoute, + protected workflowItemService: WorkflowItemDataService, + protected router: Router, + protected routeService: RouteService, + protected notificationsService: NotificationsService, + protected translationService: TranslateService) { + super(route, workflowItemService, router, routeService, notificationsService, translationService); + } + + getType(): string { + return type; + } + + sendRequest(id: string): Observable { + return observableOf(true); + } +} diff --git a/src/app/+workflowitems-edit-page/workflow-item-action-page.component.ts b/src/app/+workflowitems-edit-page/workflow-item-action-page.component.ts new file mode 100644 index 0000000000..2859ca3e44 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflow-item-action-page.component.ts @@ -0,0 +1,86 @@ +import { OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; +import { TranslateService } from '@ngx-translate/core'; +import { WorkflowItem } from '../core/submission/models/workflowitem.model'; +import { Item } from '../core/shared/item.model'; +import { ActivatedRoute, Data, Router } from '@angular/router'; +import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; +import { RouteService } from '../core/services/route.service'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { RemoteData } from '../core/data/remote-data'; +import { getAllSucceededRemoteData, getRemoteDataPayload } from '../core/shared/operators'; +import { isEmpty } from '../shared/empty.util'; + +/** + * Abstract component representing a page to perform an action on a workflow item + */ +export abstract class WorkflowItemActionPageComponent implements OnInit { + public type; + public wfi$: Observable; + public item$: Observable; + + constructor(protected route: ActivatedRoute, + protected workflowItemService: WorkflowItemDataService, + protected router: Router, + protected routeService: RouteService, + protected notificationsService: NotificationsService, + protected translationService: TranslateService) { + } + + /** + * Sets up the type, workflow item and its item object + */ + ngOnInit() { + this.type = this.getType(); + this.wfi$ = this.route.data.pipe(map((data: Data) => data.wfi as RemoteData), getRemoteDataPayload()); + this.item$ = this.wfi$.pipe(switchMap((wfi: WorkflowItem) => (wfi.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + } + + /** + * Performs the action and shows a notification based on the outcome of the action + */ + performAction() { + this.wfi$.pipe( + take(1), + switchMap((wfi: WorkflowItem) => this.sendRequest(wfi.id)) + ).subscribe((successful: boolean) => { + if (successful) { + const title = this.translationService.get('workflow-item.' + this.type + '.notification.success.title'); + const content = this.translationService.get('workflow-item.' + this.type + '.notification.success.content'); + this.notificationsService.success(title, content) + } else { + const title = this.translationService.get('workflow-item.' + this.type + '.notification.error.title'); + const content = this.translationService.get('workflow-item.' + this.type + '.notification.error.content'); + this.notificationsService.error(title, content) + } + this.previousPage(); + }) + } + + /** + * Navigates to the previous url + * If there's not previous url, it continues to the mydspace page instead + */ + previousPage() { + this.routeService.getPreviousUrl().pipe(take(1)) + .subscribe((url) => { + if (isEmpty(url)) { + url = '/mydspace'; + } + this.router.navigateByUrl(url); + } + ); + } + + /** + * Performs the action of this workflow item action page + * @param id The id of the WorkflowItem + */ + abstract sendRequest(id: string): Observable; + + /** + * Returns the type of page + */ + abstract getType(): string; +} diff --git a/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.spec.ts b/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.spec.ts new file mode 100644 index 0000000000..a70005776b --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.spec.ts @@ -0,0 +1,76 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WorkflowItemDeleteComponent } from './workflow-item-delete.component'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouteService } from '../../core/services/route.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; +import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { of as observableOf } from 'rxjs'; +import { RequestService } from '../../core/data/request.service'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; + +describe('WorkflowItemDeleteComponent', () => { + let component: WorkflowItemDeleteComponent; + let fixture: ComponentFixture; + let wfiService; + let wfi; + let itemRD$; + let id; + + function init() { + wfiService = jasmine.createSpyObj('workflowItemService', { + delete: observableOf(true) + }); + itemRD$ = createSuccessfulRemoteDataObject$(itemRD$); + wfi = new WorkflowItem(); + wfi.item = itemRD$; + id = 'de11b5e5-064a-4e98-a7ac-a1a6a65ddf80'; + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + declarations: [WorkflowItemDeleteComponent, VarDirective], + providers: [ + { provide: ActivatedRoute, useValue: new ActivatedRouteStub({}, { wfi: createSuccessfulRemoteDataObject(wfi) }) }, + { provide: Router, useClass: RouterStub }, + { provide: RouteService, useValue: {} }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: WorkflowItemDataService, useValue: wfiService }, + { provide: RequestService, useValue: getMockRequestService() }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WorkflowItemDeleteComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call delete on the workflow-item service when sendRequest is called', () => { + component.sendRequest(id); + expect(wfiService.delete).toHaveBeenCalledWith(id); + }); +}); diff --git a/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts b/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts new file mode 100644 index 0000000000..43c3e90152 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflow-item-delete/workflow-item-delete.component.ts @@ -0,0 +1,44 @@ +import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { WorkflowItemActionPageComponent } from '../workflow-item-action-page.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; +import { RouteService } from '../../core/services/route.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { RequestService } from '../../core/data/request.service'; + +@Component({ + selector: 'ds-workflow-item-delete', + templateUrl: '../workflow-item-action-page.component.html' +}) +/** + * Component representing a page to delete a workflow item + */ +export class WorkflowItemDeleteComponent extends WorkflowItemActionPageComponent { + constructor(protected route: ActivatedRoute, + protected workflowItemService: WorkflowItemDataService, + protected router: Router, + protected routeService: RouteService, + protected notificationsService: NotificationsService, + protected translationService: TranslateService, + protected requestService: RequestService) { + super(route, workflowItemService, router, routeService, notificationsService, translationService); + } + + /** + * Returns the type of page + */ + getType(): string { + return 'delete'; + } + + /** + * Performs the action of this workflow item action page + * @param id The id of the WorkflowItem + */ + sendRequest(id: string): Observable { + this.requestService.removeByHrefSubstring('/discover'); + return this.workflowItemService.delete(id); + } +} diff --git a/src/app/+workflowitems-edit-page/workflow-item-page.resolver.spec.ts b/src/app/+workflowitems-edit-page/workflow-item-page.resolver.spec.ts new file mode 100644 index 0000000000..792c642ec7 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflow-item-page.resolver.spec.ts @@ -0,0 +1,29 @@ +import { first } from 'rxjs/operators'; +import { of as observableOf } from 'rxjs'; +import { WorkflowItemPageResolver } from './workflow-item-page.resolver'; +import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; + +describe('WorkflowItemPageResolver', () => { + describe('resolve', () => { + let resolver: WorkflowItemPageResolver; + let wfiService: WorkflowItemDataService; + const uuid = '1234-65487-12354-1235'; + + beforeEach(() => { + wfiService = { + findById: (id: string) => observableOf({ payload: { id }, hasSucceeded: true }) as any + } as any; + resolver = new WorkflowItemPageResolver(wfiService); + }); + + it('should resolve a workflow item with the correct id', () => { + resolver.resolve({ params: { id: uuid } } as any, undefined) + .pipe(first()) + .subscribe( + (resolved) => { + expect(resolved.payload.id).toEqual(uuid); + } + ); + }); + }); +}); diff --git a/src/app/+workflowitems-edit-page/workflow-item-page.resolver.ts b/src/app/+workflowitems-edit-page/workflow-item-page.resolver.ts new file mode 100644 index 0000000000..19cc4b4914 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflow-item-page.resolver.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs'; +import { RemoteData } from '../core/data/remote-data'; +import { hasValue } from '../shared/empty.util'; +import { find } from 'rxjs/operators'; +import { followLink } from '../shared/utils/follow-link-config.model'; +import { WorkflowItemDataService } from '../core/submission/workflowitem-data.service'; +import { WorkflowItem } from '../core/submission/models/workflowitem.model'; + +/** + * This class represents a resolver that requests a specific workflow item before the route is activated + */ +@Injectable() +export class WorkflowItemPageResolver implements Resolve> { + constructor(private workflowItemService: WorkflowItemDataService) { + } + + /** + * Method for resolving a workflow item based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found workflow item based on the parameters in the current route, + * or an error if something went wrong + */ + resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { + return this.workflowItemService.findById(route.params.id, + followLink('item'), + ).pipe( + find((RD) => hasValue(RD.error) || RD.hasSucceeded), + ); + } +} diff --git a/src/app/+workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.spec.ts b/src/app/+workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.spec.ts new file mode 100644 index 0000000000..fde48b59e4 --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.spec.ts @@ -0,0 +1,76 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouteService } from '../../core/services/route.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; +import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { of as observableOf } from 'rxjs'; +import { WorkflowItemSendBackComponent } from './workflow-item-send-back.component'; +import { RequestService } from '../../core/data/request.service'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; +import { RouterStub } from '../../shared/testing/router.stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { getMockRequestService } from '../../shared/mocks/request.service.mock'; + +describe('WorkflowItemSendBackComponent', () => { + let component: WorkflowItemSendBackComponent; + let fixture: ComponentFixture; + let wfiService; + let wfi; + let itemRD$; + let id; + + function init() { + wfiService = jasmine.createSpyObj('workflowItemService', { + sendBack: observableOf(true) + }); + itemRD$ = createSuccessfulRemoteDataObject$(itemRD$); + wfi = new WorkflowItem(); + wfi.item = itemRD$; + id = 'de11b5e5-064a-4e98-a7ac-a1a6a65ddf80'; + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + })], + declarations: [WorkflowItemSendBackComponent, VarDirective], + providers: [ + { provide: ActivatedRoute, useValue: new ActivatedRouteStub({}, { wfi: createSuccessfulRemoteDataObject(wfi) }) }, + { provide: Router, useClass: RouterStub }, + { provide: RouteService, useValue: {} }, + { provide: NotificationsService, useClass: NotificationsServiceStub }, + { provide: WorkflowItemDataService, useValue: wfiService }, + { provide: RequestService, useValue: getMockRequestService() }, + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WorkflowItemSendBackComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call sendBack on the workflow-item service when sendRequest is called', () => { + component.sendRequest(id); + expect(wfiService.sendBack).toHaveBeenCalledWith(id); + }); +}); diff --git a/src/app/+workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.ts b/src/app/+workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.ts new file mode 100644 index 0000000000..002e5dcc9a --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component.ts @@ -0,0 +1,44 @@ +import { Component } from '@angular/core'; +import { WorkflowItemActionPageComponent } from '../workflow-item-action-page.component'; +import { Observable } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { WorkflowItemDataService } from '../../core/submission/workflowitem-data.service'; +import { RouteService } from '../../core/services/route.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { RequestService } from '../../core/data/request.service'; + +@Component({ + selector: 'ds-workflow-item-send-back', + templateUrl: '../workflow-item-action-page.component.html' +}) +/** + * Component representing a page to send back a workflow item to the submitter + */ +export class WorkflowItemSendBackComponent extends WorkflowItemActionPageComponent { + constructor(protected route: ActivatedRoute, + protected workflowItemService: WorkflowItemDataService, + protected router: Router, + protected routeService: RouteService, + protected notificationsService: NotificationsService, + protected translationService: TranslateService, + protected requestService: RequestService) { + super(route, workflowItemService, router, routeService, notificationsService, translationService); + } + + /** + * Returns the type of page + */ + getType(): string { + return 'send-back'; + } + + /** + * Performs the action of this workflow item action page + * @param id The id of the WorkflowItem + */ + sendRequest(id: string): Observable { + this.requestService.removeByHrefSubstring('/discover'); + return this.workflowItemService.sendBack(id); + } +} diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts index d5df70698c..e9989bf947 100644 --- a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing.module.ts @@ -3,21 +3,65 @@ import { RouterModule } from '@angular/router'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { SubmissionEditComponent } from '../submission/edit/submission-edit.component'; +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getWorkflowItemModulePath } from '../app-routing.module'; +import { WorkflowItemDeleteComponent } from './workflow-item-delete/workflow-item-delete.component'; +import { WorkflowItemPageResolver } from './workflow-item-page.resolver'; +import { WorkflowItemSendBackComponent } from './workflow-item-send-back/workflow-item-send-back.component'; + +export function getWorkflowItemPageRoute(wfiId: string) { + return new URLCombiner(getWorkflowItemModulePath(), wfiId).toString(); +} + +export function getWorkflowItemEditPath(wfiId: string) { + return new URLCombiner(getWorkflowItemModulePath(), wfiId, WORKFLOW_ITEM_EDIT_PATH).toString() +} + +export function getWorkflowItemDeletePath(wfiId: string) { + return new URLCombiner(getWorkflowItemModulePath(), wfiId, WORKFLOW_ITEM_DELETE_PATH).toString() +} + +export function getWorkflowItemSendBackPath(wfiId: string) { + return new URLCombiner(getWorkflowItemModulePath(), wfiId, WORKFLOW_ITEM_SEND_BACK_PATH).toString() +} + +const WORKFLOW_ITEM_EDIT_PATH = 'edit'; +const WORKFLOW_ITEM_DELETE_PATH = 'delete'; +const WORKFLOW_ITEM_SEND_BACK_PATH = 'sendback'; @NgModule({ imports: [ RouterModule.forChild([ - { path: '', redirectTo: '/home', pathMatch: 'full' }, { - canActivate: [AuthenticatedGuard], - path: ':id/edit', - component: SubmissionEditComponent, - data: { title: 'submission.edit.title' } - } - ]) - ] + path: ':id', + resolve: { wfi: WorkflowItemPageResolver }, + children: [ + { + canActivate: [AuthenticatedGuard], + path: WORKFLOW_ITEM_EDIT_PATH, + component: SubmissionEditComponent, + data: { title: 'submission.edit.title' } + }, + { + canActivate: [AuthenticatedGuard], + path: WORKFLOW_ITEM_DELETE_PATH, + component: WorkflowItemDeleteComponent, + data: { title: 'workflow-item.delete.title' } + }, + { + canActivate: [AuthenticatedGuard], + path: WORKFLOW_ITEM_SEND_BACK_PATH, + component: WorkflowItemSendBackComponent, + data: { title: 'workflow-item.send-back.title' } + } + ] + }] + ) + ], + providers: [WorkflowItemPageResolver] }) /** * This module defines the default component to load when navigating to the workflowitems edit page path. */ -export class WorkflowItemsEditPageRoutingModule { } +export class WorkflowItemsEditPageRoutingModule { +} diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts index 7a89f18c7d..ef1e49abf5 100644 --- a/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page.module.ts @@ -3,6 +3,8 @@ import { NgModule } from '@angular/core'; import { SharedModule } from '../shared/shared.module'; import { WorkflowItemsEditPageRoutingModule } from './workflowitems-edit-page-routing.module'; import { SubmissionModule } from '../submission/submission.module'; +import { WorkflowItemDeleteComponent } from './workflow-item-delete/workflow-item-delete.component'; +import { WorkflowItemSendBackComponent } from './workflow-item-send-back/workflow-item-send-back.component'; @NgModule({ imports: [ @@ -11,7 +13,7 @@ import { SubmissionModule } from '../submission/submission.module'; SharedModule, SubmissionModule, ], - declarations: [] + declarations: [WorkflowItemDeleteComponent, WorkflowItemSendBackComponent] }) /** * This module handles all modules that need to access the workflowitems edit page. diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 258848ce83..0ba0851e4e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -33,7 +33,7 @@ export function getBitstreamModulePath() { return `/${BITSTREAM_MODULE_PATH}`; } -const ADMIN_MODULE_PATH = 'admin'; +export const ADMIN_MODULE_PATH = 'admin'; export function getAdminModulePath() { return `/${ADMIN_MODULE_PATH}`; @@ -45,6 +45,12 @@ export function getProfileModulePath() { return `/${PROFILE_MODULE_PATH}`; } +const WORKFLOW_ITEM_MODULE_PATH = 'workflowitems'; + +export function getWorkflowItemModulePath() { + return `/${WORKFLOW_ITEM_MODULE_PATH}`; +} + export function getDSOPath(dso: DSpaceObject): string { switch ((dso as any).type) { case Community.type.value: @@ -60,6 +66,7 @@ export function getDSOPath(dso: DSpaceObject): string { 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' }, @@ -73,7 +80,7 @@ export function getDSOPath(dso: DSpaceObject): string { loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', canActivate: [AuthenticatedGuard] }, - { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, + { 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: [AuthenticatedGuard] }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, @@ -84,7 +91,7 @@ export function getDSOPath(dso: DSpaceObject): string { loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' }, { - path: 'workflowitems', + path: WORKFLOW_ITEM_MODULE_PATH, loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' }, { diff --git a/src/app/breadcrumbs/breadcrumbs.component.html b/src/app/breadcrumbs/breadcrumbs.component.html index b773964d1e..6c1c31c89f 100644 --- a/src/app/breadcrumbs/breadcrumbs.component.html +++ b/src/app/breadcrumbs/breadcrumbs.component.html @@ -1,17 +1,20 @@ - + + - - - + + + - - - + + + + diff --git a/src/app/breadcrumbs/breadcrumbs.component.spec.ts b/src/app/breadcrumbs/breadcrumbs.component.spec.ts index 6b903e761a..32b5dcf0d0 100644 --- a/src/app/breadcrumbs/breadcrumbs.component.spec.ts +++ b/src/app/breadcrumbs/breadcrumbs.component.spec.ts @@ -9,6 +9,9 @@ import { TranslateLoaderMock } from '../shared/testing/translate-loader.mock'; import { BreadcrumbConfig } from './breadcrumb/breadcrumb-config.model'; import { BreadcrumbsService } from '../core/breadcrumbs/breadcrumbs.service'; import { Breadcrumb } from './breadcrumb/breadcrumb.model'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { VarDirective } from '../shared/utils/var.directive'; import { getTestScheduler } from 'jasmine-marbles'; class TestBreadcrumbsService implements BreadcrumbsService { @@ -64,17 +67,16 @@ describe('BreadcrumbsComponent', () => { beforeEach(async(() => { init(); TestBed.configureTestingModule({ - declarations: [BreadcrumbsComponent], + declarations: [BreadcrumbsComponent, VarDirective], imports: [RouterTestingModule.withRoutes([]), TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateLoaderMock } - })], + }), NgbModule], providers: [ - { provide: ActivatedRoute, useValue: route } - - ] + {provide: ActivatedRoute, useValue: route} + ], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); })); @@ -92,14 +94,16 @@ describe('BreadcrumbsComponent', () => { describe('ngOnInit', () => { beforeEach(() => { - spyOn(component, 'resolveBreadcrumbs').and.returnValue(observableOf([])) + spyOn(component, 'resolveBreadcrumbs').and.returnValue(observableOf([])); }); it('should call resolveBreadcrumb on init', () => { router.events = observableOf(new NavigationEnd(0, '', '')); component.ngOnInit(); + fixture.detectChanges(); + expect(component.resolveBreadcrumbs).toHaveBeenCalledWith(route.root); - }) + }); }); describe('resolveBreadcrumbs', () => { diff --git a/src/app/breadcrumbs/breadcrumbs.component.ts b/src/app/breadcrumbs/breadcrumbs.component.ts index 2bba3c76b6..af63ec985d 100644 --- a/src/app/breadcrumbs/breadcrumbs.component.ts +++ b/src/app/breadcrumbs/breadcrumbs.component.ts @@ -1,9 +1,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { Breadcrumb } from './breadcrumb/breadcrumb.model'; -import { hasNoValue, hasValue, isNotUndefined, isUndefined } from '../shared/empty.util'; +import { hasNoValue, hasValue, isUndefined } from '../shared/empty.util'; import { filter, map, switchMap, tap } from 'rxjs/operators'; -import { combineLatest, Observable, Subscription, of as observableOf } from 'rxjs'; +import { combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; /** * Component representing the breadcrumbs of a page @@ -13,22 +13,17 @@ import { combineLatest, Observable, Subscription, of as observableOf } from 'rxj templateUrl: './breadcrumbs.component.html', styleUrls: ['./breadcrumbs.component.scss'] }) -export class BreadcrumbsComponent implements OnInit, OnDestroy { +export class BreadcrumbsComponent implements OnInit { /** - * List of breadcrumbs for this page + * Observable of the list of breadcrumbs for this page */ - breadcrumbs: Breadcrumb[]; + breadcrumbs$: Observable; /** * Whether or not to show breadcrumbs on this page */ showBreadcrumbs: boolean; - /** - * Subscription to unsubscribe from on destroy - */ - subscription: Subscription; - constructor( private route: ActivatedRoute, private router: Router @@ -39,14 +34,11 @@ export class BreadcrumbsComponent implements OnInit, OnDestroy { * Sets the breadcrumbs on init for this page */ ngOnInit(): void { - this.subscription = this.router.events.pipe( + this.breadcrumbs$ = this.router.events.pipe( filter((e): e is NavigationEnd => e instanceof NavigationEnd), tap(() => this.reset()), - switchMap(() => this.resolveBreadcrumbs(this.route.root)) - ).subscribe((breadcrumbs) => { - this.breadcrumbs = breadcrumbs; - } - ) + switchMap(() => this.resolveBreadcrumbs(this.route.root)), + ); } /** @@ -81,20 +73,10 @@ export class BreadcrumbsComponent implements OnInit, OnDestroy { return !last ? this.resolveBreadcrumbs(route.firstChild) : observableOf([]); } - /** - * Unsubscribe from subscription - */ - ngOnDestroy(): void { - if (hasValue(this.subscription)) { - this.subscription.unsubscribe(); - } - } - /** * Resets the state of the breadcrumbs */ reset() { - this.breadcrumbs = []; this.showBreadcrumbs = true; } } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 2c2224e878..9237c30db9 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -402,10 +402,10 @@ export class RetrieveAuthenticatedEpersonAction implements Action { */ export class RetrieveAuthenticatedEpersonSuccessAction implements Action { public type: string = AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS; - payload: EPerson; + payload: string; - constructor(user: EPerson) { - this.payload = user ; + constructor(userId: string) { + this.payload = userId ; } } diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index e231857159..c08615ecc9 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -1,9 +1,9 @@ -import { TestBed } from '@angular/core/testing'; +import { fakeAsync, flush, TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; -import { Store } from '@ngrx/store'; +import { Store, StoreModule } from '@ngrx/store'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { cold, hot } from 'jasmine-marbles'; - import { Observable, of as observableOf, throwError as observableThrow } from 'rxjs'; import { AuthEffects } from './auth.effects'; @@ -29,41 +29,53 @@ import { } from './auth.actions'; import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service.stub'; import { AuthService } from './auth.service'; -import { AuthState } from './auth.reducer'; - +import { authReducer } from './auth.reducer'; import { AuthStatus } from './models/auth-status.model'; import { EPersonMock } from '../../shared/testing/eperson.mock'; +import { AppState, storeModuleConfig } from '../../app.reducer'; +import { StoreActionTypes } from '../../store.actions'; +import { isAuthenticated, isAuthenticatedLoaded } from './selectors'; describe('AuthEffects', () => { let authEffects: AuthEffects; let actions: Observable; let authServiceStub; - const store: Store = jasmine.createSpyObj('store', { - /* tslint:disable:no-empty */ - dispatch: {}, - /* tslint:enable:no-empty */ - select: observableOf(true) - }); + let initialState; let token; + let store: MockStore; function init() { authServiceStub = new AuthServiceStub(); token = authServiceStub.getToken(); + initialState = { + core: { + auth: { + authenticated: false, + loaded: false, + loading: false, + authMethods: [] + } + } + }; } beforeEach(() => { init(); TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ auth: authReducer }, storeModuleConfig) + ], providers: [ AuthEffects, + provideMockStore({ initialState }), { provide: AuthService, useValue: authServiceStub }, - { provide: Store, useValue: store }, provideMockActions(() => actions), // other providers ], }); authEffects = TestBed.get(AuthEffects); + store = TestBed.get(Store); }); describe('authenticate$', () => { @@ -235,7 +247,7 @@ describe('AuthEffects', () => { } }); - const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock) }); + const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id) }); expect(authEffects.retrieveAuthenticatedEperson$).toBeObservable(expected); }); @@ -362,4 +374,40 @@ describe('AuthEffects', () => { }); }) }); + + describe('clearInvalidTokenOnRehydrate$', () => { + + beforeEach(() => { + store.overrideSelector(isAuthenticated, false); + }); + + describe('when auth loaded is false', () => { + it('should not call removeToken method', (done) => { + store.overrideSelector(isAuthenticatedLoaded, false); + actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } }); + spyOn(authServiceStub, 'removeToken'); + + authEffects.clearInvalidTokenOnRehydrate$.subscribe(() => { + expect(authServiceStub.removeToken).not.toHaveBeenCalled(); + + }); + + done(); + }); + }); + + describe('when auth loaded is true', () => { + it('should call removeToken method', fakeAsync(() => { + store.overrideSelector(isAuthenticatedLoaded, true); + actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } }); + spyOn(authServiceStub, 'removeToken'); + + authEffects.clearInvalidTokenOnRehydrate$.subscribe(() => { + expect(authServiceStub.removeToken).toHaveBeenCalled(); + flush(); + }); + + })); + }); + }); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index d153748fb9..c6d447961a 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,20 +1,18 @@ -import { Observable, of as observableOf } from 'rxjs'; - -import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { Injectable } from '@angular/core'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators'; // import @ngrx import { Actions, Effect, ofType } from '@ngrx/effects'; import { Action, select, Store } from '@ngrx/store'; // import services import { AuthService } from './auth.service'; - import { EPerson } from '../eperson/models/eperson.model'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { AppState } from '../../app.reducer'; -import { isAuthenticated } from './selectors'; +import { isAuthenticated, isAuthenticatedLoaded } from './selectors'; import { StoreActionTypes } from '../../store.actions'; import { AuthMethod } from './models/auth.method'; // import actions @@ -43,6 +41,7 @@ import { RetrieveAuthMethodsSuccessAction, RetrieveTokenAction } from './auth.actions'; +import { hasValue } from '../../shared/empty.util'; @Injectable() export class AuthEffects { @@ -97,8 +96,15 @@ export class AuthEffects { public retrieveAuthenticatedEperson$: Observable = this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON), switchMap((action: RetrieveAuthenticatedEpersonAction) => { - return this.authService.retrieveAuthenticatedUserByHref(action.payload).pipe( - map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user)), + const impersonatedUserID = this.authService.getImpersonateID(); + let user$: Observable; + if (hasValue(impersonatedUserID)) { + user$ = this.authService.retrieveAuthenticatedUserById(impersonatedUserID); + } else { + user$ = this.authService.retrieveAuthenticatedUserByHref(action.payload); + } + return user$.pipe( + map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user.id)), catchError((error) => observableOf(new RetrieveAuthenticatedEpersonErrorAction(error)))); }) ); @@ -179,10 +185,11 @@ export class AuthEffects { public clearInvalidTokenOnRehydrate$: Observable = this.actions$.pipe( ofType(StoreActionTypes.REHYDRATE), switchMap(() => { - return this.store.pipe( - select(isAuthenticated), + const isLoaded$ = this.store.pipe(select(isAuthenticatedLoaded)); + const authenticated$ = this.store.pipe(select(isAuthenticated)); + return observableCombineLatest(isLoaded$, authenticated$).pipe( take(1), - filter((authenticated) => !authenticated), + filter(([loaded, authenticated]) => loaded && !authenticated), tap(() => this.authService.removeToken()), tap(() => this.authService.resetAuthenticationError()) ); @@ -193,6 +200,7 @@ export class AuthEffects { .pipe( ofType(AuthActionTypes.LOG_OUT), switchMap(() => { + this.authService.stopImpersonating(); return this.authService.logout().pipe( map((value) => new LogOutSuccessAction()), catchError((error) => observableOf(new LogOutErrorAction(error))) diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index f5ac0c4361..be40351795 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -48,7 +48,7 @@ describe(`AuthInterceptor`, () => { describe('when has a valid token', () => { - it('should not add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint', () => { + it('should not add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint that is not the logout endpoint', () => { service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => { expect(response).toBeTruthy(); }); @@ -58,8 +58,19 @@ describe(`AuthInterceptor`, () => { const token = httpRequest.request.headers.get('authorization'); expect(token).toBeNull(); }); + it('should add an Authorization header when we’re sending a HTTP request to the\'authn/logout\' endpoint', () => { + service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/authn/logout', 'test').subscribe((response) => { + expect(response).toBeTruthy(); + }); - it('should add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint', () => { + const httpRequest = httpMock.expectOne(`dspace-spring-rest/api/authn/logout`); + + expect(httpRequest.request.headers.has('authorization')); + const token = httpRequest.request.headers.get('authorization'); + expect(token).toBe('Bearer token_test'); + }); + + it('should add an Authorization header when we’re sending a HTTP request to a non-\'authn\' endpoint', () => { service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => { expect(response).toBeTruthy(); }); diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 6d609a4ea3..f4e7aa2fd3 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -18,7 +18,7 @@ import { AppState } from '../../app.reducer'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; +import { hasValue, isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; @@ -221,7 +221,7 @@ export class AuthInterceptor implements HttpInterceptor { // Redirect to the login route this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired')); return observableOf(null); - } else if (!this.isAuthRequest(req) && isNotEmpty(token)) { + } else if ((!this.isAuthRequest(req) || this.isLogoutResponse(req)) && isNotEmpty(token)) { // Intercept a request that is not to the authentication endpoint authService.isTokenExpiring().pipe( filter((isExpiring) => isExpiring)) @@ -235,8 +235,16 @@ export class AuthInterceptor implements HttpInterceptor { }); // Get the auth header from the service. authorization = authService.buildAuthHeader(token); + let newHeaders = req.headers.set('authorization', authorization); + + // When present, add the ID of the EPerson we're impersonating to the headers + const impersonatingID = authService.getImpersonateID(); + if (hasValue(impersonatingID)) { + newHeaders = newHeaders.set('X-On-Behalf-Of', impersonatingID); + } + // Clone the request to add the new header. - newReq = req.clone({ headers: req.headers.set('authorization', authorization) }); + newReq = req.clone({ headers: newHeaders }); } else { newReq = req.clone(); } diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index 1606ed9185..cf934a7f47 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -189,7 +189,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; const action = new LogOutAction(); @@ -206,7 +206,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; const action = new LogOutSuccessAction(); @@ -219,7 +219,7 @@ describe('authReducer', () => { loading: false, info: undefined, refreshing: false, - user: undefined + userId: undefined }; expect(newState).toEqual(state); }); @@ -232,7 +232,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; const action = new LogOutErrorAction(mockError); @@ -244,7 +244,7 @@ describe('authReducer', () => { error: 'Test error message', loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; expect(newState).toEqual(state); }); @@ -258,7 +258,7 @@ describe('authReducer', () => { loading: true, info: undefined }; - const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock); + const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id); const newState = authReducer(initialState, action); state = { authenticated: true, @@ -267,7 +267,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; expect(newState).toEqual(state); }); @@ -301,7 +301,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenAction(newTokenInfo); @@ -313,7 +313,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock, + userId: EPersonMock.id, refreshing: true }; expect(newState).toEqual(state); @@ -327,7 +327,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock, + userId: EPersonMock.id, refreshing: true }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); @@ -340,7 +340,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock, + userId: EPersonMock.id, refreshing: false }; expect(newState).toEqual(state); @@ -354,7 +354,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock, + userId: EPersonMock.id, refreshing: true }; const action = new RefreshTokenErrorAction(); @@ -367,7 +367,7 @@ describe('authReducer', () => { loading: false, info: undefined, refreshing: false, - user: undefined + userId: undefined }; expect(newState).toEqual(state); }); @@ -380,7 +380,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EPersonMock + userId: EPersonMock.id }; state = { @@ -390,7 +390,7 @@ describe('authReducer', () => { loading: false, error: undefined, info: 'Message', - user: undefined + userId: undefined }; }); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 19fd162d3f..16990b35a8 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -14,7 +14,6 @@ import { SetRedirectUrlAction } from './auth.actions'; // import models -import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthMethod } from './models/auth.method'; import { AuthMethodType } from './models/auth.method-type'; @@ -49,8 +48,8 @@ export interface AuthState { // true when refreshing token refreshing?: boolean; - // the authenticated user - user?: EPerson; + // the authenticated user's id + userId?: string; // all authentication Methods enabled at the backend authMethods?: AuthMethod[]; @@ -112,7 +111,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: undefined, loading: false, info: undefined, - user: (action as RetrieveAuthenticatedEpersonSuccessAction).payload + userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload }); case AuthActionTypes.AUTHENTICATE_ERROR: @@ -144,7 +143,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false, info: undefined, refreshing: false, - user: undefined + userId: undefined }); case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED: @@ -155,7 +154,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loaded: false, loading: false, info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload, - user: undefined + userId: undefined }); case AuthActionTypes.REGISTRATION: diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 89d8cdce4e..3b6fae4dd1 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -8,7 +8,7 @@ import { of as observableOf } from 'rxjs'; import { authReducer, AuthState } from './auth.reducer'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; -import { AuthService } from './auth.service'; +import { AuthService, IMPERSONATING_COOKIE } from './auth.service'; import { RouterStub } from '../../shared/testing/router.stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; @@ -332,5 +332,120 @@ describe('AuthService test', () => { expect(routeServiceMock.getHistory).toHaveBeenCalled(); expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/'); }); + + describe('impersonate', () => { + const userId = 'testUserId'; + + beforeEach(() => { + spyOn(authService, 'refreshAfterLogout'); + authService.impersonate(userId); + }); + + it('should impersonate user', () => { + expect(storage.set).toHaveBeenCalledWith(IMPERSONATING_COOKIE, userId); + }); + + it('should call refreshAfterLogout', () => { + expect(authService.refreshAfterLogout).toHaveBeenCalled(); + }); + }); + + describe('stopImpersonating', () => { + beforeEach(() => { + authService.stopImpersonating(); + }); + + it('should impersonate user', () => { + expect(storage.remove).toHaveBeenCalledWith(IMPERSONATING_COOKIE); + }); + }); + + describe('stopImpersonatingAndRefresh', () => { + beforeEach(() => { + spyOn(authService, 'refreshAfterLogout'); + authService.stopImpersonatingAndRefresh(); + }); + + it('should impersonate user', () => { + expect(storage.remove).toHaveBeenCalledWith(IMPERSONATING_COOKIE); + }); + + it('should call refreshAfterLogout', () => { + expect(authService.refreshAfterLogout).toHaveBeenCalled(); + }); + }); + + describe('getImpersonateID', () => { + beforeEach(() => { + authService.getImpersonateID(); + }); + + it('should impersonate user', () => { + expect(storage.get).toHaveBeenCalledWith(IMPERSONATING_COOKIE); + }); + }); + + describe('isImpersonating', () => { + const userId = 'testUserId'; + let result: boolean; + + describe('when the cookie doesn\'t contain a value', () => { + beforeEach(() => { + result = authService.isImpersonating(); + }); + + it('should return false', () => { + expect(result).toBe(false); + }); + }); + + describe('when the cookie contains a value', () => { + beforeEach(() => { + storage.get = jasmine.createSpy().and.returnValue(userId); + result = authService.isImpersonating(); + }); + + it('should return true', () => { + expect(result).toBe(true); + }); + }); + }); + + describe('isImpersonatingUser', () => { + const userId = 'testUserId'; + let result: boolean; + + describe('when the cookie doesn\'t contain a value', () => { + beforeEach(() => { + result = authService.isImpersonatingUser(userId); + }); + + it('should return false', () => { + expect(result).toBe(false); + }); + }); + + describe('when the cookie contains the right value', () => { + beforeEach(() => { + storage.get = jasmine.createSpy().and.returnValue(userId); + result = authService.isImpersonatingUser(userId); + }); + + it('should return true', () => { + expect(result).toBe(true); + }); + }); + + describe('when the cookie contains the wrong value', () => { + beforeEach(() => { + storage.get = jasmine.createSpy().and.returnValue('wrongValue'); + result = authService.isImpersonatingUser(userId); + }); + + it('should return false', () => { + expect(result).toBe(false); + }); + }); + }); }); }); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 0f5c06bbc9..588d9e2675 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -4,7 +4,7 @@ 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, take, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { RouterReducerState } from '@ngrx/router-store'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; @@ -14,9 +14,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 { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import { CookieService } from '../services/cookie.service'; -import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; +import { + getAuthenticatedUserId, + getAuthenticationToken, + getRedirectUrl, + isAuthenticated, + isTokenRefreshing +} from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; import { CheckAuthenticationTokenAction, @@ -33,6 +39,7 @@ import { AuthMethod } from './models/auth.method'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; export const REDIRECT_COOKIE = 'dsRedirectUrl'; +export const IMPERSONATING_COOKIE = 'dsImpersonatingEPerson'; /** * The auth service. @@ -163,7 +170,7 @@ export class AuthService { } /** - * Returns the authenticated user + * Returns the authenticated user by href * @returns {User} */ public retrieveAuthenticatedUserByHref(userHref: string): Observable { @@ -172,6 +179,29 @@ export class AuthService { ) } + /** + * Returns the authenticated user by id + * @returns {User} + */ + public retrieveAuthenticatedUserById(userId: string): Observable { + return this.epersonService.findById(userId).pipe( + getAllSucceededRemoteDataPayload() + ) + } + + /** + * Returns the authenticated user from the store + * @returns {User} + */ + public getAuthenticatedUserFromStore(): Observable { + return this.store.pipe( + select(getAuthenticatedUserId), + hasValueOperator(), + switchMap((id: string) => this.epersonService.findById(id)), + getAllSucceededRemoteDataPayload() + ) + } + /** * Checks if token is present into browser storage and is valid. */ @@ -430,9 +460,9 @@ export class AuthService { * Refresh route navigated */ public refreshAfterLogout() { - this.router.navigate(['/home']); - // Hard redirect to home page, so that all state is definitely lost - this._window.nativeWindow.location.href = '/home'; + // 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()}`; } /** @@ -469,4 +499,51 @@ export class AuthService { this.storage.remove(REDIRECT_COOKIE); } + /** + * Start impersonating EPerson + * @param epersonId ID of the EPerson to impersonate + */ + impersonate(epersonId: string) { + this.storage.set(IMPERSONATING_COOKIE, epersonId); + this.refreshAfterLogout(); + } + + /** + * Stop impersonating EPerson + */ + stopImpersonating() { + this.storage.remove(IMPERSONATING_COOKIE); + } + + /** + * Stop impersonating EPerson and refresh the store/ui + */ + stopImpersonatingAndRefresh() { + this.stopImpersonating(); + this.refreshAfterLogout(); + } + + /** + * Get the ID of the EPerson we're currently impersonating + * Returns undefined if we're not impersonating anyone + */ + getImpersonateID(): string { + return this.storage.get(IMPERSONATING_COOKIE); + } + + /** + * Whether or not we are currently impersonating an EPerson + */ + isImpersonating(): boolean { + return hasValue(this.getImpersonateID()); + } + + /** + * Whether or not we are currently impersonating a specific EPerson + * @param epersonId ID of the EPerson to check + */ + isImpersonatingUser(epersonId: string): boolean { + return this.getImpersonateID() === epersonId; + } + } diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 4e51bc1fc9..173f82e810 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -8,7 +8,6 @@ import { createSelector } from '@ngrx/store'; */ import { AuthState } from './auth.reducer'; import { AppState } from '../../app.reducer'; -import { EPerson } from '../eperson/models/eperson.model'; /** * Returns the user state. @@ -36,12 +35,11 @@ const _isAuthenticatedLoaded = (state: AuthState) => state.loaded; /** * Return the users state - * NOTE: when state is REHYDRATED user object lose prototype so return always a new EPerson object - * @function _getAuthenticatedUser + * @function _getAuthenticatedUserId * @param {State} state - * @returns {EPerson} + * @returns {string} User ID */ -const _getAuthenticatedUser = (state: AuthState) => Object.assign(new EPerson(), state.user); +const _getAuthenticatedUserId = (state: AuthState) => state.userId; /** * Returns the authentication error. @@ -119,13 +117,13 @@ const _getAuthenticationMethods = (state: AuthState) => state.authMethods; export const getAuthenticationMethods = createSelector(getAuthState, _getAuthenticationMethods); /** - * Returns the authenticated user - * @function getAuthenticatedUser + * Returns the authenticated user id + * @function getAuthenticatedUserId * @param {AuthState} state * @param {any} props - * @return {User} + * @return {string} User ID */ -export const getAuthenticatedUser = createSelector(getAuthState, _getAuthenticatedUser); +export const getAuthenticatedUserId = createSelector(getAuthState, _getAuthenticatedUserId); /** * Returns the authentication error. diff --git a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts index 7384a031db..80d7563637 100644 --- a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts @@ -8,7 +8,9 @@ import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-con /** * The class that resolves the BreadcrumbConfig object for a Collection */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class CollectionBreadcrumbResolver extends DSOBreadcrumbResolver { constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CollectionDataService) { super(breadcrumbService, dataService); diff --git a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts index d1f21455f2..298d69133f 100644 --- a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts @@ -8,7 +8,9 @@ import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-con /** * The class that resolves the BreadcrumbConfig object for a Community */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class CommunityBreadcrumbResolver extends DSOBreadcrumbResolver { constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CommunityDataService) { super(breadcrumbService, dataService); diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts index 80e68a16f5..09292fec21 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -13,7 +13,9 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * The class that resolves the BreadcrumbConfig object for a DSpaceObject */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export abstract class DSOBreadcrumbResolver implements Resolve> { constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: DataService) { } diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts index 3cb73be876..003c11bf83 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts @@ -15,7 +15,9 @@ import { Injectable } from '@angular/core'; /** * Service to calculate DSpaceObject breadcrumbs for a single part of the route */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class DSOBreadcrumbsService implements BreadcrumbsService { constructor( private linkService: LinkService, diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts index 161c4f7254..5567137334 100644 --- a/src/app/core/breadcrumbs/dso-name.service.ts +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -28,7 +28,8 @@ export class DSONameService { return dso.firstMetadataValue('organization.legalName'); }, Default: (dso: DSpaceObject): string => { - return dso.firstMetadataValue('dc.title'); + // If object doesn't have dc.title metadata use name property + return dso.firstMetadataValue('dc.title') || dso.name; } }; diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts index 46582015cc..a6298628c7 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts @@ -7,7 +7,9 @@ import { hasNoValue } from '../../shared/empty.util'; /** * The class that resolves a BreadcrumbConfig object with an i18n key string for a route */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class I18nBreadcrumbResolver implements Resolve> { constructor(protected breadcrumbService: I18nBreadcrumbsService) { } diff --git a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts index e07d9ed541..b774b58126 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts @@ -11,7 +11,9 @@ export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs'; /** * Service to calculate i18n breadcrumbs for a single part of the route */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class I18nBreadcrumbsService implements BreadcrumbsService { /** diff --git a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts index cd0c23cf82..8e13eda01d 100644 --- a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts @@ -8,7 +8,9 @@ import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-con /** * The class that resolves the BreadcrumbConfig object for an Item */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class ItemBreadcrumbResolver extends DSOBreadcrumbResolver { constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: ItemDataService) { super(breadcrumbService, dataService); diff --git a/src/app/core/cache/models/search-param.model.ts b/src/app/core/cache/models/request-param.model.ts similarity index 86% rename from src/app/core/cache/models/search-param.model.ts rename to src/app/core/cache/models/request-param.model.ts index 3881dbe8b7..ac21fe0b8a 100644 --- a/src/app/core/cache/models/search-param.model.ts +++ b/src/app/core/cache/models/request-param.model.ts @@ -2,7 +2,7 @@ /** * Class representing a query parameter (query?fieldName=fieldValue) used in FindListOptions object */ -export class SearchParam { +export class RequestParam { constructor(public fieldName: string, public fieldValue: any) { } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index bc3814a5b6..37b3bb51e2 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -73,7 +73,7 @@ import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; import { RelationshipTypeService } from './data/relationship-type.service'; import { RelationshipService } from './data/relationship.service'; -import { ResourcePolicyService } from './data/resource-policy.service'; +import { ResourcePolicyService } from './resource-policy/resource-policy.service'; import { SearchResponseParsingService } from './data/search-response-parsing.service'; import { SiteDataService } from './data/site-data.service'; import { DSpaceRESTv2Service } from './dspace-rest-v2/dspace-rest-v2.service'; @@ -111,7 +111,7 @@ import { RelationshipType } from './shared/item-relationships/relationship-type. import { Relationship } from './shared/item-relationships/relationship.model'; import { Item } from './shared/item.model'; import { License } from './shared/license.model'; -import { ResourcePolicy } from './shared/resource-policy.model'; +import { ResourcePolicy } from './resource-policy/models/resource-policy.model'; import { SearchConfigurationService } from './shared/search/search-configuration.service'; import { SearchFilterService } from './shared/search/search-filter.service'; import { SearchService } from './shared/search/search.service'; diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 6ae40f4ca9..0639a7d8ca 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -12,7 +12,7 @@ import { PaginatedSearchOptions } from '../../shared/search/paginated-search-opt import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; @@ -94,7 +94,7 @@ export class CollectionDataService extends ComColDataService { getAuthorizedCollectionByCommunity(communityId: string, options: FindListOptions = {}): Observable>> { const searchHref = 'findAuthorizedByCommunity'; options = Object.assign({}, options, { - searchParams: [new SearchParam('uuid', communityId)] + searchParams: [new RequestParam('uuid', communityId)] }); return this.searchBy(searchHref, options).pipe( diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 91e2c832df..e31095ca65 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -20,7 +20,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { getClassForType } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; import { ErrorResponse, RestResponse } from '../cache/response.models'; @@ -112,7 +112,7 @@ export abstract class DataService { result$ = this.getSearchEndpoint(searchMethod); if (hasValue(options.searchParams)) { - options.searchParams.forEach((param: SearchParam) => { + options.searchParams.forEach((param: RequestParam) => { args.push(`${param.fieldName}=${param.fieldValue}`); }) } @@ -154,6 +154,33 @@ export abstract class DataService { } } + /** + * Turn an array of RequestParam into a query string and combine it with the given HREF + * + * @param href The HREF to which the query string should be appended + * @param params Array with additional params to combine with query string + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * + * @return {Observable} + * Return an observable that emits created HREF + */ + protected buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: Array>): string { + + let args = []; + if (hasValue(params)) { + params.forEach((param: RequestParam) => { + args.push(`${param.fieldName}=${param.fieldValue}`); + }) + } + + args = this.addEmbedParams(args, ...linksToFollow); + + if (isNotEmpty(args)) { + return new URLCombiner(href, `?${args.join('&')}`).toString(); + } else { + return href; + } + } /** * Adds the embed options to the link for the request * @param args params for the query string @@ -294,9 +321,9 @@ export abstract class DataService { * @param searchMethod The search method for the object */ protected getSearchEndpoint(searchMethod: string): Observable { - return this.halService.getEndpoint(`${this.linkPath}/search`).pipe( + return this.halService.getEndpoint(this.linkPath).pipe( filter((href: string) => isNotEmpty(href)), - map((href: string) => `${href}/${searchMethod}`)); + map((href: string) => `${href}/search/${searchMethod}`)); } /** @@ -397,30 +424,22 @@ export abstract class DataService { )); } - /** - * Get the endpoint for creating a new object - * @param parentUUID The parent object's UUID - */ - getCreateHref(parentUUID: string): Observable { - return this.halService.getEndpoint(this.linkPath).pipe( - isNotEmptyOperator(), - distinctUntilChanged(), - map((endpoint: string) => parentUUID ? `${endpoint}?parent=${parentUUID}` : endpoint) - ); - } - /** * Create a new DSpaceObject on the server, and store the response * in the object cache * * @param {DSpaceObject} dso * The object to create - * @param {string} parentUUID - * The UUID of the parent to create the new object under + * @param {RequestParam[]} params + * Array with additional params to combine with query string */ - create(dso: T, parentUUID: string): Observable> { + create(dso: T, ...params: RequestParam[]): Observable> { const requestId = this.requestService.generateRequestId(); - const endpoint$ = this.getCreateHref(parentUUID); + const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + map((endpoint: string) => this.buildHrefWithParams(endpoint, params)) + ); const serializedDso = new DSpaceSerializer(getClassForType((dso as any).type)).serialize(dso); @@ -510,7 +529,7 @@ export abstract class DataService { const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata); return this.requestService.getByUUID(requestId).pipe( - find((request: RequestEntry) => request.completed), + find((request: RequestEntry) => isNotEmpty(request) && request.completed), map((request: RequestEntry) => request.response.isSuccessful) ); } diff --git a/src/app/core/data/metadata-field-data.service.spec.ts b/src/app/core/data/metadata-field-data.service.spec.ts index 1ade4185bf..5c17b56845 100644 --- a/src/app/core/data/metadata-field-data.service.spec.ts +++ b/src/app/core/data/metadata-field-data.service.spec.ts @@ -8,9 +8,9 @@ import { CreateRequest, FindListOptions, PutRequest } from './request.models'; import { MetadataFieldDataService } from './metadata-field-data.service'; import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { SearchParam } from '../cache/models/search-param.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RequestParam } from '../cache/models/request-param.model'; describe('MetadataFieldDataService', () => { let metadataFieldService: MetadataFieldDataService; @@ -58,7 +58,7 @@ describe('MetadataFieldDataService', () => { it('should call searchBy with the correct arguments', () => { metadataFieldService.findBySchema(schema); const expectedOptions = Object.assign(new FindListOptions(), { - searchParams: [new SearchParam('schema', schema.prefix)] + searchParams: [new RequestParam('schema', schema.prefix)] }); expect(metadataFieldService.searchBy).toHaveBeenCalledWith('bySchema', expectedOptions); }); diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index 9bb0a54fa7..00bc11bdc0 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -15,11 +15,11 @@ import { MetadataField } from '../metadata/metadata-field.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { FindListOptions } from './request.models'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { SearchParam } from '../cache/models/search-param.model'; import { Observable } from 'rxjs/internal/Observable'; -import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; -import { distinctUntilChanged, map, tap } from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { tap } from 'rxjs/operators'; import { RemoteData } from './remote-data'; +import { RequestParam } from '../cache/models/request-param.model'; /** * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint @@ -50,7 +50,7 @@ export class MetadataFieldDataService extends DataService { */ findBySchema(schema: MetadataSchema, options: FindListOptions = {}, ...linksToFollow: Array>) { const optionsWithSchema = Object.assign(new FindListOptions(), options, { - searchParams: [new SearchParam('schema', schema.prefix)] + searchParams: [new RequestParam('schema', schema.prefix)] }); return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow); } @@ -69,22 +69,10 @@ export class MetadataFieldDataService extends DataService { if (isUpdate) { return this.put(field); } else { - return this.create(field, `${field.schema.id}`); + return this.create(field, new RequestParam('schemaId', field.schema.id)); } } - /** - * Get the endpoint for creating a new object - * @param parentUUID The parent object's UUID - */ - getCreateHref(parentUUID: string): Observable { - return this.halService.getEndpoint(this.linkPath).pipe( - isNotEmptyOperator(), - distinctUntilChanged(), - map((endpoint: string) => parentUUID ? `${endpoint}?schemaId=${parentUUID}` : endpoint) - ); - } - /** * Clear all metadata field requests * Used for refreshing lists after adding/updating/removing a metadata field from a metadata schema diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 570f69d051..99a3f98b8e 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -51,7 +51,7 @@ export class MetadataSchemaDataService extends DataService { if (isUpdate) { return this.put(schema); } else { - return this.create(schema, undefined); + return this.create(schema); } } diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index 4dde567c99..3d68e70206 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -21,7 +21,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; @@ -257,7 +257,7 @@ export class RelationshipService extends DataService { if (options) { findListOptions = Object.assign(new FindListOptions(), options); } - const searchParams = [new SearchParam('label', label), new SearchParam('dso', item.id)]; + const searchParams = [new RequestParam('label', label), new RequestParam('dso', item.id)]; if (findListOptions.searchParams) { findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; } else { diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 7fd78cd3f9..b484a2ba4e 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -11,7 +11,7 @@ import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service'; import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; import { RestRequestMethod } from './rest-request-method'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service'; import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service'; import { URLCombiner } from '../url-combiner/url-combiner'; @@ -144,7 +144,7 @@ export class FindListOptions { elementsPerPage?: number; currentPage?: number; sort?: SortOptions; - searchParams?: SearchParam[]; + searchParams?: RequestParam[]; startsWith?: string; } diff --git a/src/app/core/data/resource-policy.service.spec.ts b/src/app/core/data/resource-policy.service.spec.ts deleted file mode 100644 index abed805ca3..0000000000 --- a/src/app/core/data/resource-policy.service.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { HttpClient } from '@angular/common/http'; -import { cold, getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ResourcePolicy } from '../shared/resource-policy.model'; -import { RequestService } from './request.service'; -import { ResourcePolicyService } from './resource-policy.service'; - -describe('ResourcePolicyService', () => { - let scheduler: TestScheduler; - let service: ResourcePolicyService; - let requestService: RequestService; - let rdbService: RemoteDataBuildService; - let objectCache: ObjectCacheService; - const testObject = { - uuid: '664184ee-b254-45e8-970d-220e5ccc060b' - } as ResourcePolicy; - const requestURL = `https://rest.api/rest/api/resourcepolicies/${testObject.uuid}`; - const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; - - beforeEach(() => { - scheduler = getTestScheduler(); - - requestService = jasmine.createSpyObj('requestService', { - generateRequestId: requestUUID, - configure: true - }); - rdbService = jasmine.createSpyObj('rdbService', { - buildSingle: cold('a', { - a: { - payload: testObject - } - }) - }); - objectCache = {} as ObjectCacheService; - const halService = {} as HALEndpointService; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const comparator = {} as any; - - service = new ResourcePolicyService( - requestService, - rdbService, - objectCache, - halService, - notificationsService, - http, - comparator - ); - - spyOn((service as any).dataService, 'findByHref').and.callThrough(); - }); - - describe('findByHref', () => { - it('should proxy the call to dataservice.findByHref', () => { - scheduler.schedule(() => service.findByHref(requestURL)); - scheduler.flush(); - - expect((service as any).dataService.findByHref).toHaveBeenCalledWith(requestURL); - }); - - it('should return a RemoteData for the object with the given URL', () => { - const result = service.findByHref(requestURL); - const expected = cold('a', { - a: { - payload: testObject - } - }); - expect(result).toBeObservable(expected); - }); - }); -}); diff --git a/src/app/core/data/resource-policy.service.ts b/src/app/core/data/resource-policy.service.ts deleted file mode 100644 index f66032925e..0000000000 --- a/src/app/core/data/resource-policy.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; - -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; -import { dataService } from '../cache/builders/build-decorators'; - -import { DataService } from '../data/data.service'; -import { RequestService } from '../data/request.service'; -import { FindListOptions } from '../data/request.models'; -import { Collection } from '../shared/collection.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ResourcePolicy } from '../shared/resource-policy.model'; -import { RemoteData } from '../data/remote-data'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { CoreState } from '../core.reducers'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { RESOURCE_POLICY } from '../shared/resource-policy.resource-type'; -import { ChangeAnalyzer } from './change-analyzer'; -import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; -import { PaginatedList } from './paginated-list'; - -/* tslint:disable:max-classes-per-file */ - -/** - * A private DataService implementation to delegate specific methods to. - */ -class DataServiceImpl extends DataService { - protected linkPath = 'resourcepolicies'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: ChangeAnalyzer) { - super(); - } - -} - -/** - * A service responsible for fetching/sending data from/to the REST API on the resourcepolicies endpoint - */ -@Injectable() -@dataService(RESOURCE_POLICY) -export class ResourcePolicyService { - private dataService: DataServiceImpl; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); - } - - /** - * Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link ResourcePolicy} - * @param href The url of {@link ResourcePolicy} we want to retrieve - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - findByHref(href: string, ...linksToFollow: Array>): Observable> { - return this.dataService.findByHref(href, ...linksToFollow); - } - - /** - * Returns a list of observables of {@link RemoteData} of {@link ResourcePolicy}s, based on an href, with a list of {@link FollowLinkConfig}, - * to automatically resolve {@link HALLink}s of the {@link ResourcePolicy} - * @param href The url of the {@link ResourcePolicy} we want to retrieve - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - */ - findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { - return this.dataService.findAllByHref(href, findListOptions, ...linksToFollow); - } - - /** - * Return the defaultAccessConditions {@link ResourcePolicy} list for a given {@link Collection} - * - * @param collection the {@link Collection} to retrieve the defaultAccessConditions for - * @param findListOptions the {@link FindListOptions} for the request - */ - getDefaultAccessConditionsFor(collection: Collection, findListOptions?: FindListOptions): Observable>> { - return this.dataService.findAllByHref(collection._links.defaultAccessConditions.href, findListOptions); - } -} diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index d83a376da9..0cb56f14a2 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -9,7 +9,7 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs/internal/Observable'; import { TestScheduler } from 'rxjs/testing'; import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { CoreState } from '../core.reducers'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { PaginatedList } from '../data/paginated-list'; @@ -105,7 +105,7 @@ describe('EPersonDataService', () => { it('search by default scope (byMetadata) and no query', () => { service.searchByScope(null, ''); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', ''))] + searchParams: [Object.assign(new RequestParam('query', ''))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); @@ -113,7 +113,7 @@ describe('EPersonDataService', () => { it('search metadata scope and no query', () => { service.searchByScope('metadata', ''); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', ''))] + searchParams: [Object.assign(new RequestParam('query', ''))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); @@ -121,7 +121,7 @@ describe('EPersonDataService', () => { it('search metadata scope and with query', () => { service.searchByScope('metadata', 'test'); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', 'test'))] + searchParams: [Object.assign(new RequestParam('query', 'test'))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); @@ -129,7 +129,7 @@ describe('EPersonDataService', () => { it('search email scope and no query', () => { service.searchByScope('email', ''); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('email', ''))] + searchParams: [Object.assign(new RequestParam('email', ''))] }); expect(service.searchBy).toHaveBeenCalledWith('byEmail', options); }); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index a8cee6f1de..86e53178a0 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -15,7 +15,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { DataService } from '../data/data.service'; @@ -97,7 +97,7 @@ export class EPersonDataService extends DataService { * @param linksToFollow */ private getEpeopleByEmail(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { - const searchParams = [new SearchParam('email', query)]; + const searchParams = [new RequestParam('email', query)]; return this.getEPeopleBy(searchParams, this.searchByEmailPath, options, ...linksToFollow); } @@ -108,7 +108,7 @@ export class EPersonDataService extends DataService { * @param linksToFollow */ private getEpeopleByMetadata(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { - const searchParams = [new SearchParam('query', query)]; + const searchParams = [new RequestParam('query', query)]; return this.getEPeopleBy(searchParams, this.searchByMetadataPath, options, ...linksToFollow); } @@ -119,7 +119,7 @@ export class EPersonDataService extends DataService { * @param options * @param linksToFollow */ - private getEPeopleBy(searchParams: SearchParam[], searchMethod: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + private getEPeopleBy(searchParams: RequestParam[], searchMethod: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { let findListOptions = new FindListOptions(); if (options) { findListOptions = Object.assign(new FindListOptions(), options); diff --git a/src/app/core/eperson/group-data.service.spec.ts b/src/app/core/eperson/group-data.service.spec.ts index 28d10cfcf1..240e9d6805 100644 --- a/src/app/core/eperson/group-data.service.spec.ts +++ b/src/app/core/eperson/group-data.service.spec.ts @@ -11,7 +11,7 @@ import { GroupRegistryEditGroupAction } from '../../+admin/admin-access-control/group-registry/group-registry.actions'; import { GroupMock, GroupMock2 } from '../../shared/testing/group-mock'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { CoreState } from '../core.reducers'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { PaginatedList } from '../data/paginated-list'; @@ -103,7 +103,7 @@ describe('GroupDataService', () => { it('search with empty query', () => { service.searchGroups(''); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', ''))] + searchParams: [Object.assign(new RequestParam('query', ''))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); @@ -111,7 +111,7 @@ describe('GroupDataService', () => { it('search with query', () => { service.searchGroups('test'); const options = Object.assign(new FindListOptions(), { - searchParams: [Object.assign(new SearchParam('query', 'test'))] + searchParams: [Object.assign(new RequestParam('query', 'test'))] }); expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); }); diff --git a/src/app/core/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index 574b4d997a..75f00310ec 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -14,7 +14,7 @@ import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SearchParam } from '../cache/models/search-param.model'; +import { RequestParam } from '../cache/models/request-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RestResponse } from '../cache/response.models'; import { DataService } from '../data/data.service'; @@ -97,7 +97,7 @@ export class GroupDataService extends DataService { * @param linksToFollow */ public searchGroups(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { - const searchParams = [new SearchParam('query', query)]; + const searchParams = [new RequestParam('query', query)]; let findListOptions = new FindListOptions(); if (options) { findListOptions = Object.assign(new FindListOptions(), options); @@ -121,7 +121,7 @@ export class GroupDataService extends DataService { isMemberOf(groupName: string): Observable { const searchHref = 'isMemberOf'; const options = new FindListOptions(); - options.searchParams = [new SearchParam('groupName', groupName)]; + options.searchParams = [new RequestParam('groupName', groupName)]; return this.searchBy(searchHref, options).pipe( filter((groups: RemoteData>) => !groups.isResponsePending), diff --git a/src/app/core/cache/models/action-type.model.ts b/src/app/core/resource-policy/models/action-type.model.ts similarity index 75% rename from src/app/core/cache/models/action-type.model.ts rename to src/app/core/resource-policy/models/action-type.model.ts index 4965f93e89..93c69c3705 100644 --- a/src/app/core/cache/models/action-type.model.ts +++ b/src/app/core/resource-policy/models/action-type.model.ts @@ -5,27 +5,27 @@ export enum ActionType { /** * Action of reading, viewing or downloading something */ - READ = 0, + READ = 'READ', /** * Action of modifying something */ - WRITE = 1, + WRITE = 'WRITE', /** * Action of deleting something */ - DELETE = 2, + DELETE = 'DELETE', /** * Action of adding something to a container */ - ADD = 3, + ADD = 'ADD', /** * Action of removing something from a container */ - REMOVE = 4, + REMOVE = 'REMOVE', /** * Action of performing workflow step 1 @@ -50,15 +50,20 @@ export enum ActionType { /** * Default Read policies for Bitstreams submitted to container */ - DEFAULT_BITSTREAM_READ = 9, + DEFAULT_BITSTREAM_READ = 'DEFAULT_BITSTREAM_READ', /** * Default Read policies for Items submitted to container */ - DEFAULT_ITEM_READ = 10, + DEFAULT_ITEM_READ = 'DEFAULT_ITEM_READ', /** * Administrative actions */ - ADMIN = 11, + ADMIN = 'ADMIN', + + /** + * Action of withdrawn reading + */ + WITHDRAWN_READ = 'WITHDRAWN_READ' } diff --git a/src/app/core/resource-policy/models/policy-type.model.ts b/src/app/core/resource-policy/models/policy-type.model.ts new file mode 100644 index 0000000000..21193e5ce5 --- /dev/null +++ b/src/app/core/resource-policy/models/policy-type.model.ts @@ -0,0 +1,25 @@ +/** + * Enum representing the Policy Type of a Resource Policy + */ +export enum PolicyType { + /** + * A policy in place during the submission + */ + TYPE_SUBMISSION = 'TYPE_SUBMISSION', + + /** + * A policy in place during the approval workflow + */ + TYPE_WORKFLOW = 'TYPE_WORKFLOW', + + /** + * A policy that has been inherited from a container (the collection) + */ + TYPE_INHERITED = 'TYPE_INHERITED', + + /** + * A policy defined by the user during the submission or workflow phase + */ + TYPE_CUSTOM = 'TYPE_CUSTOM', + +} diff --git a/src/app/core/resource-policy/models/resource-policy.model.ts b/src/app/core/resource-policy/models/resource-policy.model.ts new file mode 100644 index 0000000000..27602557d6 --- /dev/null +++ b/src/app/core/resource-policy/models/resource-policy.model.ts @@ -0,0 +1,105 @@ +import { autoserialize, deserialize, deserializeAs } from 'cerialize'; +import { link, typedObject } from '../../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { ActionType } from './action-type.model'; +import { CacheableObject } from '../../cache/object-cache.reducer'; +import { HALLink } from '../../shared/hal-link.model'; +import { RESOURCE_POLICY } from './resource-policy.resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { PolicyType } from './policy-type.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../data/remote-data'; +import { GROUP } from '../../eperson/models/group.resource-type'; +import { Group } from '../../eperson/models/group.model'; +import { EPERSON } from '../../eperson/models/eperson.resource-type'; +import { EPerson } from '../../eperson/models/eperson.model'; + +/** + * Model class for a Resource Policy + */ +@typedObject +export class ResourcePolicy implements CacheableObject { + static type = RESOURCE_POLICY; + + /** + * The identifier for this Resource Policy + */ + @autoserialize + id: string; + + /** + * The name for this Resource Policy + */ + @autoserialize + name: string; + + /** + * The description for this Resource Policy + */ + @autoserialize + description: string; + + /** + * The classification or this Resource Policy + */ + @autoserialize + policyType: PolicyType; + + /** + * The action that is allowed by this Resource Policy + */ + @autoserialize + action: ActionType; + + /** + * The first day of validity of the policy (format YYYY-MM-DD) + */ + @autoserialize + startDate: string; + + /** + * The last day of validity of the policy (format YYYY-MM-DD) + */ + @autoserialize + endDate: string; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The universally unique identifier for this Resource Policy + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer('resource-policy'), 'id') + uuid: string; + + /** + * The {@link HALLink}s for this ResourcePolicy + */ + @deserialize + _links: { + eperson: HALLink, + group: HALLink, + self: HALLink, + }; + + /** + * The eperson linked by this resource policy + * Will be undefined unless the version {@link HALLink} has been resolved. + */ + @link(EPERSON) + eperson?: Observable>; + + /** + * The group linked by this resource policy + * Will be undefined unless the version {@link HALLink} has been resolved. + */ + @link(GROUP) + group?: Observable>; +} diff --git a/src/app/core/shared/resource-policy.resource-type.ts b/src/app/core/resource-policy/models/resource-policy.resource-type.ts similarity index 52% rename from src/app/core/shared/resource-policy.resource-type.ts rename to src/app/core/resource-policy/models/resource-policy.resource-type.ts index 1811a3a0d1..d8ff3b9485 100644 --- a/src/app/core/shared/resource-policy.resource-type.ts +++ b/src/app/core/resource-policy/models/resource-policy.resource-type.ts @@ -1,4 +1,4 @@ -import { ResourceType } from './resource-type'; +import { ResourceType } from '../../shared/resource-type'; /** * The resource type for ResourcePolicy @@ -6,4 +6,4 @@ import { ResourceType } from './resource-type'; * Needs to be in a separate file to prevent circular * dependencies in webpack. */ -export const RESOURCE_POLICY = new ResourceType('resourcePolicy'); +export const RESOURCE_POLICY = new ResourceType('resourcepolicy'); diff --git a/src/app/core/resource-policy/resource-policy.service.spec.ts b/src/app/core/resource-policy/resource-policy.service.spec.ts new file mode 100644 index 0000000000..1c6ac47405 --- /dev/null +++ b/src/app/core/resource-policy/resource-policy.service.spec.ts @@ -0,0 +1,319 @@ +import { HttpClient } from '@angular/common/http'; + +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestService } from '../data/request.service'; +import { ResourcePolicyService } from './resource-policy.service'; +import { PolicyType } from './models/policy-type.model'; +import { ActionType } from './models/action-type.model'; +import { FindListOptions } from '../data/request.models'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PageInfo } from '../shared/page-info.model'; +import { PaginatedList } from '../data/paginated-list'; +import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { RequestEntry } from '../data/request.reducer'; +import { RestResponse } from '../cache/response.models'; + +describe('ResourcePolicyService', () => { + let scheduler: TestScheduler; + let service: ResourcePolicyService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + + const resourcePolicy: any = { + id: '1', + name: null, + description: null, + policyType: PolicyType.TYPE_SUBMISSION, + action: ActionType.READ, + startDate: null, + endDate: null, + type: 'resourcepolicy', + uuid: 'resource-policy-1', + _links: { + eperson: { + href: 'https://rest.api/rest/api/eperson' + }, + group: { + href: 'https://rest.api/rest/api/group' + }, + self: { + href: 'https://rest.api/rest/api/resourcepolicies/1' + }, + } + }; + + const anotherResourcePolicy: any = { + id: '2', + name: null, + description: null, + policyType: PolicyType.TYPE_SUBMISSION, + action: ActionType.WRITE, + startDate: null, + endDate: null, + type: 'resourcepolicy', + uuid: 'resource-policy-2', + _links: { + eperson: { + href: 'https://rest.api/rest/api/eperson' + }, + group: { + href: 'https://rest.api/rest/api/group' + }, + self: { + href: 'https://rest.api/rest/api/resourcepolicies/1' + }, + } + }; + const endpointURL = `https://rest.api/rest/api/resourcepolicies`; + const requestURL = `https://rest.api/rest/api/resourcepolicies/${resourcePolicy.id}`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const resourcePolicyId = '1'; + const epersonUUID = '8b39g7ya-5a4b-438b-9686-be1d5b4a1c5a'; + const groupUUID = '8b39g7ya-5a4b-36987-9686-be1d5b4a1c5a'; + const resourceUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; + + const pageInfo = new PageInfo(); + const array = [resourcePolicy, anotherResourcePolicy]; + const paginatedList = new PaginatedList(pageInfo, array); + const resourcePolicyRD = createSuccessfulRemoteDataObject(resourcePolicy); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: resourcePolicyRD + }), + buildList: hot('a|', { + a: paginatedListRD + }), + }); + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + + service = new ResourcePolicyService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparator + ); + + spyOn((service as any).dataService, 'create').and.callThrough(); + spyOn((service as any).dataService, 'delete').and.callThrough(); + spyOn((service as any).dataService, 'update').and.callThrough(); + spyOn((service as any).dataService, 'findById').and.callThrough(); + spyOn((service as any).dataService, 'findByHref').and.callThrough(); + spyOn((service as any).dataService, 'searchBy').and.callThrough(); + spyOn((service as any).dataService, 'getSearchByHref').and.returnValue(observableOf(requestURL)); + }); + + describe('create', () => { + it('should proxy the call to dataservice.create with eperson UUID', () => { + scheduler.schedule(() => service.create(resourcePolicy, resourceUUID, epersonUUID)); + const params = [ + new RequestParam('resource', resourceUUID), + new RequestParam('eperson', epersonUUID) + ]; + scheduler.flush(); + + expect((service as any).dataService.create).toHaveBeenCalledWith(resourcePolicy, ...params); + }); + + it('should proxy the call to dataservice.create with group UUID', () => { + scheduler.schedule(() => service.create(resourcePolicy, resourceUUID, null, groupUUID)); + const params = [ + new RequestParam('resource', resourceUUID), + new RequestParam('group', groupUUID) + ]; + scheduler.flush(); + + expect((service as any).dataService.create).toHaveBeenCalledWith(resourcePolicy, ...params); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.create(resourcePolicy, resourceUUID, epersonUUID); + const expected = cold('a|', { + a: resourcePolicyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('delete', () => { + it('should proxy the call to dataservice.create', () => { + scheduler.schedule(() => service.delete(resourcePolicyId)); + scheduler.flush(); + + expect((service as any).dataService.delete).toHaveBeenCalledWith(resourcePolicyId); + }); + }); + + describe('update', () => { + it('should proxy the call to dataservice.update', () => { + scheduler.schedule(() => service.update(resourcePolicy)); + scheduler.flush(); + + expect((service as any).dataService.update).toHaveBeenCalledWith(resourcePolicy); + }); + }); + + describe('findById', () => { + it('should proxy the call to dataservice.findById', () => { + scheduler.schedule(() => service.findById(resourcePolicyId)); + scheduler.flush(); + + expect((service as any).dataService.findById).toHaveBeenCalledWith(resourcePolicyId); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.findById(resourcePolicyId); + const expected = cold('a|', { + a: resourcePolicyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findByHref', () => { + it('should proxy the call to dataservice.findByHref', () => { + scheduler.schedule(() => service.findByHref(requestURL)); + scheduler.flush(); + + expect((service as any).dataService.findByHref).toHaveBeenCalledWith(requestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.findByHref(requestURL); + const expected = cold('a|', { + a: resourcePolicyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('searchByEPerson', () => { + it('should proxy the call to dataservice.searchBy', () => { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', epersonUUID)]; + scheduler.schedule(() => service.searchByEPerson(epersonUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByEPersonMethod, options); + }); + + it('should proxy the call to dataservice.searchBy with additional search param', () => { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', epersonUUID), + new RequestParam('resource', resourceUUID), + ]; + scheduler.schedule(() => service.searchByEPerson(epersonUUID, resourceUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByEPersonMethod, options); + }); + + it('should return a RemoteData) for the search', () => { + const result = service.searchByEPerson(epersonUUID, resourceUUID); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('searchByGroup', () => { + it('should proxy the call to dataservice.searchBy', () => { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', groupUUID)]; + scheduler.schedule(() => service.searchByGroup(groupUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByGroupMethod, options); + }); + + it('should proxy the call to dataservice.searchBy with additional search param', () => { + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', groupUUID), + new RequestParam('resource', resourceUUID), + ]; + scheduler.schedule(() => service.searchByGroup(groupUUID, resourceUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByGroupMethod, options); + }); + + it('should return a RemoteData) for the search', () => { + const result = service.searchByGroup(groupUUID); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('searchByResource', () => { + it('should proxy the call to dataservice.searchBy', () => { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', resourceUUID)]; + scheduler.schedule(() => service.searchByResource(resourceUUID)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByResourceMethod, options); + }); + + it('should proxy the call to dataservice.searchBy with additional search param', () => { + const action = ActionType.READ; + const options = new FindListOptions(); + options.searchParams = [ + new RequestParam('uuid', resourceUUID), + new RequestParam('action', action), + ]; + scheduler.schedule(() => service.searchByResource(resourceUUID, action)); + scheduler.flush(); + + expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByResourceMethod, options); + }); + + it('should return a RemoteData) for the search', () => { + const result = service.searchByResource(resourceUUID); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/src/app/core/resource-policy/resource-policy.service.ts b/src/app/core/resource-policy/resource-policy.service.ts new file mode 100644 index 0000000000..291920c35a --- /dev/null +++ b/src/app/core/resource-policy/resource-policy.service.ts @@ -0,0 +1,193 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { dataService } from '../cache/builders/build-decorators'; + +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { FindListOptions } from '../data/request.models'; +import { Collection } from '../shared/collection.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ResourcePolicy } from './models/resource-policy.model'; +import { RemoteData } from '../data/remote-data'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { RESOURCE_POLICY } from './models/resource-policy.resource-type'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { PaginatedList } from '../data/paginated-list'; +import { ActionType } from './models/action-type.model'; +import { RequestParam } from '../cache/models/request-param.model'; +import { isNotEmpty } from '../../shared/empty.util'; + +/* tslint:disable:max-classes-per-file */ + +/** + * A private DataService implementation to delegate specific methods to. + */ +class DataServiceImpl extends DataService { + protected linkPath = 'resourcepolicies'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer) { + super(); + } + +} + +/** + * A service responsible for fetching/sending data from/to the REST API on the resourcepolicies endpoint + */ +@Injectable() +@dataService(RESOURCE_POLICY) +export class ResourcePolicyService { + private dataService: DataServiceImpl; + protected searchByEPersonMethod = 'eperson'; + protected searchByGroupMethod = 'group'; + protected searchByResourceMethod = 'resource'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Create a new ResourcePolicy on the server, and store the response + * in the object cache + * + * @param {ResourcePolicy} resourcePolicy + * The resource policy to create + * @param {string} resourceUUID + * The uuid of the resource target of the policy + * @param {string} epersonUUID + * The uuid of the eperson that will be grant of the permission. Exactly one of eperson or group is required + * @param {string} groupUUID + * The uuid of the group that will be grant of the permission. Exactly one of eperson or group is required + */ + create(resourcePolicy: ResourcePolicy, resourceUUID: string, epersonUUID?: string, groupUUID?: string): Observable> { + const params = []; + params.push(new RequestParam('resource', resourceUUID)); + if (isNotEmpty(epersonUUID)) { + params.push(new RequestParam('eperson', epersonUUID)); + } else if (isNotEmpty(groupUUID)) { + params.push(new RequestParam('group', groupUUID)); + } + return this.dataService.create(resourcePolicy, ...params); + } + + /** + * Delete an existing ResourcePolicy on the server + * + * @param resourcePolicyID The resource policy's id to be removed + * @return an observable that emits true when the deletion was successful, false when it failed + */ + delete(resourcePolicyID: string): Observable { + return this.dataService.delete(resourcePolicyID); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {ResourcePolicy} object The given object + */ + update(object: ResourcePolicy): Observable> { + return this.dataService.update(object); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link ResourcePolicy} + * @param href The url of {@link ResourcePolicy} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findByHref(href, ...linksToFollow); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on its ID, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param id ID of {@link ResourcePolicy} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findById(id: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findById(id, ...linksToFollow); + } + + /** + * Return the defaultAccessConditions {@link ResourcePolicy} list for a given {@link Collection} + * + * @param collection the {@link Collection} to retrieve the defaultAccessConditions for + * @param findListOptions the {@link FindListOptions} for the request + */ + getDefaultAccessConditionsFor(collection: Collection, findListOptions?: FindListOptions): Observable>> { + return this.dataService.findAllByHref(collection._links.defaultAccessConditions.href, findListOptions); + } + + /** + * Return the {@link ResourcePolicy} list for a {@link EPerson} + * + * @param UUID UUID of a given {@link EPerson} + * @param resourceUUID Limit the returned policies to the specified DSO + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchByEPerson(UUID: string, resourceUUID?: string, ...linksToFollow: Array>): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', UUID)]; + if (isNotEmpty(resourceUUID)) { + options.searchParams.push(new RequestParam('resource', resourceUUID)) + } + return this.dataService.searchBy(this.searchByEPersonMethod, options, ...linksToFollow) + } + + /** + * Return the {@link ResourcePolicy} list for a {@link Group} + * + * @param UUID UUID of a given {@link Group} + * @param resourceUUID Limit the returned policies to the specified DSO + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchByGroup(UUID: string, resourceUUID?: string, ...linksToFollow: Array>): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', UUID)]; + if (isNotEmpty(resourceUUID)) { + options.searchParams.push(new RequestParam('resource', resourceUUID)) + } + return this.dataService.searchBy(this.searchByGroupMethod, options, ...linksToFollow) + } + + /** + * Return the {@link ResourcePolicy} list for a given DSO + * + * @param UUID UUID of a given DSO + * @param action Limit the returned policies to the specified {@link ActionType} + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchByResource(UUID: string, action?: ActionType, ...linksToFollow: Array>): Observable>> { + const options = new FindListOptions(); + options.searchParams = [new RequestParam('uuid', UUID)]; + if (isNotEmpty(action)) { + options.searchParams.push(new RequestParam('action', action)) + } + return this.dataService.searchBy(this.searchByResourceMethod, options, ...linksToFollow) + } + +} diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts index c1164f0fc4..1e5c14d486 100644 --- a/src/app/core/shared/bundle.model.ts +++ b/src/app/core/shared/bundle.model.ts @@ -1,8 +1,15 @@ import { deserialize, inheritSerialization } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; + +import { Observable } from 'rxjs'; + +import { link, typedObject } from '../cache/builders/build-decorators'; import { BUNDLE } from './bundle.resource-type'; import { DSpaceObject } from './dspace-object.model'; import { HALLink } from './hal-link.model'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list'; +import { BITSTREAM } from './bitstream.resource-type'; +import { Bitstream } from './bitstream.model'; @typedObject @inheritSerialization(DSpaceObject) @@ -17,5 +24,19 @@ export class Bundle extends DSpaceObject { self: HALLink; primaryBitstream: HALLink; bitstreams: HALLink; - } + }; + + /** + * The primary Bitstream of this Bundle + * Will be undefined unless the primaryBitstream {@link HALLink} has been resolved. + */ + @link(BITSTREAM) + primaryBitstream?: Observable>; + + /** + * The list of Bitstreams that are direct children of this Bundle + * Will be undefined unless the bitstreams {@link HALLink} has been resolved. + */ + @link(BITSTREAM, true) + bitstreams?: Observable>>; } diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index 4e0b5ead83..b65ac252ef 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -10,8 +10,8 @@ import { DSpaceObject } from './dspace-object.model'; import { HALLink } from './hal-link.model'; import { License } from './license.model'; import { LICENSE } from './license.resource-type'; -import { ResourcePolicy } from './resource-policy.model'; -import { RESOURCE_POLICY } from './resource-policy.resource-type'; +import { ResourcePolicy } from '../resource-policy/models/resource-policy.model'; +import { RESOURCE_POLICY } from '../resource-policy/models/resource-policy.resource-type'; import { COMMUNITY } from './community.resource-type'; import { Community } from './community.model'; import { ChildHALResource } from './child-hal-resource.model'; diff --git a/src/app/core/shared/context.model.ts b/src/app/core/shared/context.model.ts index 6bb3d77140..ff24b7d090 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -11,4 +11,5 @@ export enum Context { AdminMenu = 'adminMenu', SubmissionModal = 'submissionModal', AdminSearch = 'adminSearch', + AdminWorkflowSearch = 'adminWorkflowSearch', } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index a51e711d26..a307b144d2 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -67,6 +67,10 @@ export const getSucceededRemoteData = () => (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => rd.hasSucceeded)); +export const getSucceededRemoteWithNotEmptyData = () => + (source: Observable>): Observable> => + source.pipe(find((rd: RemoteData) => rd.hasSucceeded && isNotEmpty(rd.payload))); + /** * Get the first successful remotely retrieved object * @@ -84,6 +88,23 @@ export const getFirstSucceededRemoteDataPayload = () => getRemoteDataPayload() ); +/** + * Get the first successful remotely retrieved object with not empty payload + * + * You usually don't want to use this, it is a code smell. + * Work with the RemoteData object instead, that way you can + * handle loading and errors correctly. + * + * These operators were created as a first step in refactoring + * out all the instances where this is used incorrectly. + */ +export const getFirstSucceededRemoteDataWithNotEmptyPayload = () => + (source: Observable>): Observable => + source.pipe( + getSucceededRemoteWithNotEmptyData(), + getRemoteDataPayload() + ); + /** * Get the all successful remotely retrieved objects * diff --git a/src/app/core/shared/resource-policy.model.ts b/src/app/core/shared/resource-policy.model.ts deleted file mode 100644 index dd00a16e97..0000000000 --- a/src/app/core/shared/resource-policy.model.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { autoserialize, deserialize, deserializeAs } from 'cerialize'; -import { typedObject } from '../cache/builders/build-decorators'; -import { IDToUUIDSerializer } from '../cache/id-to-uuid-serializer'; -import { ActionType } from '../cache/models/action-type.model'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { excludeFromEquals } from '../utilities/equals.decorators'; -import { HALLink } from './hal-link.model'; -import { RESOURCE_POLICY } from './resource-policy.resource-type'; -import { ResourceType } from './resource-type'; - -/** - * Model class for a Resource Policy - */ -@typedObject -export class ResourcePolicy implements CacheableObject { - static type = RESOURCE_POLICY; - - /** - * The object type - */ - @excludeFromEquals - @autoserialize - type: ResourceType; - - /** - * The action that is allowed by this Resource Policy - */ - @autoserialize - action: ActionType; - - /** - * The name for this Resource Policy - */ - @autoserialize - name: string; - - /** - * The uuid of the Group this Resource Policy applies to - */ - @autoserialize - groupUUID: string; - - /** - * The universally unique identifier for this Resource Policy - * This UUID is generated client-side and isn't used by the backend. - * It is based on the ID, so it will be the same for each refresh. - */ - @deserializeAs(new IDToUUIDSerializer('resource-policy'), 'id') - uuid: string; - - /** - * The {@link HALLink}s for this ResourcePolicy - */ - @deserialize - _links: { - self: HALLink, - } -} diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index a2dfca5eb3..c82f7bf0b5 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -9,13 +9,17 @@ import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { WorkflowItem } from './models/workflowitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindListOptions } from '../data/request.models'; +import { DeleteByIDRequest, FindListOptions } from '../data/request.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { Observable } from 'rxjs'; +import { find, map } from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { RequestEntry } from '../data/request.reducer'; /** - * A service that provides methods to make REST requests with workflowitems endpoint. + * A service that provides methods to make REST requests with workflow items endpoint. */ @Injectable() @dataService(WorkflowItem.type) @@ -35,4 +39,50 @@ export class WorkflowItemDataService extends DataService { super(); } + /** + * Delete an existing Workflow Item on the server + * @param id The Workflow Item's id to be removed + * @return an observable that emits true when the deletion was successful, false when it failed + */ + delete(id: string): Observable { + return this.deleteWFI(id, true) + } + + /** + * Send an existing Workflow Item back to the workflow on the server + * @param id The Workspace Item's id to be sent back + * @return an observable that emits true when sending back the item was successful, false when it failed + */ + sendBack(id: string): Observable { + return this.deleteWFI(id, false) + } + + /** + * Method to delete a workflow item from the server + * @param id The identifier of the server + * @param expunge Whether or not to expunge: + * When true, the workflow item and its item will be permanently expunged on the server + * When false, the workflow item will be removed, but the item will still be available as a workspace item + */ + private deleteWFI(id: string, expunge: boolean): Observable { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, id)), + map((endpoint: string) => endpoint + '?expunge=' + expunge) + ); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new DeleteByIDRequest(requestId, href, id); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response.isSuccessful) + ); + } } diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 58f7cb1ecf..5ce0cdb410 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -8,6 +8,7 @@ +
+ + diff --git a/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts b/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts new file mode 100644 index 0000000000..e6ddc3075d --- /dev/null +++ b/src/app/shared/impersonate-navbar/impersonate-navbar.component.spec.ts @@ -0,0 +1,80 @@ +import { ImpersonateNavbarComponent } from './impersonate-navbar.component'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../utils/var.directive'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { AuthService } from '../../core/auth/auth.service'; +import { Store, StoreModule } from '@ngrx/store'; +import { authReducer, AuthState } from '../../core/auth/auth.reducer'; +import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; +import { EPersonMock } from '../testing/eperson.mock'; +import { AppState } from '../../app.reducer'; +import { By } from '@angular/platform-browser'; + +describe('ImpersonateNavbarComponent', () => { + let component: ImpersonateNavbarComponent; + let fixture: ComponentFixture; + let authService: AuthService; + let authState: AuthState; + + beforeEach(async(() => { + authService = jasmine.createSpyObj('authService', { + isImpersonating: false, + stopImpersonatingAndRefresh: {} + }); + authState = { + authenticated: true, + loaded: true, + loading: false, + authToken: new AuthTokenInfo('test_token'), + userId: EPersonMock.id + }; + + TestBed.configureTestingModule({ + declarations: [ImpersonateNavbarComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), StoreModule.forRoot(authReducer)], + providers: [ + { provide: AuthService, useValue: authService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + fixture = TestBed.createComponent(ImpersonateNavbarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('when the user is impersonating another user', () => { + beforeEach(() => { + component.isImpersonating = true; + fixture.detectChanges(); + }); + + it('should display a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).not.toBeNull(); + }); + + it('should call authService\'s stopImpersonatingAndRefresh upon clicking the button', () => { + const button = fixture.debugElement.query(By.css('button')).nativeElement; + button.click(); + expect(authService.stopImpersonatingAndRefresh).toHaveBeenCalled(); + }); + }); + + describe('when the user is not impersonating another user', () => { + it('should not display a button', () => { + const button = fixture.debugElement.query(By.css('button')); + expect(button).toBeNull(); + }); + }); +}); diff --git a/src/app/shared/impersonate-navbar/impersonate-navbar.component.ts b/src/app/shared/impersonate-navbar/impersonate-navbar.component.ts new file mode 100644 index 0000000000..19293566ef --- /dev/null +++ b/src/app/shared/impersonate-navbar/impersonate-navbar.component.ts @@ -0,0 +1,42 @@ +import { Component, OnInit } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { AuthService } from '../../core/auth/auth.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { isAuthenticated } from '../../core/auth/selectors'; + +@Component({ + selector: 'ds-impersonate-navbar', + templateUrl: 'impersonate-navbar.component.html' +}) +/** + * Navbar component for actions to take concerning impersonating users + */ +export class ImpersonateNavbarComponent implements OnInit { + /** + * Whether or not the user is authenticated. + * @type {Observable} + */ + isAuthenticated$: Observable; + + /** + * Is the user currently impersonating another user? + */ + isImpersonating: boolean; + + constructor(private store: Store, + private authService: AuthService) { + } + + ngOnInit(): void { + this.isAuthenticated$ = this.store.pipe(select(isAuthenticated)); + this.isImpersonating = this.authService.isImpersonating(); + } + + /** + * Stop impersonating the user + */ + stopImpersonating() { + this.authService.stopImpersonatingAndRefresh(); + } +} diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.html b/src/app/shared/log-in/methods/password/log-in-password.component.html index ddd5083d44..16f42a1e16 100644 --- a/src/app/shared/log-in/methods/password/log-in-password.component.html +++ b/src/app/shared/log-in/methods/password/log-in-password.component.html @@ -1,4 +1,5 @@ -