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/.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..478dd063f6 100644 --- a/server.ts +++ b/server.ts @@ -33,6 +33,7 @@ import { enableProdMode, NgModuleFactory, Type } from '@angular/core'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; +import { createProxyMiddleware } from 'http-proxy-middleware'; /* * Set path for the browser application's dist folder @@ -106,6 +107,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 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-routing.module.ts b/src/app/+admin/admin-routing.module.ts index 43b3a4ab34..84b418772a 100644 --- a/src/app/+admin/admin-routing.module.ts +++ b/src/app/+admin/admin-routing.module.ts @@ -1,6 +1,7 @@ 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'; @@ -48,6 +49,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-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.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/+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/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/app.component.ts b/src/app/app.component.ts index 35ca4db131..10f81a9adc 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -21,7 +21,6 @@ import { HostWindowState } from './shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { isAuthenticated } 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'; diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 7f61bec9f3..a25c8ffa5b 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -206,7 +206,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() ) } 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/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