diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3e727e2bdf..b4063b0550 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -21,8 +21,5 @@ _This checklist provides a reminder of what we are going to look for when review - [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible. - [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint` - [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods. -- [ ] My PR passes all specs/tests and includes new/updated specs for any bug fixes, improvements or new features. A few reminders about what constitutes good tests: - * Include tests for different user types (if behavior differs), including: (1) Anonymous user, (2) Logged in user (non-admin), and (3) Administrator. - * Include tests for error scenarios, e.g. when errors/warnings should appear (or buttons should be disabled). - * For bug fixes, include a test that reproduces the bug and proves it is fixed. For clarity, it may be useful to provide the test in a separate commit from the bug fix. +- [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide). - [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation. diff --git a/.github/workflows/label_merge_conflicts.yml b/.github/workflows/label_merge_conflicts.yml new file mode 100644 index 0000000000..dcbab18f1b --- /dev/null +++ b/.github/workflows/label_merge_conflicts.yml @@ -0,0 +1,25 @@ +# This workflow checks open PRs for merge conflicts and labels them when conflicts are found +name: Check for merge conflicts + +# Run whenever the "main" branch is updated +# NOTE: This means merge conflicts are only checked for when a PR is merged to main. +on: + push: + branches: + - main + +jobs: + triage: + runs-on: ubuntu-latest + steps: + # See: https://github.com/mschilde/auto-label-merge-conflicts/ + - name: Auto-label PRs with merge conflicts + uses: mschilde/auto-label-merge-conflicts@v2.0 + # Add "merge conflict" label if a merge conflict is detected. Remove it when resolved. + # Note, the authentication token is created automatically + # See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token + with: + CONFLICT_LABEL_NAME: 'merge conflict' + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Ignore errors + continue-on-error: true diff --git a/.travis.yml b/.travis.yml index 54b3c4752a..13a159bfd0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -51,10 +51,10 @@ before_script: script: # build app and run all tests - - ng lint - - travis_wait yarn run build:prod - - yarn test:headless - - yarn run e2e:ci + - ng lint || travis_terminate 1; + - travis_wait yarn run build:prod || travis_terminate 1; + - yarn test:headless || travis_terminate 1; + - yarn run e2e:ci || travis_terminate 1; after_script: # Shutdown docker after everything runs diff --git a/angular.json b/angular.json index 9c55d648b3..d8d8a2dc2e 100644 --- a/angular.json +++ b/angular.json @@ -18,7 +18,7 @@ "builder": "@angular-builders/custom-webpack:browser", "options": { "customWebpackConfig": { - "path": "./webpack/webpack.common.ts", + "path": "./webpack/webpack.browser.ts", "mergeStrategies": { "loaders": "prepend" } @@ -30,7 +30,8 @@ "tsConfig": "tsconfig.app.json", "aot": false, "assets": [ - "src/assets" + "src/assets", + "src/robots.txt" ], "styles": [ "src/styles.scss" @@ -84,7 +85,7 @@ "builder": "@angular-builders/custom-webpack:karma", "options": { "customWebpackConfig": { - "path": "./webpack/webpack.common.ts", + "path": "./webpack/webpack.test.ts", "mergeStrategies": { "loaders": "prepend" } diff --git a/package.json b/package.json index c1e5b05010..52afb7c4c0 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,7 @@ "dotenv": "^8.2.0", "fork-ts-checker-webpack-plugin": "^0.4.10", "html-webpack-plugin": "^3.2.0", + "http-proxy-middleware": "^1.0.5", "jasmine-core": "^3.3.0", "jasmine-marbles": "0.3.1", "jasmine-spec-reporter": "~4.2.1", diff --git a/scripts/set-env.ts b/scripts/set-env.ts index 5eee22a4be..5570b77218 100644 --- a/scripts/set-env.ts +++ b/scripts/set-env.ts @@ -54,6 +54,13 @@ import(environmentFilePath) function generateEnvironmentFile(file: GlobalConfig): void { file.production = production; buildBaseUrls(file); + + // TODO remove workaround in beta 5 + if (file.rest.nameSpace.match("(.*)/api/?$") !== null) { + const newValue = getNameSpace(file.rest.nameSpace); + console.log(colors.white.bgMagenta.bold(`The rest.nameSpace property in your environment file or in your DSPACE_REST_NAMESPACE environment variable ends with '/api'.\nThis is deprecated. As '/api' isn't configurable on the rest side, it shouldn't be repeated in every environment file.\nPlease change the rest nameSpace to '${newValue}'`)); + } + const contents = `export const environment = ` + JSON.stringify(file); writeFile(targetPath, contents, (err) => { if (err) { @@ -112,5 +119,16 @@ function getPort(port: number): string { } function getNameSpace(nameSpace: string): string { - return nameSpace ? nameSpace.charAt(0) === '/' ? nameSpace : '/' + nameSpace : ''; + // TODO remove workaround in beta 5 + const apiMatches = nameSpace.match("(.*)/api/?$"); + if (apiMatches != null) { + let newValue = '/' + if (hasValue(apiMatches[1])) { + newValue = apiMatches[1]; + } + return newValue; + } + else { + return nameSpace ? nameSpace.charAt(0) === '/' ? nameSpace : '/' + nameSpace : ''; + } } diff --git a/server.ts b/server.ts index a5d47d8bd7..99e2b25e1e 100644 --- a/server.ts +++ b/server.ts @@ -15,7 +15,6 @@ * import for `ngExpressEngine`. */ -import 'zone.js/dist/zone-node'; import 'reflect-metadata'; import 'rxjs'; @@ -33,6 +32,8 @@ import { enableProdMode, NgModuleFactory, Type } from '@angular/core'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { hasValue } from './src/app/shared/empty.util'; /* * Set path for the browser application's dist folder @@ -98,7 +99,6 @@ app.engine('html', (_, options, callback) => /* * Register the view engines for html and ejs */ -app.set('view engine', 'ejs'); app.set('view engine', 'html'); /* @@ -106,6 +106,11 @@ app.set('view engine', 'html'); */ app.set('views', DIST_FOLDER); +/** + * Proxy the sitemaps + */ +app.use('/sitemap**', createProxyMiddleware({ target: `${environment.rest.baseUrl}/sitemaps`, changeOrigin: true })); + /* * 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 @@ -125,56 +130,27 @@ app.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false })); * The callback function to serve server side angular */ function ngApp(req, res) { - // Object to be set to window.dspace when CSR is used - // this allows us to pass the info in the original request - // to the dspace7-angular instance running in the client's browser - const dspace = { - originalRequest: { - headers: req.headers, - body: req.body, - method: req.method, - params: req.params, - reportProgress: req.reportProgress, - withCredentials: req.withCredentials, - responseType: req.responseType, - urlWithParams: req.urlWithParams - } - }; - - // callback function for the case when SSR throws an error. - function onHandleError(parentZoneDelegate, currentZone, targetZone, error) { - if (!res._headerSent) { - console.warn('Error in SSR, serving for direct CSR. Error details : ', error); - res.sendFile('index.csr.ejs', { - root: DIST_FOLDER, - scripts: `` - }); - } - } - if (environment.universal.preboot) { - // If preboot is enabled, create a new zone for SSR, and - // register the error handler for when it throws an error - Zone.current.fork({ name: 'CSR fallback', onHandleError }).run(() => { - res.render(DIST_FOLDER + '/index.html', { - req, - res, - preboot: environment.universal.preboot, - async: environment.universal.async, - time: environment.universal.time, - baseUrl: environment.ui.nameSpace, - originUrl: environment.ui.baseUrl, - requestUrl: req.originalUrl - }); - }); + res.render(DIST_FOLDER + '/index.html', { + req, + res, + preboot: environment.universal.preboot, + async: environment.universal.async, + time: environment.universal.time, + baseUrl: environment.ui.nameSpace, + originUrl: environment.ui.baseUrl, + requestUrl: req.originalUrl + }, (err) => { + console.warn('Error in SSR, serving for direct CSR.'); + if (hasValue(err)) { + console.warn('Error details : ', err); + } + res.sendFile(DIST_FOLDER + '/index.html'); + }) } else { - // If preboot is disabled, just serve the client side ejs template and pass it the required - // variables + // If preboot is disabled, just serve the client console.log('Universal off, serving for direct CSR'); - res.render('index-csr.ejs', { - root: DIST_FOLDER, - scripts: `` - }); + res.sendFile(DIST_FOLDER + '/index.html'); } } diff --git a/src/app/+admin/admin-access-control/admin-access-control-routing-paths.ts b/src/app/+admin/admin-access-control/admin-access-control-routing-paths.ts new file mode 100644 index 0000000000..2080cb14a7 --- /dev/null +++ b/src/app/+admin/admin-access-control/admin-access-control-routing-paths.ts @@ -0,0 +1,8 @@ +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { getAccessControlModuleRoute } from '../admin-routing-paths'; + +export const GROUP_EDIT_PATH = 'groups'; + +export function getGroupEditRoute(id: string) { + return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString(); +} 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 f61a3c2f71..d2f2233eee 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 @@ -3,14 +3,7 @@ import { RouterModule } from '@angular/router'; import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component'; import { GroupFormComponent } from './group-registry/group-form/group-form.component'; import { GroupsRegistryComponent } from './group-registry/groups-registry.component'; -import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { getAccessControlModulePath } from '../admin-routing.module'; - -export const GROUP_EDIT_PATH = 'groups'; - -export function getGroupEditPath(id: string) { - return new URLCombiner(getAccessControlModulePath(), GROUP_EDIT_PATH, id).toString(); -} +import { GROUP_EDIT_PATH } from './admin-access-control-routing-paths'; @NgModule({ imports: [ diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html index 20593756c1..9cf733a394 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html @@ -7,87 +7,88 @@ -
- -
- - -
-
- +
+
+
-
-
- - - - + + + +
+
+
+
+ + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + + +
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{eperson.id}}{{eperson.name}}{{eperson.email}} +
+ + +
+
+
+ +
+ + - - - - -
- - - - - - - - - - - - - - - - - -
{{labelPrefix + 'table.id' | translate}}{{labelPrefix + 'table.name' | translate}}{{labelPrefix + 'table.email' | translate}}{{labelPrefix + 'table.edit' | translate}}
{{eperson.id}}{{eperson.name}}{{eperson.email}} -
- - -
-
-
- -
- - -
diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts index 17d8655bdd..4cc68a5540 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts @@ -175,7 +175,7 @@ describe('EPeopleRegistryComponent', () => { it('editEPerson form is toggled', () => { const ePeopleIds = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); ePersonDataServiceStub.getActiveEPerson().subscribe((activeEPerson: EPerson) => { - if (activeEPerson === ePeopleIds[0].nativeElement.textContent) { + if (ePeopleIds[0] && activeEPerson === ePeopleIds[0].nativeElement.textContent) { expect(component.isEPersonFormShown).toEqual(false); } else { expect(component.isEPersonFormShown).toEqual(true); @@ -183,6 +183,10 @@ describe('EPeopleRegistryComponent', () => { }) }); + + it('EPerson search section is hidden', () => { + expect(fixture.debugElement.query(By.css('#search'))).toBeNull(); + }); }); }); diff --git a/src/app/+admin/admin-import-metadata-page/metadata-import-page.component.html b/src/app/+admin/admin-import-metadata-page/metadata-import-page.component.html new file mode 100644 index 0000000000..42a04b0de6 --- /dev/null +++ b/src/app/+admin/admin-import-metadata-page/metadata-import-page.component.html @@ -0,0 +1,15 @@ +
+ +

{{'admin.metadata-import.page.help' | translate}}

+ + + + + + +
diff --git a/src/app/+admin/admin-import-metadata-page/metadata-import-page.component.spec.ts b/src/app/+admin/admin-import-metadata-page/metadata-import-page.component.spec.ts new file mode 100644 index 0000000000..9c4efb6796 --- /dev/null +++ b/src/app/+admin/admin-import-metadata-page/metadata-import-page.component.spec.ts @@ -0,0 +1,151 @@ +import { Location } from '@angular/common'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { AuthService } from '../../core/auth/auth.service'; +import { + METADATA_IMPORT_SCRIPT_NAME, + ScriptDataService +} from '../../core/data/processes/script-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { FileValueAccessorDirective } from '../../shared/utils/file-value-accessor.directive'; +import { FileValidator } from '../../shared/utils/require-file.validator'; +import { MetadataImportPageComponent } from './metadata-import-page.component'; + +describe('MetadataImportPageComponent', () => { + let comp: MetadataImportPageComponent; + let fixture: ComponentFixture; + + let user; + + let notificationService: NotificationsServiceStub; + let scriptService: any; + let router; + let authService; + let locationStub; + + function init() { + notificationService = new NotificationsServiceStub(); + scriptService = jasmine.createSpyObj('scriptService', + { + invoke: observableOf({ + response: + { + isSuccessful: true, + resourceSelfLinks: ['https://localhost:8080/api/core/processes/45'] + } + }) + } + ); + user = Object.assign(new EPerson(), { + id: 'userId', + email: 'user@test.com' + }); + authService = jasmine.createSpyObj('authService', { + getAuthenticatedUserFromStore: observableOf(user) + }); + router = jasmine.createSpyObj('router', { + navigateByUrl: jasmine.createSpy('navigateByUrl') + }); + locationStub = jasmine.createSpyObj('location', { + back: jasmine.createSpy('back') + }); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + FormsModule, + TranslateModule.forRoot(), + RouterTestingModule.withRoutes([]) + ], + declarations: [MetadataImportPageComponent, FileValueAccessorDirective, FileValidator], + providers: [ + { provide: NotificationsService, useValue: notificationService }, + { provide: ScriptDataService, useValue: scriptService }, + { provide: Router, useValue: router }, + { provide: AuthService, useValue: authService }, + { provide: Location, useValue: locationStub }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MetadataImportPageComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(comp).toBeTruthy(); + }); + + describe('if back button is pressed', () => { + beforeEach(fakeAsync(() => { + const proceed = fixture.debugElement.query(By.css('#backButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('should do location.back', () => { + expect(locationStub.back).toHaveBeenCalled(); + }); + }); + + describe('if file is set', () => { + let fileMock: File; + + beforeEach(() => { + fileMock = new File([''], 'filename.txt', { type: 'text/plain' }); + comp.setFile(fileMock); + }); + + describe('if proceed button is pressed', () => { + beforeEach(fakeAsync(() => { + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('metadata-import script is invoked with its -e currentUserEmail, -f fileName and the mockFile', () => { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '-e', value: user.email }), + Object.assign(new ProcessParameter(), { name: '-f', value: 'filename.txt' }), + ]; + expect(scriptService.invoke).toHaveBeenCalledWith(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [fileMock]); + }); + it('success notification is shown', () => { + expect(notificationService.success).toHaveBeenCalled(); + }); + it('redirected to process page', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith('/processes/45'); + }); + }); + + describe('if proceed is pressed; but script invoke fails', () => { + beforeEach(fakeAsync(() => { + jasmine.getEnv().allowRespy(true); + spyOn(scriptService, 'invoke').and.returnValue(observableOf({ + response: + { + isSuccessful: false, + } + })); + const proceed = fixture.debugElement.query(By.css('#proceedButton')).nativeElement; + proceed.click(); + fixture.detectChanges(); + })); + it('error notification is shown', () => { + expect(notificationService.error).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/+admin/admin-import-metadata-page/metadata-import-page.component.ts b/src/app/+admin/admin-import-metadata-page/metadata-import-page.component.ts new file mode 100644 index 0000000000..3db6ad1c7c --- /dev/null +++ b/src/app/+admin/admin-import-metadata-page/metadata-import-page.component.ts @@ -0,0 +1,106 @@ +import { Location } from '@angular/common'; +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { map, switchMap, take } from 'rxjs/operators'; +import { AuthService } from '../../core/auth/auth.service'; +import { METADATA_IMPORT_SCRIPT_NAME, ScriptDataService } from '../../core/data/processes/script-data.service'; +import { RequestEntry } from '../../core/data/request.reducer'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ProcessParameter } from '../../process-page/processes/process-parameter.model'; +import { isNotEmpty } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-metadata-import-page', + templateUrl: './metadata-import-page.component.html' +}) + +/** + * Component that represents a metadata import page for administrators + */ +export class MetadataImportPageComponent implements OnInit { + + /** + * The current value of the file + */ + fileObject: File; + + /** + * The authenticated user's email + */ + private currentUserEmail$: Observable; + + public constructor(protected authService: AuthService, + private location: Location, + protected translate: TranslateService, + protected notificationsService: NotificationsService, + private scriptDataService: ScriptDataService, + private router: Router) { + } + + /** + * Set file + * @param file + */ + setFile(file) { + this.fileObject = file; + } + + /** + * Method provided by Angular. Invoked after the constructor. + */ + ngOnInit() { + this.currentUserEmail$ = this.authService.getAuthenticatedUserFromStore().pipe( + map((user: EPerson) => user.email) + ); + } + + /** + * When return button is pressed go to previous location + */ + public onReturn() { + this.location.back(); + } + + /** + * Starts import-metadata script with -e currentUserEmail -f fileName (and the selected file) + */ + public importMetadata() { + if (this.fileObject == null) { + this.notificationsService.error(this.translate.get('admin.metadata-import.page.error.addFile')); + } else { + this.currentUserEmail$.pipe( + switchMap((email: string) => { + if (isNotEmpty(email)) { + const parameterValues: ProcessParameter[] = [ + Object.assign(new ProcessParameter(), { name: '-e', value: email }), + Object.assign(new ProcessParameter(), { name: '-f', value: this.fileObject.name }), + ]; + return this.scriptDataService.invoke(METADATA_IMPORT_SCRIPT_NAME, parameterValues, [this.fileObject]) + .pipe( + take(1), + map((requestEntry: RequestEntry) => { + if (requestEntry.response.isSuccessful) { + const title = this.translate.get('process.new.notification.success.title'); + const content = this.translate.get('process.new.notification.success.content'); + this.notificationsService.success(title, content); + const response: any = requestEntry.response; + if (isNotEmpty(response.resourceSelfLinks)) { + const processNumber = response.resourceSelfLinks[0].split('/').pop(); + this.router.navigateByUrl('/processes/' + processNumber); + } + } else { + const title = this.translate.get('process.new.notification.error.title'); + const content = this.translate.get('process.new.notification.error.content'); + this.notificationsService.error(title, content); + } + })); + } + }), + take(1) + ).subscribe(); + } + } +} diff --git a/src/app/+admin/admin-registries/admin-registries-routing-paths.ts b/src/app/+admin/admin-registries/admin-registries-routing-paths.ts new file mode 100644 index 0000000000..fa8ee0b209 --- /dev/null +++ b/src/app/+admin/admin-registries/admin-registries-routing-paths.ts @@ -0,0 +1,8 @@ +import { URLCombiner } from '../../core/url-combiner/url-combiner'; +import { getRegistriesModuleRoute } from '../admin-routing-paths'; + +export const BITSTREAMFORMATS_MODULE_PATH = 'bitstream-formats'; + +export function getBitstreamFormatsModuleRoute() { + return new URLCombiner(getRegistriesModuleRoute(), BITSTREAMFORMATS_MODULE_PATH).toString(); +} diff --git a/src/app/+admin/admin-registries/admin-registries-routing.module.ts b/src/app/+admin/admin-registries/admin-registries-routing.module.ts index 8833b307b9..d291827b12 100644 --- a/src/app/+admin/admin-registries/admin-registries-routing.module.ts +++ b/src/app/+admin/admin-registries/admin-registries-routing.module.ts @@ -2,15 +2,8 @@ import { MetadataRegistryComponent } from './metadata-registry/metadata-registry import { RouterModule } from '@angular/router'; import { NgModule } from '@angular/core'; import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component'; -import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { getRegistriesModulePath } from '../admin-routing.module'; import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; - -const BITSTREAMFORMATS_MODULE_PATH = 'bitstream-formats'; - -export function getBitstreamFormatsModulePath() { - return new URLCombiner(getRegistriesModulePath(), BITSTREAMFORMATS_MODULE_PATH).toString(); -} +import { BITSTREAMFORMATS_MODULE_PATH } from './admin-registries-routing-paths'; @NgModule({ imports: [ diff --git a/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.ts b/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.ts index 9712be70ca..ecc26bd5d9 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/add-bitstream-format/add-bitstream-format.component.ts @@ -5,8 +5,8 @@ import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model' import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; import { RestResponse } from '../../../../core/cache/response.models'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { getBitstreamFormatsModulePath } from '../../admin-registries-routing.module'; import { TranslateService } from '@ngx-translate/core'; +import { getBitstreamFormatsModuleRoute } from '../../admin-registries-routing-paths'; /** * This component renders the page to create a new bitstream format. @@ -37,7 +37,7 @@ export class AddBitstreamFormatComponent { if (response.isSuccessful) { this.notificationService.success(this.translateService.get('admin.registries.bitstream-formats.create.success.head'), this.translateService.get('admin.registries.bitstream-formats.create.success.content')); - this.router.navigate([getBitstreamFormatsModulePath()]); + this.router.navigate([getBitstreamFormatsModuleRoute()]); this.bitstreamFormatDataService.clearBitStreamFormatRequests().subscribe(); } else { this.notificationService.error(this.translateService.get('admin.registries.bitstream-formats.create.failure.head'), diff --git a/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.ts b/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.ts index 0fdcc75689..0b63e4d4dd 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/edit-bitstream-format/edit-bitstream-format.component.ts @@ -7,8 +7,8 @@ import { BitstreamFormat } from '../../../../core/shared/bitstream-format.model' import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; import { RestResponse } from '../../../../core/cache/response.models'; import { NotificationsService } from '../../../../shared/notifications/notifications.service'; -import { getBitstreamFormatsModulePath } from '../../admin-registries-routing.module'; import { TranslateService } from '@ngx-translate/core'; +import { getBitstreamFormatsModuleRoute } from '../../admin-registries-routing-paths'; /** * This component renders the edit page of a bitstream format. @@ -51,7 +51,7 @@ export class EditBitstreamFormatComponent implements OnInit { if (response.isSuccessful) { this.notificationService.success(this.translateService.get('admin.registries.bitstream-formats.edit.success.head'), this.translateService.get('admin.registries.bitstream-formats.edit.success.content')); - this.router.navigate([getBitstreamFormatsModulePath()]); + this.router.navigate([getBitstreamFormatsModuleRoute()]); } else { this.notificationService.error('admin.registries.bitstream-formats.edit.failure.head', 'admin.registries.bitstream-formats.create.edit.content'); diff --git a/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.ts b/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.ts index 505ccccd91..7212288ab8 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/format-form/format-form.component.ts @@ -12,9 +12,9 @@ import { DynamicTextAreaModel } from '@ng-dynamic-forms/core'; import { Router } from '@angular/router'; -import { getBitstreamFormatsModulePath } from '../../admin-registries-routing.module'; import { hasValue, isEmpty } from '../../../../shared/empty.util'; import { TranslateService } from '@ngx-translate/core'; +import { getBitstreamFormatsModuleRoute } from '../../admin-registries-routing-paths'; /** * The component responsible for rendering the form to create/edit a bitstream format @@ -189,6 +189,6 @@ export class FormatFormComponent implements OnInit { * Cancels the edit/create action of the bitstream format and navigates back to the bitstream format registry */ onCancel() { - this.router.navigate([getBitstreamFormatsModulePath()]); + this.router.navigate([getBitstreamFormatsModuleRoute()]); } } diff --git a/src/app/+admin/admin-routing-paths.ts b/src/app/+admin/admin-routing-paths.ts new file mode 100644 index 0000000000..11eac49fe2 --- /dev/null +++ b/src/app/+admin/admin-routing-paths.ts @@ -0,0 +1,13 @@ +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getAdminModuleRoute } from '../app-routing-paths'; + +export const REGISTRIES_MODULE_PATH = 'registries'; +export const ACCESS_CONTROL_MODULE_PATH = 'access-control'; + +export function getRegistriesModuleRoute() { + return new URLCombiner(getAdminModuleRoute(), REGISTRIES_MODULE_PATH).toString(); +} + +export function getAccessControlModuleRoute() { + return new URLCombiner(getAdminModuleRoute(), ACCESS_CONTROL_MODULE_PATH).toString(); +} diff --git a/src/app/+admin/admin-routing.module.ts b/src/app/+admin/admin-routing.module.ts index 43b3a4ab34..7495eab003 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -1,23 +1,12 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; -import { getAdminModulePath } from '../app-routing.module'; +import { MetadataImportPageComponent } from './admin-import-metadata-page/metadata-import-page.component'; 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'; import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curation-tasks.component'; - -const REGISTRIES_MODULE_PATH = 'registries'; -export const ACCESS_CONTROL_MODULE_PATH = 'access-control'; - -export function getRegistriesModulePath() { - return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString(); -} - -export function getAccessControlModulePath() { - return new URLCombiner(getAdminModulePath(), ACCESS_CONTROL_MODULE_PATH).toString(); -} +import { ACCESS_CONTROL_MODULE_PATH, REGISTRIES_MODULE_PATH } from './admin-routing-paths'; @NgModule({ imports: [ @@ -48,6 +37,12 @@ export function getAccessControlModulePath() { component: AdminCurationTasksComponent, data: { title: 'admin.curation-tasks.title', breadcrumbKey: 'admin.curation-tasks' } }, + { + path: 'metadata-import', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + component: MetadataImportPageComponent, + data: { title: 'admin.metadata-import.title', breadcrumbKey: 'admin.metadata-import' } + }, ]) ], providers: [ diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts index 1dcd978095..34db71db77 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.spec.ts @@ -12,7 +12,7 @@ import { CollectionSearchResult } from '../../../../../shared/object-collection/ import { Collection } from '../../../../../core/shared/collection.model'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; -import { getCollectionEditPath } from '../../../../../+collection-page/collection-page-routing.module'; +import { getCollectionEditRoute } from '../../../../../+collection-page/collection-page-routing-paths'; describe('CollectionAdminSearchResultGridElementComponent', () => { let component: CollectionAdminSearchResultGridElementComponent; @@ -61,6 +61,6 @@ describe('CollectionAdminSearchResultGridElementComponent', () => { it('should render an edit button with the correct link', () => { const a = fixture.debugElement.query(By.css('a.edit-link')); const link = a.nativeElement.href; - expect(link).toContain(getCollectionEditPath(id)); + expect(link).toContain(getCollectionEditRoute(id)); }) }); diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.ts index 5e784165ab..9477544f60 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/collection-search-result/collection-admin-search-result-grid-element.component.ts @@ -4,8 +4,8 @@ import { listableObjectComponent } from '../../../../../shared/object-collection import { Context } from '../../../../../core/shared/context.model'; import { CollectionSearchResult } from '../../../../../shared/object-collection/shared/collection-search-result.model'; import { Collection } from '../../../../../core/shared/collection.model'; -import { getCollectionEditPath } from '../../../../../+collection-page/collection-page-routing.module'; import { SearchResultGridElementComponent } from '../../../../../shared/object-grid/search-result-grid-element/search-result-grid-element.component'; +import { getCollectionEditRoute } from '../../../../../+collection-page/collection-page-routing-paths'; @listableObjectComponent(CollectionSearchResult, ViewMode.GridElement, Context.AdminSearch) @Component({ @@ -21,6 +21,6 @@ export class CollectionAdminSearchResultGridElementComponent extends SearchResul ngOnInit() { super.ngOnInit(); - this.editPath = getCollectionEditPath(this.dso.uuid); + this.editPath = getCollectionEditRoute(this.dso.uuid); } } diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts index 99d33f841a..85c81d55a4 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.spec.ts @@ -13,9 +13,9 @@ import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { CommunityAdminSearchResultGridElementComponent } from './community-admin-search-result-grid-element.component'; import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model'; -import { getCommunityEditPath } from '../../../../../+community-page/community-page-routing.module'; import { Community } from '../../../../../core/shared/community.model'; import { CommunityAdminSearchResultListElementComponent } from '../../admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component'; +import { getCommunityEditRoute } from '../../../../../+community-page/community-page-routing-paths'; describe('CommunityAdminSearchResultGridElementComponent', () => { let component: CommunityAdminSearchResultGridElementComponent; @@ -65,6 +65,6 @@ describe('CommunityAdminSearchResultGridElementComponent', () => { it('should render an edit button with the correct link', () => { const a = fixture.debugElement.query(By.css('a.edit-link')); const link = a.nativeElement.href; - expect(link).toContain(getCommunityEditPath(id)); + expect(link).toContain(getCommunityEditRoute(id)); }) }); diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.ts index 8df12e703f..59117b1f65 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/community-search-result/community-admin-search-result-grid-element.component.ts @@ -4,8 +4,8 @@ import { listableObjectComponent } from '../../../../../shared/object-collection import { Context } from '../../../../../core/shared/context.model'; import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model'; import { Community } from '../../../../../core/shared/community.model'; -import { getCommunityEditPath } from '../../../../../+community-page/community-page-routing.module'; import { SearchResultGridElementComponent } from '../../../../../shared/object-grid/search-result-grid-element/search-result-grid-element.component'; +import { getCommunityEditRoute } from '../../../../../+community-page/community-page-routing-paths'; @listableObjectComponent(CommunitySearchResult, ViewMode.GridElement, Context.AdminSearch) @Component({ @@ -21,6 +21,6 @@ export class CommunityAdminSearchResultGridElementComponent extends SearchResult ngOnInit() { super.ngOnInit(); - this.editPath = getCommunityEditPath(this.dso.uuid); + this.editPath = getCommunityEditRoute(this.dso.uuid); } } diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts index 04558f6320..3fcb33502d 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts @@ -4,16 +4,6 @@ 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 { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; -import { getItemEditPath } from '../../../../../+item-page/item-page-routing.module'; -import { URLCombiner } from '../../../../../core/url-combiner/url-combiner'; -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 { 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'; diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts index 259d1d64aa..e4c151eee9 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.spec.ts @@ -10,7 +10,7 @@ import { CollectionSearchResult } from '../../../../../shared/object-collection/ import { Collection } from '../../../../../core/shared/collection.model'; import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; -import { getCollectionEditPath } from '../../../../../+collection-page/collection-page-routing.module'; +import { getCollectionEditRoute } from '../../../../../+collection-page/collection-page-routing-paths'; describe('CollectionAdminSearchResultListElementComponent', () => { let component: CollectionAdminSearchResultListElementComponent; @@ -55,6 +55,6 @@ describe('CollectionAdminSearchResultListElementComponent', () => { it('should render an edit button with the correct link', () => { const a = fixture.debugElement.query(By.css('a')); const link = a.nativeElement.href; - expect(link).toContain(getCollectionEditPath(id)); + expect(link).toContain(getCollectionEditRoute(id)); }) }); diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.ts index e49f272184..9622888660 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/collection-search-result/collection-admin-search-result-list-element.component.ts @@ -5,7 +5,7 @@ import { Context } from '../../../../../core/shared/context.model'; import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; import { CollectionSearchResult } from '../../../../../shared/object-collection/shared/collection-search-result.model'; import { Collection } from '../../../../../core/shared/collection.model'; -import { getCollectionEditPath } from '../../../../../+collection-page/collection-page-routing.module'; +import { getCollectionEditRoute } from '../../../../../+collection-page/collection-page-routing-paths'; @listableObjectComponent(CollectionSearchResult, ViewMode.ListElement, Context.AdminSearch) @Component({ @@ -21,6 +21,6 @@ export class CollectionAdminSearchResultListElementComponent extends SearchResul ngOnInit() { super.ngOnInit(); - this.editPath = getCollectionEditPath(this.dso.uuid); + this.editPath = getCollectionEditRoute(this.dso.uuid); } } diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts index a7922d7cf5..31709914c5 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.spec.ts @@ -9,8 +9,8 @@ import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { CommunityAdminSearchResultListElementComponent } from './community-admin-search-result-list-element.component'; import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model'; -import { getCommunityEditPath } from '../../../../../+community-page/community-page-routing.module'; import { Community } from '../../../../../core/shared/community.model'; +import { getCommunityEditRoute } from '../../../../../+community-page/community-page-routing-paths'; describe('CommunityAdminSearchResultListElementComponent', () => { let component: CommunityAdminSearchResultListElementComponent; @@ -55,6 +55,6 @@ describe('CommunityAdminSearchResultListElementComponent', () => { it('should render an edit button with the correct link', () => { const a = fixture.debugElement.query(By.css('a')); const link = a.nativeElement.href; - expect(link).toContain(getCommunityEditPath(id)); + expect(link).toContain(getCommunityEditRoute(id)); }) }); diff --git a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.ts b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.ts index 71fe4203ef..2e82e7f3b8 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/admin-search-result-list-element/community-search-result/community-admin-search-result-list-element.component.ts @@ -5,7 +5,7 @@ import { Context } from '../../../../../core/shared/context.model'; import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; import { CommunitySearchResult } from '../../../../../shared/object-collection/shared/community-search-result.model'; import { Community } from '../../../../../core/shared/community.model'; -import { getCommunityEditPath } from '../../../../../+community-page/community-page-routing.module'; +import { getCommunityEditRoute } from '../../../../../+community-page/community-page-routing-paths'; @listableObjectComponent(CommunitySearchResult, ViewMode.ListElement, Context.AdminSearch) @Component({ @@ -21,6 +21,6 @@ export class CommunityAdminSearchResultListElementComponent extends SearchResult ngOnInit() { super.ngOnInit(); - this.editPath = getCommunityEditPath(this.dso.uuid); + this.editPath = getCommunityEditRoute(this.dso.uuid); } } diff --git a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html index a4a923e725..2ee828e870 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html +++ b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html @@ -1,27 +1,27 @@ - + {{"admin.search.item.edit" | translate}} - + {{"admin.search.item.withdraw" | translate}} - + {{"admin.search.item.reinstate" | translate}} - + {{"admin.search.item.make-private" | translate}} - + {{"admin.search.item.make-public" | translate}} - + {{"admin.search.item.delete" | translate}} - + {{"admin.search.item.move" | translate}} diff --git a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.spec.ts b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.spec.ts index c1aceb477d..f26fde1311 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.spec.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.spec.ts @@ -6,16 +6,16 @@ import { By } from '@angular/platform-browser'; import { RouterTestingModule } from '@angular/router/testing'; import { ItemAdminSearchResultActionsComponent } from './item-admin-search-result-actions.component'; import { Item } from '../../../core/shared/item.model'; +import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { getItemEditRoute } from '../../../+item-page/item-page-routing-paths'; import { - ITEM_EDIT_DELETE_PATH, ITEM_EDIT_MOVE_PATH, - ITEM_EDIT_PRIVATE_PATH, + ITEM_EDIT_DELETE_PATH, ITEM_EDIT_PUBLIC_PATH, + ITEM_EDIT_PRIVATE_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'; +} from '../../../+item-page/edit-item-page/edit-item-page.routing-paths'; describe('ItemAdminSearchResultActionsComponent', () => { let component: ItemAdminSearchResultActionsComponent; @@ -55,19 +55,19 @@ describe('ItemAdminSearchResultActionsComponent', () => { it('should render an edit button with the correct link', () => { const button = fixture.debugElement.query(By.css('a.edit-link')); const link = button.nativeElement.href; - expect(link).toContain(getItemEditPath(id)); + expect(link).toContain(getItemEditRoute(id)); }); 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(getItemEditPath(id), ITEM_EDIT_DELETE_PATH).toString()); + expect(link).toContain(new URLCombiner(getItemEditRoute(id), ITEM_EDIT_DELETE_PATH).toString()); }); it('should render a move button with the correct link', () => { const a = fixture.debugElement.query(By.css('a.move-link')); const link = a.nativeElement.href; - expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_MOVE_PATH).toString()); + expect(link).toContain(new URLCombiner(getItemEditRoute(id), ITEM_EDIT_MOVE_PATH).toString()); }); describe('when the item is not withdrawn', () => { @@ -79,7 +79,7 @@ describe('ItemAdminSearchResultActionsComponent', () => { it('should render a withdraw button with the correct link', () => { const a = fixture.debugElement.query(By.css('a.withdraw-link')); const link = a.nativeElement.href; - expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_WITHDRAW_PATH).toString()); + expect(link).toContain(new URLCombiner(getItemEditRoute(id), ITEM_EDIT_WITHDRAW_PATH).toString()); }); it('should not render a reinstate button with the correct link', () => { @@ -102,7 +102,7 @@ describe('ItemAdminSearchResultActionsComponent', () => { it('should render a reinstate button with the correct link', () => { const a = fixture.debugElement.query(By.css('a.reinstate-link')); const link = a.nativeElement.href; - expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_REINSTATE_PATH).toString()); + expect(link).toContain(new URLCombiner(getItemEditRoute(id), ITEM_EDIT_REINSTATE_PATH).toString()); }); }); @@ -115,7 +115,7 @@ describe('ItemAdminSearchResultActionsComponent', () => { it('should render a make private button with the correct link', () => { const a = fixture.debugElement.query(By.css('a.private-link')); const link = a.nativeElement.href; - expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_PRIVATE_PATH).toString()); + expect(link).toContain(new URLCombiner(getItemEditRoute(id), ITEM_EDIT_PRIVATE_PATH).toString()); }); it('should not render a make public button with the correct link', () => { @@ -138,7 +138,7 @@ describe('ItemAdminSearchResultActionsComponent', () => { it('should render a make private button with the correct link', () => { const a = fixture.debugElement.query(By.css('a.public-link')); const link = a.nativeElement.href; - expect(link).toContain(new URLCombiner(getItemEditPath(id), ITEM_EDIT_PUBLIC_PATH).toString()); + expect(link).toContain(new URLCombiner(getItemEditRoute(id), ITEM_EDIT_PUBLIC_PATH).toString()); }); }) }); diff --git a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts index 40cddc816d..d3cbc025c2 100644 --- a/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts +++ b/src/app/+admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts @@ -1,15 +1,15 @@ import { Component, Input } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; -import { getItemEditPath } from '../../../+item-page/item-page-routing.module'; import { URLCombiner } from '../../../core/url-combiner/url-combiner'; +import { getItemEditRoute } from '../../../+item-page/item-page-routing-paths'; import { - ITEM_EDIT_DELETE_PATH, ITEM_EDIT_MOVE_PATH, - ITEM_EDIT_PRIVATE_PATH, + ITEM_EDIT_DELETE_PATH, ITEM_EDIT_PUBLIC_PATH, + ITEM_EDIT_PRIVATE_PATH, ITEM_EDIT_REINSTATE_PATH, ITEM_EDIT_WITHDRAW_PATH -} from '../../../+item-page/edit-item-page/edit-item-page.routing.module'; +} from '../../../+item-page/edit-item-page/edit-item-page.routing-paths'; @Component({ selector: 'ds-item-admin-search-result-actions-element', @@ -33,49 +33,49 @@ export class ItemAdminSearchResultActionsComponent { /** * Returns the path to the edit page of this item */ - getEditPath(): string { - return getItemEditPath(this.item.uuid) + getEditRoute(): string { + return getItemEditRoute(this.item.uuid) } /** * Returns the path to the move page of this item */ - getMovePath(): string { - return new URLCombiner(this.getEditPath(), ITEM_EDIT_MOVE_PATH).toString(); + getMoveRoute(): string { + return new URLCombiner(this.getEditRoute(), ITEM_EDIT_MOVE_PATH).toString(); } /** * Returns the path to the delete page of this item */ - getDeletePath(): string { - return new URLCombiner(this.getEditPath(), ITEM_EDIT_DELETE_PATH).toString(); + getDeleteRoute(): string { + return new URLCombiner(this.getEditRoute(), ITEM_EDIT_DELETE_PATH).toString(); } /** * Returns the path to the withdraw page of this item */ - getWithdrawPath(): string { - return new URLCombiner(this.getEditPath(), ITEM_EDIT_WITHDRAW_PATH).toString(); + getWithdrawRoute(): string { + return new URLCombiner(this.getEditRoute(), ITEM_EDIT_WITHDRAW_PATH).toString(); } /** * Returns the path to the reinstate page of this item */ - getReinstatePath(): string { - return new URLCombiner(this.getEditPath(), ITEM_EDIT_REINSTATE_PATH).toString(); + getReinstateRoute(): string { + return new URLCombiner(this.getEditRoute(), ITEM_EDIT_REINSTATE_PATH).toString(); } /** * Returns the path to the page where the user can make this item private */ - getPrivatePath(): string { - return new URLCombiner(this.getEditPath(), ITEM_EDIT_PRIVATE_PATH).toString(); + getPrivateRoute(): string { + return new URLCombiner(this.getEditRoute(), ITEM_EDIT_PRIVATE_PATH).toString(); } /** * Returns the path to the page where the user can make this item public */ - getPublicPath(): string { - return new URLCombiner(this.getEditPath(), ITEM_EDIT_PUBLIC_PATH).toString(); + getPublicRoute(): string { + return new URLCombiner(this.getEditRoute(), ITEM_EDIT_PUBLIC_PATH).toString(); } } diff --git a/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html index e72a17aac1..eb06df3630 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html +++ b/src/app/+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.html @@ -5,7 +5,7 @@ - \ No newline at end of file + diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.html b/src/app/+admin/admin-sidebar/admin-sidebar.component.html index 02a25a8227..357ed058d1 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.html +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.html @@ -25,7 +25,7 @@ + *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;">
@@ -49,4 +49,4 @@ - \ No newline at end of file + diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts index 40539d3e13..9cdcccba28 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -2,6 +2,7 @@ import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ScriptDataService } from '../../core/data/processes/script-data.service'; import { AdminSidebarComponent } from './admin-sidebar.component'; import { MenuService } from '../../shared/menu/menu.service'; import { MenuServiceStub } from '../../shared/testing/menu-service.stub'; @@ -21,11 +22,13 @@ describe('AdminSidebarComponent', () => { let fixture: ComponentFixture; const menuService = new MenuServiceStub(); let authorizationService: AuthorizationDataService; + let scriptService; beforeEach(async(() => { authorizationService = jasmine.createSpyObj('authorizationService', { isAuthorized: observableOf(true) }); + scriptService = jasmine.createSpyObj('scriptService', { scriptWithNameExistsAndCanExecute: observableOf(true) }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), NoopAnimationsModule, RouterTestingModule], declarations: [AdminSidebarComponent], @@ -36,9 +39,11 @@ describe('AdminSidebarComponent', () => { { provide: AuthService, useClass: AuthServiceStub }, { provide: ActivatedRoute, useValue: {} }, { provide: AuthorizationDataService, useValue: authorizationService }, + { provide: ScriptDataService, useValue: scriptService }, { provide: NgbModal, useValue: { - open: () => {/*comment*/} + open: () => {/*comment*/ + } } } ], diff --git a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts index eb86de5f3c..3bfbf2de5b 100644 --- a/src/app/+admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/+admin/admin-sidebar/admin-sidebar.component.ts @@ -1,9 +1,14 @@ import { Component, Injector, OnInit } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { combineLatest as combineLatestObservable } from 'rxjs'; +import { combineLatest as observableCombineLatest } from 'rxjs'; import { Observable } from 'rxjs/internal/Observable'; -import { first, map } from 'rxjs/operators'; +import { first, map, take } from 'rxjs/operators'; import { AuthService } from '../../core/auth/auth.service'; +import { + METADATA_EXPORT_SCRIPT_NAME, + METADATA_IMPORT_SCRIPT_NAME, + ScriptDataService +} from '../../core/data/processes/script-data.service'; import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; import { CreateCollectionParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; import { CreateCommunityParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; @@ -11,6 +16,9 @@ import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/mod import { EditCollectionSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; import { EditCommunitySelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; import { EditItemSelectorComponent } from '../../shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; +import { + ExportMetadataSelectorComponent +} from '../../shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; import { MenuID, MenuItemType } from '../../shared/menu/initial-menus-state'; import { LinkMenuItemModel } from '../../shared/menu/menu-item/models/link.model'; import { OnClickMenuItemModel } from '../../shared/menu/menu-item/models/onclick.model'; @@ -64,7 +72,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { private variableService: CSSVariableService, private authService: AuthService, private modalService: NgbModal, - private authorizationService: AuthorizationDataService + private authorizationService: AuthorizationDataService, + private scriptDataService: ScriptDataService, ) { super(menuService, injector); } @@ -75,6 +84,8 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { ngOnInit(): void { this.createMenu(); this.createSiteAdministratorMenuSections(); + this.createExportMenuSections(); + this.createImportMenuSections(); super.ngOnInit(); this.sidebarWidth = this.variableService.getVariable('sidebarItemsWidth'); this.authService.isAuthenticated() @@ -88,7 +99,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { this.sidebarOpen = !collapsed; this.sidebarClosed = collapsed; }); - this.sidebarExpanded = combineLatestObservable(this.menuCollapsed, this.menuPreviewCollapsed) + this.sidebarExpanded = observableCombineLatest(this.menuCollapsed, this.menuPreviewCollapsed) .pipe( map(([collapsed, previewCollapsed]) => (!collapsed || !previewCollapsed)) ); @@ -225,94 +236,18 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { } as OnClickMenuItemModel, }, - /* Import */ + /* Curation tasks */ { - id: 'import', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.import' - } as TextMenuItemModel, - icon: 'sign-in-alt', - index: 2 - }, - { - id: 'import_metadata', - parentID: 'import', + id: 'curation_tasks', active: false, visible: true, model: { type: MenuItemType.LINK, - text: 'menu.section.import_metadata', - link: '' - } as LinkMenuItemModel, - }, - { - id: 'import_batch', - parentID: 'import', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.import_batch', - link: '' - } as LinkMenuItemModel, - }, - /* Export */ - { - id: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.TEXT, - text: 'menu.section.export' - } as TextMenuItemModel, - icon: 'sign-out-alt', - index: 3 - }, - { - id: 'export_community', - parentID: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.export_community', - link: '' - } as LinkMenuItemModel, - }, - { - id: 'export_collection', - parentID: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.export_collection', - link: '' - } as LinkMenuItemModel, - }, - { - id: 'export_item', - parentID: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.export_item', - link: '' - } as LinkMenuItemModel, - }, { - id: 'export_metadata', - parentID: 'export', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.export_metadata', + text: 'menu.section.curation_task', link: '' } as LinkMenuItemModel, + icon: 'filter', + index: 7 }, /* Statistics */ @@ -362,6 +297,146 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit { }))); } + /** + * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not + * the export scripts exist and the current user is allowed to execute them + */ + createExportMenuSections() { + const menuList = [ + /* Export */ + { + id: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.export' + } as TextMenuItemModel, + icon: 'sign-out-alt', + index: 3, + shouldPersistOnRouteChange: true + }, + { + id: 'export_community', + parentID: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.export_community', + link: '' + } as LinkMenuItemModel, + shouldPersistOnRouteChange: true + }, + { + id: 'export_collection', + parentID: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.export_collection', + link: '' + } as LinkMenuItemModel, + shouldPersistOnRouteChange: true + }, + { + id: 'export_item', + parentID: 'export', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.export_item', + link: '' + } as LinkMenuItemModel, + shouldPersistOnRouteChange: true + }, + ]; + menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, menuSection)); + + observableCombineLatest( + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + // this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_EXPORT_SCRIPT_NAME) + ).pipe( + // TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed; otherwise even in production mode, the metadata export button is only available after a refresh (and not in dev mode) + // filter(([authorized, metadataExportScriptExists]: boolean[]) => authorized && metadataExportScriptExists), + take(1) + ).subscribe(() => { + this.menuService.addSection(this.menuID, { + id: 'export_metadata', + parentID: 'export', + active: true, + visible: true, + model: { + type: MenuItemType.ONCLICK, + text: 'menu.section.export_metadata', + function: () => { + this.modalService.open(ExportMetadataSelectorComponent); + } + } as OnClickMenuItemModel, + shouldPersistOnRouteChange: true + }); + }); + } + + /** + * Create menu sections dependent on whether or not the current user is a site administrator and on whether or not + * the import scripts exist and the current user is allowed to execute them + */ + createImportMenuSections() { + const menuList = [ + /* Import */ + { + id: 'import', + active: false, + visible: true, + model: { + type: MenuItemType.TEXT, + text: 'menu.section.import' + } as TextMenuItemModel, + icon: 'sign-in-alt', + index: 2 + }, + { + id: 'import_batch', + parentID: 'import', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.import_batch', + link: '' + } as LinkMenuItemModel, + } + ]; + menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { + shouldPersistOnRouteChange: true + }))); + + observableCombineLatest( + this.authorizationService.isAuthorized(FeatureID.AdministratorOf), + // this.scriptDataService.scriptWithNameExistsAndCanExecute(METADATA_IMPORT_SCRIPT_NAME) + ).pipe( + // TODO uncomment when #635 (https://github.com/DSpace/dspace-angular/issues/635) is fixed + // filter(([authorized, metadataImportScriptExists]: boolean[]) => authorized && metadataImportScriptExists), + take(1) + ).subscribe(() => { + this.menuService.addSection(this.menuID, { + id: 'import_metadata', + parentID: 'import', + active: true, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.import_metadata', + link: '/admin/metadata-import' + } as LinkMenuItemModel, + shouldPersistOnRouteChange: true + }); + }); + } + /** * Create menu sections dependent on whether or not the current user is a site administrator */ diff --git a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html index 808683910e..d9869ddf1f 100644 --- a/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html +++ b/src/app/+admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html @@ -12,16 +12,16 @@ (click)="toggleSection($event)"> + *ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"> - \ No newline at end of file + 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 index 1a90a4cff4..4d7266514c 100644 --- 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 @@ -1,7 +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.spec.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/workflow-item-admin-workflow-actions.component.spec.ts index bca2684364..5e0ff481a0 100644 --- 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 @@ -5,19 +5,22 @@ 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'; +import { + getWorkflowItemSendBackRoute, + getWorkflowItemDeleteRoute +} from '../../../+workflowitems-edit-page/workflowitems-edit-page-routing-paths'; +import { getItemEditRoute } from '../../../+item-page/item-page-routing-paths'; +import { + ITEM_EDIT_MOVE_PATH, + ITEM_EDIT_DELETE_PATH, + ITEM_EDIT_PUBLIC_PATH, + ITEM_EDIT_PRIVATE_PATH, + ITEM_EDIT_REINSTATE_PATH, + ITEM_EDIT_WITHDRAW_PATH +} from '../../../+item-page/edit-item-page/edit-item-page.routing-paths'; describe('WorkflowItemAdminWorkflowActionsComponent', () => { let component: WorkflowItemAdminWorkflowActionsComponent; @@ -57,12 +60,12 @@ describe('WorkflowItemAdminWorkflowActionsComponent', () => { 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()); + expect(link).toContain(new URLCombiner(getWorkflowItemDeleteRoute(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()); + expect(link).toContain(new URLCombiner(getWorkflowItemSendBackRoute(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 index d44f870b14..9dba0e9fd4 100644 --- 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 @@ -1,6 +1,9 @@ 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'; +import { + getWorkflowItemSendBackRoute, + getWorkflowItemDeleteRoute +} from '../../../+workflowitems-edit-page/workflowitems-edit-page-routing-paths'; @Component({ selector: 'ds-workflow-item-admin-workflow-actions-element', @@ -25,15 +28,15 @@ export class WorkflowItemAdminWorkflowActionsComponent { /** * Returns the path to the delete page of this workflow item */ - getDeletePath(): string { + getDeleteRoute(): string { - return getWorkflowItemDeletePath(this.wfi.id) + return getWorkflowItemDeleteRoute(this.wfi.id) } /** * Returns the path to the send back page of this workflow item */ - getSendBackPath(): string { - return getWorkflowItemSendBackPath(this.wfi.id); + getSendBackRoute(): string { + return getWorkflowItemSendBackRoute(this.wfi.id); } } diff --git a/src/app/+admin/admin.module.ts b/src/app/+admin/admin.module.ts index 85749afe03..c350272c3b 100644 --- a/src/app/+admin/admin.module.ts +++ b/src/app/+admin/admin.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core'; import { SharedModule } from '../shared/shared.module'; import { AdminAccessControlModule } from './admin-access-control/admin-access-control.module'; +import { MetadataImportPageComponent } from './admin-import-metadata-page/metadata-import-page.component'; import { AdminRegistriesModule } from './admin-registries/admin-registries.module'; import { AdminRoutingModule } from './admin-routing.module'; import { AdminSearchPageComponent } from './admin-search-page/admin-search-page.component'; @@ -40,7 +41,9 @@ import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curati WorkflowItemSearchResultAdminWorkflowListElementComponent, WorkflowItemSearchResultAdminWorkflowGridElementComponent, - WorkflowItemAdminWorkflowActionsComponent + WorkflowItemAdminWorkflowActionsComponent, + + MetadataImportPageComponent ], entryComponents: [ @@ -54,7 +57,9 @@ import { AdminCurationTasksComponent } from './admin-curation-tasks/admin-curati WorkflowItemSearchResultAdminWorkflowListElementComponent, WorkflowItemSearchResultAdminWorkflowGridElementComponent, - WorkflowItemAdminWorkflowActionsComponent + WorkflowItemAdminWorkflowActionsComponent, + + MetadataImportPageComponent ] }) export class AdminModule { diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts index cce6932cd1..3e8b686e48 100644 --- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts +++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts @@ -36,7 +36,7 @@ import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { followLink } from '../../shared/utils/follow-link-config.model'; -import { getItemEditPath } from '../../+item-page/item-page-routing.module'; +import { getItemEditRoute } from '../../+item-page/item-page-routing-paths'; @Component({ selector: 'ds-edit-bitstream-page', @@ -506,7 +506,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy { */ navigateToItemEditBitstreams() { if (hasValue(this.itemId)) { - this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']); + this.router.navigate([getItemEditRoute(this.itemId), 'bitstreams']); } else { this.location.back(); } diff --git a/src/app/+browse-by/browse-by-dso-breadcrumb.resolver.ts b/src/app/+browse-by/browse-by-dso-breadcrumb.resolver.ts index 5759e28754..e64fa8a89d 100644 --- a/src/app/+browse-by/browse-by-dso-breadcrumb.resolver.ts +++ b/src/app/+browse-by/browse-by-dso-breadcrumb.resolver.ts @@ -9,7 +9,7 @@ import { Observable } from 'rxjs'; import { getRemoteDataPayload, getSucceededRemoteData } from '../core/shared/operators'; import { map } from 'rxjs/operators'; import { hasValue } from '../shared/empty.util'; -import { getDSOPath } from '../app-routing.module'; +import { getDSORoute } from '../app-routing-paths'; /** * The class that resolves the BreadcrumbConfig object for a DSpaceObject on a browse by page @@ -32,7 +32,7 @@ export class BrowseByDSOBreadcrumbResolver { getSucceededRemoteData(), getRemoteDataPayload(), map((object: Community | Collection) => { - return { provider: this.breadcrumbService, key: object, url: getDSOPath(object) }; + return { provider: this.breadcrumbService, key: object, url: getDSORoute(object) }; }) ); } diff --git a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts index 64ad426584..84475c95eb 100644 --- a/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts +++ b/src/app/+collection-page/collection-item-mapper/collection-item-mapper.component.ts @@ -133,7 +133,7 @@ export class CollectionItemMapperComponent implements OnInit { return this.searchService.search(Object.assign(new PaginatedSearchOptions(options), { query: this.buildQuery(collectionRD.payload.id, options.query), scope: undefined, - dsoType: DSpaceObjectType.ITEM, + dsoTypes: [DSpaceObjectType.ITEM], sort: this.defaultSortOptions }), 10000).pipe( toDSpaceObjectListRD(), diff --git a/src/app/+collection-page/collection-page-routing-paths.ts b/src/app/+collection-page/collection-page-routing-paths.ts new file mode 100644 index 0000000000..7a1b2923bf --- /dev/null +++ b/src/app/+collection-page/collection-page-routing-paths.ts @@ -0,0 +1,25 @@ +import { URLCombiner } from '../core/url-combiner/url-combiner'; + +export const COLLECTION_PARENT_PARAMETER = 'parent'; + +export const COLLECTION_MODULE_PATH = 'collections'; + +export function getCollectionModuleRoute() { + return `/${COLLECTION_MODULE_PATH}`; +} + +export function getCollectionPageRoute(collectionId: string) { + return new URLCombiner(getCollectionModuleRoute(), collectionId).toString(); +} + +export function getCollectionEditRoute(id: string) { + return new URLCombiner(getCollectionModuleRoute(), id, COLLECTION_EDIT_PATH).toString() +} + +export function getCollectionCreateRoute() { + return new URLCombiner(getCollectionModuleRoute(), COLLECTION_CREATE_PATH).toString() +} + +export const COLLECTION_CREATE_PATH = 'create'; +export const COLLECTION_EDIT_PATH = 'edit'; +export const ITEMTEMPLATE_PATH = 'itemtemplate'; diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts index ebe086375f..a03f2d0b5f 100644 --- a/src/app/+collection-page/collection-page-routing.module.ts +++ b/src/app/+collection-page/collection-page-routing.module.ts @@ -7,8 +7,6 @@ import { CreateCollectionPageComponent } from './create-collection-page/create-c import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard'; import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component'; -import { URLCombiner } from '../core/url-combiner/url-combiner'; -import { getCollectionModulePath } from '../app-routing.module'; import { EditItemTemplatePageComponent } from './edit-item-template-page/edit-item-template-page.component'; import { ItemTemplatePageResolver } from './edit-item-template-page/item-template-page.resolver'; import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component'; @@ -16,26 +14,13 @@ import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-bre import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { + ITEMTEMPLATE_PATH, + COLLECTION_EDIT_PATH, + COLLECTION_CREATE_PATH +} from './collection-page-routing-paths'; import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard'; -export const COLLECTION_PARENT_PARAMETER = 'parent'; - -export function getCollectionPageRoute(collectionId: string) { - return new URLCombiner(getCollectionModulePath(), collectionId).toString(); -} - -export function getCollectionEditPath(id: string) { - return new URLCombiner(getCollectionModulePath(), id, COLLECTION_EDIT_PATH).toString() -} - -export function getCollectionCreatePath() { - return new URLCombiner(getCollectionModulePath(), COLLECTION_CREATE_PATH).toString() -} - -const COLLECTION_CREATE_PATH = 'create'; -const COLLECTION_EDIT_PATH = 'edit'; -const ITEMTEMPLATE_PATH = 'itemtemplate'; - @NgModule({ imports: [ RouterModule.forChild([ diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index 7f54e0f9d7..c7d287ed6a 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -87,7 +87,7 @@ export class CollectionPageComponent implements OnInit { scope: id, pagination: dto.paginationConfig, sort: dto.sortConfig, - dsoType: DSpaceObjectType.ITEM + dsoTypes: [DSpaceObjectType.ITEM] })).pipe(toDSpaceObjectListRD()) as Observable>> }), startWith(undefined) // Make sure switching pages shows loading component diff --git a/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts b/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts index 8384385572..b5e03b7983 100644 --- a/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts +++ b/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.spec.ts @@ -30,24 +30,27 @@ describe('CollectionRolesComponent', () => { undefined, Object.assign(new Collection(), { _links: { - 'irrelevant': { + irrelevant: { href: 'irrelevant link', }, - 'adminGroup': { + adminGroup: { href: 'adminGroup link', }, - 'submittersGroup': { + submittersGroup: { href: 'submittersGroup link', }, - 'itemReadGroup': { + itemReadGroup: { href: 'itemReadGroup link', }, - 'bitstreamReadGroup': { + bitstreamReadGroup: { href: 'bitstreamReadGroup link', }, - 'workflowGroups/test': { - href: 'test workflow group link', - }, + workflowGroups: [ + { + name: 'test', + href: 'test workflow group link', + }, + ], }, }), ), diff --git a/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.ts b/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.ts index 45f2f37b9b..996933e43d 100644 --- a/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.ts +++ b/src/app/+collection-page/edit-collection-page/collection-roles/collection-roles.component.ts @@ -5,7 +5,7 @@ import { first, map } from 'rxjs/operators'; import { RemoteData } from '../../../core/data/remote-data'; import { Collection } from '../../../core/shared/collection.model'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators'; -import { ComcolRole } from '../../../shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role'; +import { HALLink } from '../../../core/shared/hal-link.model'; /** * Component for managing a collection's roles @@ -31,19 +31,27 @@ export class CollectionRolesComponent implements OnInit { /** * The different roles for the collection, as an observable. */ - getComcolRoles(): Observable { + getComcolRoles(): Observable { return this.collection$.pipe( - map((collection) => - [ - ComcolRole.COLLECTION_ADMIN, - ComcolRole.SUBMITTERS, - ComcolRole.ITEM_READ, - ComcolRole.BITSTREAM_READ, - ...Object.keys(collection._links) - .filter((link) => link.startsWith('workflowGroups/')) - .map((link) => new ComcolRole(link.substr('workflowGroups/'.length), link)), - ] - ), + map((collection) => [ + { + name: 'collection-admin', + href: collection._links.adminGroup.href, + }, + { + name: 'submitters', + href: collection._links.submittersGroup.href, + }, + { + name: 'item_read', + href: collection._links.itemReadGroup.href, + }, + { + name: 'bitstream_read', + href: collection._links.bitstreamReadGroup.href, + }, + ...collection._links.workflowGroups, + ]), ); } diff --git a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts index 209ce5149a..f225fc3e97 100644 --- a/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts +++ b/src/app/+collection-page/edit-collection-page/edit-collection-page.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component'; import { Collection } from '../../core/shared/collection.model'; -import { getCollectionPageRoute } from '../collection-page-routing.module'; +import { getCollectionPageRoute } from '../collection-page-routing-paths'; /** * Component that represents the page where a user can edit an existing Collection diff --git a/src/app/+collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts b/src/app/+collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts index 6d5ffc8768..3cd2a7a638 100644 --- a/src/app/+collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts +++ b/src/app/+collection-page/edit-item-template-page/edit-item-template-page.component.spec.ts @@ -9,8 +9,8 @@ import { ActivatedRoute } from '@angular/router'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { Collection } from '../../core/shared/collection.model'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { getCollectionEditPath } from '../collection-page-routing.module'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { getCollectionEditRoute } from '../collection-page-routing-paths'; describe('EditItemTemplatePageComponent', () => { let comp: EditItemTemplatePageComponent; @@ -45,7 +45,7 @@ describe('EditItemTemplatePageComponent', () => { describe('getCollectionEditUrl', () => { it('should return the collection\'s edit url', () => { const url = comp.getCollectionEditUrl(collection); - expect(url).toEqual(getCollectionEditPath(collection.uuid)); + expect(url).toEqual(getCollectionEditRoute(collection.uuid)); }); }); }); diff --git a/src/app/+collection-page/edit-item-template-page/edit-item-template-page.component.ts b/src/app/+collection-page/edit-item-template-page/edit-item-template-page.component.ts index 329c72d683..ac8d9b47d4 100644 --- a/src/app/+collection-page/edit-item-template-page/edit-item-template-page.component.ts +++ b/src/app/+collection-page/edit-item-template-page/edit-item-template-page.component.ts @@ -5,7 +5,7 @@ import { Collection } from '../../core/shared/collection.model'; import { ActivatedRoute } from '@angular/router'; import { first, map } from 'rxjs/operators'; import { ItemTemplateDataService } from '../../core/data/item-template-data.service'; -import { getCollectionEditPath } from '../collection-page-routing.module'; +import { getCollectionEditRoute } from '../collection-page-routing-paths'; @Component({ selector: 'ds-edit-item-template-page', @@ -35,7 +35,7 @@ export class EditItemTemplatePageComponent implements OnInit { */ getCollectionEditUrl(collection: Collection): string { if (collection) { - return getCollectionEditPath(collection.uuid); + return getCollectionEditRoute(collection.uuid); } else { return ''; } diff --git a/src/app/+community-page/community-page-routing-paths.ts b/src/app/+community-page/community-page-routing-paths.ts new file mode 100644 index 0000000000..2d0a3e5308 --- /dev/null +++ b/src/app/+community-page/community-page-routing-paths.ts @@ -0,0 +1,24 @@ +import { URLCombiner } from '../core/url-combiner/url-combiner'; + +export const COMMUNITY_PARENT_PARAMETER = 'parent'; + +export const COMMUNITY_MODULE_PATH = 'communities'; + +export function getCommunityModuleRoute() { + return `/${COMMUNITY_MODULE_PATH}`; +} + +export function getCommunityPageRoute(communityId: string) { + return new URLCombiner(getCommunityModuleRoute(), communityId).toString(); +} + +export function getCommunityEditRoute(id: string) { + return new URLCombiner(getCommunityModuleRoute(), id, COMMUNITY_EDIT_PATH).toString() +} + +export function getCommunityCreateRoute() { + return new URLCombiner(getCommunityModuleRoute(), COMMUNITY_CREATE_PATH).toString() +} + +export const COMMUNITY_CREATE_PATH = 'create'; +export const COMMUNITY_EDIT_PATH = 'edit'; diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts index 384574d9be..f266bd7df9 100644 --- a/src/app/+community-page/community-page-routing.module.ts +++ b/src/app/+community-page/community-page-routing.module.ts @@ -7,30 +7,12 @@ import { CreateCommunityPageComponent } from './create-community-page/create-com import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard'; import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; -import { URLCombiner } from '../core/url-combiner/url-combiner'; -import { getCommunityModulePath } from '../app-routing.module'; import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; +import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths'; import { CommunityPageAdministratorGuard } from './community-page-administrator.guard'; -export const COMMUNITY_PARENT_PARAMETER = 'parent'; - -export function getCommunityPageRoute(communityId: string) { - return new URLCombiner(getCommunityModulePath(), communityId).toString(); -} - -export function getCommunityEditPath(id: string) { - return new URLCombiner(getCommunityModulePath(), id, COMMUNITY_EDIT_PATH).toString() -} - -export function getCommunityCreatePath() { - return new URLCombiner(getCommunityModulePath(), COMMUNITY_CREATE_PATH).toString() -} - -const COMMUNITY_CREATE_PATH = 'create'; -const COMMUNITY_EDIT_PATH = 'edit'; - @NgModule({ imports: [ RouterModule.forChild([ diff --git a/src/app/+community-page/edit-community-page/community-roles/community-roles.component.html b/src/app/+community-page/edit-community-page/community-roles/community-roles.component.html index 231645a6a5..07d5d96fcd 100644 --- a/src/app/+community-page/edit-community-page/community-roles/community-roles.component.html +++ b/src/app/+community-page/edit-community-page/community-roles/community-roles.component.html @@ -1,5 +1,5 @@ diff --git a/src/app/+community-page/edit-community-page/community-roles/community-roles.component.ts b/src/app/+community-page/edit-community-page/community-roles/community-roles.component.ts index 336a56a584..62e1f73bad 100644 --- a/src/app/+community-page/edit-community-page/community-roles/community-roles.component.ts +++ b/src/app/+community-page/edit-community-page/community-roles/community-roles.component.ts @@ -4,8 +4,8 @@ import { Observable } from 'rxjs'; import { first, map } from 'rxjs/operators'; import { Community } from '../../../core/shared/community.model'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators'; -import { ComcolRole } from '../../../shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role'; import { RemoteData } from '../../../core/data/remote-data'; +import { HALLink } from '../../../core/shared/hal-link.model'; /** * Component for managing a community's roles @@ -31,10 +31,15 @@ export class CommunityRolesComponent implements OnInit { /** * The different roles for the community. */ - getComcolRoles(): ComcolRole[] { - return [ - ComcolRole.COMMUNITY_ADMIN, - ]; + getComcolRoles$(): Observable { + return this.community$.pipe( + map((community) => [ + { + name: 'community-admin', + href: community._links.adminGroup.href, + }, + ]), + ); } constructor( diff --git a/src/app/+community-page/edit-community-page/edit-community-page.component.ts b/src/app/+community-page/edit-community-page/edit-community-page.component.ts index c0adfe0ff1..b8bf3a08ac 100644 --- a/src/app/+community-page/edit-community-page/edit-community-page.component.ts +++ b/src/app/+community-page/edit-community-page/edit-community-page.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core'; import { Community } from '../../core/shared/community.model'; import { ActivatedRoute, Router } from '@angular/router'; import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component'; -import { getCommunityPageRoute } from '../community-page-routing.module'; +import { getCommunityPageRoute } from '../community-page-routing-paths'; /** * Component that represents the page where a user can edit an existing Community diff --git a/src/app/+import-external-page/import-external-page.component.html b/src/app/+import-external-page/import-external-page.component.html new file mode 100644 index 0000000000..5edccd55cb --- /dev/null +++ b/src/app/+import-external-page/import-external-page.component.html @@ -0,0 +1 @@ + diff --git a/src/app/+import-external-page/import-external-page.component.scss b/src/app/+import-external-page/import-external-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+import-external-page/import-external-page.component.spec.ts b/src/app/+import-external-page/import-external-page.component.spec.ts new file mode 100644 index 0000000000..5a2b7c5f8e --- /dev/null +++ b/src/app/+import-external-page/import-external-page.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ImportExternalPageComponent } from './import-external-page.component'; + +describe('ImportExternalPageComponent', () => { + let component: ImportExternalPageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ImportExternalPageComponent ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ImportExternalPageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create ImportExternalPageComponent', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/+import-external-page/import-external-page.component.ts b/src/app/+import-external-page/import-external-page.component.ts new file mode 100644 index 0000000000..00709dad16 --- /dev/null +++ b/src/app/+import-external-page/import-external-page.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +/** + * Component representing the external import page of the submission. + */ +@Component({ + selector: 'ds-import-external-page', + templateUrl: './import-external-page.component.html', + styleUrls: ['./import-external-page.component.scss'] +}) +export class ImportExternalPageComponent { + +} diff --git a/src/app/+import-external-page/import-external-page.module.ts b/src/app/+import-external-page/import-external-page.module.ts new file mode 100644 index 0000000000..017c723ad9 --- /dev/null +++ b/src/app/+import-external-page/import-external-page.module.ts @@ -0,0 +1,29 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { CoreModule } from '../core/core.module'; +import { ImportExternalRoutingModule } from './import-external-routing.module'; +import { SubmissionModule } from '../submission/submission.module'; +import { ImportExternalPageComponent } from './import-external-page.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CoreModule.forRoot(), + ImportExternalRoutingModule, + SubmissionModule, + ], + declarations: [ + ImportExternalPageComponent + ], + entryComponents: [ ] +}) + +/** + * This module handles all components that are necessary for the submission external import page + */ +export class ImportExternalPageModule { + +} diff --git a/src/app/+import-external-page/import-external-routing.module.ts b/src/app/+import-external-page/import-external-routing.module.ts new file mode 100644 index 0000000000..91cdbf9877 --- /dev/null +++ b/src/app/+import-external-page/import-external-routing.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; +import { SubmissionImportExternalComponent } from '../submission/import-external/submission-import-external.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + canActivate: [ AuthenticatedGuard ], + path: '', + component: SubmissionImportExternalComponent, + pathMatch: 'full', + data: { + title: 'submission.import-external.page.title' + } + } + ]) + ], + providers: [ ] +}) +export class ImportExternalRoutingModule { + +} diff --git a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts index 536fc0931f..eb42b1c30c 100644 --- a/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts +++ b/src/app/+item-page/bitstreams/upload/upload-bitstream.component.ts @@ -11,7 +11,6 @@ import { ItemDataService } from '../../../core/data/item-data.service'; import { AuthService } from '../../../core/auth/auth.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; -import { getBitstreamModulePath } from '../../../app-routing.module'; import { PaginatedList } from '../../../core/data/paginated-list'; import { Bundle } from '../../../core/shared/bundle.model'; import { BundleDataService } from '../../../core/data/bundle-data.service'; @@ -19,8 +18,9 @@ import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { UploaderComponent } from '../../../shared/uploader/uploader.component'; -import { getItemEditPath } from '../../item-page-routing.module'; import { RequestService } from '../../../core/data/request.service'; +import { getBitstreamModuleRoute } from '../../../app-routing-paths'; +import { getItemEditRoute } from '../../item-page-routing-paths'; @Component({ selector: 'ds-upload-bitstream', @@ -171,7 +171,7 @@ export class UploadBitstreamComponent implements OnInit, OnDestroy { // Bring over the item ID as a query parameter const queryParams = { itemId: this.itemId }; - this.router.navigate([getBitstreamModulePath(), bitstream.id, 'edit'], { queryParams: queryParams }); + this.router.navigate([getBitstreamModuleRoute(), bitstream.id, 'edit'], { queryParams: queryParams }); } /** @@ -196,7 +196,7 @@ export class UploadBitstreamComponent implements OnInit, OnDestroy { * When cancel is clicked, navigate back to the item's edit bitstreams page */ onCancel() { - this.router.navigate([getItemEditPath(this.itemId), 'bitstreams']); + this.router.navigate([getItemEditRoute(this.itemId), 'bitstreams']); } /** diff --git a/src/app/+item-page/edit-item-page/edit-item-page.component.ts b/src/app/+item-page/edit-item-page/edit-item-page.component.ts index eafc04ae0b..655582064c 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.component.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.component.ts @@ -6,7 +6,7 @@ import { Item } from '../../core/shared/item.model'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { isNotEmpty } from '../../shared/empty.util'; -import { getItemPageRoute } from '../item-page-routing.module'; +import { getItemPageRoute } from '../item-page-routing-paths'; @Component({ selector: 'ds-edit-item-page', diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing-paths.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing-paths.ts new file mode 100644 index 0000000000..ce76a614dd --- /dev/null +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing-paths.ts @@ -0,0 +1,7 @@ +export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; +export const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; +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'; 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 ff1b79a247..3acbd77c40 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 @@ -20,17 +20,18 @@ import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers 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'; +import { + ITEM_EDIT_AUTHORIZATIONS_PATH, + ITEM_EDIT_MOVE_PATH, + ITEM_EDIT_DELETE_PATH, + ITEM_EDIT_PUBLIC_PATH, + ITEM_EDIT_PRIVATE_PATH, + ITEM_EDIT_REINSTATE_PATH, + ITEM_EDIT_WITHDRAW_PATH +} from './edit-item-page.routing-paths'; import { ItemPageReinstateGuard } from './item-page-reinstate.guard'; import { ItemPageWithdrawGuard } from './item-page-withdraw.guard'; -export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; -export const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; -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 */ diff --git a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts index 8477ae5c21..1409e06ddb 100644 --- a/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts +++ b/src/app/+item-page/edit-item-page/item-collection-mapper/item-collection-mapper.component.ts @@ -125,7 +125,7 @@ export class ItemCollectionMapperComponent implements OnInit { switchMap(([itemCollectionsRD, owningCollectionRD, searchOptions]) => { return this.searchService.search(Object.assign(new PaginatedSearchOptions(searchOptions), { query: this.buildQuery([...itemCollectionsRD.payload.page, owningCollectionRD.payload], searchOptions.query), - dsoType: DSpaceObjectType.COLLECTION + dsoTypes: [DSpaceObjectType.COLLECTION] }), 10000).pipe( toDSpaceObjectListRD(), startWith(undefined) diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts index 7233dbeaa1..18cbd6e855 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.spec.ts @@ -16,7 +16,6 @@ import { NotificationsService } from '../../../shared/notifications/notification import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { By } from '@angular/platform-browser'; import { ItemDeleteComponent } from './item-delete.component'; -import { getItemEditPath } from '../../item-page-routing.module'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; import { VarDirective } from '../../../shared/utils/var.directive'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; @@ -26,6 +25,7 @@ import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { PageInfo } from '../../../core/shared/page-info.model'; import { EntityTypeService } from '../../../core/data/entity-type.service'; +import { getItemEditRoute } from '../../item-page-routing-paths'; let comp: ItemDeleteComponent; let fixture: ComponentFixture; @@ -233,7 +233,7 @@ describe('ItemDeleteComponent', () => { describe('notify', () => { it('should navigate to the item edit page on failed deletion of the item', () => { comp.notify(false); - expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath('fake-id')]); + expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditRoute('fake-id')]); }); }); }) diff --git a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts index 933919c572..ac73c561b2 100644 --- a/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts +++ b/src/app/+item-page/edit-item-page/item-delete/item-delete.component.ts @@ -1,7 +1,6 @@ import { Component, Input, OnInit } from '@angular/core'; import { defaultIfEmpty, filter, first, map, switchMap, take } from 'rxjs/operators'; import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component'; -import { getItemEditPath } from '../../item-page-routing.module'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { combineLatest as observableCombineLatest, combineLatest, Observable, of as observableOf } from 'rxjs'; import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; @@ -22,6 +21,7 @@ import { EntityTypeService } from '../../../core/data/entity-type.service'; import { LinkService } from '../../../core/cache/builders/link.service'; import { followLink } from '../../../shared/utils/follow-link-config.model'; import { RestResponse } from '../../../core/cache/response.models'; +import { getItemEditRoute } from '../../item-page-routing-paths'; @Component({ selector: 'ds-item-delete', @@ -345,7 +345,7 @@ export class ItemDeleteComponent this.router.navigate(['']); } else { this.notificationsService.error(this.translateService.get('item.edit.' + this.messageKey + '.error')); - this.router.navigate([getItemEditPath(this.item.id)]); + this.router.navigate([getItemEditRoute(this.item.id)]); } } } diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts index 4db7cf94da..abadd2ec4a 100644 --- a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts @@ -10,7 +10,6 @@ import { NotificationsService } from '../../../shared/notifications/notification import { TranslateService } from '@ngx-translate/core'; import { getSucceededRemoteData } from '../../../core/shared/operators'; import { ItemDataService } from '../../../core/data/item-data.service'; -import { getItemEditPath } from '../../item-page-routing.module'; import { Observable, of as observableOf } from 'rxjs'; import { RestResponse } from '../../../core/cache/response.models'; import { Collection } from '../../../core/shared/collection.model'; @@ -18,6 +17,7 @@ import { PaginationComponentOptions } from '../../../shared/pagination/paginatio import { SearchService } from '../../../core/shared/search/search.service'; import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; import { SearchResult } from '../../../shared/search/search-result.model'; +import { getItemEditRoute } from '../../item-page-routing-paths'; @Component({ selector: 'ds-item-move', @@ -79,7 +79,7 @@ export class ItemMoveComponent implements OnInit { loadSuggestions(query): void { this.collectionSearchResults = this.searchService.search(new PaginatedSearchOptions({ pagination: this.pagination, - dsoType: DSpaceObjectType.COLLECTION, + dsoTypes: [DSpaceObjectType.COLLECTION], query: query })).pipe( first(), @@ -116,7 +116,7 @@ export class ItemMoveComponent implements OnInit { this.processing = true; this.itemDataService.moveToCollection(this.itemId, this.selectedCollection).pipe(first()).subscribe( (response: RestResponse) => { - this.router.navigate([getItemEditPath(this.itemId)]); + this.router.navigate([getItemEditRoute(this.itemId)]); if (response.isSuccessful) { this.notificationsService.success(this.translateService.get('item.edit.move.success')); } else { 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 93792acb35..dd043330d6 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 @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { fadeIn, fadeInOut } from '../../../shared/animations/fade'; import { Item } from '../../../core/shared/item.model'; import { ActivatedRoute } from '@angular/router'; @@ -6,7 +6,7 @@ import { ItemOperation } from '../item-operation/itemOperation.model'; import { distinctUntilChanged, first, map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; -import { getItemEditPath, getItemPageRoute } from '../../item-page-routing.module'; +import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { hasValue } from '../../../shared/empty.util'; @@ -126,7 +126,7 @@ export class ItemStatusComponent implements OnInit { * @returns {string} url */ getCurrentUrl(item: Item): string { - return getItemEditPath(item.id); + return getItemEditRoute(item.id); } trackOperation(index: number, operation: ItemOperation) { diff --git a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts index a6e64250cb..e6c5cfefc0 100644 --- a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec.ts @@ -15,12 +15,12 @@ import { RemoteData } from '../../../core/data/remote-data'; import { AbstractSimpleItemActionComponent } from './abstract-simple-item-action.component'; import { By } from '@angular/platform-browser'; import { of as observableOf } from 'rxjs'; -import { getItemEditPath } from '../../item-page-routing.module'; import { RestResponse } from '../../../core/cache/response.models'; import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { getItemEditRoute } from '../../item-page-routing-paths'; /** * Test component that implements the AbstractSimpleItemActionComponent used to test the @@ -136,14 +136,14 @@ describe('AbstractSimpleItemActionComponent', () => { comp.processRestResponse(successfulRestResponse); expect(notificationsServiceStub.success).toHaveBeenCalled(); - expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath(mockItem.id)]); + expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditRoute(mockItem.id)]); }); it('should process a RestResponse to navigate and display success notification', () => { comp.processRestResponse(failRestResponse); expect(notificationsServiceStub.error).toHaveBeenCalled(); - expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath(mockItem.id)]); + expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditRoute(mockItem.id)]); }); }); diff --git a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts index 7773dbb573..ca347e1298 100644 --- a/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts +++ b/src/app/+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.ts @@ -9,8 +9,8 @@ import {Observable} from 'rxjs'; import {getSucceededRemoteData} from '../../../core/shared/operators'; import {first, map} from 'rxjs/operators'; import {findSuccessfulAccordingTo} from '../edit-item-operators'; -import {getItemEditPath} from '../../item-page-routing.module'; import { RestResponse } from '../../../core/cache/response.models'; +import { getItemEditRoute } from '../../item-page-routing-paths'; /** * Component to render and handle simple item edit actions such as withdrawal and reinstatement. @@ -73,11 +73,11 @@ export class AbstractSimpleItemActionComponent implements OnInit { this.itemDataService.findById(this.item.id).pipe( findSuccessfulAccordingTo(this.predicate)).subscribe(() => { this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success')); - this.router.navigate([getItemEditPath(this.item.id)]); + this.router.navigate([getItemEditRoute(this.item.id)]); }); } else { this.notificationsService.error(this.translateService.get('item.edit.' + this.messageKey + '.error')); - this.router.navigate([getItemEditPath(this.item.id)]); + this.router.navigate([getItemEditRoute(this.item.id)]); } } diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index 7c1719eb82..c6f9f8e944 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -1,29 +1,87 @@ -
-
- -
-
-
-
{{"item.page.filesection.name" | translate}}
-
{{file.name}}
- -
{{"item.page.filesection.size" | translate}}
-
{{(file.sizeBytes) | dsFileSize }}
+
+
{{"item.page.filesection.original.bundle" | translate}}
+ -
{{"item.page.filesection.format" | translate}}
-
{{(file.format | async)?.payload?.description}}
+ +
+
+ +
+
+
+
{{"item.page.filesection.name" | translate}}
+
{{file.name}}
+ +
{{"item.page.filesection.size" | translate}}
+
{{(file.sizeBytes) | dsFileSize }}
-
{{"item.page.filesection.description" | translate}}
-
{{file.firstMetadataValue("dc.description")}}
-
-
-
- - {{"item.page.filesection.download" | translate}} - -
+
{{"item.page.filesection.format" | translate}}
+
{{(file.format | async)?.payload?.description}}
+ + +
{{"item.page.filesection.description" | translate}}
+
{{file.firstMetadataValue("dc.description")}}
+
+
+
+ + {{"item.page.filesection.download" | translate}} + +
+
+ + + +
+
{{"item.page.filesection.license.bundle" | translate}}
+ + + + +
+
+ +
+
+
+
{{"item.page.filesection.name" | translate}}
+
{{file.name}}
+ +
{{"item.page.filesection.size" | translate}}
+
{{(file.sizeBytes) | dsFileSize }}
+ + +
{{"item.page.filesection.format" | translate}}
+
{{(file.format | async)?.payload?.description}}
+ + +
{{"item.page.filesection.description" | translate}}
+
{{file.firstMetadataValue("dc.description")}}
+
+
+
+ + {{"item.page.filesection.download" | translate}} + +
+
+
diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts b/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts new file mode 100644 index 0000000000..970420f252 --- /dev/null +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts @@ -0,0 +1,117 @@ +import {FullFileSectionComponent} from './full-file-section.component'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {createSuccessfulRemoteDataObject$} from '../../../../shared/remote-data.utils'; +import {createPaginatedList} from '../../../../shared/testing/utils.test'; +import {TranslateLoader, TranslateModule} from '@ngx-translate/core'; +import {TranslateLoaderMock} from '../../../../shared/mocks/translate-loader.mock'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {VarDirective} from '../../../../shared/utils/var.directive'; +import {FileSizePipe} from '../../../../shared/utils/file-size-pipe'; +import {MetadataFieldWrapperComponent} from '../../../field-components/metadata-field-wrapper/metadata-field-wrapper.component'; +import {BitstreamDataService} from '../../../../core/data/bitstream-data.service'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {Bitstream} from '../../../../core/shared/bitstream.model'; +import {of as observableOf} from 'rxjs'; +import {MockBitstreamFormat1} from '../../../../shared/mocks/item.mock'; +import {By} from '@angular/platform-browser'; + +describe('FullFileSectionComponent', () => { + let comp: FullFileSectionComponent; + let fixture: ComponentFixture; + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), + { + sizeBytes: 10201, + content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713' + }, + content: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content' + } + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx' + } + ] + } + }); + + const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(createPaginatedList([mockBitstream, mockBitstream, mockBitstream])) + }); + + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), BrowserAnimationsModule], + declarations: [FullFileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent], + providers: [ + {provide: BitstreamDataService, useValue: bitstreamDataService} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(FullFileSectionComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('when the full file section gets loaded with bitstreams available', () => { + it ('should contain a list with bitstreams', () => { + const fileSection = fixture.debugElement.queryAll(By.css('.file-section')); + expect(fileSection.length).toEqual(6); + }); + + describe('when we press the pageChange button for original bundle', () => { + beforeEach(() => { + comp.switchOriginalPage(2); + fixture.detectChanges(); + }); + + it ('should give the value to the currentpage', () => { + expect(comp.originalOptions.currentPage).toBe(2); + }) + it ('should call the next function on the originalCurrentPage', (done) => { + comp.originalCurrentPage$.subscribe((event) => { + expect(event).toEqual(2); + done(); + }) + }) + }) + + describe('when we press the pageChange button for license bundle', () => { + beforeEach(() => { + comp.switchLicensePage(2); + fixture.detectChanges(); + }); + + it ('should give the value to the currentpage', () => { + expect(comp.licenseOptions.currentPage).toBe(2); + }) + it ('should call the next function on the licenseCurrentPage', (done) => { + comp.licenseCurrentPage$.subscribe((event) => { + expect(event).toEqual(2); + done(); + }) + }) + }) + }) +}) diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts index f18fccd7e9..fdbe662ed9 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts @@ -1,13 +1,15 @@ -import { Component, Injector, Input, OnInit } from '@angular/core'; -import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { map, startWith } from 'rxjs/operators'; +import { Component, Input, OnInit } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; -import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators'; import { followLink } from '../../../../shared/utils/follow-link-config.model'; import { FileSectionComponent } from '../../../simple/field-components/file-section/file-section.component'; +import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { switchMap } from 'rxjs/operators'; /** * This component renders the file section of the item @@ -25,7 +27,23 @@ export class FullFileSectionComponent extends FileSectionComponent implements On label: string; - bitstreams$: Observable; + originals$: Observable>>; + licenses$: Observable>>; + + pageSize = 5; + originalOptions = Object.assign(new PaginationComponentOptions(),{ + id: 'original-bitstreams-options', + currentPage: 1, + pageSize: this.pageSize + }); + originalCurrentPage$ = new BehaviorSubject(1); + + licenseOptions = Object.assign(new PaginationComponentOptions(),{ + id: 'license-bitstreams-options', + currentPage: 1, + pageSize: this.pageSize + }); + licenseCurrentPage$ = new BehaviorSubject(1); constructor( bitstreamDataService: BitstreamDataService @@ -34,40 +52,45 @@ export class FullFileSectionComponent extends FileSectionComponent implements On } ngOnInit(): void { - super.ngOnInit(); + this.initialize(); } initialize(): void { - // TODO pagination - const originals$ = this.bitstreamDataService.findAllByItemAndBundleName( - this.item, - 'ORIGINAL', - { elementsPerPage: Number.MAX_SAFE_INTEGER }, - followLink( 'format') - ).pipe( - getFirstSucceededRemoteListPayload(), - startWith([]) + this.originals$ = this.originalCurrentPage$.pipe( + switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName( + this.item, + 'ORIGINAL', + { elementsPerPage: this.pageSize, currentPage: pageNumber }, + followLink( 'format') + )) ); - const licenses$ = this.bitstreamDataService.findAllByItemAndBundleName( - this.item, - 'LICENSE', - { elementsPerPage: Number.MAX_SAFE_INTEGER }, - followLink( 'format') - ).pipe( - getFirstSucceededRemoteListPayload(), - startWith([]) - ); - this.bitstreams$ = observableCombineLatest(originals$, licenses$).pipe( - map(([o, l]) => [...o, ...l]), - map((files: Bitstream[]) => - files.map( - (original) => { - original.thumbnail = this.bitstreamDataService.getMatchingThumbnail(this.item, original); - return original; - } - ) - ) + + this.licenses$ = this.licenseCurrentPage$.pipe( + switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName( + this.item, + 'LICENSE', + { elementsPerPage: this.pageSize, currentPage: pageNumber }, + followLink( 'format') + )) ); + } + /** + * Update the current page for the original bundle bitstreams + * @param page + */ + switchOriginalPage(page: number) { + this.originalOptions.currentPage = page; + this.originalCurrentPage$.next(page); + } + + /** + * Update the current page for the license bundle bitstreams + * @param page + */ + switchLicensePage(page: number) { + this.licenseOptions.currentPage = page; + this.licenseCurrentPage$.next(page); + } } diff --git a/src/app/+item-page/item-page-routing-paths.ts b/src/app/+item-page/item-page-routing-paths.ts new file mode 100644 index 0000000000..bb8f052eda --- /dev/null +++ b/src/app/+item-page/item-page-routing-paths.ts @@ -0,0 +1,18 @@ +import { URLCombiner } from '../core/url-combiner/url-combiner'; + +export const ITEM_MODULE_PATH = 'items'; + +export function getItemModuleRoute() { + return `/${ITEM_MODULE_PATH}`; +} + +export function getItemPageRoute(itemId: string) { + return new URLCombiner(getItemModuleRoute(), itemId).toString(); +} + +export function getItemEditRoute(id: string) { + return new URLCombiner(getItemModuleRoute(), id, ITEM_EDIT_PATH).toString() +} + +export const ITEM_EDIT_PATH = 'edit'; +export const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index fc5cfa3522..66dbcbb10d 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -4,26 +4,14 @@ import { RouterModule } from '@angular/router'; import { ItemPageComponent } from './simple/item-page.component'; import { FullItemPageComponent } from './full/full-item-page.component'; import { ItemPageResolver } from './item-page.resolver'; -import { URLCombiner } from '../core/url-combiner/url-combiner'; -import { getItemModulePath } from '../app-routing.module'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver'; import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; +import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths'; import { ItemPageAdministratorGuard } from './item-page-administrator.guard'; -export function getItemPageRoute(itemId: string) { - return new URLCombiner(getItemModulePath(), itemId).toString(); -} - -export function getItemEditPath(id: string) { - return new URLCombiner(getItemModulePath(), id, ITEM_EDIT_PATH).toString() -} - -const ITEM_EDIT_PATH = 'edit'; -const UPLOAD_BITSTREAM_PATH = 'bitstreams/new'; - @NgModule({ imports: [ RouterModule.forChild([ diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.html b/src/app/+item-page/simple/field-components/file-section/file-section.component.html index 17e4a795e7..1fdee6dc4d 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.html @@ -6,6 +6,13 @@ ({{(file?.sizeBytes) | dsFileSize }}) + + + diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts new file mode 100644 index 0000000000..1b7fa75ce5 --- /dev/null +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts @@ -0,0 +1,169 @@ +import {FileSectionComponent} from './file-section.component'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {TranslateLoader, TranslateModule} from '@ngx-translate/core'; +import {TranslateLoaderMock} from '../../../../shared/mocks/translate-loader.mock'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {VarDirective} from '../../../../shared/utils/var.directive'; +import {NO_ERRORS_SCHEMA} from '@angular/core'; +import {BitstreamDataService} from '../../../../core/data/bitstream-data.service'; +import {createSuccessfulRemoteDataObject$} from '../../../../shared/remote-data.utils'; +import {By} from '@angular/platform-browser'; +import {Bitstream} from '../../../../core/shared/bitstream.model'; +import {of as observableOf} from 'rxjs'; +import {MockBitstreamFormat1} from '../../../../shared/mocks/item.mock'; +import {FileSizePipe} from '../../../../shared/utils/file-size-pipe'; +import {PageInfo} from '../../../../core/shared/page-info.model'; +import {MetadataFieldWrapperComponent} from '../../../field-components/metadata-field-wrapper/metadata-field-wrapper.component'; +import {createPaginatedList} from '../../../../shared/testing/utils.test'; + +describe('FileSectionComponent', () => { + let comp: FileSectionComponent; + let fixture: ComponentFixture; + + const bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', { + findAllByItemAndBundleName: createSuccessfulRemoteDataObject$(createPaginatedList([])) + }); + + const mockBitstream: Bitstream = Object.assign(new Bitstream(), + { + sizeBytes: 10201, + content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713' + }, + content: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content' + } + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx' + } + ] + } + }); + + beforeEach(async(() => { + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), BrowserAnimationsModule], + declarations: [FileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent], + providers: [ + {provide: BitstreamDataService, useValue: bitstreamDataService} + ], + + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(FileSectionComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('when the bitstreams are loading', () => { + beforeEach(() => { + comp.bitstreams$.next([mockBitstream]); + comp.isLoading = true; + fixture.detectChanges(); + }); + + it('should display a loading component', () => { + const loading = fixture.debugElement.query(By.css('ds-loading')); + expect(loading.nativeElement).toBeDefined(); + }); + }); + + describe('when the "Show more" button is clicked', () => { + + beforeEach(() => { + comp.bitstreams$.next([mockBitstream]); + comp.currentPage = 1; + comp.isLastPage = false; + fixture.detectChanges(); + }); + + it('should call the service to retrieve more bitstreams', () => { + const viewMore = fixture.debugElement.query(By.css('.bitstream-view-more')); + viewMore.triggerEventHandler('click', null); + expect(bitstreamDataService.findAllByItemAndBundleName).toHaveBeenCalled() + }) + + it('one bitstream should be on the page', () => { + const viewMore = fixture.debugElement.query(By.css('.bitstream-view-more')); + viewMore.triggerEventHandler('click', null); + const fileDownloadLink = fixture.debugElement.queryAll(By.css('ds-file-download-link')); + expect(fileDownloadLink.length).toEqual(1); + }) + + describe('when it is then clicked again', () => { + beforeEach(() => { + bitstreamDataService.findAllByItemAndBundleName.and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList([mockBitstream]))); + const viewMore = fixture.debugElement.query(By.css('.bitstream-view-more')); + viewMore.triggerEventHandler('click', null); + fixture.detectChanges(); + + }) + it('should contain another bitstream', () => { + const fileDownloadLink = fixture.debugElement.queryAll(By.css('ds-file-download-link')); + expect(fileDownloadLink.length).toEqual(2); + }) + }) + }); + + describe('when its the last page of bitstreams', () => { + beforeEach(() => { + comp.bitstreams$.next([mockBitstream]); + comp.isLastPage = true; + comp.currentPage = 2; + fixture.detectChanges(); + }); + + it('should not contain a view more link', () => { + const viewMore = fixture.debugElement.query(By.css('.bitstream-view-more')); + expect(viewMore).toBeNull(); + }) + + it('should contain a view less link', () => { + const viewLess = fixture.debugElement.query(By.css('.bitstream-collapse')); + expect(viewLess).toBeDefined(); + }) + + it('clicking on the view less link should reset the pages and call getNextPage()', () => { + const pageInfo = Object.assign(new PageInfo(), { + elementsPerPage: 3, + totalElements: 5, + totalPages: 2, + currentPage: 1, + _links: { + self: {href: 'https://rest.api/core/bitstreams/'}, + next: {href: 'https://rest.api/core/bitstreams?page=2'} + } + }); + const PaginatedList = Object.assign(createPaginatedList([mockBitstream]), { + pageInfo: pageInfo + }); + bitstreamDataService.findAllByItemAndBundleName.and.returnValue(createSuccessfulRemoteDataObject$(PaginatedList)); + const viewLess = fixture.debugElement.query(By.css('.bitstream-collapse')); + viewLess.triggerEventHandler('click', null); + expect(bitstreamDataService.findAllByItemAndBundleName).toHaveBeenCalled(); + expect(comp.currentPage).toBe(1); + expect(comp.isLastPage).toBeFalse(); + }) + + }) +}) diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts index 2e09c1cd49..25b214e200 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts @@ -1,10 +1,13 @@ import { Component, Input, OnInit } from '@angular/core'; -import { Observable } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; -import { getFirstSucceededRemoteListPayload } from '../../../../core/shared/operators'; +import { filter, takeWhile } from 'rxjs/operators'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { hasNoValue, hasValue } from '../../../../shared/empty.util'; +import { PaginatedList } from '../../../../core/data/paginated-list'; /** * This component renders the file section of the item @@ -22,7 +25,15 @@ export class FileSectionComponent implements OnInit { separator = '
'; - bitstreams$: Observable; + bitstreams$: BehaviorSubject; + + currentPage: number; + + isLoading: boolean; + + isLastPage: boolean; + + pageSize = 5; constructor( protected bitstreamDataService: BitstreamDataService @@ -30,13 +41,31 @@ export class FileSectionComponent implements OnInit { } ngOnInit(): void { - this.initialize(); + this.getNextPage(); } - initialize(): void { - this.bitstreams$ = this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL').pipe( - getFirstSucceededRemoteListPayload() - ); + /** + * This method will retrieve the next page of Bitstreams from the external BitstreamDataService call. + * It'll retrieve the currentPage from the class variables and it'll add the next page of bitstreams with the + * already existing one. + * If the currentPage variable is undefined, we'll set it to 1 and retrieve the first page of Bitstreams + */ + getNextPage(): void { + this.isLoading = true; + if (this.currentPage === undefined) { + this.currentPage = 1; + this.bitstreams$ = new BehaviorSubject([]); + } else { + this.currentPage++; + } + this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: this.currentPage, elementsPerPage: this.pageSize }).pipe( + filter((bitstreamsRD: RemoteData>) => hasValue(bitstreamsRD)), + takeWhile((bitstreamsRD: RemoteData>) => hasNoValue(bitstreamsRD.payload) && hasNoValue(bitstreamsRD.error), true) + ).subscribe((bitstreamsRD: RemoteData>) => { + const current: Bitstream[] = this.bitstreams$.getValue(); + this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]); + this.isLoading = false; + this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages; + }); } - } diff --git a/src/app/+my-dspace-page/collection-selector/collection-selector.component.html b/src/app/+my-dspace-page/collection-selector/collection-selector.component.html new file mode 100644 index 0000000000..83cc4151a3 --- /dev/null +++ b/src/app/+my-dspace-page/collection-selector/collection-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/app/+my-dspace-page/collection-selector/collection-selector.component.scss b/src/app/+my-dspace-page/collection-selector/collection-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+my-dspace-page/collection-selector/collection-selector.component.spec.ts b/src/app/+my-dspace-page/collection-selector/collection-selector.component.spec.ts new file mode 100644 index 0000000000..982d06aa75 --- /dev/null +++ b/src/app/+my-dspace-page/collection-selector/collection-selector.component.spec.ts @@ -0,0 +1,164 @@ +import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; + +import { CollectionSelectorComponent } from './collection-selector.component'; +import { CollectionDropdownComponent } from 'src/app/shared/collection-dropdown/collection-dropdown.component'; +import { Collection } from 'src/app/core/shared/collection.model'; +import { of, Observable } from 'rxjs'; +import { RemoteData } from 'src/app/core/data/remote-data'; +import { Community } from 'src/app/core/shared/community.model'; +import { FindListOptions } from 'src/app/core/data/request.models'; +import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; +import { PaginatedList } from 'src/app/core/data/paginated-list'; +import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils'; +import { PageInfo } from 'src/app/core/shared/page-info.model'; +import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; +import { TranslateLoaderMock } from 'src/app/shared/mocks/translate-loader.mock'; +import { CollectionDataService } from 'src/app/core/data/collection-data.service'; +import { ChangeDetectorRef, ElementRef, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute } from '@angular/router'; +import { hot } from 'jasmine-marbles'; +import { By } from '@angular/platform-browser'; + +describe('CollectionSelectorComponent', () => { + let component: CollectionSelectorComponent; + let fixture: ComponentFixture; + const modal = jasmine.createSpyObj('modal', ['close', 'dismiss']); + + const community: Community = Object.assign(new Community(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + name: 'Community 1' + }); + + const collections: Collection[] = [ + Object.assign(new Collection(), { + id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88', + name: 'Collection 1', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }), + Object.assign(new Collection(), { + id: '59ee713b-ee53-4220-8c3f-9860dc84fe33', + name: 'Collection 2', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 2' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }), + Object.assign(new Collection(), { + id: 'e9dbf393-7127-415f-8919-55be34a6e9ed', + name: 'Collection 3', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 3' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }), + Object.assign(new Collection(), { + id: '59da2ff0-9bf4-45bf-88be-e35abd33f304', + name: 'Collection 4', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 4' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }), + Object.assign(new Collection(), { + id: 'a5159760-f362-4659-9e81-e3253ad91ede', + name: 'Collection 5', + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 5' + }], + parentCommunity: of( + new RemoteData(false, false, true, undefined, community, 200) + ) + }) + ]; + + // tslint:disable-next-line: max-classes-per-file + const collectionDataServiceMock = { + getAuthorizedCollection(query: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return hot( 'a|', { + a: createSuccessfulRemoteDataObject( + new PaginatedList(new PageInfo(), collections) + ) + }); + } + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }) + ], + declarations: [ CollectionSelectorComponent, CollectionDropdownComponent ], + providers: [ + {provide: CollectionDataService, useValue: collectionDataServiceMock}, + {provide: ChangeDetectorRef, useValue: {}}, + {provide: ElementRef, userValue: {}}, + {provide: NgbActiveModal, useValue: modal}, + {provide: ActivatedRoute, useValue: {}} + ], + schemas: [NO_ERRORS_SCHEMA] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CollectionSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call selectObject', fakeAsync(() => { + spyOn(component, 'selectObject'); + fixture.detectChanges(); + tick(); + fixture.whenStable().then(() => { + const collectionItem = fixture.debugElement.query(By.css('.collection-item:nth-child(2)')); + collectionItem.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + expect(component.selectObject).toHaveBeenCalled(); + }); + })); + + it('should close the dialog', () => { + component.close(); + expect((component as any).activeModal.close).toHaveBeenCalled(); + }); +}); diff --git a/src/app/+my-dspace-page/collection-selector/collection-selector.component.ts b/src/app/+my-dspace-page/collection-selector/collection-selector.component.ts new file mode 100644 index 0000000000..f930fc3f54 --- /dev/null +++ b/src/app/+my-dspace-page/collection-selector/collection-selector.component.ts @@ -0,0 +1,33 @@ +import { Component } from '@angular/core'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +/** + * This component displays the dialog that shows the list of selectable collections + * on the MyDSpace page + */ +@Component({ + selector: 'ds-collection-selector', + templateUrl: './collection-selector.component.html', + styleUrls: ['./collection-selector.component.scss'] +}) +export class CollectionSelectorComponent { + + constructor(protected activeModal: NgbActiveModal) {} + + /** + * Method called when an element has been selected from collection list. + * Its close the active modal and send selected value to the component container + * @param dso The selected DSpaceObject + */ + selectObject(dso: DSpaceObject) { + this.activeModal.close(dso); + } + + /** + * Close the modal + */ + close() { + this.activeModal.close(); + } +} diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html index 4809f206ae..9ae38a2205 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html @@ -3,13 +3,19 @@ + (onUploadError)="onUploadError($event)" + (onFileSelected)="afterFileLoaded($event)">
-
+
+ + + +
diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts index 16b50d18f0..c395408cf5 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { async, ComponentFixture, inject, TestBed, tick, fakeAsync } from '@angular/core/testing'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { Store } from '@ngrx/store'; @@ -15,7 +15,6 @@ import { createTestComponent } from '../../shared/testing/utils.test'; import { MyDSpaceNewSubmissionComponent } from './my-dspace-new-submission.component'; import { AppState } from '../../app.reducer'; import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; -import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { SharedModule } from '../../shared/shared.module'; @@ -23,10 +22,27 @@ import { getMockScrollToService } from '../../shared/mocks/scroll-to-service.moc import { UploaderService } from '../../shared/uploader/uploader.service'; import { By } from '@angular/platform-browser'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { HostWindowService } from '../../shared/host-window.service'; +import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; +import { UploaderComponent } from 'src/app/shared/uploader/uploader.component'; describe('MyDSpaceNewSubmissionComponent test', () => { - const translateService: any = getMockTranslateService(); + const translateService: TranslateService = jasmine.createSpyObj('translateService', { + get: (key: string): any => { observableOf(key) }, + instant: jasmine.createSpy('instant') + }); + + const uploader: any = jasmine.createSpyObj('uploader', { + clearQueue: jasmine.createSpy('clearQueue') + }); + + const modalService = { + open: () => { + return { result: new Promise((res, rej) => {/****/}) }; + } + }; + const store: Store = jasmine.createSpyObj('store', { /* tslint:disable:no-empty */ dispatch: {}, @@ -56,14 +72,11 @@ describe('MyDSpaceNewSubmissionComponent test', () => { { provide: ScrollToService, useValue: getMockScrollToService() }, { provide: Store, useValue: store }, { provide: TranslateService, useValue: translateService }, - { - provide: NgbModal, useValue: { - open: () => {/*comment*/} - } - }, + { provide: NgbModal, useValue: modalService }, ChangeDetectorRef, MyDSpaceNewSubmissionComponent, - UploaderService + UploaderService, + { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -100,6 +113,10 @@ describe('MyDSpaceNewSubmissionComponent test', () => { beforeEach(() => { fixture = TestBed.createComponent(MyDSpaceNewSubmissionComponent); comp = fixture.componentInstance; + comp.uploadFilesOptions.authToken = 'user-auth-token'; + comp.uploadFilesOptions.url = 'https://fake.upload-api.url'; + comp.uploaderComponent = TestBed.createComponent(UploaderComponent).componentInstance; + comp.uploaderComponent.uploader = uploader; }); it('should call app.openDialog', () => { @@ -111,6 +128,12 @@ describe('MyDSpaceNewSubmissionComponent test', () => { }); expect(comp.openDialog).toHaveBeenCalled(); }); + + it('should show a collection selector if only one file are uploaded', () => { + spyOn((comp as any).modalService, 'open').and.returnValue({ result: new Promise((res, rej) => {/****/}) }); + comp.afterFileLoaded(['']); + expect((comp as any).modalService.open).toHaveBeenCalled(); + }); }); }); diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts index 8d20a5736a..d14fe46afd 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts @@ -1,11 +1,10 @@ -import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { SubmissionState } from '../../submission/submission.reducers'; import { AuthService } from '../../core/auth/auth.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -15,9 +14,10 @@ import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { NotificationType } from '../../shared/notifications/models/notification-type'; import { hasValue } from '../../shared/empty.util'; import { SearchResult } from '../../shared/search/search-result.model'; -import { Router } from '@angular/router'; -import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { CreateItemParentSelectorComponent } from 'src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { CreateItemParentSelectorComponent } from '../../shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { CollectionSelectorComponent } from '../collection-selector/collection-selector.component'; +import { UploaderComponent } from '../../shared/uploader/uploader.component'; +import { UploaderError } from '../../shared/uploader/uploader-error.model'; /** * This component represents the whole mydspace page header @@ -43,6 +43,11 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { */ private sub: Subscription; + /** + * Reference to uploaderComponent + */ + @ViewChild(UploaderComponent, { static: false }) uploaderComponent: UploaderComponent; + /** * Initialize instance variables * @@ -52,14 +57,14 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { * @param {NotificationsService} notificationsService * @param {Store} store * @param {TranslateService} translate + * @param {Router} router + * @param {NgbModal} modalService */ constructor(private authService: AuthService, private changeDetectorRef: ChangeDetectorRef, private halService: HALEndpointService, private notificationsService: NotificationsService, - private store: Store, private translate: TranslateService, - private router: Router, private modalService: NgbModal) { } @@ -67,6 +72,7 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { * Initialize url and Bearer token */ ngOnInit() { + this.uploadFilesOptions.autoUpload = false; this.sub = this.halService.getEndpoint('workspaceitems').pipe(first()).subscribe((url) => { this.uploadFilesOptions.url = url; this.uploadFilesOptions.authToken = this.authService.buildAuthHeader(); @@ -106,8 +112,12 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { /** * Method called on file upload error */ - public onUploadError() { - this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed')); + public onUploadError(error: UploaderError) { + let errorMessageKey = 'mydspace.upload.upload-failed'; + if (hasValue(error.status) && error.status === 422) { + errorMessageKey = 'mydspace.upload.upload-failed-manyentries'; + } + this.notificationsService.error(null, this.translate.get(errorMessageKey)); } /** @@ -118,6 +128,28 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { this.modalService.open(CreateItemParentSelectorComponent); } + /** + * Method invoked after all file are loaded from upload plugin + */ + afterFileLoaded(items) { + const uploader = this.uploaderComponent.uploader; + if (hasValue(items) && items.length > 1) { + this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed-moreonefile')); + uploader.clearQueue(); + this.changeDetectorRef.detectChanges(); + } else { + const modalRef = this.modalService.open(CollectionSelectorComponent); + // When the dialog are closes its takes the collection selected and + // uploads choosed file after adds owningCollection parameter + modalRef.result.then( (result) => { + uploader.onBuildItemForm = (fileItem: any, form: any) => { + form.append('owningCollection', result.uuid); + }; + uploader.uploadAll(); + }); + } + } + /** * Unsubscribe from the subscription */ diff --git a/src/app/+my-dspace-page/my-dspace-page.module.ts b/src/app/+my-dspace-page/my-dspace-page.module.ts index 1cf30c4ec9..49570fec6d 100644 --- a/src/app/+my-dspace-page/my-dspace-page.module.ts +++ b/src/app/+my-dspace-page/my-dspace-page.module.ts @@ -20,6 +20,7 @@ import { SearchResultListElementComponent } from '../shared/object-list/search-r import { ItemSearchResultListElementSubmissionComponent } from '../shared/object-list/my-dspace-result-list-element/item-search-result/item-search-result-list-element-submission.component'; import { WorkflowItemSearchResultListElementComponent } from '../shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component'; import { PoolSearchResultDetailElementComponent } from '../shared/object-detail/my-dspace-result-detail-element/pool-search-result/pool-search-result-detail-element.component'; +import { CollectionSelectorComponent } from './collection-selector/collection-selector.component'; @NgModule({ imports: [ @@ -40,7 +41,8 @@ import { PoolSearchResultDetailElementComponent } from '../shared/object-detail/ ClaimedTaskSearchResultDetailElementComponent, PoolSearchResultDetailElementComponent, MyDSpaceNewSubmissionComponent, - ItemSearchResultListElementSubmissionComponent + ItemSearchResultListElementSubmissionComponent, + CollectionSelectorComponent ], providers: [ MyDSpaceGuard, @@ -57,7 +59,8 @@ import { PoolSearchResultDetailElementComponent } from '../shared/object-detail/ WorkflowItemSearchResultDetailElementComponent, ClaimedTaskSearchResultDetailElementComponent, PoolSearchResultDetailElementComponent, - ItemSearchResultListElementSubmissionComponent + ItemSearchResultListElementSubmissionComponent, + CollectionSelectorComponent ] }) diff --git a/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts new file mode 100644 index 0000000000..7d09a6915e --- /dev/null +++ b/src/app/+workflowitems-edit-page/workflowitems-edit-page-routing-paths.ts @@ -0,0 +1,22 @@ +import { URLCombiner } from '../core/url-combiner/url-combiner'; +import { getWorkflowItemModuleRoute } from '../app-routing-paths'; + +export function getWorkflowItemPageRoute(wfiId: string) { + return new URLCombiner(getWorkflowItemModuleRoute(), wfiId).toString(); +} + +export function getWorkflowItemEditRoute(wfiId: string) { + return new URLCombiner(getWorkflowItemModuleRoute(), wfiId, WORKFLOW_ITEM_EDIT_PATH).toString() +} + +export function getWorkflowItemDeleteRoute(wfiId: string) { + return new URLCombiner(getWorkflowItemModuleRoute(), wfiId, WORKFLOW_ITEM_DELETE_PATH).toString() +} + +export function getWorkflowItemSendBackRoute(wfiId: string) { + return new URLCombiner(getWorkflowItemModuleRoute(), wfiId, WORKFLOW_ITEM_SEND_BACK_PATH).toString() +} + +export const WORKFLOW_ITEM_EDIT_PATH = 'edit'; +export const WORKFLOW_ITEM_DELETE_PATH = 'delete'; +export const WORKFLOW_ITEM_SEND_BACK_PATH = 'sendback'; 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 e9989bf947..27b7fe1199 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,31 +3,14 @@ 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'; +import { + WORKFLOW_ITEM_SEND_BACK_PATH, + WORKFLOW_ITEM_DELETE_PATH, + WORKFLOW_ITEM_EDIT_PATH +} from './workflowitems-edit-page-routing-paths'; @NgModule({ imports: [ diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts new file mode 100644 index 0000000000..4e64a4a552 --- /dev/null +++ b/src/app/app-routing-paths.ts @@ -0,0 +1,67 @@ +import { DSpaceObject } from './core/shared/dspace-object.model'; +import { Community } from './core/shared/community.model'; +import { Collection } from './core/shared/collection.model'; +import { Item } from './core/shared/item.model'; +import { getCommunityPageRoute } from './+community-page/community-page-routing-paths'; +import { getCollectionPageRoute } from './+collection-page/collection-page-routing-paths'; +import { getItemPageRoute } from './+item-page/item-page-routing-paths'; + +export const BITSTREAM_MODULE_PATH = 'bitstreams'; + +export function getBitstreamModuleRoute() { + return `/${BITSTREAM_MODULE_PATH}`; +} + +export const ADMIN_MODULE_PATH = 'admin'; + +export function getAdminModuleRoute() { + return `/${ADMIN_MODULE_PATH}`; +} + +export const PROFILE_MODULE_PATH = 'profile'; + +export function getProfileModuleRoute() { + return `/${PROFILE_MODULE_PATH}`; +} + +export const REGISTER_PATH = 'register'; + +export function getRegisterRoute() { + return `/${REGISTER_PATH}`; + +} + +export const FORGOT_PASSWORD_PATH = 'forgot'; + +export function getForgotPasswordRoute() { + return `/${FORGOT_PASSWORD_PATH}`; + +} + +export const WORKFLOW_ITEM_MODULE_PATH = 'workflowitems'; + +export function getWorkflowItemModuleRoute() { + return `/${WORKFLOW_ITEM_MODULE_PATH}`; +} + +export function getDSORoute(dso: DSpaceObject): string { + switch ((dso as any).type) { + case Community.type.value: + return getCommunityPageRoute(dso.uuid); + case Collection.type.value: + return getCollectionPageRoute(dso.uuid); + case Item.type.value: + return getItemPageRoute(dso.uuid); + } +} + +export const UNAUTHORIZED_PATH = 'unauthorized'; + +export function getUnauthorizedRoute() { + return `/${UNAUTHORIZED_PATH}`; +} + +export const INFO_MODULE_PATH = 'info'; +export function getInfoModulePath() { + return `/${INFO_MODULE_PATH}`; +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 5842e8b06b..50e2f6b532 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,132 +1,77 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { AuthenticatedGuard } from './core/auth/authenticated.guard'; -import { DSpaceObject } from './core/shared/dspace-object.model'; -import { Community } from './core/shared/community.model'; -import { getCommunityPageRoute } from './+community-page/community-page-routing.module'; -import { Collection } from './core/shared/collection.model'; -import { Item } from './core/shared/item.model'; -import { getItemPageRoute } from './+item-page/item-page-routing.module'; -import { getCollectionPageRoute } from './+collection-page/collection-page-routing.module'; import { SiteAdministratorGuard } from './core/data/feature-authorization/feature-authorization-guard/site-administrator.guard'; import { UnauthorizedComponent } from './unauthorized/unauthorized.component'; +import { + UNAUTHORIZED_PATH, + WORKFLOW_ITEM_MODULE_PATH, + FORGOT_PASSWORD_PATH, + REGISTER_PATH, + PROFILE_MODULE_PATH, + ADMIN_MODULE_PATH, + BITSTREAM_MODULE_PATH, + INFO_MODULE_PATH +} from './app-routing-paths'; +import { COLLECTION_MODULE_PATH } from './+collection-page/collection-page-routing-paths'; +import { COMMUNITY_MODULE_PATH } from './+community-page/community-page-routing-paths'; +import { ITEM_MODULE_PATH } from './+item-page/item-page-routing-paths'; +import { ReloadGuard } from './core/reload/reload.guard'; +import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard'; import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; -const ITEM_MODULE_PATH = 'items'; - -export function getItemModulePath() { - return `/${ITEM_MODULE_PATH}`; -} - -const COLLECTION_MODULE_PATH = 'collections'; - -export function getCollectionModulePath() { - return `/${COLLECTION_MODULE_PATH}`; -} - -const COMMUNITY_MODULE_PATH = 'communities'; - -export function getCommunityModulePath() { - return `/${COMMUNITY_MODULE_PATH}`; -} -const BITSTREAM_MODULE_PATH = 'bitstreams'; -export function getBitstreamModulePath() { - return `/${BITSTREAM_MODULE_PATH}`; -} - -export const ADMIN_MODULE_PATH = 'admin'; - -export function getAdminModulePath() { - return `/${ADMIN_MODULE_PATH}`; -} - -const PROFILE_MODULE_PATH = 'profile'; - -export function getProfileModulePath() { - return `/${PROFILE_MODULE_PATH}`; -} - -const REGISTER_PATH = 'register'; - -export function getRegisterPath() { - return `/${REGISTER_PATH}`; - -} - -const FORGOT_PASSWORD_PATH = 'forgot'; - -export function getForgotPasswordPath() { - return `/${FORGOT_PASSWORD_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: - return getCommunityPageRoute(dso.uuid); - case Collection.type.value: - return getCollectionPageRoute(dso.uuid); - case Item.type.value: - return getItemPageRoute(dso.uuid); - } -} - -const UNAUTHORIZED_PATH = 'unauthorized'; - -export function getUnauthorizedPath() { - return `/${UNAUTHORIZED_PATH}`; -} - @NgModule({ imports: [ RouterModule.forRoot([ - { path: '', redirectTo: '/home', pathMatch: 'full' }, - { path: 'reload/:rnd', redirectTo: '/home', pathMatch: 'full' }, - { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } }, - { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, - { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, - { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, - { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] }, - { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' }, - { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, - { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, - { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, - { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' }, - { - path: 'mydspace', - loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', - canActivate: [AuthenticatedGuard] - }, - { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' }, - { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, - { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] }, - { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, - { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, - { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, - { - path: 'workspaceitems', - loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' - }, - { - path: WORKFLOW_ITEM_MODULE_PATH, - loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' - }, - { - path: PROFILE_MODULE_PATH, - loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] - }, - { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] }, - { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, - { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, - ], + { path: '', canActivate: [AuthBlockingGuard], + children: [ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: 'reload/:rnd', component: PageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] }, + { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false }, canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] }, + { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { + path: 'mydspace', + loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', + canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, + { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, EndUserAgreementCurrentUserGuard] }, + { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, + { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, + { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { + path: 'workspaceitems', + loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule', + canActivate: [EndUserAgreementCurrentUserGuard] + }, + { + path: WORKFLOW_ITEM_MODULE_PATH, + loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule', + canActivate: [EndUserAgreementCurrentUserGuard] + }, + { + path: PROFILE_MODULE_PATH, + loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, + { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] }, + { path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' }, + { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, + { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, + ]} + ], { onSameUrlNavigation: 'reload', }) diff --git a/src/app/app.component.html b/src/app/app.component.html index 8656970f31..fa534855e7 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,4 @@ -
+
+ +
+ diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 7793b7529c..b18e7e1402 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -47,3 +47,7 @@ ds-admin-sidebar { position: fixed; z-index: $sidebar-z-index; } + +.ds-full-screen-loader { + height: 100vh; +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index da3cf9537b..31507831be 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,9 +1,8 @@ +import * as ngrx from '@ngrx/store'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { By } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; - import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Store, StoreModule } from '@ngrx/store'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -32,11 +31,11 @@ import { RouterMock } from './shared/mocks/router.mock'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { storeModuleConfig } from './app.reducer'; import { LocaleService } from './core/locale/locale.service'; +import { authReducer } from './core/auth/auth.reducer'; +import { cold } from 'jasmine-marbles'; let comp: AppComponent; let fixture: ComponentFixture; -let de: DebugElement; -let el: HTMLElement; const menuService = new MenuServiceStub(); describe('App component', () => { @@ -52,7 +51,7 @@ describe('App component', () => { return TestBed.configureTestingModule({ imports: [ CommonModule, - StoreModule.forRoot({}, storeModuleConfig), + StoreModule.forRoot(authReducer, storeModuleConfig), TranslateModule.forRoot({ loader: { provide: TranslateLoader, @@ -82,12 +81,19 @@ describe('App component', () => { // synchronous beforeEach beforeEach(() => { - fixture = TestBed.createComponent(AppComponent); + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => cold('a', { + a: { + core: { auth: { loading: false } } + } + }) + }; + }); + fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; // component test instance - // query for the
by CSS element selector - de = fixture.debugElement.query(By.css('div.outer-wrapper')); - el = de.nativeElement; + fixture.detectChanges(); }); it('should create component', inject([AppComponent], (app: AppComponent) => { diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 35ca4db131..fae2df3220 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { delay, filter, map, take } from 'rxjs/operators'; +import { delay, map, distinctUntilChanged } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, @@ -19,9 +19,8 @@ import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowState } from './shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; -import { isAuthenticated } from './core/auth/selectors'; +import { isAuthenticationBlocking } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; -import variables from '../styles/_exposed_variables.scss'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; import { MenuID } from './shared/menu/initial-menus-state'; @@ -53,6 +52,11 @@ export class AppComponent implements OnInit, AfterViewInit { notificationOptions = environment.notifications; models; + /** + * Whether or not the authentication is currently blocking the UI + */ + isNotAuthBlocking$: Observable; + constructor( @Inject(NativeWindowService) private _window: NativeWindowRef, private translate: TranslateService, @@ -90,16 +94,15 @@ export class AppComponent implements OnInit, AfterViewInit { } ngOnInit() { + this.isNotAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe( + map((isBlocking: boolean) => isBlocking === false), + distinctUntilChanged() + ); const env: string = environment.production ? 'Production' : 'Development'; const color: string = environment.production ? 'red' : 'green'; console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); - // Whether is not authenticathed try to retrieve a possible stored auth token - this.store.pipe(select(isAuthenticated), - take(1), - filter((authenticated) => !authenticated) - ).subscribe((authenticated) => this.authService.checkAuthenticationToken()); this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth'); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 33454ed6c5..f1cdd5f2e5 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,11 +1,11 @@ import { APP_BASE_HREF, CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EffectsModule } from '@ngrx/effects'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; -import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; +import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; import { TranslateModule } from '@ngx-translate/core'; @@ -21,6 +21,7 @@ import { AppComponent } from './app.component'; import { appEffects } from './app.effects'; import { appMetaReducers, debugMetaReducers } from './app.metareducers'; import { appReducers, AppState, storeModuleConfig } from './app.reducer'; +import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { CoreModule } from './core/core.module'; import { ClientCookieService } from './core/services/client-cookie.service'; @@ -91,6 +92,15 @@ const PROVIDERS = [ useClass: DSpaceRouterStateSerializer }, ClientCookieService, + // Check the authentication token when the app initializes + { + provide: APP_INITIALIZER, + useFactory: (store: Store,) => { + return () => store.dispatch(new CheckAuthenticationTokenAction()); + }, + deps: [ Store ], + multi: true + }, ...DYNAMIC_MATCHER_PROVIDERS, ]; diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts index 2e3914fe03..57b016bc6e 100644 --- a/src/app/community-list-page/community-list-page.module.ts +++ b/src/app/community-list-page/community-list-page.module.ts @@ -4,7 +4,6 @@ import { SharedModule } from '../shared/shared.module'; import { CommunityListPageComponent } from './community-list-page.component'; import { CommunityListPageRoutingModule } from './community-list-page.routing.module'; import { CommunityListComponent } from './community-list/community-list.component'; -import { CdkTreeModule } from '@angular/cdk/tree'; /** * The page which houses a title and the community list, as described in community-list.component @@ -13,8 +12,7 @@ import { CdkTreeModule } from '@angular/cdk/tree'; imports: [ CommonModule, SharedModule, - CommunityListPageRoutingModule, - CdkTreeModule, + CommunityListPageRoutingModule ], declarations: [ CommunityListPageComponent, diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index a5c3506e3d..4699e6faaa 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -13,11 +13,11 @@ import { PageInfo } from '../core/shared/page-info.model'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; import { PaginatedList } from '../core/data/paginated-list'; -import { getCommunityPageRoute } from '../+community-page/community-page-routing.module'; -import { getCollectionPageRoute } from '../+collection-page/collection-page-routing.module'; import { CollectionDataService } from '../core/data/collection-data.service'; import { CommunityListSaveAction } from './community-list.actions'; import { CommunityListState } from './community-list.reducer'; +import { getCommunityPageRoute } from '../+community-page/community-page-routing-paths'; +import { getCollectionPageRoute } from '../+collection-page/collection-page-routing-paths'; /** * Each node in the tree is represented by a flatNode which contains info about the node itself and its position and diff --git a/src/app/core/auth/auth-blocking.guard.spec.ts b/src/app/core/auth/auth-blocking.guard.spec.ts new file mode 100644 index 0000000000..2a89b01a85 --- /dev/null +++ b/src/app/core/auth/auth-blocking.guard.spec.ts @@ -0,0 +1,62 @@ +import { Store } from '@ngrx/store'; +import * as ngrx from '@ngrx/store'; +import { cold, getTestScheduler, initTestScheduler, resetTestScheduler } from 'jasmine-marbles/es6'; +import { of as observableOf } from 'rxjs'; +import { AppState } from '../../app.reducer'; +import { AuthBlockingGuard } from './auth-blocking.guard'; + +describe('AuthBlockingGuard', () => { + let guard: AuthBlockingGuard; + beforeEach(() => { + guard = new AuthBlockingGuard(new Store(undefined, undefined, undefined)); + initTestScheduler(); + }); + + afterEach(() => { + getTestScheduler().flush(); + resetTestScheduler(); + }); + + describe(`canActivate`, () => { + + describe(`when authState.loading is undefined`, () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(undefined); + }; + }) + }); + it(`should not emit anything`, () => { + expect(guard.canActivate()).toBeObservable(cold('|')); + }); + }); + + describe(`when authState.loading is true`, () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(true); + }; + }) + }); + it(`should not emit anything`, () => { + expect(guard.canActivate()).toBeObservable(cold('|')); + }); + }); + + describe(`when authState.loading is false`, () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(false); + }; + }) + }); + it(`should succeed`, () => { + expect(guard.canActivate()).toBeObservable(cold('(a|)', { a: true })); + }); + }); + }); + +}); diff --git a/src/app/core/auth/auth-blocking.guard.ts b/src/app/core/auth/auth-blocking.guard.ts new file mode 100644 index 0000000000..9054f66f8b --- /dev/null +++ b/src/app/core/auth/auth-blocking.guard.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { CanActivate } from '@angular/router'; +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; +import { AppState } from '../../app.reducer'; +import { isAuthenticationBlocking } from './selectors'; + +/** + * A guard that blocks the loading of any + * route until the authentication status has loaded. + * To ensure all rest requests get the correct auth header. + */ +@Injectable({ + providedIn: 'root' +}) +export class AuthBlockingGuard implements CanActivate { + + constructor(private store: Store) { + } + + /** + * True when the authentication isn't blocking everything + */ + canActivate(): Observable { + return this.store.pipe(select(isAuthenticationBlocking)).pipe( + map((isBlocking: boolean) => isBlocking === false), + distinctUntilChanged(), + filter((finished: boolean) => finished === true), + take(1), + ); + } + +} diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index be4bdf2a26..f80be89034 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -34,6 +34,7 @@ export const AuthActionTypes = { RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'), RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'), RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'), + REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS') }; /* tslint:disable:max-classes-per-file */ @@ -335,6 +336,20 @@ export class SetRedirectUrlAction implements Action { } } +/** + * Start loading for a hard redirect + * @class StartHardRedirectLoadingAction + * @implements {Action} + */ +export class RedirectAfterLoginSuccessAction implements Action { + public type: string = AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS; + payload: string; + + constructor(url: string) { + this.payload = url; + } +} + /** * Retrieve the authenticated eperson. * @class RetrieveAuthenticatedEpersonAction @@ -402,8 +417,8 @@ export type AuthActions | RetrieveAuthMethodsSuccessAction | RetrieveAuthMethodsErrorAction | RetrieveTokenAction - | ResetAuthenticationMessagesAction | RetrieveAuthenticatedEpersonAction | RetrieveAuthenticatedEpersonErrorAction | RetrieveAuthenticatedEpersonSuccessAction - | SetRedirectUrlAction; + | SetRedirectUrlAction + | RedirectAfterLoginSuccessAction; diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 37ef3b79bc..ab18dcb508 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -27,6 +27,7 @@ import { CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, + RedirectAfterLoginSuccessAction, RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, @@ -79,7 +80,26 @@ export class AuthEffects { public authenticatedSuccess$: Observable = this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)), - map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref)) + switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe( + take(1), + map((redirectUrl: string) => [action, redirectUrl]) + )), + map(([action, redirectUrl]: [AuthenticatedSuccessAction, string]) => { + if (hasValue(redirectUrl)) { + return new RedirectAfterLoginSuccessAction(redirectUrl); + } else { + return new RetrieveAuthenticatedEpersonAction(action.payload.userHref); + } + }) + ); + + @Effect({ dispatch: false }) + public redirectAfterLoginSuccess$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS), + tap((action: RedirectAfterLoginSuccessAction) => { + this.authService.clearRedirectUrl(); + this.authService.navigateToRedirectUrl(action.payload); + }) ); // It means "reacts to this action but don't send another" @@ -201,13 +221,6 @@ export class AuthEffects { tap(() => this.authService.refreshAfterLogout()) ); - @Effect({ dispatch: false }) - public redirectToLogin$: Observable = this.actions$ - .pipe(ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED), - tap(() => this.authService.removeToken()), - tap(() => this.authService.redirectToLogin()) - ); - @Effect({ dispatch: false }) public redirectToLoginTokenExpired$: Observable = this.actions$ .pipe( diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index f4e7aa2fd3..3366cdb3d8 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -251,7 +251,6 @@ export class AuthInterceptor implements HttpInterceptor { // Pass on the new request instead of the original request. return next.handle(newReq).pipe( - // tap((response) => console.log('next.handle: ', response)), map((response) => { // Intercept a Login/Logout response if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) { diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index cf934a7f47..4c6f1e2a25 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -42,6 +42,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: true, loading: false, }; const action = new AuthenticateAction('user', 'password'); @@ -49,6 +50,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, error: undefined, loading: true, info: undefined @@ -62,6 +64,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -76,6 +79,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -84,6 +88,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, info: undefined, authToken: undefined, @@ -96,6 +101,7 @@ describe('authReducer', () => { it('should properly set the state, in response to a AUTHENTICATED action', () => { initialState = { authenticated: false, + blocking: false, loaded: false, error: undefined, loading: true, @@ -103,8 +109,15 @@ describe('authReducer', () => { }; const action = new AuthenticatedAction(mockTokenInfo); const newState = authReducer(initialState, action); - - expect(newState).toEqual(initialState); + state = { + authenticated: false, + blocking: true, + loaded: false, + error: undefined, + loading: true, + info: undefined + }; + expect(newState).toEqual(state); }); it('should properly set the state, in response to a AUTHENTICATED_SUCCESS action', () => { @@ -112,6 +125,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -122,6 +136,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -133,6 +148,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -143,6 +159,7 @@ describe('authReducer', () => { authToken: undefined, error: 'Test error message', loaded: true, + blocking: false, loading: false, info: undefined }; @@ -153,6 +170,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, }; const action = new CheckAuthenticationTokenAction(); @@ -160,6 +178,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, loading: true, }; expect(newState).toEqual(state); @@ -169,6 +188,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: true, }; const action = new CheckAuthenticationTokenCookieAction(); @@ -176,6 +196,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, loading: true, }; expect(newState).toEqual(state); @@ -187,6 +208,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -204,6 +226,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -216,7 +239,8 @@ describe('authReducer', () => { authToken: undefined, error: undefined, loaded: false, - loading: false, + blocking: true, + loading: true, info: undefined, refreshing: false, userId: undefined @@ -230,6 +254,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -242,6 +267,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: 'Test error message', + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -255,6 +281,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -265,6 +292,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -277,6 +305,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -287,6 +316,7 @@ describe('authReducer', () => { authToken: undefined, error: 'Test error message', loaded: true, + blocking: false, loading: false, info: undefined }; @@ -299,6 +329,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -311,6 +342,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -325,6 +357,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -338,6 +371,7 @@ describe('authReducer', () => { authToken: newTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -352,6 +386,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -364,6 +399,7 @@ describe('authReducer', () => { authToken: undefined, error: undefined, loaded: false, + blocking: false, loading: false, info: undefined, refreshing: false, @@ -378,6 +414,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -387,6 +424,7 @@ describe('authReducer', () => { authenticated: false, authToken: undefined, loaded: false, + blocking: false, loading: false, error: undefined, info: 'Message', @@ -410,6 +448,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, }; const action = new AddAuthenticationMessageAction('Message'); @@ -417,6 +456,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, info: 'Message' }; @@ -427,6 +467,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, error: 'Error', info: 'Message' @@ -436,6 +477,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, error: undefined, info: undefined @@ -447,6 +489,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false }; const action = new SetRedirectUrlAction('redirect.url'); @@ -454,6 +497,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, redirectUrl: 'redirect.url' }; @@ -464,6 +508,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, authMethods: [] }; @@ -472,6 +517,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, loading: true, authMethods: [] }; @@ -482,6 +528,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: true, loading: true, authMethods: [] }; @@ -494,6 +541,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, authMethods: authMethods }; @@ -504,6 +552,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: true, loading: true, authMethods: [] }; @@ -513,6 +562,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, authMethods: [new AuthMethod(AuthMethodType.Password)] }; diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 34c8fe2b41..6d5635f263 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -39,6 +39,10 @@ export interface AuthState { // true when loading loading: boolean; + // true when everything else should wait for authorization + // to complete + blocking: boolean; + // info message info?: string; @@ -62,6 +66,7 @@ export interface AuthState { const initialState: AuthState = { authenticated: false, loaded: false, + blocking: true, loading: false, authMethods: [] }; @@ -86,7 +91,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: return Object.assign({}, state, { - loading: true + loading: true, + blocking: true }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -96,6 +102,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authToken: undefined, error: (action as AuthenticationErrorAction).payload.message, loaded: true, + blocking: false, loading: false }); @@ -110,6 +117,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loaded: true, error: undefined, loading: false, + blocking: false, info: undefined, userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload }); @@ -119,6 +127,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authenticated: false, authToken: undefined, error: (action as AuthenticationErrorAction).payload.message, + blocking: false, loading: false }); @@ -132,25 +141,39 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: (action as LogOutErrorAction).payload.message }); - case AuthActionTypes.LOG_OUT_SUCCESS: case AuthActionTypes.REFRESH_TOKEN_ERROR: return Object.assign({}, state, { authenticated: false, authToken: undefined, error: undefined, loaded: false, + blocking: false, loading: false, info: undefined, refreshing: false, userId: undefined }); + case AuthActionTypes.LOG_OUT_SUCCESS: + return Object.assign({}, state, { + authenticated: false, + authToken: undefined, + error: undefined, + loaded: false, + blocking: true, + loading: true, + info: undefined, + refreshing: false, + userId: undefined + }); + case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED: case AuthActionTypes.REDIRECT_TOKEN_EXPIRED: return Object.assign({}, state, { authenticated: false, authToken: undefined, loaded: false, + blocking: false, loading: false, info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload, userId: undefined @@ -181,18 +204,21 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut // next three cases are used by dynamic rendering of login methods case AuthActionTypes.RETRIEVE_AUTH_METHODS: return Object.assign({}, state, { - loading: true + loading: true, + blocking: true }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: return Object.assign({}, state, { loading: false, + blocking: false, authMethods: (action as RetrieveAuthMethodsSuccessAction).payload }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: return Object.assign({}, state, { loading: false, + blocking: false, authMethods: [new AuthMethod(AuthMethodType.Password)] }); @@ -201,6 +227,12 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut redirectUrl: (action as SetRedirectUrlAction).payload, }); + case AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS: + return Object.assign({}, state, { + loading: true, + blocking: true, + }); + default: return state; } diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 7f2c1e29cc..d3c2b6c44d 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -27,6 +27,7 @@ import { EPersonDataService } from '../eperson/eperson-data.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { authMethodsMock } from '../../shared/testing/auth-service.stub'; import { AuthMethod } from './models/auth.method'; +import { HardRedirectService } from '../services/hard-redirect.service'; describe('AuthService test', () => { @@ -48,6 +49,7 @@ describe('AuthService test', () => { let authenticatedState; let unAuthenticatedState; let linkService; + let hardRedirectService; function init() { mockStore = jasmine.createSpyObj('store', { @@ -77,6 +79,7 @@ describe('AuthService test', () => { linkService = { resolveLinks: {} }; + hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']); spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) }); } @@ -104,6 +107,7 @@ describe('AuthService test', () => { { provide: ActivatedRoute, useValue: routeStub }, { provide: Store, useValue: mockStore }, { provide: EPersonDataService, useValue: mockEpersonDataService }, + { provide: HardRedirectService, useValue: hardRedirectService }, CookieService, AuthService ], @@ -210,7 +214,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); })); it('should return true when user is logged in', () => { @@ -289,7 +293,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); storage = (authService as any).storage; routeServiceMock = TestBed.get(RouteService); routerStub = TestBed.get(Router); @@ -318,36 +322,28 @@ describe('AuthService test', () => { expect(storage.remove).toHaveBeenCalled(); }); - it('should set redirect url to previous page', () => { - spyOn(routeServiceMock, 'getHistory').and.callThrough(); - spyOn(routerStub, 'navigateByUrl'); - authService.redirectAfterLoginSuccess(true); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/collection/123'); + it('should redirect to reload with redirect url', () => { + authService.navigateToRedirectUrl('/collection/123'); + // Reload with redirect URL set to /collection/123 + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); }); - it('should set redirect url to current page', () => { - spyOn(routeServiceMock, 'getHistory').and.callThrough(); - spyOn(routerStub, 'navigateByUrl'); - authService.redirectAfterLoginSuccess(false); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/home'); + it('should redirect to reload with /home', () => { + authService.navigateToRedirectUrl('/home'); + // Reload with redirect URL set to /home + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); }); - it('should redirect to / and not to /login', () => { - spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login'])); - spyOn(routerStub, 'navigateByUrl'); - authService.redirectAfterLoginSuccess(true); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/'); + it('should redirect to regular reload and not to /login', () => { + authService.navigateToRedirectUrl('/login'); + // Reload without a redirect URL + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); }); - it('should redirect to / when no redirect url is found', () => { - spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf([''])); - spyOn(routerStub, 'navigateByUrl'); - authService.redirectAfterLoginSuccess(true); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/'); + it('should redirect to regular reload when no redirect url is found', () => { + authService.navigateToRedirectUrl(undefined); + // Reload without a redirect URL + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); }); describe('impersonate', () => { @@ -464,6 +460,14 @@ describe('AuthService test', () => { }); }); }); + + describe('refreshAfterLogout', () => { + it('should call navigateToRedirectUrl with no url', () => { + spyOn(authService as any, 'navigateToRedirectUrl').and.stub(); + authService.refreshAfterLogout(); + expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled(); + }); + }); }); describe('when user is not logged in', () => { @@ -496,7 +500,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = unAuthenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); })); it('should return null for the shortlived token', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 85e5eebb9e..06906346ed 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,11 +1,10 @@ import { Inject, Injectable, Optional } from '@angular/core'; -import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router'; +import { Router } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { Observable, of as observableOf } from 'rxjs'; -import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators'; -import { RouterReducerState } from '@ngrx/router-store'; +import { map, startWith, switchMap, take } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; @@ -14,7 +13,15 @@ import { AuthRequestService } from './auth-request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; -import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; +import { + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, + isNotNull, + isNotUndefined, + hasNoValue +} from '../../shared/empty.util'; import { CookieService } from '../services/cookie.service'; import { getAuthenticatedUserId, @@ -24,7 +31,7 @@ import { isTokenRefreshing, isAuthenticatedLoaded } from './selectors'; -import { AppState, routerStateSelector } from '../../app.reducer'; +import { AppState } from '../../app.reducer'; import { CheckAuthenticationTokenAction, ResetAuthenticationMessagesAction, @@ -36,6 +43,7 @@ import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; import { getAllSucceededRemoteDataPayload } from '../shared/operators'; import { AuthMethod } from './models/auth.method'; +import { HardRedirectService } from '../services/hard-redirect.service'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -62,43 +70,13 @@ export class AuthService { protected router: Router, protected routeService: RouteService, protected storage: CookieService, - protected store: Store + protected store: Store, + protected hardRedirectService: HardRedirectService ) { this.store.pipe( select(isAuthenticated), startWith(false) ).subscribe((authenticated: boolean) => this._authenticated = authenticated); - - // If current route is different from the one setted in authentication guard - // and is not the login route, clear redirect url and messages - const routeUrl$ = this.store.pipe( - select(routerStateSelector), - filter((routerState: RouterReducerState) => isNotUndefined(routerState) - && isNotUndefined(routerState.state) && isNotEmpty(routerState.state.url)), - filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url)), - map((routerState: RouterReducerState) => routerState.state.url) - ); - const redirectUrl$ = this.store.pipe(select(getRedirectUrl), distinctUntilChanged()); - routeUrl$.pipe( - withLatestFrom(redirectUrl$), - map(([routeUrl, redirectUrl]) => [routeUrl, redirectUrl]) - ).pipe(filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl))) - .subscribe(() => { - this.clearRedirectUrl(); - }); - } - - /** - * Check if is a login page route - * - * @param {string} url - * @returns {Boolean}. - */ - protected isLoginRoute(url: string) { - const urlTree: UrlTree = this.router.parseUrl(url); - const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET]; - const segment = '/' + g.toString(); - return segment === LOGIN_ROUTE; } /** @@ -206,7 +184,7 @@ export class AuthService { return this.store.pipe( select(getAuthenticatedUserId), hasValueOperator(), - switchMap((id: string) => this.epersonService.findById(id)), + switchMap((id: string) => this.epersonService.findById(id) ), getAllSucceededRemoteDataPayload() ) } @@ -409,69 +387,38 @@ export class AuthService { } /** - * Redirect to the route navigated before the login + * Perform a hard redirect to the URL + * @param redirectUrl */ - public redirectAfterLoginSuccess(isStandalonePage: boolean) { - this.getRedirectUrl().pipe( - take(1)) - .subscribe((redirectUrl) => { - - if (isNotEmpty(redirectUrl)) { - this.clearRedirectUrl(); - this.router.onSameUrlNavigation = 'reload'; - this.navigateToRedirectUrl(redirectUrl); - } else { - // If redirectUrl is empty use history. - this.routeService.getHistory().pipe( - take(1) - ).subscribe((history) => { - let redirUrl; - if (isStandalonePage) { - // For standalone login pages, use the previous route. - redirUrl = history[history.length - 2] || ''; - } else { - redirUrl = history[history.length - 1] || ''; - } - this.navigateToRedirectUrl(redirUrl); - }); - } - }); - - } - - protected navigateToRedirectUrl(redirectUrl: string) { - const url = decodeURIComponent(redirectUrl); - // in case the user navigates directly to /login (via bookmark, etc), or the route history is not found. - if (isEmpty(url) || url.startsWith(LOGIN_ROUTE)) { - this.router.navigateByUrl('/'); - /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ - // this._window.nativeWindow.location.href = '/'; - } else { - /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ - // this._window.nativeWindow.location.href = url; - this.router.navigateByUrl(url); + public navigateToRedirectUrl(redirectUrl: string) { + let url = `/reload/${new Date().getTime()}`; + if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { + url += `?redirect=${encodeURIComponent(redirectUrl)}`; } + this.hardRedirectService.redirect(url); } /** * Refresh route navigated */ public refreshAfterLogout() { - // Hard redirect to the reload page with a unique number behind it - // so that all state is definitely lost - this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`; + this.navigateToRedirectUrl(undefined); } /** * Get redirect url */ getRedirectUrl(): Observable { - const redirectUrl = this.storage.get(REDIRECT_COOKIE); - if (isNotEmpty(redirectUrl)) { - return observableOf(redirectUrl); - } else { - return this.store.pipe(select(getRedirectUrl)); - } + return this.store.pipe( + select(getRedirectUrl), + map((urlFromStore: string) => { + if (hasValue(urlFromStore)) { + return urlFromStore; + } else { + return this.storage.get(REDIRECT_COOKIE); + } + }) + ); } /** @@ -488,6 +435,20 @@ export class AuthService { this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); } + /** + * Set the redirect url if the current one has not been set yet + * @param newRedirectUrl + */ + setRedirectUrlIfNotSet(newRedirectUrl: string) { + this.getRedirectUrl().pipe( + take(1)) + .subscribe((currentRedirectUrl) => { + if (hasNoValue(currentRedirectUrl)) { + this.setRedirectUrl(newRedirectUrl); + } + }) + } + /** * Clear redirect url */ diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 7a2f39854c..0b9eeec509 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -1,21 +1,26 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanActivate, + Router, + RouterStateSnapshot, + UrlTree +} from '@angular/router'; import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { map, find, switchMap } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; -import { isAuthenticated } from './selectors'; -import { AuthService } from './auth.service'; -import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions'; +import { isAuthenticated, isAuthenticationLoading } from './selectors'; +import { AuthService, LOGIN_ROUTE } from './auth.service'; /** * Prevent unauthorized activating and loading of routes * @class AuthenticatedGuard */ @Injectable() -export class AuthenticatedGuard implements CanActivate, CanLoad { +export class AuthenticatedGuard implements CanActivate { /** * @constructor @@ -24,46 +29,37 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { /** * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated * @method canActivate */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { const url = state.url; return this.handleAuth(url); } /** * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated * @method canActivateChild */ - canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return this.canActivate(route, state); } - /** - * True when user is authenticated - * @method canLoad - */ - canLoad(route: Route): Observable { - const url = `/${route.path}`; - - return this.handleAuth(url); - } - - private handleAuth(url: string): Observable { - // get observable - const observable = this.store.pipe(select(isAuthenticated)); - + private handleAuth(url: string): Observable { // redirect to sign in page if user is not authenticated - observable.pipe( - // .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url) - take(1)) - .subscribe((authenticated) => { - if (!authenticated) { + return this.store.pipe(select(isAuthenticationLoading)).pipe( + find((isLoading: boolean) => isLoading === false), + switchMap(() => this.store.pipe(select(isAuthenticated))), + map((authenticated) => { + if (authenticated) { + return authenticated; + } else { this.authService.setRedirectUrl(url); - this.store.dispatch(new RedirectWhenAuthenticationIsRequiredAction('Login required')); + this.authService.removeToken(); + return this.router.createUrlTree([LOGIN_ROUTE]); } - }); - - return observable; + }) + ); } } diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 173f82e810..c4e95a0fb3 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -65,6 +65,14 @@ const _getAuthenticationInfo = (state: AuthState) => state.info; */ const _isLoading = (state: AuthState) => state.loading; +/** + * Returns true if everything else should wait for authentication. + * @function _isBlocking + * @param {State} state + * @returns {boolean} + */ +const _isBlocking = (state: AuthState) => state.blocking; + /** * Returns true if a refresh token request is in progress. * @function _isRefreshing @@ -170,6 +178,16 @@ export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticat */ export const isAuthenticationLoading = createSelector(getAuthState, _isLoading); +/** + * Returns true if the authentication should block everything else + * + * @function isAuthenticationBlocking + * @param {AuthState} state + * @param {any} props + * @return {boolean} + */ +export const isAuthenticationBlocking = createSelector(getAuthState, _isBlocking); + /** * Returns true if the refresh token request is loading. * @function isTokenRefreshing diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 7b78255001..88a4ac406e 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { filter, map, take } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { isNotEmpty } from '../../shared/empty.util'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -58,32 +58,4 @@ export class ServerAuthService extends AuthService { map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) ); } - - /** - * Redirect to the route navigated before the login - */ - public redirectAfterLoginSuccess(isStandalonePage: boolean) { - this.getRedirectUrl().pipe( - take(1)) - .subscribe((redirectUrl) => { - if (isNotEmpty(redirectUrl)) { - // override the route reuse strategy - this.router.routeReuseStrategy.shouldReuseRoute = () => { - return false; - }; - this.router.navigated = false; - const url = decodeURIComponent(redirectUrl); - this.router.navigateByUrl(url); - } else { - // If redirectUrl is empty use history. For ssr the history array should contain the requested url. - this.routeService.getHistory().pipe( - filter((history) => history.length > 0), - take(1) - ).subscribe((history) => { - this.navigateToRedirectUrl(history[history.length - 1] || ''); - }); - } - }) - } - } diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts index 5c31e40362..4bd7560e02 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts @@ -10,8 +10,8 @@ import { Community } from '../shared/community.model'; import { Collection } from '../shared/collection.model'; import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model'; import { getTestScheduler } from 'jasmine-marbles'; -import { getDSOPath } from '../../app-routing.module'; import { DSONameService } from './dso-name.service'; +import { getDSORoute } from '../../app-routing-paths'; describe('DSOBreadcrumbsService', () => { let service: DSOBreadcrumbsService; @@ -108,9 +108,9 @@ describe('DSOBreadcrumbsService', () => { it('should return the breadcrumbs based on an Item', () => { const breadcrumbs = service.getBreadcrumbs(testItem, testItem._links.self); const expectedCrumbs = [ - new Breadcrumb(getName(testCommunity), getDSOPath(testCommunity)), - new Breadcrumb(getName(testCollection), getDSOPath(testCollection)), - new Breadcrumb(getName(testItem), getDSOPath(testItem)), + new Breadcrumb(getName(testCommunity), getDSORoute(testCommunity)), + new Breadcrumb(getName(testCollection), getDSORoute(testCollection)), + new Breadcrumb(getName(testItem), getDSORoute(testItem)), ]; getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: expectedCrumbs }); }) diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts index 003c11bf83..fc1bb9e770 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts @@ -7,10 +7,10 @@ import { LinkService } from '../cache/builders/link.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { followLink } from '../../shared/utils/follow-link-config.model'; import { find, map, switchMap } from 'rxjs/operators'; -import { getDSOPath } from '../../app-routing.module'; import { RemoteData } from '../data/remote-data'; import { hasValue } from '../../shared/empty.util'; import { Injectable } from '@angular/core'; +import { getDSORoute } from '../../app-routing-paths'; /** * Service to calculate DSpaceObject breadcrumbs for a single part of the route @@ -41,7 +41,7 @@ export class DSOBreadcrumbsService implements BreadcrumbsService) => { if (hasValue(parentRD.payload)) { const parent = parentRD.payload; - return this.getBreadcrumbs(parent, getDSOPath(parent)) + return this.getBreadcrumbs(parent, getDSORoute(parent)) } return observableOf([]); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index ae3b0e4fd1..83cecca502 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -139,8 +139,8 @@ export class RemoteDataBuildService { const pageInfo$ = requestEntry$.pipe( filterSuccessfulResponses(), map((response: DSOSuccessResponse) => { - if (hasValue((response as DSOSuccessResponse).pageInfo)) { - return (response as DSOSuccessResponse).pageInfo; + if (hasValue(response.pageInfo)) { + return Object.assign(new PageInfo(), response.pageInfo); } }) ); diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 5f19185d1c..b33080b641 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -5,7 +5,6 @@ import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; import { FacetValue } from '../../shared/search/facet-value.model'; import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; -import { IntegrationModel } from '../integration/models/integration.model'; import { PaginatedList } from '../data/paginated-list'; import { SubmissionObject } from '../submission/models/submission-object.model'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -181,17 +180,6 @@ export class TokenResponse extends RestResponse { } } -export class IntegrationSuccessResponse extends RestResponse { - constructor( - public dataDefinition: PaginatedList, - public statusCode: number, - public statusText: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode, statusText); - } -} - export class PostPatchSuccessResponse extends RestResponse { constructor( public dataDefinition: any, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 64ed79b2e0..63fd8119b4 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; + import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { EffectsModule } from '@ngrx/effects'; @@ -15,8 +16,8 @@ import { MenuService } from '../shared/menu/menu.service'; import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; import { MOCK_RESPONSE_MAP, - ResponseMapMock, - mockResponseMap + mockResponseMap, + ResponseMapMock } from '../shared/mocks/dspace-rest-v2/mocks/response-map.mock'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service'; @@ -80,9 +81,6 @@ import { EPersonDataService } from './eperson/eperson-data.service'; import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service'; import { EPerson } from './eperson/models/eperson.model'; import { Group } from './eperson/models/group.model'; -import { AuthorityService } from './integration/authority.service'; -import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; -import { AuthorityValue } from './integration/models/authority.value'; import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; import { MetadataField } from './metadata/metadata-field.model'; import { MetadataSchema } from './metadata/metadata-schema.model'; @@ -160,8 +158,18 @@ import { SubmissionCcLicenseDataService } from './submission/submission-cc-licen import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model'; import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; +import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model'; +import { Vocabulary } from './submission/vocabularies/models/vocabulary.model'; +import { VocabularyEntriesResponseParsingService } from './submission/vocabularies/vocabulary-entries-response-parsing.service'; +import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model'; +import { VocabularyService } from './submission/vocabularies/vocabulary.service'; +import { VocabularyTreeviewService } from '../shared/vocabulary-treeview/vocabulary-treeview.service'; import { ConfigurationDataService } from './data/configuration-data.service'; import { ConfigurationProperty } from './shared/configuration-property.model'; +import { ReloadGuard } from './reload/reload.guard'; +import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard'; +import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard'; +import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service'; import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard'; /** @@ -196,7 +204,7 @@ const PROVIDERS = [ SiteDataService, DSOResponseParsingService, { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, - { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient]}, + { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] }, DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService, @@ -238,8 +246,6 @@ const PROVIDERS = [ SubmissionResponseParsingService, SubmissionJsonPatchOperationsService, JsonPatchOperationsBuilder, - AuthorityService, - IntegrationResponseParsingService, UploaderService, UUIDService, NotificationsService, @@ -291,6 +297,10 @@ const PROVIDERS = [ MetadataSchemaDataService, MetadataFieldDataService, TokenResponseParsingService, + ReloadGuard, + EndUserAgreementCurrentUserGuard, + EndUserAgreementCookieGuard, + EndUserAgreementService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, @@ -305,7 +315,10 @@ const PROVIDERS = [ }, NotificationsService, FilteredDiscoveryPageResponseParsingService, - { provide: NativeWindowService, useFactory: NativeWindowFactory } + { provide: NativeWindowService, useFactory: NativeWindowFactory }, + VocabularyService, + VocabularyEntriesResponseParsingService, + VocabularyTreeviewService ]; /** @@ -336,7 +349,6 @@ export const models = SubmissionSectionModel, SubmissionUploadsModel, AuthStatus, - AuthorityValue, BrowseEntry, BrowseDefinition, ClaimedTask, @@ -356,6 +368,9 @@ export const models = Feature, Authorization, Registration, + Vocabulary, + VocabularyEntry, + VocabularyEntryDetail, ConfigurationProperty ]; diff --git a/src/app/core/data/browse-entries-response-parsing.service.ts b/src/app/core/data/browse-entries-response-parsing.service.ts index 98385f0237..1009a07bca 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -1,40 +1,22 @@ -import { Inject, Injectable } from '@angular/core'; -import { isNotEmpty } from '../../shared/empty.util'; +import { Injectable } from '@angular/core'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { BrowseEntry } from '../shared/browse-entry.model'; -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; +import { EntriesResponseParsingService } from './entries-response-parsing.service'; +import { GenericConstructor } from '../shared/generic-constructor'; @Injectable() -export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { +export class BrowseEntriesResponseParsingService extends EntriesResponseParsingService { protected toCache = false; constructor( protected objectCache: ObjectCacheService, - ) { super(); + ) { + super(objectCache); } - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload)) { - let browseEntries = []; - if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceSerializer(BrowseEntry); - browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); - } - return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); - } else { - return new ErrorResponse( - Object.assign( - new Error('Unexpected response from browse endpoint'), - { statusCode: data.statusCode, statusText: data.statusText } - ) - ); - } + getSerializerModel(): GenericConstructor { + return BrowseEntry; } } diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index 1e1bf0eb9c..6c63ca8978 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -1,21 +1,11 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { compare, Operation } from 'fast-json-patch'; -import { Observable, of as observableOf } from 'rxjs'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { ChangeAnalyzer } from './change-analyzer'; -import { DataService } from './data.service'; -import { FindListOptions, PatchRequest } from './request.models'; -import { RequestService } from './request.service'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { BundleDataService } from './bundle-data.service'; diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 76aad4ad56..4b0dee7df7 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -6,18 +6,18 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { fakeAsync, tick } from '@angular/core/testing'; -import { ContentSourceRequest, GetRequest, RequestError, UpdateContentSourceRequest } from './request.models'; +import { ContentSourceRequest, GetRequest, UpdateContentSourceRequest } from './request.models'; import { ContentSource } from '../shared/content-source.model'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { RequestEntry } from './request.reducer'; -import { ErrorResponse, RestResponse } from '../cache/response.models'; +import { ErrorResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Collection } from '../shared/collection.model'; import { PageInfo } from '../shared/page-info.model'; import { PaginatedList } from './paginated-list'; import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils'; -import { hot, getTestScheduler, cold } from 'jasmine-marbles'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; const url = 'fake-url'; diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 123c3eccd1..474bdef44a 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -24,7 +24,7 @@ import { RequestService } from './request.service'; @dataService(COMMUNITY) export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; - protected topLinkPath = 'communities/search/top'; + protected topLinkPath = 'search/top'; protected cds = this; constructor( diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index a99fc54269..31013c5132 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -18,6 +18,7 @@ import { FindListOptions, PatchRequest } from './request.models'; import { RequestService } from './request.service'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { RequestParam } from '../cache/models/request-param.model'; const endpoint = 'https://rest.api/core'; @@ -150,7 +151,8 @@ describe('DataService', () => { currentPage: 6, elementsPerPage: 10, sort: sortOptions, - startsWith: 'ab' + startsWith: 'ab', + }; const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; @@ -160,6 +162,26 @@ describe('DataService', () => { }); }); + it('should include all searchParams in href if any provided in options', () => { + options = { searchParams: [ + new RequestParam('param1', 'test'), + new RequestParam('param2', 'test2'), + ] }; + const expected = `${endpoint}?param1=test¶m2=test2`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include linkPath in href if any provided', () => { + const expected = `${endpoint}/test/entries`; + + (service as any).getFindAllHref({}, 'test/entries').subscribe((value) => { + expect(value).toBe(expected); + }); + }); + it('should include single linksToFollow as embed', () => { const expected = `${endpoint}?embed=bundles`; diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 0d818f2030..528889cd83 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -3,7 +3,7 @@ import { Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -71,13 +71,17 @@ export abstract class DataService implements UpdateDa * Return an observable that emits created HREF * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable { - let result$: Observable; + public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable { + let endpoint$: Observable; const args = []; - result$ = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); + endpoint$ = this.getBrowseEndpoint(options).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), + distinctUntilChanged() + ); - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); + return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } /** @@ -89,18 +93,12 @@ export abstract class DataService implements UpdateDa * Return an observable that emits created HREF * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { + public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { let result$: Observable; const args = []; result$ = this.getSearchEndpoint(searchMethod); - if (hasValue(options.searchParams)) { - options.searchParams.forEach((param: RequestParam) => { - args.push(`${param.fieldName}=${param.fieldValue}`); - }) - } - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } @@ -114,7 +112,7 @@ export abstract class DataService implements UpdateDa * Return an observable that emits created HREF * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string { + public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string { let args = [...extraArgs]; if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { @@ -130,6 +128,11 @@ export abstract class DataService implements UpdateDa if (hasValue(options.startsWith)) { args = [...args, `startsWith=${options.startsWith}`]; } + if (hasValue(options.searchParams)) { + options.searchParams.forEach((param: RequestParam) => { + args = [...args, `${param.fieldName}=${param.fieldValue}`]; + }) + } args = this.addEmbedParams(args, ...linksToFollow); if (isNotEmpty(args)) { return new URLCombiner(href, `?${args.join('&')}`).toString(); @@ -373,6 +376,7 @@ export abstract class DataService implements UpdateDa ).subscribe(); return this.requestService.getByUUID(requestId).pipe( + hasValueOperator(), find((request: RequestEntry) => request.completed), map((request: RequestEntry) => request.response) ); diff --git a/src/app/core/data/entries-response-parsing.service.ts b/src/app/core/data/entries-response-parsing.service.ts new file mode 100644 index 0000000000..09ae8ae1c5 --- /dev/null +++ b/src/app/core/data/entries-response-parsing.service.ts @@ -0,0 +1,54 @@ +import { isNotEmpty } from '../../shared/empty.util'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { GenericConstructor } from '../shared/generic-constructor'; + +/** + * An abstract class to extend, responsible for parsing data for an entries response + */ +export abstract class EntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected toCache = false; + + constructor( + protected objectCache: ObjectCacheService, + ) { + super(); + } + + /** + * Abstract method to implement that must return the dspace serializer Constructor to use during parse + */ + abstract getSerializerModel(): GenericConstructor; + + /** + * Parse response + * + * @param request + * @param data + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload)) { + let entries = []; + if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceSerializer(this.getSerializerModel()); + entries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + } + return new GenericSuccessResponse(entries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from browse endpoint'), + { statusCode: data.statusCode, statusText: data.statusText } + ) + ); + } + } + +} diff --git a/src/app/core/data/external-source.service.ts b/src/app/core/data/external-source.service.ts index 0c1a8d255c..edc538a39b 100644 --- a/src/app/core/data/external-source.service.ts +++ b/src/app/core/data/external-source.service.ts @@ -19,6 +19,7 @@ import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; /** * A service handling all external source requests diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts index 6600444ea0..cecfeabf18 100644 --- a/src/app/core/data/processes/script-data.service.ts +++ b/src/app/core/data/processes/script-data.service.ts @@ -12,12 +12,17 @@ import { Script } from '../../../process-page/scripts/script.model'; import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; import { find, map, switchMap } from 'rxjs/operators'; import { URLCombiner } from '../../url-combiner/url-combiner'; +import { RemoteData } from '../remote-data'; import { MultipartPostRequest, RestRequest } from '../request.models'; import { RequestService } from '../request.service'; import { Observable } from 'rxjs'; import { RequestEntry } from '../request.reducer'; import { dataService } from '../../cache/builders/build-decorators'; import { SCRIPT } from '../../../process-page/scripts/script.resource-type'; +import { hasValue } from '../../../shared/empty.util'; + +export const METADATA_IMPORT_SCRIPT_NAME = 'metadata-import'; +export const METADATA_EXPORT_SCRIPT_NAME = 'metadata-export'; @Injectable() @dataService(SCRIPT) @@ -58,4 +63,16 @@ export class ScriptDataService extends DataService